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:
@@ -30,6 +30,7 @@ import (
|
|||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cabrillo"
|
"hamlog/internal/cabrillo"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/catemu"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/contest"
|
"hamlog/internal/contest"
|
||||||
@@ -917,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
a.emitRadioUDP(s)
|
a.emitRadioUDP(s)
|
||||||
|
// Feed the frequency to any amplifier we are pretending to be a radio
|
||||||
|
// for. Just two atomic stores per amp — the reply itself is built when
|
||||||
|
// the amp polls, so a fast-tuning VFO costs nothing here.
|
||||||
|
a.feedAmpBandFollow(s)
|
||||||
// Drive station relays by the current frequency/band (PstRotator-style
|
// Drive station relays by the current frequency/band (PstRotator-style
|
||||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||||
// feature is off; when on, run off this callback so a slow relay board never
|
// feature is off; when on, run off this callback so a slow relay board never
|
||||||
@@ -13343,6 +13348,18 @@ type AmpConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
ComPort string `json:"com_port"`
|
ComPort string `json:"com_port"`
|
||||||
Baud int `json:"baud"`
|
Baud int `json:"baud"`
|
||||||
|
|
||||||
|
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
|
||||||
|
// transceiver and changes band from the reply, so following OpsLog means
|
||||||
|
// answering those polls on a SECOND serial port (independent of the one used
|
||||||
|
// above for metering; both run at once). See internal/catemu.
|
||||||
|
FreqOut bool `json:"freq_out"`
|
||||||
|
FreqComPort string `json:"freq_com_port"`
|
||||||
|
FreqBaud int `json:"freq_baud"`
|
||||||
|
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
|
||||||
|
// for an amplifier that listens to a transceiver's CAT stream instead of
|
||||||
|
// polling it. 0 = answer polls only.
|
||||||
|
FreqBroadcastMs int `json:"freq_broadcast_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ampInst struct {
|
type ampInst struct {
|
||||||
@@ -13350,6 +13367,7 @@ type ampInst struct {
|
|||||||
pgxl *powergenius.Client
|
pgxl *powergenius.Client
|
||||||
spe *spe.Client
|
spe *spe.Client
|
||||||
acom *acom.Client
|
acom *acom.Client
|
||||||
|
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *ampInst) stopAll() {
|
func (i *ampInst) stopAll() {
|
||||||
@@ -13362,6 +13380,9 @@ func (i *ampInst) stopAll() {
|
|||||||
if i.acom != nil {
|
if i.acom != nil {
|
||||||
i.acom.Stop()
|
i.acom.Stop()
|
||||||
}
|
}
|
||||||
|
if i.catemu != nil {
|
||||||
|
i.catemu.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ampTypeLabel is the default display name for an amp type.
|
// ampTypeLabel is the default display name for an amp type.
|
||||||
@@ -13491,12 +13512,61 @@ func (a *App) startAmps() {
|
|||||||
a.spe = inst.spe
|
a.spe = inst.spe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Band-follow on a SECOND serial port, for any amp that takes its band from
|
||||||
|
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
|
||||||
|
// transport above, so metering and band-follow run at the same time.
|
||||||
|
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
|
||||||
|
inst.catemu = catemu.New(catemu.Config{
|
||||||
|
ComPort: c.FreqComPort, Baud: c.FreqBaud,
|
||||||
|
BroadcastMs: c.FreqBroadcastMs,
|
||||||
|
}, applog.Printf)
|
||||||
|
if st := a.cat.State(); st.Connected {
|
||||||
|
inst.catemu.SetFrequency(st.FreqHz)
|
||||||
|
inst.catemu.SetMode(st.Mode)
|
||||||
|
}
|
||||||
|
inst.catemu.Start()
|
||||||
|
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
|
||||||
|
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
|
||||||
|
}
|
||||||
a.ampsMu.Lock()
|
a.ampsMu.Lock()
|
||||||
a.ampInsts[c.ID] = inst
|
a.ampInsts[c.ID] = inst
|
||||||
a.ampsMu.Unlock()
|
a.ampsMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
|
||||||
|
// emulating a transceiver for. Called on each CAT state change.
|
||||||
|
//
|
||||||
|
// The TX frequency is what the amp must follow: in split it has to be tuned
|
||||||
|
// for where we transmit, not where we listen.
|
||||||
|
func (a *App) feedAmpBandFollow(s cat.RigState) {
|
||||||
|
if !s.Connected || s.FreqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for _, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
inst.catemu.SetFrequency(s.FreqHz)
|
||||||
|
inst.catemu.SetMode(s.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
|
||||||
|
// so the settings panel can show whether the amp is actually polling us.
|
||||||
|
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
|
||||||
|
out := map[string]catemu.Status{}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for id, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
out[id] = inst.catemu.GetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
||||||
// per-family payloads is set, per the amp's type.
|
// per-family payloads is set, per the amp's type.
|
||||||
type AmpStatus struct {
|
type AmpStatus struct {
|
||||||
|
|||||||
@@ -627,7 +627,10 @@ function ADIFMonitorPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
// 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
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
// 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, {
|
const addAmp = () => setAmps((l) => [...l, {
|
||||||
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2927,6 +2931,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</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} />}
|
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||||
{!isPGXL && !isACOM && (
|
{!isPGXL && !isACOM && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const en: Dict = {
|
|||||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
'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.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.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)',
|
'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
|
// Password encryption
|
||||||
'gen.pwEnc': '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.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.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.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)',
|
'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
|
// Chiffrement des mots de passe
|
||||||
'gen.pwEnc': 'Chiffrement des mots de passe',
|
'gen.pwEnc': 'Chiffrement des mots de passe',
|
||||||
|
|||||||
Vendored
+3
@@ -6,6 +6,7 @@ import {main} from '../models';
|
|||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {acom} from '../models';
|
import {acom} from '../models';
|
||||||
|
import {catemu} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||||
|
|
||||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
|
|||||||
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus() {
|
||||||
|
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAmpStatuses() {
|
export function GetAmpStatuses() {
|
||||||
return window['go']['main']['App']['GetAmpStatuses']();
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,6 +1442,10 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
|
freq_out: boolean;
|
||||||
|
freq_com_port: string;
|
||||||
|
freq_baud: number;
|
||||||
|
freq_broadcast_ms: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AmpConfig(source);
|
return new AmpConfig(source);
|
||||||
@@ -1458,6 +1462,10 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
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 {
|
export class AmpStatus {
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// Package catemu emulates a transceiver on a serial port so a device that
|
||||||
|
// POLLS a radio for its frequency can follow OpsLog instead.
|
||||||
|
//
|
||||||
|
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
|
||||||
|
// the master — it polls the transceiver every few hundred milliseconds and
|
||||||
|
// changes band only when it gets a valid reply. There is no way to push a
|
||||||
|
// frequency to it, so following OpsLog means answering its polls.
|
||||||
|
//
|
||||||
|
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
|
||||||
|
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
|
||||||
|
// the simplest of the five sets by a wide margin, which is why the SDC utility
|
||||||
|
// uses it to steer an ACOM with no physical radio attached.
|
||||||
|
//
|
||||||
|
// Only the handful of commands an amp actually asks for are implemented:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; TX frequency, 11 digits, Hz
|
||||||
|
// FB; → same (sub VFO — amps poll it on some firmware)
|
||||||
|
// IF; → the 38-character TS-2000 status frame
|
||||||
|
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
|
||||||
|
//
|
||||||
|
// Anything else is ignored rather than answered: a wrong-length reply is worse
|
||||||
|
// than none, because it desynchronises the amp's parser for the next poll.
|
||||||
|
package catemu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
|
||||||
|
// a SECOND port, independent of the one used for the amp's own remote/metering
|
||||||
|
// protocol — both run at the same time on an ACOM.
|
||||||
|
type Config struct {
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
|
||||||
|
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
|
||||||
|
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
|
||||||
|
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
|
||||||
|
// hear us, since answering polls means speaking only when spoken to.
|
||||||
|
// 0 = answer polls only.
|
||||||
|
BroadcastMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the settings panel shows about the link.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
Polls int64 `json:"polls"` // replies sent since start
|
||||||
|
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
|
||||||
|
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
|
||||||
|
FreqHz int64 `json:"freq_hz"` // what we are currently answering
|
||||||
|
Error string `json:"error"` // last open/IO failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server answers a polling amplifier on one serial port.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
status Status
|
||||||
|
|
||||||
|
freqHz atomic.Int64
|
||||||
|
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a server. Nothing is opened until Start.
|
||||||
|
func New(cfg Config, logf func(string, ...any)) *Server {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, logf: logf}
|
||||||
|
s.mode.Store("")
|
||||||
|
s.status.Port = cfg.ComPort
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) log(format string, args ...any) {
|
||||||
|
if s.logf != nil {
|
||||||
|
s.logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency updates the frequency reported to the amplifier. Called from the
|
||||||
|
// CAT state callback; safe from any goroutine and never blocks — the serve loop
|
||||||
|
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
|
||||||
|
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
|
||||||
|
|
||||||
|
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
|
||||||
|
// about the frequency, but a coherent frame avoids odd firmware behaviour.
|
||||||
|
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
|
||||||
|
|
||||||
|
// Start opens the port and serves polls until Stop. It returns immediately;
|
||||||
|
// a port that is missing or busy is retried every 5 s, because the amplifier is
|
||||||
|
// often powered on after the software.
|
||||||
|
func (s *Server) Start() {
|
||||||
|
s.stop = make(chan struct{})
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the port and waits for the loop to end.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
if s.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(s.stop)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
<-s.done
|
||||||
|
s.stop = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns a snapshot for the UI.
|
||||||
|
func (s *Server) GetStatus() Status {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.status
|
||||||
|
st.Enabled = true
|
||||||
|
st.FreqHz = s.freqHz.Load()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setErr(msg string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Error = msg
|
||||||
|
s.status.Connected = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) run() {
|
||||||
|
defer close(s.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := s.open(); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.serve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) open() error {
|
||||||
|
if strings.TrimSpace(s.cfg.ComPort) == "" {
|
||||||
|
return fmt.Errorf("no COM port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
|
||||||
|
BaudRate: s.cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A short read timeout keeps the loop responsive to Stop while idle: the amp
|
||||||
|
// may poll only every few hundred ms, and a blocking read would hold the
|
||||||
|
// port open past shutdown.
|
||||||
|
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.port = p
|
||||||
|
s.status.Connected = true
|
||||||
|
s.status.Error = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast sends an unsolicited FA frame at the configured interval, for an
|
||||||
|
// amplifier that listens to the CAT line rather than polling it. It stops when
|
||||||
|
// the port is closed or Stop is called.
|
||||||
|
func (s *Server) broadcast(stopServe <-chan struct{}) {
|
||||||
|
if s.cfg.BroadcastMs <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Below ~100 ms this is pure noise on the wire; the band only ever changes
|
||||||
|
// at human speed.
|
||||||
|
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
|
||||||
|
if every < 100*time.Millisecond {
|
||||||
|
every = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-stopServe:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
if hz <= 0 {
|
||||||
|
continue // nothing known yet — say nothing rather than "0 Hz"
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve reads commands until the port fails or Stop is called.
|
||||||
|
func (s *Server) serve() {
|
||||||
|
// The broadcaster shares this port and must die with it, or it would write
|
||||||
|
// into a closed handle after a reopen.
|
||||||
|
stopServe := make(chan struct{})
|
||||||
|
defer close(stopServe)
|
||||||
|
go s.broadcast(stopServe)
|
||||||
|
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var acc []byte
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := p.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc = append(acc, buf[:n]...)
|
||||||
|
// Commands are ';'-terminated; handle every complete one in the buffer.
|
||||||
|
for {
|
||||||
|
i := indexByte(acc, ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
|
||||||
|
acc = acc[i+1:]
|
||||||
|
s.handle(p, cmd)
|
||||||
|
}
|
||||||
|
// A runaway buffer means we are seeing something that is not this
|
||||||
|
// protocol (wrong baud, or the amp's other port); drop it rather than
|
||||||
|
// grow without bound.
|
||||||
|
if len(acc) > 512 {
|
||||||
|
acc = acc[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle answers one command. cmd has no trailing ';'.
|
||||||
|
func (s *Server) handle(p serial.Port, cmd string) {
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
var reply string
|
||||||
|
switch {
|
||||||
|
case cmd == "FA" || cmd == "FB":
|
||||||
|
reply = fmt.Sprintf("%s%011d;", cmd, hz)
|
||||||
|
case cmd == "IF":
|
||||||
|
reply = s.ifFrame(hz)
|
||||||
|
case cmd == "ID":
|
||||||
|
reply = "ID019;" // TS-2000
|
||||||
|
default:
|
||||||
|
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
|
||||||
|
// never sets our frequency, and answering the wrong length would break
|
||||||
|
// its parser for the following poll.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(reply)); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Polls++
|
||||||
|
s.status.LastCmd = cmd + ";"
|
||||||
|
s.status.LastAt = time.Now().Format(time.RFC3339)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
|
||||||
|
func (s *Server) modeDigit() byte {
|
||||||
|
m, _ := s.mode.Load().(string)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "CW"):
|
||||||
|
return '3'
|
||||||
|
case strings.HasPrefix(m, "LSB"):
|
||||||
|
return '1'
|
||||||
|
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
|
||||||
|
return '2'
|
||||||
|
case strings.HasPrefix(m, "FM"):
|
||||||
|
return '4'
|
||||||
|
case strings.HasPrefix(m, "AM"):
|
||||||
|
return '5'
|
||||||
|
case m == "RTTY", strings.HasPrefix(m, "FSK"):
|
||||||
|
return '6'
|
||||||
|
case m == "":
|
||||||
|
return '2'
|
||||||
|
default:
|
||||||
|
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ifFrame builds the 38-character TS-2000 IF status frame:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
|
||||||
|
// FR | scan | split | tone | tone#(2) | shift | ;
|
||||||
|
//
|
||||||
|
// Only the frequency and mode carry meaning here; the rest is a valid, inert
|
||||||
|
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
|
||||||
|
func (s *Server) ifFrame(hz int64) string {
|
||||||
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
|
||||||
|
hz, // P1 frequency, Hz
|
||||||
|
0, // P2 frequency step
|
||||||
|
0, // P3 RIT/XIT offset, signed 5 digits
|
||||||
|
0, // P4-P6 RIT off, XIT off, channel-bank
|
||||||
|
0, // P7 memory channel
|
||||||
|
0, // P8 0 = RX
|
||||||
|
s.modeDigit(), // P9 mode
|
||||||
|
0, // P10 VFO A
|
||||||
|
0, // P11 scan off
|
||||||
|
0, // P12 split off
|
||||||
|
0, // P13 tone off
|
||||||
|
0, // P14 tone number
|
||||||
|
0, // P15 shift
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package catemu
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The reply LENGTHS are the contract: an amplifier parses these frames by
|
||||||
|
// fixed offsets, so a frame one character short desynchronises its parser for
|
||||||
|
// every following poll. Pin them.
|
||||||
|
func TestReplyShapes(t *testing.T) {
|
||||||
|
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
|
||||||
|
s.SetFrequency(14025000)
|
||||||
|
s.SetMode("CW")
|
||||||
|
|
||||||
|
if got := s.ifFrame(14025000); len(got) != 38 {
|
||||||
|
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
|
||||||
|
}
|
||||||
|
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
|
||||||
|
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
|
||||||
|
t.Errorf("IF frame frequency field = %q", got[:13])
|
||||||
|
}
|
||||||
|
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
|
||||||
|
t.Errorf("IF frame not terminated by ';': %q", got)
|
||||||
|
}
|
||||||
|
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
|
||||||
|
if got := s.ifFrame(14025000)[29]; got != '3' {
|
||||||
|
t.Errorf("CW mode digit = %q, want '3'", got)
|
||||||
|
}
|
||||||
|
s.SetMode("FT8") // data rides on SSB as far as an amp cares
|
||||||
|
if got := s.ifFrame(14074000)[29]; got != '2' {
|
||||||
|
t.Errorf("FT8 mode digit = %q, want '2'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeDigit(t *testing.T) {
|
||||||
|
cases := map[string]byte{
|
||||||
|
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
|
||||||
|
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
|
||||||
|
}
|
||||||
|
for mode, want := range cases {
|
||||||
|
s := New(Config{}, nil)
|
||||||
|
s.SetMode(mode)
|
||||||
|
if got := s.modeDigit(); got != want {
|
||||||
|
t.Errorf("mode %q → %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user