From d0c6e420d271347b399cab2ec3f36e376a7163fb Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 13 Aug 2026 14:02:34 +0200 Subject: [PATCH] feat(amp): command linked amplifiers together for a combiner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPE CO1-2 combiner is an RF device: it sums two amplifiers and commands nothing. So "combined" operation is really two amplifiers that must be held in the same state, and one left in STANDBY while the other keys means the combiner sees power on a single input. A switch in Settings → Amplifier makes ON, OFF and OPERATE act on every configured amplifier. Offered only with two or more: coupling one amplifier to itself is a switch that cannot do anything. The METERS stay per amplifier, deliberately. Two amps combined are still two amps, and an operator watching for one of them to run away needs to see them apart — a summed bar would hide exactly the fault worth catching. Fanned out in AmpOperate/AmpPower rather than in the UI: the card and the docked widget both call these, and a coupling built into one would be missing from the other, which on a combiner means one amplifier keyed and one not. The clicked amplifier goes first, so a partial failure still did what the operator asked before it stopped. While linked, an amplifier with no power command — a PGXL on its direct link — is skipped silently rather than reported: it would make a successful pair look broken. --- amps_linked_test.go | 36 +++++++++++ app.go | 74 +++++++++++++++++++++++ changelog.json | 8 ++- frontend/src/components/SettingsModal.tsx | 16 ++++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 4 ++ frontend/wailsjs/go/main/App.js | 8 +++ 7 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 amps_linked_test.go diff --git a/amps_linked_test.go b/amps_linked_test.go new file mode 100644 index 0000000..3596a8d --- /dev/null +++ b/amps_linked_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "sort" + "testing" +) + +// A combiner sums two amplifiers and commands nothing, so "combined" operation +// is really two amplifiers that must be kept in the same state. Leaving one in +// OPERATE while the other sits in STANDBY is exactly what must not happen — +// the combiner would then see power on one input only. +func TestAmpTargets(t *testing.T) { + a := &App{} + a.ampInsts = map[string]*ampInst{"left": {}, "right": {}, "third": {}} + + // Unlinked: only the amplifier that was clicked. + if got := a.ampTargets("left", false); len(got) != 1 || got[0] != "left" { + t.Errorf("unlinked = %v, want just [left]", got) + } + + // Linked: every running amplifier, and the one clicked FIRST — a partial + // failure should still have done what the operator asked before it stopped. + + got := a.ampTargets("right", true) + if len(got) != 3 { + t.Fatalf("linked = %v, want all three", got) + } + if got[0] != "right" { + t.Errorf("clicked amplifier is %q, want it first", got[0]) + } + rest := append([]string{}, got[1:]...) + sort.Strings(rest) + if rest[0] != "left" || rest[1] != "third" { + t.Errorf("rest = %v, want the other two", rest) + } +} diff --git a/app.go b/app.go index 6c8d7aa..effcecc 100644 --- a/app.go +++ b/app.go @@ -16226,6 +16226,19 @@ func (a *App) ampInstByID(id string) *ampInst { // AmpOperate puts the given amp in OPERATE (true) or STANDBY (false). func (a *App) AmpOperate(id string, on bool) error { + // Fan out here rather than in the UI: the card and the docked widget both + // call this, and a coupling implemented in one of them would be missing from + // the other — which on a combiner means one amplifier keyed and one not. + var firstErr error + for _, tid := range a.ampTargets(id, a.GetAmpsLinked()) { + if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (a *App) ampOperateOne(id string, on bool) error { inst := a.ampInstByID(id) if inst == nil { return fmt.Errorf("amplifier not running — check Settings → Amplifier") @@ -16250,6 +16263,17 @@ func (a *App) AmpPower(id string, on bool) (err error) { applog.Printf("amp %s: power %v failed: %v", id, on, err) } }() + linked := a.GetAmpsLinked() + var firstErr error + for _, tid := range a.ampTargets(id, linked) { + if e := a.ampPowerOne(tid, on, linked); e != nil && firstErr == nil { + firstErr = e + } + } + return firstErr +} + +func (a *App) ampPowerOne(id string, on, linked bool) error { inst := a.ampInstByID(id) if inst == nil { return fmt.Errorf("amplifier not running — check Settings → Amplifier") @@ -16266,6 +16290,12 @@ func (a *App) AmpPower(id string, on bool) (err error) { } return inst.acom.PowerOff() } + // Not an error worth surfacing when linked: a PGXL alongside two SPEs simply + // has no power command on its direct link, and reporting that as a failure + // would make a successful pair look broken. + if linked { + return nil + } return fmt.Errorf("power on/off is not available for this amplifier") } @@ -17810,3 +17840,47 @@ func (a *App) SetCompactHeight(h int) { wruntime.WindowSetMinSize(a.ctx, 900, h) wruntime.WindowSetSize(a.ctx, w, h) } + +// keyAmpsLinked couples the configured amplifiers so ON, OFF and OPERATE act on +// all of them at once — the SPE CO1-2 combiner case. +// +// The combiner is an RF device: it sums two amplifiers and commands nothing. So +// "combined" operation is really two amplifiers that must be kept in the same +// state, and leaving one in OPERATE while the other sits in STANDBY is exactly +// what must not happen — the combiner would see power on one input only. +const keyAmpsLinked = "amps.linked" + +// GetAmpsLinked reports whether the amplifiers are commanded together. +func (a *App) GetAmpsLinked() bool { return a.settingOr(keyAmpsLinked, "") == "1" } + +// SetAmpsLinked turns coupling on or off. +func (a *App) SetAmpsLinked(on bool) error { + a.setSetting(keyAmpsLinked, boolStr(on)) + return nil +} + +// ampTargets returns the amplifier ids a command should reach: the one asked +// for, or every running amplifier when they are linked. +// +// The METERS are deliberately untouched by this — each amplifier keeps its own +// bars. Two amps combined are still two amps, and an operator watching for one +// of them to run away needs to see them apart. +func (a *App) ampTargets(id string, linked bool) []string { + if !linked { + return []string{id} + } + a.ampsMu.Lock() + defer a.ampsMu.Unlock() + out := make([]string, 0, len(a.ampInsts)) + // The one that was asked for goes FIRST, so a partial failure still did what + // the operator clicked before it stopped. + if _, ok := a.ampInsts[id]; ok { + out = append(out, id) + } + for k := range a.ampInsts { + if k != id { + out = append(out, k) + } + } + return out +} diff --git a/changelog.json b/changelog.json index f9a0de9..152fff7 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,12 @@ { "version": "0.25.1", "date": "", - "en": [], - "fr": [] + "en": [ + "Amplifiers: an option commands them together — ON, OFF and OPERATE act on all at once, for a combiner. Each keeps its own meters." + ], + "fr": [ + "Amplificateurs : une option les commande ensemble — ON, OFF et OPERATE agissent sur tous à la fois, pour un combiner. Chacun garde ses mesures." + ] }, { "version": "0.25.0", diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 95f9ae5..1bbf5e6 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -52,7 +52,7 @@ import { GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetRelayAuto, SaveRelayAuto, GetStationDevices, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, - GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes, + GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetAmpsLinked, SetAmpsLinked, GetSpotTTLMinutes, SetSpotTTLMinutes, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; @@ -1274,6 +1274,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // 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([]); + const [ampsLinked, setAmpsLinked] = useState(false); // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; @@ -1571,6 +1572,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan (async () => { try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ } + try { setAmpsLinked(await GetAmpsLinked()); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } })(); // Poll the feed while the panel is open: a live count is the only thing that @@ -3408,6 +3410,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {amps.length === 0 && (

{t('amp.none')}

)} + {/* Only offered with two or more: coupling one amplifier to itself is + a switch that cannot do anything. */} + {amps.length > 1 && ( + + )} {amps.map((amp, i) => { const brand = brandOf(amp.type); const isPGXL = brand === 'pgxl'; diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index ac65b37..cb3e937 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -190,7 +190,7 @@ 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)', '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', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.', + '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.linked': 'Command these amplifiers together', 'amp.linkedHint': 'ON, OFF and OPERATE act on every amplifier at once — for a combiner, where one amp left in STANDBY would feed power to one input only. Each keeps its own meters.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.', '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)', // Password encryption 'gen.pwEnc': 'Password encryption', @@ -616,7 +616,7 @@ 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)', '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', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.", + '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.linked': 'Commander ces amplificateurs ensemble', 'amp.linkedHint': "ON, OFF et OPERATE agissent sur tous les amplificateurs à la fois — pour un combiner, où un ampli resté en STANDBY n'alimenterait qu'une seule entrée. Chacun garde ses propres mesures.", 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.", '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)', // Chiffrement des mots de passe 'gen.pwEnc': 'Chiffrement des mots de passe', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index c5b83c5..a201422 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -366,6 +366,8 @@ export function GetAmpStatuses():Promise>; export function GetAmplifiers():Promise>; +export function GetAmpsLinked():Promise; + export function GetAntGeniusSettings():Promise; export function GetAntGeniusStatus():Promise; @@ -972,6 +974,8 @@ export function SetActiveRotor(arg1:number):Promise; export function SetAlertEmailTo(arg1:string):Promise; +export function SetAmpsLinked(arg1:boolean):Promise; + export function SetCATFrequency(arg1:number):Promise; export function SetCATMode(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3f322c2..e067fce 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -674,6 +674,10 @@ export function GetAmplifiers() { return window['go']['main']['App']['GetAmplifiers'](); } +export function GetAmpsLinked() { + return window['go']['main']['App']['GetAmpsLinked'](); +} + export function GetAntGeniusSettings() { return window['go']['main']['App']['GetAntGeniusSettings'](); } @@ -1886,6 +1890,10 @@ export function SetAlertEmailTo(arg1) { return window['go']['main']['App']['SetAlertEmailTo'](arg1); } +export function SetAmpsLinked(arg1) { + return window['go']['main']['App']['SetAmpsLinked'](arg1); +} + export function SetCATFrequency(arg1) { return window['go']['main']['App']['SetCATFrequency'](arg1); }