diff --git a/app.go b/app.go index 0ebe494..414960f 100644 --- a/app.go +++ b/app.go @@ -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 // /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 // /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. diff --git a/catlinksig_test.go b/catlinksig_test.go new file mode 100644 index 0000000..82a1d4b --- /dev/null +++ b/catlinksig_test.go @@ -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)) + } +}