fix(rotator): one held TCP session for a DCU-1 controller

The DCU-1 client opened a connection per command and closed it again,
"mirroring the gs232/pst/rotgenius idiom". That idiom is right for the
UDP backends beside it and wrong over TCP to an embedded serial server,
which is what an RT-21's Ethernet option is: the heading is polled every
500 ms while the antenna turns, GoTo sends two commands (AP1 then AM1),
Stop sends two more — each its own connect and close. Modules of that
class commonly accept a SINGLE session and need a moment to release it,
so the churn on its own looks like a controller ignoring half of what it
is told.

The socket is now kept between calls, one mutex serialises every
exchange — which also stops the poll and an operator command from
holding two sessions at once — and a write or read error drops it so the
next call redials. One retry after a redial, because a kept socket's
first write succeeds long after the far end has gone. Serial keeps
open-per-call: a COM port has one owner, and holding it would lock out
the controller's own software.

Keeping a session only helps if the client survives the call, and
dcu1Client built a fresh one every time, so it is cached per controller
identity. Two rotors on the same box share one client, which is the
point when one session is all there is. SaveRotators drops the cache:
a client left over from the previous host would hold the very session
its replacement needs.

Three tests against a fake controller that accepts one session at a
time: four commands share one session, a dropped session is redialled
exactly once, and a failed dial leaves nothing behind.
This commit is contained in:
2026-09-10 22:42:34 +02:00
parent 9e67ddcea4
commit c293cc1391
4 changed files with 309 additions and 24 deletions
+53 -1
View File
@@ -17419,6 +17419,10 @@ func (a *App) migrateLegacyRotors() []RotatorDevice {
// SaveRotators persists the rotor list. Connections are per-call (no socket to
// (re)open) so no reload step is needed.
func (a *App) SaveRotators(list []RotatorDevice) error {
// Any kept DCU-1 session belongs to the OLD settings. Dropped before the new
// ones are stored, so a stale socket cannot hold a single-session controller
// against its replacement.
defer dropDCU1Clients()
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
@@ -17538,10 +17542,58 @@ func easycommClient(l rotorLink) *easycomm.Client {
return easycomm.New(l.Host, l.Port, l.MaxAz)
}
// dcu1Clients keeps ONE client per controller, because over TCP the client
// holds its session open between commands.
//
// Every other rotator backend here is built fresh per call, which is right for
// the UDP ones and wrong for a TCP embedded serial server: the heading is
// polled twice a second while the antenna turns and GoTo sends two commands,
// so a new client each time meant a new connect and close each time. Those
// modules commonly accept one session at a time. Cached, the churn is gone.
//
// Keyed on the controller's identity, so two rotors on two boxes get one
// client each and two rotors on the SAME box share it — sharing being the
// point, when only one session is on offer.
var (
dcu1Mu sync.Mutex
dcu1Clients = map[string]*dcu1.Client{}
)
func dcu1Key(l rotorLink) string {
if l.Transport == "serial" {
return fmt.Sprintf("serial|%s|%d", strings.ToUpper(strings.TrimSpace(l.ComPort)), l.Baud)
}
return fmt.Sprintf("tcp|%s|%d", strings.ToLower(strings.TrimSpace(l.Host)), l.Port)
}
// dropDCU1Clients closes every kept session. Called when the rotator settings
// are saved: a client left over from the previous host would hold the very
// session the new one needs, which is worse than the churn it replaced.
func dropDCU1Clients() {
dcu1Mu.Lock()
defer dcu1Mu.Unlock()
for k, c := range dcu1Clients {
c.Close()
delete(dcu1Clients, k)
}
}
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
// or a serial-over-IP bridge on TCP.
// or the controller's own Ethernet port / a serial-over-IP bridge on TCP.
func dcu1Client(l rotorLink) *dcu1.Client {
key := dcu1Key(l)
dcu1Mu.Lock()
defer dcu1Mu.Unlock()
if c := dcu1Clients[key]; c != nil {
return c
}
c := newDCU1Client(l)
dcu1Clients[key] = c
return c
}
func newDCU1Client(l rotorLink) *dcu1.Client {
if l.Transport == "serial" {
return dcu1.NewSerial(l.ComPort, l.Baud)
}