fix(cat): rebuild the rig link only when the connection itself changed

Pressing Save in Preferences tore down the CAT link and the shared CAT server
unconditionally. Two consequences an operator sees at once: WSJT-X and JTDX are
thrown off their rig mid-QSO with an error box, and every spot disappears from a
FlexRadio panadapter — a Flex reconnect clears them by design, so the wipe on
save was the reconnect, not a stray command. Switching profile re-applies the
same settings and did exactly the same.

Two signatures decide it now. catLinkSig covers what shapes the physical link
(backend, host/port, COM port and baud, CI-V address, network credentials);
catShareSig covers the protocol and port of the rigctl/TCI server, which is the
socket WSJT-X actually holds. Same string in, same connection out, so there is
nothing to gain by rebuilding it. The poll interval, command delay, transverter
offset and spot options are applied to the running manager as before — the tests
pin that a spot option in particular can never trigger a reconnect, since that
is the one that would wipe the panadapter.

Also, two things the log would not say:

- WSJT-X/JTDX report their Enable Tx state in every Status and we wrote it
  nowhere. When a station is called and nothing goes out, that flag is the whole
  answer — a Reply does what a double-click does, it cannot arm a transmitter —
  and the only way to see it was to watch a button in another window. Logged on
  change, so it costs a few lines a QSO.

- The antenna log said "steppir connected" before anything had talked to it,
  which made an unopenable serial port look like a working link with tracking
  switched off. It says "configured", which is what it knows.
This commit is contained in:
2026-08-21 14:35:32 +02:00
parent e6889caae9
commit f45952d7fb
2 changed files with 177 additions and 22 deletions
+104 -22
View File
@@ -789,27 +789,32 @@ type App struct {
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
livePublishTimer *time.Timer // debounced live-status publish on activity change
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
liveTableMu sync.Mutex // guards liveTableFor
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
awardSnapCap int // rows the last build produced, the capacity hint for the next
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
bandOpen bandOpenState // sporadic-E / band-opening detector over the spot stream
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
// catSig / catShareSig fingerprint the last APPLIED link and share
// configuration, so re-applying an identical one is a no-op instead of a
// reconnection. Empty at startup, which is what makes the first call connect.
catSig string
catShareSig string
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
livePublishTimer *time.Timer // debounced live-status publish on activity change
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
liveTableMu sync.Mutex // guards liveTableFor
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
awardSnapCap int // rows the last build produced, the capacity hint for the next
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
bandOpen bandOpenState // sporadic-E / band-opening detector over the spot stream
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -12901,6 +12906,17 @@ func (a *App) consumeUDPEvents() {
// 15 s. Single-goroutine loop → the map needs no lock.
const decodeSpotWindow = 20 * time.Second
lastDecodeSpot := map[string]time.Time{}
// The sending application's transmit state, as last logged.
//
// "Enable Tx" is the one thing that decides whether a Reply we sent turns
// into a transmission, and it lives entirely in WSJT-X/JTDX — we can ask for
// a station, we cannot arm the transmitter. When an operator reports "OpsLog
// called it and nothing went out", the only honest way to tell which side
// stopped is to see whether the far end ever armed at all. Status carries it
// every second; logging the CHANGES costs a line a QSO and answers the
// question without watching a button.
type txFlags struct{ enabled, transmitting bool }
lastTxFlags := map[string]txFlags{}
// Panadapter spots go out on their OWN goroutine.
//
// SendSpot writes to the radio's TCP socket, and this loop is the only
@@ -12934,6 +12950,13 @@ func (a *App) consumeUDPEvents() {
if ev.DECall != "" || ev.TxMessage != "" {
if ev.ProgramID != "" {
a.lastTxID.Store(ev.ProgramID)
now := txFlags{enabled: ev.TxEnabled, transmitting: ev.Transmitting}
if was, seen := lastTxFlags[ev.ProgramID]; !seen || was != now {
lastTxFlags[ev.ProgramID] = now
applog.Printf("udp: [%s] tx enable=%v transmitting=%v dx=%q%s",
ev.ProgramID, now.enabled, now.transmitting, ev.DXCall,
map[bool]string{true: "", false: " — nothing we send can start a transmission while this is false"}[now.enabled])
}
}
wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{
"msg": ev.TxMessage,
@@ -14436,6 +14459,46 @@ func (a *App) SwitchCATRig(n int) error {
return nil
}
// catLinkSig fingerprints everything that shapes the PHYSICAL rig link.
//
// Two settings that produce the same string produce the same connection, so
// there is nothing to gain by tearing the old one down — and a great deal to
// lose: rebuilding the link drops the rig for a second or two, and with it
// every client of the shared CAT server. Pressing Save in Preferences (or
// switching profile, which re-applies the same settings) therefore threw
// WSJT-X and JTDX off their rig, mid-QSO, with an error box — for a change
// they had usually made somewhere else entirely.
//
// Deliberately does NOT include the poll interval, the command delay, the
// transverter offset or the spot options: those are applied to the running
// manager without reconnecting anything.
func catLinkSig(s CATSettings) string {
if !s.Enabled {
return "off"
}
return strings.Join([]string{
s.Backend, s.DigitalDefault,
strconv.Itoa(s.OmniRigNum), s.OmniRigVFO,
s.FlexHost, strconv.Itoa(s.FlexPort), strconv.FormatBool(s.FlexSpots),
s.XieguPort, strconv.Itoa(s.XieguBaud), strconv.Itoa(s.XieguAddr), s.XieguPTTLine,
s.YaesuPort, strconv.Itoa(s.YaesuBaud), strconv.FormatBool(s.YaesuLowLines),
s.KenwoodHost, s.KenwoodPort, strconv.Itoa(s.KenwoodBaud),
s.KenwoodDataMode, strconv.FormatBool(s.KenwoodLowLines),
s.IcomPort, strconv.Itoa(s.IcomBaud), strconv.Itoa(s.IcomAddr),
s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, strconv.FormatBool(s.IcomNetAudio),
s.TCIHost, strconv.Itoa(s.TCIPort), strconv.FormatBool(s.TCISpots),
}, "|")
}
// catShareSig does the same for the shared-CAT server: which protocol, which
// port, on or off. Restarting it is what actually disconnects WSJT-X.
func catShareSig(s CATSettings) string {
if !s.Enabled || !s.ShareEnabled {
return "off"
}
return fmt.Sprintf("%s|%d|%d", s.ShareProto, s.SharePort, s.ShareTCIPort)
}
// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
// restartAsync runs a hardware (re)start off the caller's goroutine.
@@ -14483,6 +14546,13 @@ func (a *App) reloadCAT() {
a.catFlexDecodeSecs = s.FlexDecodeSecs
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
a.reloadCATShare(s)
// Nothing about the link changed → leave it connected. See catLinkSig.
if sig := catLinkSig(s); sig == a.catSig {
applog.Printf("cat: settings saved, link unchanged — staying connected")
return
} else {
a.catSig = sig
}
if !s.Enabled {
a.cat.Stop()
return
@@ -16777,7 +16847,11 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
return
}
if !s.Follow {
applog.Printf("antenna: %s connected, but TRACKING IS OFF in Settings — it will not follow the rig, and only moves when you tune it by hand", s.Type)
// "configured", not "connected": nothing here has talked to the antenna
// yet. The poll loop has only just been started, and it reports its own
// failures ("steppir: cannot open COM5…"). Saying connected made an
// unopenable port look like a working link with tracking switched off.
applog.Printf("antenna: %s configured, but TRACKING IS OFF in Settings — it will not follow the rig, and only moves when you tune it by hand", s.Type)
return
}
stop := make(chan struct{})
@@ -19099,6 +19173,14 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
// 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
// Same protocol on the same port → the running server is already the one
// asked for. Rebuilding it closes the socket WSJT-X is sitting on, which is
// the error box an operator sees on pressing Save.
if sig := catShareSig(s); sig == a.catShareSig && (a.catShare != nil || a.catShareTCI != nil || !want) {
return
} else {
a.catShareSig = sig
}
// 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.
+73
View File
@@ -0,0 +1,73 @@
package main
import "testing"
// The whole point of the signature is that pressing Save in Preferences must
// not drop the rig — and with it every WSJT-X/JTDX client of the shared CAT
// server, mid-QSO, with an error box. Only a change to the LINK may reconnect.
func TestCatLinkSigIgnoresNonLinkSettings(t *testing.T) {
base := CATSettings{
Enabled: true, Backend: "flex", FlexHost: "192.168.1.20", FlexPort: 4992,
PollMs: 250, DigitalDefault: "FT8",
}
same := base
// Everything here is applied to the RUNNING manager; none of it justifies a
// reconnection, and a spot option in particular must not wipe the panadapter
// (a Flex reconnect clears every spot on it).
same.PollMs = 500
same.DelayMs = 20
same.OffsetOn = true
same.OffsetHz = 116_000_000
same.FlexDecodeSpots = !base.FlexDecodeSpots
same.FlexDecodeSecs = 300
same.FlexDVKDax = !base.FlexDVKDax
same.PTTHotkeyEnabled = true
same.PTTHotkey = "Pause"
if catLinkSig(base) != catLinkSig(same) {
t.Errorf("non-link settings changed the signature:\n%s\n%s", catLinkSig(base), catLinkSig(same))
}
// And the ones that DO describe the connection must still force a restart.
for name, mutate := range map[string]func(*CATSettings){
"backend": func(c *CATSettings) { c.Backend = "kenwood" },
"flex host": func(c *CATSettings) { c.FlexHost = "192.168.1.21" },
"flex port": func(c *CATSettings) { c.FlexPort = 4993 },
"serial port": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuPort = "COM7" },
"baud": func(c *CATSettings) { c.Backend = "yaesu"; c.YaesuBaud = 19200 },
"civ address": func(c *CATSettings) { c.Backend = "icom"; c.IcomAddr = 152 },
"disabled": func(c *CATSettings) { c.Enabled = false },
} {
c := base
mutate(&c)
if catLinkSig(c) == catLinkSig(base) {
t.Errorf("%s did not change the signature — the rig would never reconnect", name)
}
}
}
// The share server is the thing WSJT-X actually holds a socket to: restarting
// it is what produced the error box, so it has a signature of its own.
func TestCatShareSig(t *testing.T) {
base := CATSettings{Enabled: true, ShareEnabled: true, ShareProto: "rigctl", SharePort: 4532}
other := base
other.FlexHost = "10.0.0.5" // a rig-link change must not restart the server
if catShareSig(base) != catShareSig(other) {
t.Errorf("a rig-link change restarted the share server")
}
moved := base
moved.SharePort = 4533
if catShareSig(base) == catShareSig(moved) {
t.Errorf("a new port must restart the server")
}
off := base
off.ShareEnabled = false
if catShareSig(off) != "off" || catShareSig(off) == catShareSig(base) {
t.Errorf("switching sharing off must be a change")
}
// CAT off means the server has nothing to serve, whatever the share fields say.
catOff := base
catOff.Enabled = false
if catShareSig(catOff) != "off" {
t.Errorf("share signature with CAT off = %q, want \"off\"", catShareSig(catOff))
}
}