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.
235 lines
7.7 KiB
Go
235 lines
7.7 KiB
Go
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
|
|
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
|
|
//
|
|
// DCU-1 is used by the Hy-Gain DCU-1, the Green Heron RT-21, the Idiom Press
|
|
// Rotor-EZ, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
|
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
|
|
// semicolon-terminated, azimuth only.
|
|
//
|
|
// The RT-21 selects its protocol on the controller, and only its DCU-1 /
|
|
// Rotor-EZ setting is this one — an RT-21 left on GS-232 belongs to the gs232
|
|
// package instead. With the Ethernet option it is a TCP endpoint in its own
|
|
// right, so the TCP transport below reaches it without a serial-over-IP
|
|
// bridge. Its NATIVE Green Heron protocol is a third command set, with 0.1°
|
|
// readback and a real stop, and is not implemented here.
|
|
//
|
|
// Commands (';' terminated — roundTrip appends the ';'):
|
|
//
|
|
// AP1nnn set the target bearing nnn (000-359)
|
|
// AM1 rotate to the target (some controllers move on AP1 alone; AM1 is
|
|
// harmless and makes the Rotor-EZ/DCU-1 variants that need it work)
|
|
// AI1 query the current bearing → the reply carries the 3-digit azimuth
|
|
//
|
|
// 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 (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
const (
|
|
dialTimeout = 3 * time.Second
|
|
ioTimeout = 2 * time.Second
|
|
)
|
|
|
|
// 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
|
|
ComPort string // serial transport: "COM5" etc.
|
|
// 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).
|
|
func New(host string, port int) *Client {
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
if port <= 0 || port > 65535 {
|
|
port = 4001
|
|
}
|
|
return &Client{Host: host, Port: port}
|
|
}
|
|
|
|
// NewSerial returns a Client over the controller's COM port.
|
|
func NewSerial(comPort string, baud int) *Client {
|
|
return &Client{ComPort: comPort, Baud: baud}
|
|
}
|
|
|
|
// roundTrip opens a connection, sends one ';'-terminated command and (when
|
|
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
|
// ';'.
|
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.ComPort != "" {
|
|
return c.exchangeSerial(cmd, wantReply)
|
|
}
|
|
// 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)
|
|
}
|
|
if !wantReply {
|
|
return "", nil
|
|
}
|
|
buf := make([]byte, 64)
|
|
var sb strings.Builder
|
|
deadline := time.Now().Add(ioTimeout)
|
|
for time.Now().Before(deadline) {
|
|
n, err := conn.Read(buf)
|
|
if n > 0 {
|
|
sb.Write(buf[:n])
|
|
// The DCU-1 reply carries the bearing as three digits (framing varies —
|
|
// ";nnn", "nnn;", "+0nnn"). Stop once we have them rather than on a
|
|
// specific terminator, so any flavour reads cleanly.
|
|
if azRe.MatchString(sb.String()) {
|
|
break
|
|
}
|
|
}
|
|
// A serial read that times out returns (0, nil) — keep polling until the
|
|
// overall deadline; a real error ends the read.
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
line := strings.TrimSpace(sb.String())
|
|
if line == "" {
|
|
return "", fmt.Errorf("no reply to %q", cmd)
|
|
}
|
|
return line, nil
|
|
}
|
|
|
|
// GoTo points the antenna at az (0-359): set the target, then rotate.
|
|
func (c *Client) GoTo(az int) error {
|
|
az = ((az % 360) + 360) % 360
|
|
if _, err := c.roundTrip(fmt.Sprintf("AP1%03d", az), false); err != nil {
|
|
return err
|
|
}
|
|
_, err := c.roundTrip("AM1", false)
|
|
return err
|
|
}
|
|
|
|
// Stop halts rotation. The base DCU-1 set has no stop command, so re-command the
|
|
// current bearing — the controller stops when the target equals where it is.
|
|
func (c *Client) Stop() error {
|
|
az, _, err := c.Heading()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.roundTrip(fmt.Sprintf("AP1%03d", az), false)
|
|
return err
|
|
}
|
|
|
|
// azRe matches the 3-digit bearing in any of the DCU-1 reply framings.
|
|
var azRe = regexp.MustCompile(`(\d{3})`)
|
|
|
|
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
|
func (c *Client) Heading() (az int, raw string, err error) {
|
|
raw, err = c.roundTrip("AI1", true)
|
|
if err != nil {
|
|
return 0, raw, err
|
|
}
|
|
m := azRe.FindStringSubmatch(raw)
|
|
if m == nil {
|
|
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
|
}
|
|
az, _ = strconv.Atoi(m[1])
|
|
return az % 360, raw, nil
|
|
}
|