fix(amp): couple a chosen GROUP, not every amplifier

The first version was a single switch meaning "command them all", and that is
wrong the moment a station has three: two SPE on a combiner and a PowerGenius
on another antenna would all go into OPERATE together, keying an amplifier that
has nothing to do with the pair.

It is now a set. Each amplifier is ticked into the group or not, the group is
stored as a list of ids, and an amplifier outside it keeps its own buttons —
which is the entire point of it being a set.

A group of fewer than two members is stored as none: one amplifier coupled to
itself would make every command fan out to a single member for ever.

A remembered member that is no longer running is skipped rather than failing
the command — deleting one amplifier must not break the button on the other.

An amplifier saved without an id cannot join, and the panel says so instead of
quietly omitting it from the list.
This commit is contained in:
2026-08-13 15:24:43 +02:00
parent 345be94c65
commit fabd1becce
7 changed files with 154 additions and 74 deletions
+32 -16
View File
@@ -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<AmpUI[]>([]);
const [ampsLinked, setAmpsLinked] = useState(false);
const [linkedAmps, setLinkedAmps] = useState<string[]>([]);
// 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
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button>
{/* 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 && (
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={ampsLinked} className="mt-0.5"
onCheckedChange={(c) => { setAmpsLinked(!!c); SetAmpsLinked(!!c).catch(() => {}); }} />
<span>
{t('amp.linked')}
<span className="block text-xs text-muted-foreground mt-0.5">{t('amp.linkedHint')}</span>
</span>
</label>
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm">{t('amp.linked')}</div>
<p className="text-xs text-muted-foreground">{t('amp.linkedHint')}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{amps.filter((x) => (x.id ?? '') !== '').map((x) => {
const on = linkedAmps.includes(x.id!);
return (
<label key={x.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
<Checkbox checked={on} onCheckedChange={(c) => {
const next = c ? [...linkedAmps, x.id!] : linkedAmps.filter((v) => v !== x.id);
setLinkedAmps(next);
SetLinkedAmps(next).catch(() => {});
}} />
{x.name?.trim() || x.type}
</label>
);
})}
</div>
{/* 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 ?? '') === '') && (
<p className="text-xs text-muted-foreground">{t('amp.linkedSaveFirst')}</p>
)}
</div>
)}
</div>
+2 -2
View File
@@ -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',
+4 -4
View File
@@ -366,8 +366,6 @@ export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
export function GetAmpsLinked():Promise<boolean>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -448,6 +446,8 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetLinkedAmps():Promise<Array<string>>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
@@ -974,8 +974,6 @@ export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetAmpsLinked(arg1:boolean):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
@@ -1000,6 +998,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
+8 -8
View File
@@ -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);
}