// 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 Idiom Press Rotor-EZ, Green Heron // controllers, 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. // // 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. package dcu1 import ( "fmt" "io" "net" "regexp" "strconv" "strings" "time" "go.bug.st/serial" ) const ( dialTimeout = 3 * time.Second 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. 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 } // 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) { var conn io.ReadWriteCloser 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 } defer conn.Close() 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 }