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:
@@ -22,6 +22,21 @@
|
||||
//
|
||||
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
|
||||
// which halts rotation.
|
||||
//
|
||||
// ONE TCP session, held open and serialised.
|
||||
//
|
||||
// This started out opening a connection per command, like the UDP backends
|
||||
// beside it. Over TCP to an embedded serial server — which is what the RT-21's
|
||||
// Ethernet option is — that is the wrong shape: the heading is polled twice a
|
||||
// second while the antenna turns, GoTo sends two commands, and each was its
|
||||
// own connect and close. Those modules commonly accept a SINGLE session and
|
||||
// need a moment to release it, so the churn alone can look like a controller
|
||||
// that ignores half of what it is told.
|
||||
//
|
||||
// So the socket is kept between calls and one mutex serialises every
|
||||
// exchange, which also stops a poll and a command from holding two sessions
|
||||
// at once. A write or read error drops the socket; the next call redials.
|
||||
// Serial keeps its open-per-call, where a COM port has one owner anyway.
|
||||
package dcu1
|
||||
|
||||
import (
|
||||
@@ -31,6 +46,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
@@ -41,8 +57,9 @@ const (
|
||||
ioTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
|
||||
// Exactly one of (Host, Port) or ComPort is used.
|
||||
// Client talks to one controller. Exactly one of (Host, Port) or ComPort is
|
||||
// used. Hold onto it: over TCP it keeps its session open between calls, so a
|
||||
// fresh Client per command would give the churn back.
|
||||
type Client struct {
|
||||
Host string
|
||||
Port int
|
||||
@@ -50,6 +67,29 @@ type Client struct {
|
||||
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
|
||||
// can differ). Zero keeps 4800.
|
||||
Baud int
|
||||
|
||||
// mu serialises every exchange. Two goroutines are in here in normal use —
|
||||
// the heading poll and the operator's own commands — and on a single-session
|
||||
// controller their overlap is the fault, not just a race on one socket.
|
||||
mu sync.Mutex
|
||||
// conn is the kept TCP session. nil when not connected, or after an error
|
||||
// dropped it. Unused on serial.
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// Close drops the kept session. Safe to call at any time and on any Client.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.dropLocked()
|
||||
}
|
||||
|
||||
// dropLocked closes the session so the next exchange redials. Caller holds mu.
|
||||
func (c *Client) dropLocked() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
|
||||
@@ -72,27 +112,56 @@ func NewSerial(comPort string, baud int) *Client {
|
||||
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
||||
// ';'.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.ComPort != "" {
|
||||
baud := c.Baud
|
||||
if baud <= 0 {
|
||||
baud = 4800
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
conn = sp
|
||||
} else {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||
conn = nc
|
||||
return c.exchangeSerial(cmd, wantReply)
|
||||
}
|
||||
defer conn.Close()
|
||||
// A kept socket can be half-dead: the far end went away and the first write
|
||||
// still succeeds because nothing has been acknowledged yet. So one retry on
|
||||
// a FRESH connection, and only when the session was one we had already —
|
||||
// a dial that fails is a dial that fails.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
reused := c.conn != nil
|
||||
if c.conn == nil {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
c.conn = nc
|
||||
}
|
||||
_ = c.conn.SetDeadline(time.Now().Add(ioTimeout))
|
||||
line, err := c.exchange(c.conn, cmd, wantReply)
|
||||
if err == nil {
|
||||
return line, nil
|
||||
}
|
||||
c.dropLocked()
|
||||
if !reused {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no reply to %q", cmd)
|
||||
}
|
||||
|
||||
// exchangeSerial opens the port for one exchange and closes it again. A COM
|
||||
// port has one owner, so holding it open would lock out the controller's own
|
||||
// software for the whole session.
|
||||
func (c *Client) exchangeSerial(cmd string, wantReply bool) (string, error) {
|
||||
baud := c.Baud
|
||||
if baud <= 0 {
|
||||
baud = 4800
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
}
|
||||
defer sp.Close()
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
return c.exchange(sp, cmd, wantReply)
|
||||
}
|
||||
|
||||
// exchange sends one ';'-terminated command and reads the reply, if any.
|
||||
func (c *Client) exchange(conn io.ReadWriter, cmd string, wantReply bool) (string, error) {
|
||||
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
|
||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user