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); }