// Package gs232 drives rotator controllers that speak the Yaesu GS-232A // protocol, over a raw TCP socket or a serial COM port. // // Any controller set to GS-232A works, which is most of them: the microHAM ARCO // natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface), // which EMULATES GS-232A/B over its USB port. An ERC can also be configured for // Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232. // // On the 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) // Waaa eee move to azimuth aaa AND elevation eee (az/el controllers) // S stop rotation // C query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B // flavour); both are parsed. // C2 query both axes — "+0aaa+0eee" / "AZ=aaa EL=eee" // B query elevation alone, for the controllers that do not answer C2 package gs232 import ( "fmt" "io" "net" "regexp" "strconv" "strings" "sync" "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. // Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but // an ERC (Easy Rotor Control) runs at whatever rate its own configuration // sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator. // Zero keeps the historical 9600. Baud int } // 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, baud int) *Client { return &Client{ComPort: comPort, Baud: baud} } // bootSettle is how long a freshly opened serial port is left alone before the // first command. // // An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when // the serial port is opened: the DTR line pulses its reset pin, and the // bootloader then holds the processor for a second or more. A command sent into // that window is simply lost, which is exactly how a controller that answers // PuTTY perfectly reports "no reply" here. const bootSettle = 2 * time.Second // heldPort is an open serial port, kept between calls. // // The package holds it rather than the Client because the callers build a FRESH // Client for every poll (one per heading request), and the port has to outlive // them. Reopening per command is what made an Arduino controller reboot several // times a second and never answer anything. A serial port is a single-owner // resource in any case: two clients for COM5 would be two handles on one cable. type heldPort struct { p serial.Port openedAt time.Time } var ( portsMu sync.Mutex openPorts = map[string]*heldPort{} ) // acquire returns the open port for com, opening it if needed. func acquire(com string, baud int) (*heldPort, error) { portsMu.Lock() defer portsMu.Unlock() if h, ok := openPorts[com]; ok && h.p != nil { return h, nil } if baud <= 0 { baud = 9600 } sp, err := serial.Open(com, &serial.Mode{BaudRate: baud}) if err != nil { return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err) } _ = sp.SetReadTimeout(200 * time.Millisecond) h := &heldPort{p: sp, openedAt: time.Now()} openPorts[com] = h return h, nil } // drop closes and forgets a port, so the next call opens a fresh one. Called // when an exchange fails: a half-spoken conversation is worse than a new one. func drop(com string) { portsMu.Lock() defer portsMu.Unlock() if h, ok := openPorts[com]; ok { if h.p != nil { _ = h.p.Close() } delete(openPorts, com) } } // roundTrip sends one CR-terminated command and (when wantReply) reads one // CR/LF-terminated reply line. Serial keeps its port open between calls; TCP // dials per call, which is what the ARCO's LAN side expects. func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) { var conn io.ReadWriteCloser if c.ComPort != "" { h, err := acquire(c.ComPort, c.Baud) if err != nil { return "", err } // Let a just-reset controller finish booting before speaking to it. if wait := bootSettle - time.Since(h.openedAt); wait > 0 { time.Sleep(wait) } conn = h.p // Whatever is already in the buffer belongs to the last exchange — the // trailing LF of the previous reply, or a line the controller volunteered // while nobody was reading. Read as the answer to THIS command it would // be an answer to the wrong question. drain(h.p) } 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)) defer nc.Close() conn = nc } if _, err := conn.Write([]byte(cmd + "\r")); err != nil { if c.ComPort != "" { drop(c.ComPort) } 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 == "" { // Silence may mean the port is fine and the controller is not, or that // the handle is stale (a USB adapter unplugged and replugged). Let go of // it so the next attempt starts from a clean open rather than repeating // the same silence for ever. if c.ComPort != "" { drop(c.ComPort) } return "", fmt.Errorf("no reply to %q", cmd) } return line, nil } // drain empties whatever is waiting, without blocking for long. func drain(sp serial.Port) { buf := make([]byte, 128) for i := 0; i < 4; i++ { n, err := sp.Read(buf) if n == 0 || err != nil { return } } } // 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 } // --- Elevation: the az/el controllers --- // // The ERC-M (Easy Rotor Control, DF9GR) is the reason this half exists. It // drives a Yaesu G-5500 — the az/el pair most satellite stations own — and // emulates GS-232 over its USB port, so the same three commands that already // pointed an azimuth rotator point a satellite antenna once elevation is added. // // A plain ERC or a microHAM ARCO answers the azimuth commands and ignores // these; that is why the elevation capability is a property of the configured // TYPE and not something probed at runtime. Asking a controller with no // elevation motor where its elevation is gets an answer, and the answer is // zero, for ever. // GoToAzEl points an az/el controller at both axes in one command. GS-232's W // takes the two angles separated by a space, azimuth first. // // Elevation is clamped to 0-180 rather than 0-90: a G-5500 goes past the zenith // and keeps counting, which is how an overhead pass is followed without swinging // the azimuth 180° through the middle of it. func (c *Client) GoToAzEl(az, el int) error { az = ((az % 360) + 360) % 360 if el < 0 { el = 0 } if el > 180 { el = 180 } _, err := c.roundTrip(fmt.Sprintf("W%03d %03d", az, el), false) return err } // elRe matches the elevation half of a reply, in either flavour. The GS-232A // form of C2 is "+0aaa+0eee" — two identically-shaped groups — so the azimuth // is taken from the first match and the elevation from the second, which is // what bothRe below does; this one is for the reply to a bare B. var elRe = regexp.MustCompile(`(?:\+0|EL=)(\d{3})`) // bothRe pulls both angles out of a C2 reply. var bothRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})[^0-9+]*(?:\+0|EL=)(\d{3})`) // Position queries both axes. // // C2 first, because one exchange is one chance for a serial line to go quiet. // Controllers that answer C2 with the azimuth alone — some ERC firmware does — // fall through to the two separate queries rather than reporting an elevation // of zero, which would read as "the antenna is on the horizon" and is the one // wrong answer that looks plausible. func (c *Client) Position() (az, el int, raw string, err error) { raw, err = c.roundTrip("C2", true) if err == nil { if m := bothRe.FindStringSubmatch(raw); m != nil { a, _ := strconv.Atoi(m[1]) e, _ := strconv.Atoi(m[2]) return a % 360, e, raw, nil } } a, azRaw, aerr := c.Heading() if aerr != nil { return 0, 0, azRaw, aerr } e, elRaw, eerr := c.Elevation() if eerr != nil { return a, 0, azRaw + " " + elRaw, eerr } return a, e, azRaw + " " + elRaw, nil } // Elevation queries the elevation axis alone. func (c *Client) Elevation() (el int, raw string, err error) { raw, err = c.roundTrip("B", true) if err != nil { return 0, raw, err } m := elRe.FindStringSubmatch(raw) if m == nil { return 0, raw, fmt.Errorf("unrecognised elevation reply %q", raw) } el, _ = strconv.Atoi(m[1]) return el, raw, nil }