// Package gs232 drives rotator controllers that speak the Yaesu GS-232A // protocol, over a raw TCP socket or a serial COM port. The target device is // the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and // its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is // irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it // directly, no PstRotator in between. ARCO accepts up to four parallel LAN // connections, and commands are single CR-terminated lines, so short // per-call connections (same idiom as the other rotator backends) work fine. // // GS-232A subset used: // // Maaa move to azimuth aaa (000-450) // S stop rotation // C query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B // flavour); both are parsed. package gs232 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 pst/rotgenius idiom. // Exactly one of (Host, Port) or ComPort is used, per Transport. type Client struct { Host string Port int ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud) } // New returns a TCP Client with sane defaults applied for empty fields. There // is no standard port: the number is whatever the user typed into the ARCO's // LAN CONTROL PROTOCOL setting — 4001 is only a placeholder. 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 talking over the ARCO's USB virtual COM port. The // baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we // open at 9600 which also suits a real RS-232 hookup left at its default. func NewSerial(comPort string) *Client { return &Client{ComPort: comPort} } // roundTrip opens a connection (TCP or serial per the client's config), sends // one CR-terminated command and (when wantReply) reads one CR/LF-terminated // reply line. func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) { var conn io.ReadWriteCloser if c.ComPort != "" { sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600}) if err != nil { return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, 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 ARCO %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 + "\r")); 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]) if strings.ContainsAny(sb.String(), "\r\n") { 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 the given azimuth (0-359). GS-232A takes M000-M450 // (overlap rotators accept >360); we normalise to [0,360). func (c *Client) GoTo(az int) error { az = ((az % 360) + 360) % 360 _, err := c.roundTrip(fmt.Sprintf("M%03d", az), false) return err } // Stop interrupts any in-progress rotation. func (c *Client) Stop() error { _, err := c.roundTrip("S", false) return err } // azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B). var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\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("C", 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 }