From b7dd8d4852bc4830849ba71c051c5a729cf6f60c Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 22 Jul 2026 17:25:10 +0200 Subject: [PATCH] feat: multiple amplifiers + SmartSDR v4 DSP filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 372 ++++++++++++++++-- changelog.json | 8 +- frontend/src/App.tsx | 90 ++--- frontend/src/components/FlexPanel.tsx | 126 ++++-- frontend/src/components/SettingsModal.tsx | 334 ++++++++-------- .../src/components/StationControlPanel.tsx | 93 ++--- frontend/src/lib/i18n.tsx | 10 +- frontend/wailsjs/go/main/App.d.ts | 34 ++ frontend/wailsjs/go/main/App.js | 68 ++++ frontend/wailsjs/go/models.ts | 90 +++++ internal/cat/cat.go | 125 +++--- internal/cat/flex.go | 112 +++++- 12 files changed, 1051 insertions(+), 411 deletions(-) diff --git a/app.go b/app.go index 156febe..95cbed6 100644 --- a/app.go +++ b/app.go @@ -469,8 +469,10 @@ type App struct { motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled - spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE - acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM + spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings) + acom *acom.Client // legacy pointer: FIRST enabled ACOM amp + ampsMu sync.Mutex // guards ampInsts + ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping @@ -1073,7 +1075,7 @@ func (a *App) startup(ctx context.Context) { // Antenna Genius switch: connect in the background if enabled. a.startAntGenius() // PowerGenius XL amp fan control: connect in the background if enabled. - a.startPGXL() + a.startAmps() // Autostart: launch the active profile's configured external programs that // aren't already running (WSJT-X, JTAlert, rotator control, …). Background @@ -10895,6 +10897,69 @@ func (a *App) FlexSetWNBLevel(l int) error { } return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) }) } + +// ── SmartSDR v4 DSP (8000/Aurora series): NRL / ANFL / NRS / RNN / ANFT / NRF ── + +func (a *App) FlexSetLMSNR(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNR(on) }) +} +func (a *App) FlexSetLMSNRLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNRLevel(l) }) +} +func (a *App) FlexSetLMSANF(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANF(on) }) +} +func (a *App) FlexSetLMSANFLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANFLevel(l) }) +} +func (a *App) FlexSetSpeexNR(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNR(on) }) +} +func (a *App) FlexSetSpeexNRLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNRLevel(l) }) +} +func (a *App) FlexSetRNN(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRNN(on) }) +} +func (a *App) FlexSetANFT(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFT(on) }) +} +func (a *App) FlexSetNRF(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRF(on) }) +} +func (a *App) FlexSetNRFLevel(l int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRFLevel(l) }) +} func (a *App) FlexSetTXFilter(low, high int) error { if a.cat == nil { return fmt.Errorf("cat not initialized") @@ -12459,53 +12524,288 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error { return err } } - a.startPGXL() + a.startAmps() return nil } -// startPGXL stops any existing client and starts a fresh one if enabled. -func (a *App) startPGXL() { - if a.pgxl != nil { +// ── Multiple amplifiers ──────────────────────────────────────────────── +// Operators can run SEVERAL amps (even two SPEs combined for more power), so +// the config is a LIST. The legacy single-amp keyPGXL* settings migrate into +// entry #1 the first time the list is read. Each enabled entry gets its own +// client; the legacy a.pgxl/a.spe/a.acom fields point at the FIRST enabled amp +// of each family so the pre-multi bindings (and the Flex-panel merge) keep +// working unchanged. + +const keyAmpsList = "amps.json" + +// AmpConfig is one configured amplifier in Settings → Amplifier. +type AmpConfig struct { + ID string `json:"id"` + Name string `json:"name"` // user label, e.g. "SPE left" + Enabled bool `json:"enabled"` + Type string `json:"type"` // "pgxl" | "spe13"|"spe15"|"spe2k" | "acom500"…"acom2020" + Transport string `json:"transport"` // "tcp" | "serial" + Host string `json:"host"` + Port int `json:"port"` + ComPort string `json:"com_port"` + Baud int `json:"baud"` +} + +type ampInst struct { + cfg AmpConfig + pgxl *powergenius.Client + spe *spe.Client + acom *acom.Client +} + +func (i *ampInst) stopAll() { + if i.pgxl != nil { + i.pgxl.Stop() + } + if i.spe != nil { + i.spe.Stop() + } + if i.acom != nil { + i.acom.Stop() + } +} + +// ampTypeLabel is the default display name for an amp type. +func ampTypeLabel(t string) string { + switch { + case t == "" || t == "pgxl": + return "PowerGenius XL" + case strings.HasPrefix(t, "spe"): + return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t] + case strings.HasPrefix(t, "acom"): + return "ACOM " + strings.TrimPrefix(t, "acom") + "S" + } + return t +} + +// GetAmplifiers returns the configured amplifier list. When none was ever +// saved, the legacy single-amp settings (if any) are presented as entry #1 — +// persisted on the next SaveAmplifiers. +func (a *App) GetAmplifiers() ([]AmpConfig, error) { + if a.settings == nil { + return nil, fmt.Errorf("db not initialized") + } + raw, _ := a.settings.Get(a.ctx, keyAmpsList) + if strings.TrimSpace(raw) != "" { + var list []AmpConfig + if err := json.Unmarshal([]byte(raw), &list); err == nil { + return list, nil + } + } + s, err := a.GetPGXLSettings() + if err != nil || (!s.Enabled && strings.TrimSpace(s.Host) == "" && strings.TrimSpace(s.ComPort) == "") { + return []AmpConfig{}, nil // never configured + } + return []AmpConfig{{ + ID: "amp-1", Name: ampTypeLabel(s.Type), Enabled: s.Enabled, + Type: s.Type, Transport: s.Transport, Host: s.Host, Port: s.Port, ComPort: s.ComPort, Baud: s.Baud, + }}, nil +} + +// SaveAmplifiers persists the amplifier list and (re)starts the clients. +func (a *App) SaveAmplifiers(list []AmpConfig) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + for i := range list { + c := &list[i] + if strings.TrimSpace(c.ID) == "" { + c.ID = fmt.Sprintf("amp-%d-%d", time.Now().Unix(), i) + } + if c.Type == "" { + c.Type = "pgxl" + } + if c.Type == "pgxl" || c.Transport != "serial" { + c.Transport = "tcp" + } + if c.Port <= 0 || c.Port > 65535 { + c.Port = 9008 + } + if c.Baud <= 0 { + if strings.HasPrefix(c.Type, "acom") { + c.Baud = 9600 + } else { + c.Baud = 115200 + } + } + if strings.TrimSpace(c.Name) == "" { + c.Name = ampTypeLabel(c.Type) + } + } + b, err := json.Marshal(list) + if err != nil { + return err + } + if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil { + return err + } + a.startAmps() + return nil +} + +// startAmps stops every running amp client and starts one per enabled entry. +func (a *App) startAmps() { + a.ampsMu.Lock() + old := a.ampInsts + a.ampInsts = map[string]*ampInst{} + a.ampsMu.Unlock() + for _, inst := range old { // Stop() can block up to the dial timeout waiting for an in-progress // connect; tear down in the background so saving Settings (this runs on // the Wails RPC goroutine) doesn't freeze the UI. - go a.pgxl.Stop() - a.pgxl = nil + go inst.stopAll() } - if a.spe != nil { - go a.spe.Stop() - a.spe = nil - } - if a.acom != nil { - go a.acom.Stop() - a.acom = nil - } - s, err := a.GetPGXLSettings() - if err != nil || !s.Enabled { + a.pgxl, a.spe, a.acom = nil, nil, nil + list, err := a.GetAmplifiers() + if err != nil { return } - if s.Type == "" || s.Type == "pgxl" { - if strings.TrimSpace(s.Host) == "" { - return + for _, c := range list { + if !c.Enabled { + continue } - a.pgxl = powergenius.New(s.Host, s.Port) - _ = a.pgxl.Start() - return + isPGXL := c.Type == "" || c.Type == "pgxl" + if !isPGXL && c.Transport == "serial" && strings.TrimSpace(c.ComPort) == "" { + continue + } + if (isPGXL || c.Transport == "tcp") && strings.TrimSpace(c.Host) == "" { + continue + } + inst := &Inst{cfg: c} + switch { + case isPGXL: + inst.pgxl = powergenius.New(c.Host, c.Port) + _ = inst.pgxl.Start() + if a.pgxl == nil { + a.pgxl = inst.pgxl + } + case strings.HasPrefix(c.Type, "acom"): + inst.acom = acom.New(acom.Config{Model: strings.TrimPrefix(c.Type, "acom") + "S", Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port}) + _ = inst.acom.Start() + if a.acom == nil { + a.acom = inst.acom + } + default: // spe* + inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port}) + _ = inst.spe.Start() + if a.spe == nil { + a.spe = inst.spe + } + } + a.ampsMu.Lock() + a.ampInsts[c.ID] = inst + a.ampsMu.Unlock() } - // SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge. - if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" { - return +} + +// AmpStatus is one amp's live state for the UI poll — exactly one of the +// per-family payloads is set, per the amp's type. +type AmpStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + PGXL *powergenius.Status `json:"pgxl,omitempty"` + SPE *spe.Status `json:"spe,omitempty"` + ACOM *acom.Status `json:"acom,omitempty"` +} + +// GetAmpStatuses returns the live state of every ENABLED amplifier, in the +// configured order — one poll feeds every amp card and the status-bar chips. +func (a *App) GetAmpStatuses() []AmpStatus { + list, _ := a.GetAmplifiers() + a.ampsMu.Lock() + insts := a.ampInsts + a.ampsMu.Unlock() + out := []AmpStatus{} + for _, c := range list { + if !c.Enabled { + continue + } + st := AmpStatus{ID: c.ID, Name: c.Name, Type: c.Type} + if inst, ok := insts[c.ID]; ok { + switch { + case inst.pgxl != nil: + v := inst.pgxl.GetStatus() + st.PGXL = &v + case inst.spe != nil: + v := inst.spe.GetStatus() + st.SPE = &v + case inst.acom != nil: + v := inst.acom.GetStatus() + st.ACOM = &v + } + } + out = append(out, st) } - if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" { - return + return out +} + +func (a *App) ampInstByID(id string) *ampInst { + a.ampsMu.Lock() + defer a.ampsMu.Unlock() + return a.ampInsts[id] +} + +// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false). +func (a *App) AmpOperate(id string, on bool) error { + inst := a.ampInstByID(id) + if inst == nil { + return fmt.Errorf("amplifier not running — check Settings → Amplifier") } - if model, ok := strings.CutPrefix(s.Type, "acom"); ok { - a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) - _ = a.acom.Start() - return + switch { + case inst.pgxl != nil: + return inst.pgxl.SetOperate(on) + case inst.spe != nil: + return inst.spe.Operate(on) + case inst.acom != nil: + return inst.acom.Operate(on) } - a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) - _ = a.spe.Start() + return fmt.Errorf("amplifier not running") +} + +// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power +// command on its direct link). +func (a *App) AmpPower(id string, on bool) error { + inst := a.ampInstByID(id) + if inst == nil { + return fmt.Errorf("amplifier not running — check Settings → Amplifier") + } + switch { + case inst.spe != nil: + if on { + return inst.spe.PowerOn() + } + return inst.spe.PowerOff() + case inst.acom != nil: + if on { + return inst.acom.PowerOn() + } + return inst.acom.PowerOff() + } + return fmt.Errorf("power on/off is not available for this amplifier") +} + +// AmpPowerLevel selects the output power level — SPE only (L/M/H). +func (a *App) AmpPowerLevel(id, level string) error { + inst := a.ampInstByID(id) + if inst == nil || inst.spe == nil { + return fmt.Errorf("power level is an SPE feature") + } + return inst.spe.SetPowerLevel(level) +} + +// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST). +func (a *App) AmpFanMode(id, mode string) error { + inst := a.ampInstByID(id) + if inst == nil || inst.pgxl == nil { + return fmt.Errorf("fan mode is a PowerGenius feature") + } + return inst.pgxl.SetFanMode(mode) } // GetSPEStatus returns the SPE Expert amplifier state for the UI poll. diff --git a/changelog.json b/changelog.json index abe173b..ace1eeb 100644 --- a/changelog.json +++ b/changelog.json @@ -12,7 +12,9 @@ "Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).", "New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.", "NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).", - "New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot." + "New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.", + "Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.", + "FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series)." ], "fr": [ "La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.", @@ -24,7 +26,9 @@ "Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).", "Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».", "NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).", - "Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct." + "Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.", + "Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.", + "Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora)." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0542f69..a8e3071 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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({ 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([]); + const [flexAmp, setFlexAmp] = useState(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 ( ); - })()} + })} {/* 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 diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index cfcd92f..aa6128e 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -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('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([]); + const [ampSel, setAmpSel] = useState(() => 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({ 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({ 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 ? ( + + ) : null; const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise) => { 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))} /> )} + {/* 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 && ( +
+ change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))} + onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} /> + change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))} + onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} /> + 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 && ( + 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. */} +
+ AI/FFT + + {!isCW && ( + + )} +
+
+ )} {isCW && (
+
+ {ampPicker}
{/* 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 (
@@ -935,6 +978,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number {st.amp_available && !isSPE && !isACOM && (
+ {ampPicker}
- {st.connected && ( + {st.connected && isSPE && (
{st.tx ? 'TX' : 'RX'}
Band {st.band}
@@ -667,35 +675,7 @@ function SPEStatusCard() { {(st.warnings || st.alarms) &&
⚠ {st.warnings} {st.alarms}
}
)} - - ); -} -// 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({ 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 ( -
-
- - {st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`} - {!st.connected && st.last_error && {st.last_error}} - - -
- {st.connected && ( + {st.connected && isACOM && (
{st.state}
Band {st.band || '—'}
@@ -708,6 +688,13 @@ function ACOMStatusCard() { {st.err_text &&
⚠ {st.err_text} ({st.err_code})
}
)} + {st.connected && !isSPE && !isACOM && ( +
+
{st.state || ''}
+
Fan {st.fan_mode || '—'}
+
{st.temperature ? `${Math.round(st.temperature)}°C` : ''}
+
+ )}
); } @@ -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([]); // 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 = { 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) => 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 ( <> - +
- - - {/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was - truncated with 3 equal columns. */} -
-
- - -
-
- - {isPGXL ? ( - // The PowerGenius is the brand's single model — fixed, no choice. - - ) : ( - - )} -
- {/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or - an RS232-to-Ethernet bridge, so they offer both. */} - {!isPGXL && ( -
- - -
- )} -
- - {isSerial ? ( -
-
- + {amps.length === 0 && ( +

{t('amp.none')}

+ )} + {amps.map((amp, i) => { + const brand = brandOf(amp.type); + const isPGXL = brand === 'pgxl'; + const isACOM = brand === 'acom'; + const isSerial = !isPGXL && amp.transport === 'serial'; + return ( +
- -
-
-
- - setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" /> -
-
- ) : ( -
-
- - setPgxl((s) => ({ ...s, host: e.target.value }))} - placeholder="192.168.1.70" className="font-mono" /> -
-
- - setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" /> -
-
- )} - {!isPGXL && pgxl.enabled && (isACOM ? : )} - {!isPGXL && !isACOM && ( -

- 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. -

- )} - {isACOM && ( -

- 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. -

- )} + {/* Connection gets the widest column — "Network (RS232-to-Ethernet)" + was truncated with 3 equal columns. */} +
+
+ + +
+
+ + {isPGXL ? ( + // The PowerGenius is the brand's single model — fixed, no choice. + + ) : ( + + )} +
+ {/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial + or an RS232-to-Ethernet bridge, so they offer both. */} + {!isPGXL && ( +
+ + +
+ )} +
+ + {isSerial ? ( +
+
+ +
+ + +
+
+
+ + patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" /> +
+
+ ) : ( +
+
+ + patchAmp(i, { host: e.target.value })} + placeholder="192.168.1.70" className="font-mono" /> +
+
+ + patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" /> +
+
+ )} + + {amp.enabled && amp.id && } + {!isPGXL && !isACOM && ( +

+ 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. +

+ )} + {isACOM && ( +

+ 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. +

+ )} +
+ ); + })} +
); diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index f2e1656..17a8f5d 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -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({ 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([]); + const [sel, setSel] = useState(() => localStorage.getItem('opslog.ampSel.station') || ''); + const [flex, setFlex] = useState(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)[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
{t('flxp.amplifier')}
-
{title}
+ {list.length <= 1 &&
{amp.name}
}
+ {list.length > 1 && ( + + )} @@ -301,14 +306,14 @@ function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: a {isPGXL ? (
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
{!isACOM && (
{(['L', 'M', 'H'] as const).map((lvl, i) => (