// 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 }