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:
@@ -51,6 +51,7 @@ import (
|
|||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
|
"hamlog/internal/rigctld"
|
||||||
"hamlog/internal/rotator/gs232"
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/rotgenius"
|
"hamlog/internal/rotgenius"
|
||||||
@@ -108,6 +109,8 @@ const (
|
|||||||
keyCATPollMs = "cat.poll_ms"
|
keyCATPollMs = "cat.poll_ms"
|
||||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
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)
|
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
||||||
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
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)
|
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
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/…)
|
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
|
// ModePreset is a mode entry with default RST values to auto-populate
|
||||||
@@ -474,6 +479,11 @@ type App struct {
|
|||||||
lookup *lookup.Manager
|
lookup *lookup.Manager
|
||||||
cache *lookup.Cache
|
cache *lookup.Cache
|
||||||
cat *cat.Manager
|
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
|
dxcc *dxcc.Manager
|
||||||
cluster *cluster.Manager
|
cluster *cluster.Manager
|
||||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
// 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
|
// 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
|
// control session for minutes, refusing every new login (even from the Icom
|
||||||
// Remote Utility) until it times out on its own.
|
// 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 {
|
if a.cat != nil {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
}
|
}
|
||||||
@@ -6566,7 +6582,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -6594,6 +6610,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
PollMs: 250,
|
PollMs: 250,
|
||||||
DelayMs: 0,
|
DelayMs: 0,
|
||||||
DigitalDefault: m[keyCATDigitalDefault],
|
DigitalDefault: m[keyCATDigitalDefault],
|
||||||
|
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||||
|
SharePort: 4532,
|
||||||
}
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||||
out.FlexPort = n
|
out.FlexPort = n
|
||||||
@@ -6604,6 +6622,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
||||||
out.TCIPort = n
|
out.TCIPort = n
|
||||||
}
|
}
|
||||||
|
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||||
|
out.SharePort = n
|
||||||
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
||||||
out.YaesuBaud = n
|
out.YaesuBaud = n
|
||||||
}
|
}
|
||||||
@@ -6648,6 +6669,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.FlexPort <= 0 || s.FlexPort > 65535 {
|
if s.FlexPort <= 0 || s.FlexPort > 65535 {
|
||||||
s.FlexPort = 4992
|
s.FlexPort = 4992
|
||||||
}
|
}
|
||||||
|
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||||
|
s.SharePort = 4532
|
||||||
|
}
|
||||||
if s.YaesuBaud <= 0 {
|
if s.YaesuBaud <= 0 {
|
||||||
s.YaesuBaud = 38400
|
s.YaesuBaud = 38400
|
||||||
}
|
}
|
||||||
@@ -6689,6 +6713,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.DigitalDefault == "" {
|
if s.DigitalDefault == "" {
|
||||||
s.DigitalDefault = "FT8"
|
s.DigitalDefault = "FT8"
|
||||||
}
|
}
|
||||||
|
shareEnabled := "0"
|
||||||
|
if s.ShareEnabled {
|
||||||
|
shareEnabled = "1"
|
||||||
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyCATEnabled: enabled,
|
keyCATEnabled: enabled,
|
||||||
keyCATBackend: s.Backend,
|
keyCATBackend: s.Backend,
|
||||||
@@ -6714,6 +6742,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
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 {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
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.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||||
|
a.reloadCATShare(s)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
return
|
return
|
||||||
@@ -14776,3 +14807,54 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.21.9",
|
"version": "0.21.9",
|
||||||
"date": "2026-07-28",
|
"date": "2026-07-28",
|
||||||
"en": [
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"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": [
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -1064,7 +1064,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
yaesu_port: '', yaesu_baud: 38400,
|
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,
|
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,
|
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>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</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' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
|||||||
@@ -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 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'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 1–2 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 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'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 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 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 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.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.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)',
|
'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.",
|
'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 1–2 min pour corriger un QSO mal saisi avant).",
|
'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 1–2 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',
|
'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.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.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 l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
|
|||||||
@@ -1850,6 +1850,8 @@ export namespace main {
|
|||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
|
share_enabled: boolean;
|
||||||
|
share_port: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new CATSettings(source);
|
return new CATSettings(source);
|
||||||
@@ -1881,6 +1883,8 @@ export namespace main {
|
|||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
|
this.share_enabled = source["share_enabled"];
|
||||||
|
this.share_port = source["share_port"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class CabrilloResult {
|
export class CabrilloResult {
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ package cat
|
|||||||
// remembered. Every unrecognised reply is logged raw, because that log is the
|
// 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.
|
// 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
|
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
|
||||||
// awaiting a first on-air run. Anything this file asserts about a rig it has not
|
// against the radio. The other models are still inference from the same CAT
|
||||||
// met should be read as a hypothesis with a log line attached.
|
// reference; anything this file asserts about a rig it has not met should be
|
||||||
|
// read as a hypothesis with a log line attached.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|||||||
@@ -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 kHz–1500 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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user