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:
+34
-56
@@ -43,7 +43,7 @@ import {
|
||||
GetAwardDefs,
|
||||
GetUIPref, GetActiveProfile, QuitApp,
|
||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
|
||||
GetAmpStatuses, AmpOperate,
|
||||
GetFlexState, FlexAmpOperate,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
@@ -441,45 +441,22 @@ export default function App() {
|
||||
// click reverts the UI and the click looks like it did nothing.
|
||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
|
||||
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
|
||||
// the chip appear); status polled every 2s while enabled.
|
||||
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
|
||||
const [ampSt, setAmpSt] = useState<any>({ connected: false });
|
||||
// Amplifier chips in the status bar — ONE PER configured amp (several are
|
||||
// possible: some ops run two SPEs in parallel). Green OPERATE / orange
|
||||
// STANDBY / red offline; click toggles. The Flex state rides along because a
|
||||
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
|
||||
let alive = true;
|
||||
// PGXL: the Flex reports the amp's OPERATE state authoritatively (the direct
|
||||
// GSCP status doesn't carry it), so merge GetFlexState in when a Flex sees
|
||||
// the amp — state AND toggle then go through the radio; the direct TCP link
|
||||
// remains the fallback (fan mode always uses it).
|
||||
const tick = ampCfg.type === 'pgxl'
|
||||
? () => Promise.all([
|
||||
GetPGXLStatus().catch(() => ({ connected: false })),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([pg, fx]: any[]) => {
|
||||
if (!alive) return;
|
||||
const viaFlex = !!fx?.amp_available;
|
||||
setAmpSt({
|
||||
...(pg || {}),
|
||||
connected: !!pg?.connected || viaFlex,
|
||||
operate: viaFlex ? !!fx.amp_operate : !!pg?.operate,
|
||||
via_flex: viaFlex,
|
||||
});
|
||||
})
|
||||
: () => (ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus)()
|
||||
.then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
|
||||
const tick = () => Promise.all([
|
||||
GetAmpStatuses().catch(() => []),
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [ampCfg.enabled, ampCfg.type]);
|
||||
}, []);
|
||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||
@@ -5188,38 +5165,39 @@ export default function App() {
|
||||
disabled={!rotatorHeading.enabled}
|
||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||
/>
|
||||
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
|
||||
CLICK toggles OPERATE ↔ STANDBY on every backend (PGXL included —
|
||||
its direct TCP link takes operate=0/1); optimistic flip, the 2s
|
||||
poll reconciles. Offline → click opens the settings instead. */}
|
||||
{ampCfg.enabled && (() => {
|
||||
const isPGXL = ampCfg.type === 'pgxl';
|
||||
const name = isPGXL ? 'PGXL'
|
||||
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
|
||||
: `SPE ${ampSt.model || ''}`.trim();
|
||||
const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
|
||||
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
|
||||
{/* Amplifier chips — one per configured amp: green = OPERATE, orange =
|
||||
STANDBY, red = offline. CLICK toggles OPERATE ↔ STANDBY (optimistic
|
||||
flip, the 2s poll reconciles); offline → click opens the settings. */}
|
||||
{ampSts.map((a: any) => {
|
||||
const isPGXL = !a.spe && !a.acom;
|
||||
const viaFlex = isPGXL && !!flexAmp?.amp_available;
|
||||
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
|
||||
const connected = !!raw.connected || viaFlex;
|
||||
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
|
||||
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
|
||||
const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
|
||||
const toggle = () => {
|
||||
// Offline — nothing to command; open the settings to fix the link.
|
||||
if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
|
||||
const want = !ampSt.operate;
|
||||
setAmpSt((s: any) => ({ ...s, operate: want }));
|
||||
(isPGXL ? (ampSt.via_flex ? FlexAmpOperate(want) : PGXLSetOperate(want))
|
||||
: ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
|
||||
: SPESetOperate(want)).catch(() => {});
|
||||
if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
|
||||
const want = !operate;
|
||||
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
|
||||
else setAmpSts((l) => l.map((x: any) => x.id === a.id
|
||||
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
|
||||
: x));
|
||||
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
title={`${name}: ${state}${ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
|
||||
title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
|
||||
onClick={toggle}
|
||||
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', dot)} />
|
||||
{name}
|
||||
{a.name}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
})}
|
||||
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||
so it is always shown. Gating it on MySQL made it vanish for
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,7 +145,8 @@ const en: Dict = {
|
||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier',
|
||||
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||
'gen.checkUpdates': 'Check for updates at startup', 'gen.checkUpdatesHint': '(notifies when a newer OpsLog is published)',
|
||||
'email.title': 'E-mail',
|
||||
// Station panel
|
||||
@@ -281,7 +282,7 @@ const en: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||
@@ -456,7 +457,8 @@ const fr: Dict = {
|
||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur',
|
||||
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||
'gen.checkUpdates': 'Vérifier les mises à jour au démarrage', 'gen.checkUpdatesHint': '(prévient quand une version plus récente est publiée)',
|
||||
'email.title': 'E-mail',
|
||||
'station.loading': 'Chargement du profil…',
|
||||
@@ -577,7 +579,7 @@ const fr: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||
|
||||
Reference in New Issue
Block a user