feat(cat): share the rig over TCI as well as Hamlib — one or the other
internal/rigctld exists because Windows gives a COM port to ONE process: the moment OpsLog talks to the radio natively, nothing else can. It answers the programs that speak Hamlib NET rigctl. This answers the ones built around Expert Electronics' TCI instead — and it answers them whatever radio is connected, because it sits on the same backend-agnostic Rig interface. An operator with an Icom or a Yaesu can now hand a TCI-only program a working rig. One server or the other, never both. They answer the same questions about the same radio, nothing speaks both, and a second listener is only a second thing to go wrong. Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January 2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1 in its documented order, and the argument order of every command from §4.2. A client will not proceed past connect without that block, which is why it is written out in full rather than stubbed. The one dangerous detail is the VFO mapping. TCI's channel A is where you LISTEN and channel B where you transmit — the opposite way round from OpsLog's RigState, which follows ADIF. Getting that backwards would put a station on the DX's own frequency, so it is pinned in both directions by a test, and RxFreq was added to the adapter rather than inferred. Writing channel B while the rig is simplex is ignored: the client asked to prepare a split transmit frequency, not to QSY, and a logger doing that on every spot click would drag the operator off the station they were listening to. A backend that cannot split still refuses out loud. Only changes are pushed. TCI clients redraw on each command, so re-sending an unchanged frequency four times a second makes a VFO readout flicker and fights the operator's own tuning. Nine tests, no socket needed — the protocol is the decision, not the transport.
This commit is contained in:
@@ -68,6 +68,7 @@ import (
|
||||
"hamlog/internal/spe"
|
||||
"hamlog/internal/steppir"
|
||||
"hamlog/internal/syncfolder"
|
||||
"hamlog/internal/tciserver"
|
||||
"hamlog/internal/tunergenius"
|
||||
"hamlog/internal/uls"
|
||||
"hamlog/internal/ultrabeam"
|
||||
@@ -120,6 +121,8 @@ const (
|
||||
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)
|
||||
keyCATShareProto = "cat.share.proto" // which sharing server runs: "rigctl" (Hamlib NET) or "tci"
|
||||
keyCATShareTCIPort = "cat.share.tci_port" // WebSocket port for the TCI server (TCI default 40001)
|
||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||
@@ -457,8 +460,13 @@ 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)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
|
||||
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
|
||||
// ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
|
||||
// not both: they are two ways of asking the same radio the same questions,
|
||||
// and a second listener is only a second thing to go wrong.
|
||||
ShareProto string `json:"share_proto"`
|
||||
ShareTCIPort int `json:"share_tci_port"` // WebSocket port for the TCI server (default 40001)
|
||||
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
|
||||
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
||||
// falling back to CAT keying when that is VOX/none.
|
||||
@@ -596,8 +604,12 @@ type App struct {
|
||||
// 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
|
||||
// catShareTCI serves the same link to programs built around Expert
|
||||
// Electronics' TCI instead. One or the other runs, never both — they answer
|
||||
// the same questions about the same radio, and nothing speaks both.
|
||||
catShareTCI *tciserver.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
||||
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
|
||||
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
|
||||
@@ -1691,6 +1703,10 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if a.cat != nil {
|
||||
a.cat.Stop()
|
||||
}
|
||||
@@ -7449,7 +7465,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, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -7491,6 +7507,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
ShareProto: "rigctl",
|
||||
ShareTCIPort: tciserver.DefaultPort,
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
@@ -7504,6 +7522,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||
out.SharePort = n
|
||||
}
|
||||
if p := strings.ToLower(strings.TrimSpace(m[keyCATShareProto])); p == "tci" {
|
||||
out.ShareProto = p
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATShareTCIPort]); n > 0 && n <= 65535 {
|
||||
out.ShareTCIPort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
@@ -7561,6 +7585,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||
s.SharePort = 4532
|
||||
}
|
||||
if s.ShareProto != "tci" {
|
||||
s.ShareProto = "rigctl"
|
||||
}
|
||||
if s.ShareTCIPort <= 0 || s.ShareTCIPort > 65535 {
|
||||
s.ShareTCIPort = tciserver.DefaultPort
|
||||
}
|
||||
if s.XieguBaud <= 0 {
|
||||
s.XieguBaud = 19200
|
||||
}
|
||||
@@ -7661,6 +7691,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
keyCATShareProto: s.ShareProto,
|
||||
keyCATShareTCIPort: strconv.Itoa(s.ShareTCIPort),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -18177,6 +18209,18 @@ func (r catShareRig) Split() (bool, int64) {
|
||||
return true, st.FreqHz
|
||||
}
|
||||
|
||||
// RxFreq is where we LISTEN. Only the TCI server asks for it: TCI's channel A
|
||||
// is the receive frequency and channel B the transmit one, the opposite way
|
||||
// round from RigState, and a client handed these two the wrong way about would
|
||||
// transmit on the DX's own frequency.
|
||||
func (r catShareRig) RxFreq() int64 {
|
||||
st := r.a.cat.State()
|
||||
if st.Split && st.RxFreqHz > 0 {
|
||||
return st.RxFreqHz
|
||||
}
|
||||
return 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) }
|
||||
@@ -18191,17 +18235,35 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
|
||||
|
||||
// reloadCATShare starts, stops or restarts the sharing server to match the
|
||||
// settings. Called from reloadCAT so one "Save & Close" settles both.
|
||||
//
|
||||
// One server or the other, never both. rigctl and TCI are two ways of asking
|
||||
// the same radio the same questions; running both would only double the ways a
|
||||
// port clash or a confused client can go wrong, and no program speaks 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.
|
||||
// Always tear down first: the port — or the protocol — 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 a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if !want {
|
||||
return
|
||||
}
|
||||
if s.ShareProto == "tci" {
|
||||
srv := tciserver.New(s.ShareTCIPort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
applog.Printf("cat share: %v", err)
|
||||
return
|
||||
}
|
||||
a.catShareTCI = srv
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user