diff --git a/amps_linked_test.go b/amps_linked_test.go index 3596a8d..f34e69c 100644 --- a/amps_linked_test.go +++ b/amps_linked_test.go @@ -1,36 +1,63 @@ package main -import ( - "sort" - "testing" -) +import "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) { +// The coupling is a SET, not a global switch. +// +// A station can run a combiner pair AND a third amplifier that has nothing to +// do with it — two SPE on the combiner, a PowerGenius on another antenna. A +// global flag would send that third one into OPERATE alongside them. +func TestAmpTargetsFollowTheGroup(t *testing.T) { a := &App{} - a.ampInsts = map[string]*ampInst{"left": {}, "right": {}, "third": {}} + a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}} + group := []string{"spe1", "spe2"} - // 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) + // A member commands the whole group, itself first. + got := a.ampTargets("spe2", group) + if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" { + t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got) } - // Linked: every running amplifier, and the one clicked FIRST — a partial - // failure should still have done what the operator asked before it stopped. + // The amplifier OUTSIDE the group keeps its buttons to itself. + if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" { + t.Errorf("outsider = %v, want just [pgxl]", got) + } - got := a.ampTargets("right", true) - if len(got) != 3 { - t.Fatalf("linked = %v, want all three", got) + // No group at all: everyone is on their own. + if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" { + t.Errorf("no group = %v, want just [spe1]", got) } - if got[0] != "right" { - t.Errorf("clicked amplifier is %q, want it first", got[0]) + + // A group remembering an amplifier that is gone must not carry it: it would + // fail the command for a member that no longer exists. + if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" { + t.Errorf("stale member = %v, want it dropped", got) } - rest := append([]string{}, got[1:]...) - sort.Strings(rest) - if rest[0] != "left" || rest[1] != "third" { - t.Errorf("rest = %v, want the other two", rest) +} + +// One amplifier coupled to itself is not a group — storing it would make every +// command fan out to a single member for ever, which is just noise. +func TestLinkedAmpsNeedsTwo(t *testing.T) { + for _, tc := range []struct { + in []string + want int + }{ + {[]string{"a", "b"}, 2}, + {[]string{"a"}, 0}, + {[]string{" ", "a"}, 0}, // blanks are not members + {nil, 0}, + } { + clean := make([]string, 0, len(tc.in)) + for _, id := range tc.in { + if id != "" && id != " " { + clean = append(clean, id) + } + } + if len(clean) < 2 { + clean = nil + } + if len(clean) != tc.want { + t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want) + } } } diff --git a/app.go b/app.go index effcecc..b93f283 100644 --- a/app.go +++ b/app.go @@ -16230,7 +16230,7 @@ func (a *App) AmpOperate(id string, on bool) error { // 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()) { + for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) { if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil { firstErr = err } @@ -16263,10 +16263,11 @@ func (a *App) AmpPower(id string, on bool) (err error) { applog.Printf("amp %s: power %v failed: %v", id, on, err) } }() - linked := a.GetAmpsLinked() + linked := a.GetLinkedAmps() + targets := a.ampTargets(id, linked) var firstErr error - for _, tid := range a.ampTargets(id, linked) { - if e := a.ampPowerOne(tid, on, linked); e != nil && firstErr == nil { + for _, tid := range targets { + if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil { firstErr = e } } @@ -17850,12 +17851,36 @@ func (a *App) SetCompactHeight(h int) { // 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" } +// GetLinkedAmps returns the ids of the amplifiers commanded together. +// +// A SET, not a global flag. A station can have a combiner pair AND a third +// amplifier that has nothing to do with it — two SPE on the combiner and a +// PowerGenius on another antenna — and a global switch would send that third +// one into OPERATE alongside them. +func (a *App) GetLinkedAmps() []string { + out := []string{} + for _, id := range strings.Split(a.settingOr(keyAmpsLinked, ""), ",") { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + return out +} -// SetAmpsLinked turns coupling on or off. -func (a *App) SetAmpsLinked(on bool) error { - a.setSetting(keyAmpsLinked, boolStr(on)) +// SetLinkedAmps records which amplifiers are coupled. +func (a *App) SetLinkedAmps(ids []string) error { + clean := make([]string, 0, len(ids)) + for _, id := range ids { + if id = strings.TrimSpace(id); id != "" { + clean = append(clean, id) + } + } + // One amplifier coupled to itself is not a group; store nothing rather than + // a set that would make ampTargets fan out to a single member for ever. + if len(clean) < 2 { + clean = nil + } + a.setSetting(keyAmpsLinked, strings.Join(clean, ",")) return nil } @@ -17865,21 +17890,33 @@ func (a *App) SetAmpsLinked(on bool) error { // 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 { +func (a *App) ampTargets(id string, linked []string) []string { + inGroup := false + for _, l := range linked { + if l == id { + inGroup = true + break + } + } + // An amplifier outside the group keeps its own buttons to itself — that is + // the whole point of the group being a set. + if !inGroup { return []string{id} } a.ampsMu.Lock() defer a.ampsMu.Unlock() - out := make([]string, 0, len(a.ampInsts)) + out := make([]string, 0, len(linked)) // 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) + out = append(out, id) + for _, l := range linked { + if l == id { + continue + } + // Only amplifiers actually running: a group remembering one that has been + // deleted or switched off must not fail the whole command for it. + if _, ok := a.ampInsts[l]; ok { + out = append(out, l) } } return out diff --git a/changelog.json b/changelog.json index 152fff7..3911ada 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,10 @@ "version": "0.25.1", "date": "", "en": [ - "Amplifiers: an option commands them together — ON, OFF and OPERATE act on all at once, for a combiner. Each keeps its own meters." + "Amplifiers: tick the ones sharing a combiner and ON, OFF and OPERATE act on all of them at once. 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." + "Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c2e521a..97c5350 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, GetAmpsLinked, SetAmpsLinked, GetSpotTTLMinutes, SetSpotTTLMinutes, + GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, 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,7 +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); + const [linkedAmps, setLinkedAmps] = useState([]); // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; @@ -1572,7 +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 { setLinkedAmps((await GetLinkedAmps()) ?? []); } 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 @@ -3604,20 +3604,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('amp.add')} - {/* Coupling belongs to the SET of amplifiers, not to any one of them, - so it sits with the other set-level control rather than above the - first card — where three tall cards push it out of view and it has - to be hunted for. Shown from two amplifiers up: coupling one to - itself is a switch that cannot do anything. */} + {/* WHICH amplifiers are coupled, not whether coupling is on. + A station can run a combiner pair AND a third amplifier that has + nothing to do with it — two SPE on the combiner, a PowerGenius on + another antenna — so a single switch would send that third one into + OPERATE alongside them. Shown from two amplifiers up. */} {amps.length > 1 && ( - +
+
{t('amp.linked')}
+

{t('amp.linkedHint')}

+
+ {amps.filter((x) => (x.id ?? '') !== '').map((x) => { + const on = linkedAmps.includes(x.id!); + return ( + + ); + })} +
+ {/* An amplifier saved without an id cannot be a member — say so + rather than silently leaving it out of the list. */} + {amps.some((x) => (x.id ?? '') === '') && ( +

{t('amp.linkedSaveFirst')}

+ )} +
)} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index cb3e937..2d73cb9 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.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.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': 'Amplifiers commanded together', 'amp.linkedHint': 'Tick the ones sharing a combiner: ON, OFF and OPERATE will act on all of them at once, since one left in STANDBY would feed power to a single input. Any amplifier left unticked keeps its own buttons. Each keeps its own meters.', 'amp.linkedSaveFirst': 'Save first — an amplifier needs an id before it can join a group.', '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.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.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': 'Amplificateurs commandés ensemble', 'amp.linkedHint': "Coche ceux qui partagent un combiner : ON, OFF et OPERATE agiront sur tous à la fois, puisqu'un ampli resté en STANDBY n'alimenterait qu'une seule entrée. Un amplificateur non coché garde ses propres boutons. Chacun garde ses mesures.", 'amp.linkedSaveFirst': "Enregistre d'abord — un amplificateur a besoin d'un identifiant pour rejoindre un groupe.", '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 a201422..42aa295 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -366,8 +366,6 @@ export function GetAmpStatuses():Promise>; export function GetAmplifiers():Promise>; -export function GetAmpsLinked():Promise; - export function GetAntGeniusSettings():Promise; export function GetAntGeniusStatus():Promise; @@ -448,6 +446,8 @@ export function GetGridCacheStatus():Promise; export function GetIcomState():Promise; +export function GetLinkedAmps():Promise>; + export function GetListsSettings():Promise; export function GetLiveOpenings():Promise>; @@ -974,8 +974,6 @@ 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; @@ -1000,6 +998,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise; export function SetKenwoodKeySpeed(arg1:number):Promise; +export function SetLinkedAmps(arg1:Array):Promise; + export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise; export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e067fce..91d99bd 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -674,10 +674,6 @@ 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'](); } @@ -838,6 +834,10 @@ export function GetIcomState() { return window['go']['main']['App']['GetIcomState'](); } +export function GetLinkedAmps() { + return window['go']['main']['App']['GetLinkedAmps'](); +} + export function GetListsSettings() { return window['go']['main']['App']['GetListsSettings'](); } @@ -1890,10 +1890,6 @@ 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); } @@ -1942,6 +1938,10 @@ export function SetKenwoodKeySpeed(arg1) { return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1); } +export function SetLinkedAmps(arg1) { + return window['go']['main']['App']['SetLinkedAmps'](arg1); +} + export function SetMotorFollow(arg1, arg2, arg3) { return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3); }