feat: amplifier band-follow — answer the amp's frequency polls

On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and
changes band from the reply, so nothing can be pushed to it. internal/catemu
answers those polls on a second serial port, in ACOM command set 5 (Kenwood /
Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is
worse than none, it desynchronises the amp's parser for the following poll.

Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT
line in parallel; those never hear a responder, so an optional unprompted send
(500/1000 ms) covers them.

The TX frequency is what is sent: in split the amp must be tuned where we
transmit. Frame lengths are pinned by tests — the failure they guard against is
silent and only shows up as an amp that mistunes.

Untested on hardware.
This commit is contained in:
2026-07-27 11:56:30 +02:00
parent 05d64024ef
commit 5394b55bb7
8 changed files with 573 additions and 7 deletions
+61 -1
View File
@@ -627,7 +627,10 @@ function ADIFMonitorPanel() {
}
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
// frequency polls, pretending to be a Kenwood-format transceiver.
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
@@ -2811,6 +2814,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
});
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
}]);
return (
<>
@@ -2927,6 +2931,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
{/* Band-follow, for any amp that takes its band from a transceiver
CAT link (ACOM and SPE both do) never PowerGenius, which is
driven over its network protocol. A SECOND serial port,
separate from the metering one above. */}
{!isPGXL && (
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!amp.freq_out}
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
/>
{t('amp.freqOut')}
</label>
{amp.freq_out && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqPort')}</Label>
<div className="flex items-center gap-2">
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('amp.freqBroadcast')}</Label>
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
</>
)}
</div>
)}
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
{!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground">
+2 -2
View File
@@ -170,7 +170,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',
'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.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',
@@ -563,7 +563,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',
'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.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',
+3
View File
@@ -6,6 +6,7 @@ import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {acom} from '../models';
import {catemu} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
+4
View File
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAmpBandFollowStatus() {
return window['go']['main']['App']['GetAmpBandFollowStatus']();
}
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
+8
View File
@@ -1442,6 +1442,10 @@ export namespace main {
port: number;
com_port: string;
baud: number;
freq_out: boolean;
freq_com_port: string;
freq_baud: number;
freq_broadcast_ms: number;
static createFrom(source: any = {}) {
return new AmpConfig(source);
@@ -1458,6 +1462,10 @@ export namespace main {
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
this.freq_out = source["freq_out"];
this.freq_com_port = source["freq_com_port"];
this.freq_baud = source["freq_baud"];
this.freq_broadcast_ms = source["freq_broadcast_ms"];
}
}
export class AmpStatus {