feat: share the CAT link with other programs (Hamlib NET rigctl server)

A native CAT backend owns the rig's serial port, and Windows gives a COM port to
one process — so choosing native CAT locked WSJT-X, MSHV and JTDX out of the
radio entirely. That is the cost of dropping OmniRig, which was itself a sharing
layer, and it has to be paid back.

OpsLog now becomes the server, as wfview does. It speaks the Hamlib net rigctl
protocol, which every one of those programs supports natively (rig model "Hamlib
NET rigctl", 127.0.0.1:4532) with no driver to install. It sits in front of the
MANAGER, not a backend, so an operator on OmniRig, Flex, Icom or TCI gets the
same server.

Two details that decide whether a client works at all rather than degrading:
dump_state is parsed positionally and WSJT-X refuses to proceed without a
well-formed block, so it is written out in full and its shape is pinned by a
test; and set_vfo / set_split_vfo answer RPRT 0 rather than an error, because
OpsLog follows the rig's own VFO and a refusal makes WSJT-X abandon the
connection. Unknown commands answer RPRT -11 — never silence, which hangs a
client instead.

The whole protocol is tested against a fake rig, plus one end-to-end exchange
over a real socket, since the framing is as much the contract as the text.

Also: the Yaesu backend is confirmed working on a real FTDX10 (frequency, mode,
VFO, split), so its "not yet verified" note is now wrong and is corrected.
This commit is contained in:
2026-07-29 10:49:14 +02:00
parent 67005a8d50
commit 38b480a985
8 changed files with 717 additions and 7 deletions
+83 -1
View File
@@ -51,6 +51,7 @@ import (
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/relaydev"
"hamlog/internal/rigctld"
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
@@ -108,6 +109,8 @@ const (
keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
@@ -359,6 +362,8 @@ type CATSettings struct {
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
SharePort int `json:"share_port"` // TCP port for it (default 4532)
}
// ModePreset is a mode entry with default RST values to auto-populate
@@ -474,6 +479,11 @@ type App struct {
lookup *lookup.Manager
cache *lookup.Cache
cat *cat.Manager
// catShare serves OpsLog's CAT link to other programs over the Hamlib NET
// rigctl protocol. It exists because a native backend OWNS the rig's serial
// port: without it, choosing native CAT locks WSJT-X and friends out of the
// radio entirely. nil when the operator has not enabled sharing.
catShare *rigctld.Server
dxcc *dxcc.Manager
cluster *cluster.Manager
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
@@ -1431,6 +1441,12 @@ func (a *App) shutdown(ctx context.Context) {
// backend: without this the rig never gets a disconnect and holds its single
// control session for minutes, refusing every new login (even from the Icom
// Remote Utility) until it times out on its own.
if a.catShare != nil {
// Before the CAT stop, so no client is mid-command against a backend that
// is disconnecting — and so the port is free for the next launch.
a.catShare.Stop()
a.catShare = nil
}
if a.cat != nil {
a.cat.Stop()
}
@@ -6566,7 +6582,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil {
return CATSettings{}, err
}
@@ -6594,6 +6610,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
ShareEnabled: m[keyCATShareEnabled] == "1",
SharePort: 4532,
}
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
@@ -6604,6 +6622,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
out.SharePort = n
}
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
out.YaesuBaud = n
}
@@ -6648,6 +6669,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexPort <= 0 || s.FlexPort > 65535 {
s.FlexPort = 4992
}
if s.SharePort <= 0 || s.SharePort > 65535 {
s.SharePort = 4532
}
if s.YaesuBaud <= 0 {
s.YaesuBaud = 38400
}
@@ -6689,6 +6713,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.DigitalDefault == "" {
s.DigitalDefault = "FT8"
}
shareEnabled := "0"
if s.ShareEnabled {
shareEnabled = "1"
}
for k, v := range map[string]string{
keyCATEnabled: enabled,
keyCATBackend: s.Backend,
@@ -6714,6 +6742,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
keyCATShareEnabled: shareEnabled,
keyCATSharePort: strconv.Itoa(s.SharePort),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -12051,6 +12081,7 @@ func (a *App) reloadCAT() {
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs
a.reloadCATShare(s)
if !s.Enabled {
a.cat.Stop()
return
@@ -14776,3 +14807,54 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
return out
}
// ── CAT sharing (Hamlib NET rigctl server) ────────────────────────────────
// catShareRig adapts the CAT manager to what the rigctld server needs. A thin
// interface rather than a direct dependency, so the protocol stays testable
// without a radio — and so sharing works with EVERY backend, not just the
// native ones: an operator on OmniRig or Flex gets the same server.
type catShareRig struct{ a *App }
func (r catShareRig) Freq() int64 { return r.a.cat.State().FreqHz }
func (r catShareRig) Mode() string { return r.a.cat.State().Mode }
// Split reports the flag and the OTHER VFO's frequency. RigState follows ADIF —
// FreqHz is where we TRANSMIT and RxFreqHz where we listen — while a rigctl
// client asks for the split TX frequency, which is FreqHz. Getting this pair
// backwards would make a client transmit on the listening frequency.
func (r catShareRig) Split() (bool, int64) {
st := r.a.cat.State()
if !st.Split {
return false, 0
}
return true, st.FreqHz
}
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
// reloadCATShare starts, stops or restarts the sharing server to match the
// settings. Called from reloadCAT so one "Save & Close" settles both.
func (a *App) reloadCATShare(s CATSettings) {
want := s.Enabled && s.ShareEnabled
// Always tear down first: the port may have changed, and a listener bound to
// the old one would keep answering while the client is told to use the new.
if a.catShare != nil {
a.catShare.Stop()
a.catShare = nil
}
if !want {
return
}
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
if err := srv.Start(); err != nil {
// The usual cause is another rigctld — or a previous OpsLog — already on
// the port. Logged rather than surfaced: CAT itself is unaffected, and the
// operator finds it in the log the moment a client fails to connect.
applog.Printf("cat share: %v", err)
return
}
a.catShare = srv
}
+2
View File
@@ -3,6 +3,7 @@
"version": "0.21.9",
"date": "2026-07-28",
"en": [
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend.",
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
@@ -13,6 +14,7 @@
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
],
"fr": [
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends.",
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
+23 -1
View File
@@ -1064,7 +1064,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
yaesu_port: '', yaesu_baud: 38400,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
digital_default: 'FT8', share_enabled: false, share_port: 4532,
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
@@ -2590,6 +2590,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
</div>
</div>
{/* CAT sharing. A native backend owns the rig's serial port, so without
this WSJT-X and friends are locked out of the radio entirely. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!catCfg.share_enabled}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, share_enabled: !!c }))}
/>
{t('cat.share')}
</label>
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
{catCfg.share_enabled && (
<div className="space-y-1 max-w-[200px]">
<Label>{t('cat.sharePort')}</Label>
<PortInput
value={catCfg.share_port || 4532}
fallback={4532}
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
/>
</div>
)}
</div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
+2 -2
View File
@@ -277,7 +277,7 @@ const en: Dict = {
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optYaesu': 'Yaesu (native CAT)', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optYaesu': 'Yaesu (native CAT)', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -679,7 +679,7 @@ const fr: Dict = {
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optYaesu': 'Yaesu (CAT natif)', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optYaesu': 'Yaesu (CAT natif)', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
+4
View File
@@ -1850,6 +1850,8 @@ export namespace main {
poll_ms: number;
delay_ms: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
static createFrom(source: any = {}) {
return new CATSettings(source);
@@ -1881,6 +1883,8 @@ export namespace main {
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
}
}
export class CabrilloResult {
+4 -3
View File
@@ -30,9 +30,10 @@ package cat
// remembered. Every unrecognised reply is logged raw, because that log is the
// only way to learn a model's real behaviour from an operator's shack.
//
// Verified on: nothing yet — written from the FTDX10/FTDX101 CAT reference and
// awaiting a first on-air run. Anything this file asserts about a rig it has not
// met should be read as a hypothesis with a log line attached.
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
// against the radio. The other models are still inference from the same CAT
// reference; anything this file asserts about a rig it has not met should be
// read as a hypothesis with a log line attached.
import (
"fmt"
+398
View File
@@ -0,0 +1,398 @@
// Package rigctld shares OpsLog's CAT link with other programs.
//
// A native CAT backend owns the rig's serial port, and Windows gives a COM port
// to ONE process. So the moment OpsLog talks to the radio directly, WSJT-X,
// MSHV or JTDX can no longer reach it — the cost of dropping OmniRig, which was
// itself a sharing layer.
//
// The answer is the one wfview uses: OpsLog becomes the server. It speaks the
// Hamlib "net rigctl" protocol, which WSJT-X, JTDX, MSHV, Log4OM and CQRLOG all
// support natively (rig model "Hamlib NET rigctl", host:4532) with no driver to
// install. The other program asks us, and we relay to whichever backend is
// connected — OmniRig, Flex, Icom, TCI or Yaesu alike.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Line-based ASCII. A lowercase letter reads, its uppercase counterpart writes,
// and long names are prefixed with a backslash. A write answers "RPRT 0" for
// success or "RPRT -n" for an error; a read answers the value(s), one per line.
//
// f → 14074000 get_freq
// F 14074000 → RPRT 0 set_freq
// m → USB\n2400 get_mode (mode + passband)
// M USB 2400 → RPRT 0 set_mode
// t / T 1 → 0 get/set PTT
// s → 0\nVFOB get_split_vfo
// v → VFOA get_vfo
// \dump_state → capability block asked once by WSJT-X at connect
//
// WSJT-X will not proceed past connect without a well-formed dump_state, which
// is why that block is written out in full rather than stubbed.
package rigctld
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
)
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
// package stays testable without a radio and without importing internal/cat.
type Rig interface {
Freq() int64 // current TX frequency in Hz, 0 if unknown
Mode() string // ADIF mode (SSB, CW, FT8…)
Split() (bool, int64) // split on?, and the other VFO's frequency
SetFreq(hz int64) error
SetMode(mode string) error
SetPTT(on bool) error
}
type Server struct {
port int
rig Rig
log func(string, ...any)
mu sync.Mutex
ln net.Listener
conns map[net.Conn]struct{}
closed bool
}
func New(port int, rig Rig, logf func(string, ...any)) *Server {
if port <= 0 || port > 65535 {
port = 4532 // the rigctld default every client pre-fills
}
if logf == nil {
logf = func(string, ...any) {}
}
return &Server{port: port, rig: rig, log: logf, conns: map[net.Conn]struct{}{}}
}
func (s *Server) Start() error {
s.mu.Lock()
if s.ln != nil {
s.mu.Unlock()
return nil // already listening
}
s.closed = false
s.mu.Unlock()
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if err != nil {
return fmt.Errorf("rigctld: listen on %d: %w", s.port, err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
s.log("rigctld: sharing CAT on port %d (Hamlib NET rigctl)", s.port)
go func() {
for {
c, err := ln.Accept()
if err != nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if !closed {
s.log("rigctld: accept failed: %v", err)
}
return
}
s.mu.Lock()
s.conns[c] = struct{}{}
s.mu.Unlock()
go s.serve(c)
}
}()
return nil
}
func (s *Server) Stop() {
s.mu.Lock()
s.closed = true
ln := s.ln
s.ln = nil
conns := make([]net.Conn, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.conns = map[net.Conn]struct{}{}
s.mu.Unlock()
if ln != nil {
_ = ln.Close()
}
// Close the live sessions too. Leaving them open would keep a client happily
// talking to a server the operator has switched off.
for _, c := range conns {
_ = c.Close()
}
}
func (s *Server) serve(c net.Conn) {
defer func() {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
_ = c.Close()
}()
s.log("rigctld: client connected from %s", c.RemoteAddr())
r := bufio.NewReader(c)
w := bufio.NewWriter(c)
for {
// No deadline: WSJT-X polls every few seconds but a client may legitimately
// sit idle between band changes, and dropping it would look like a fault.
line, err := r.ReadString('\n')
if err != nil {
s.log("rigctld: client %s disconnected", c.RemoteAddr())
return
}
resp, quit := s.handle(strings.TrimSpace(line))
if resp != "" {
if _, err := w.WriteString(resp); err != nil {
return
}
if err := w.Flush(); err != nil {
return
}
}
if quit {
return
}
}
}
// handle answers one command line. Pure apart from the Rig calls, so the whole
// protocol is testable with a fake rig.
func (s *Server) handle(line string) (resp string, quit bool) {
if line == "" {
return "", false
}
// Extended mode: clients may prefix a command with '+' or '-' to ask for a
// verbose reply. We answer in the plain format, which every client also
// accepts, so the prefix is simply stripped.
line = strings.TrimLeft(line, "+-")
fields := strings.Fields(line)
if len(fields) == 0 {
return "", false
}
cmd, args := fields[0], fields[1:]
switch cmd {
case "\\dump_state", "dump_state":
return dumpState, false
case "\\chk_vfo", "chk_vfo":
// "is VFO mode on?" — we answer for one VFO at a time, so: no.
return "CHKVFO 0\n", false
case "\\get_powerstat", "get_powerstat":
return "1\n", false
case "q", "Q", "\\quit":
return "", true
case "f", "\\get_freq":
return fmt.Sprintf("%d\n", s.rig.Freq()), false
case "F", "\\set_freq":
if len(args) < 1 {
return rprt(-1), false
}
hz, err := parseFreq(args[0])
if err != nil {
return rprt(-1), false
}
if err := s.rig.SetFreq(hz); err != nil {
s.log("rigctld: set_freq %d failed: %v", hz, err)
return rprt(-9), false
}
return rprt(0), false
case "m", "\\get_mode":
// Passband width is required by the protocol. We do not read the rig's
// filter, and a made-up number is harmless here: clients use it to display
// a bandwidth, never to decide anything.
return fmt.Sprintf("%s\n%d\n", adifToHamlib(s.rig.Mode()), passbandFor(s.rig.Mode())), false
case "M", "\\set_mode":
if len(args) < 1 {
return rprt(-1), false
}
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
s.log("rigctld: set_mode %q failed: %v", args[0], err)
return rprt(-9), false
}
return rprt(0), false
case "t", "\\get_ptt":
// We do not read PTT back from every backend, and answering "transmitting"
// wrongly would make a client hold off for ever. Reporting RX is the safe
// direction: the worst case is a client that transmits when we said it
// could, which is what it was going to do anyway.
return "0\n", false
case "T", "\\set_ptt":
if len(args) < 1 {
return rprt(-1), false
}
on := args[0] != "0"
if err := s.rig.SetPTT(on); err != nil {
s.log("rigctld: set_ptt %v failed: %v", on, err)
return rprt(-9), false
}
return rprt(0), false
case "v", "\\get_vfo":
return "VFOA\n", false
case "V", "\\set_vfo":
// Accepted and ignored: OpsLog follows the rig's own VFO selection, and
// answering an error here makes WSJT-X abandon the connection entirely.
return rprt(0), false
case "s", "\\get_split_vfo":
on, _ := s.rig.Split()
n := 0
if on {
n = 1
}
return fmt.Sprintf("%d\nVFOB\n", n), false
case "S", "\\set_split_vfo":
return rprt(0), false // see set_vfo — split is driven from the rig
case "i", "\\get_split_freq":
_, tx := s.rig.Split()
if tx <= 0 {
tx = s.rig.Freq()
}
return fmt.Sprintf("%d\n", tx), false
case "I", "\\set_split_freq":
return rprt(0), false
default:
// RPRT -11 is "command not implemented". Answering something is essential:
// a client waiting on a silent socket hangs rather than degrading.
s.log("rigctld: unimplemented command %q", line)
return rprt(-11), false
}
}
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
// parseFreq accepts both the integer Hz and the "14074000.000000" form clients
// send interchangeably.
func parseFreq(s string) (int64, error) {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '.'); i >= 0 {
s = s[:i]
}
hz, err := strconv.ParseInt(s, 10, 64)
if err != nil || hz <= 0 {
return 0, fmt.Errorf("rigctld: bad frequency %q", s)
}
return hz, nil
}
// adifToHamlib maps our mode vocabulary to Hamlib's. Every digital sub-mode
// becomes PKTUSB: that is what a client expects to see when the rig is in DATA,
// and it is what WSJT-X sets when it takes control.
func adifToHamlib(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB":
return "USB"
case "LSB":
return "LSB"
case "CW":
return "CW"
case "AM":
return "AM"
case "FM":
return "FM"
case "RTTY":
return "RTTY"
case "":
return "USB"
default:
return "PKTUSB"
}
}
// hamlibToADIF is the reverse. PKTUSB/PKTLSB/DATA become "DATA": the CAT backend
// then applies the operator's configured digital mode, so a client switching the
// rig to data does not silently relabel their QSOs as FT8 when they run JS8.
func hamlibToADIF(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "USB":
return "USB"
case "LSB":
return "LSB"
case "CW", "CWR":
return "CW"
case "AM":
return "AM"
case "FM", "FMN", "WFM":
return "FM"
case "RTTY", "RTTYR":
return "RTTY"
case "PKTUSB", "PKTLSB", "PKTFM", "DATA", "DIGU", "DIGL":
return "DATA"
default:
return strings.ToUpper(strings.TrimSpace(mode))
}
}
func passbandFor(mode string) int {
switch adifToHamlib(mode) {
case "CW":
return 500
case "RTTY", "PKTUSB":
return 3000
case "AM":
return 6000
case "FM":
return 15000
default:
return 2400
}
}
// dumpState is the capability block Hamlib clients read once at connect. WSJT-X
// refuses to go further without it, and parses it positionally — the field
// ORDER is the contract, so this is kept as one literal rather than assembled.
//
// It declares protocol version 0, a generic rig, and one 150 kHz1500 MHz range
// with the common modes. The numbers are deliberately permissive: they say what
// a client may ASK for, and OpsLog's backend refuses anything the radio cannot
// really do.
const dumpState = `0
1
2
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
0 0 0 0 0 0 0
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
0 0 0 0 0 0 0
0 0
0 0
0x1ff 1
0x1ff 0
0 0
0x1e 2400
0x2 500
0x1 8000
0x1 2400
0x20 15000
0x20 8000
0x40 230000
0 0
9990
9990
10000
0
10
10 20 30
0x3effffff
0x3effffff
0x7fffffff
0x7fffffff
0x7fffffff
0x7fffffff
`
// dialTimeout is only used by tests, kept here so the value is one place.
const dialTimeout = 2 * time.Second
+201
View File
@@ -0,0 +1,201 @@
package rigctld
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"testing"
)
// fakeRig stands in for the CAT manager.
type fakeRig struct {
mu sync.Mutex
freq int64
mode string
split bool
txFreq int64
ptt bool
setFreqs []int64
setModes []string
failSet bool
}
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode }
func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq }
func (f *fakeRig) SetFreq(hz int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.freq = hz
f.setFreqs = append(f.setFreqs, hz)
return nil
}
func (f *fakeRig) SetMode(m string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.mode = m
f.setModes = append(f.setModes, m)
return nil
}
func (f *fakeRig) SetPTT(on bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSet {
return fmt.Errorf("rig refused")
}
f.ptt = on
return nil
}
// The command table. These exact strings are what WSJT-X and MSHV put on the
// wire, so they are the contract — a reply in the wrong shape does not degrade
// gracefully, the client simply refuses to work with the rig.
func TestHandleCommands(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8", split: true, txFreq: 14100000}
s := New(0, rig, nil)
cases := []struct{ in, want string }{
{"f", "14074000\n"},
{"\\get_freq", "14074000\n"},
{"m", "PKTUSB\n3000\n"}, // a digital mode reads as PKTUSB
{"t", "0\n"}, // PTT always reads RX — see the comment
{"v", "VFOA\n"},
{"s", "1\nVFOB\n"}, // split on, TX on B
{"i", "14100000\n"}, // split TX frequency
{"F 14200000", "RPRT 0\n"},
{"F 14200000.000000", "RPRT 0\n"}, // the float form clients also send
{"M USB 2400", "RPRT 0\n"},
{"T 1", "RPRT 0\n"},
{"V VFOB", "RPRT 0\n"}, // accepted and ignored, never an error
{"S 1 VFOB", "RPRT 0\n"},
{"\\chk_vfo", "CHKVFO 0\n"},
{"F", "RPRT -1\n"}, // missing argument
{"F not_a_number", "RPRT -1\n"},
{"Z", "RPRT -11\n"}, // unknown → answered, never silence
{"", ""},
}
for _, c := range cases {
got, _ := s.handle(c.in)
if got != c.want {
t.Errorf("handle(%q) = %q, want %q", c.in, got, c.want)
}
}
if q := func() bool { _, q := s.handle("q"); return q }(); !q {
t.Error("q must end the session")
}
}
// A rig that refuses must produce an error report, not a success — a client told
// "RPRT 0" believes the radio moved and will log the wrong frequency.
func TestHandleReportsBackendFailure(t *testing.T) {
s := New(0, &fakeRig{failSet: true}, nil)
for _, in := range []string{"F 14200000", "M USB 2400", "T 1"} {
if got, _ := s.handle(in); got != "RPRT -9\n" {
t.Errorf("handle(%q) with a failing rig = %q, want RPRT -9", in, got)
}
}
}
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
// line as the protocol version and refuses to continue if the block is short or
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
// breaking every client.
func TestDumpStateShape(t *testing.T) {
lines := strings.Split(strings.TrimRight(dumpState, "\n"), "\n")
if len(lines) < 20 {
t.Fatalf("dump_state has %d lines — clients expect the full capability block", len(lines))
}
if lines[0] != "0" {
t.Errorf("dump_state protocol version = %q, want \"0\"", lines[0])
}
// The frequency-range lines must carry seven fields, or the client's parse
// slides and every later capability is read from the wrong place.
for _, i := range []int{3, 5} {
if n := len(strings.Fields(lines[i])); n != 7 {
t.Errorf("dump_state line %d has %d fields, want 7: %q", i, n, lines[i])
}
}
}
// End to end over a real socket, because the framing (one reply per line,
// flushed immediately) is as much a part of the contract as the text.
func TestServerOverTCP(t *testing.T) {
rig := &fakeRig{freq: 7074000, mode: "SSB"}
s := New(0, rig, nil)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go s.serve(c)
}
}()
defer s.Stop()
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
r := bufio.NewReader(c)
if _, err := c.Write([]byte("f\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.TrimSpace(line) != "7074000" {
t.Errorf("get_freq over TCP = %q, want 7074000", strings.TrimSpace(line))
}
if _, err := c.Write([]byte("F 14074000\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, _ = r.ReadString('\n')
if strings.TrimSpace(line) != "RPRT 0" {
t.Errorf("set_freq over TCP = %q, want RPRT 0", strings.TrimSpace(line))
}
if got := rig.Freq(); got != 14074000 {
t.Errorf("rig frequency = %d, want 14074000 — the command never reached it", got)
}
}
func TestModeMapping(t *testing.T) {
for _, c := range []struct{ adif, hamlib string }{
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},
{"FT8", "PKTUSB"}, {"JS8", "PKTUSB"}, {"", "USB"},
} {
if got := adifToHamlib(c.adif); got != c.hamlib {
t.Errorf("adifToHamlib(%q) = %q, want %q", c.adif, got, c.hamlib)
}
}
// Digital comes back as DATA, never as a specific sub-mode: the CAT backend
// applies the operator's own digital default, so a client that switches the
// rig to data does not relabel a JS8 operator's QSOs as FT8.
for _, c := range []struct{ hamlib, adif string }{
{"PKTUSB", "DATA"}, {"PKTLSB", "DATA"}, {"DIGU", "DATA"},
{"USB", "USB"}, {"CWR", "CW"}, {"FMN", "FM"},
} {
if got := hamlibToADIF(c.hamlib); got != c.adif {
t.Errorf("hamlibToADIF(%q) = %q, want %q", c.hamlib, got, c.adif)
}
}
}