// Package easycomm drives azimuth/elevation rotator controllers that speak // EasyComm II, over a raw TCP socket or a serial port. // // EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict // and Hamlib all speak it, so a controller that works with any of those works // here. The dialect matters less than it looks — every command is a two-letter // name with a number stuck to it, on one line, and a controller that does not // recognise one ignores it. // // The subset used: // // AZ123.4 EL45.0 point there // AZ EL ask where it is — the reply is the same shape // SA SE stop both axes // // Not every controller ANSWERS. A great many EasyComm boxes — the Arduino // trackers above all — accept commands and never say a word back, which is // perfectly legal in EasyComm I and common in II. So a silent controller is not // treated as a broken one: the last commanded position is reported instead, and // the rotator keeps being driven. Refusing to work with a write-only controller // would rule out half the satellite stations in the hobby. package easycomm import ( "fmt" "io" "math" "net" "strconv" "strings" "sync" "time" "go.bug.st/serial" ) const ( dialTimeout = 3 * time.Second ioTimeout = 1500 * time.Millisecond // replyWait is how long a query waits before deciding the controller is one // of the silent ones. Short: this runs once a second inside a pass, and a // controller that is going to answer answers in milliseconds. replyWait = 400 * time.Millisecond ) // Client is one rotator controller. Exactly one of (Host, Port) or ComPort is // used. type Client struct { Host string Port int ComPort string Baud int // MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow // a pass straight through north without unwinding, which is the difference // between hearing the whole of an overhead pass and losing the middle of it. MaxAz int mu sync.Mutex // lastAz/lastEl are what was last commanded — the answer for a controller // that does not talk back. lastAz, lastEl float64 commanded bool // silent latches once a query has gone unanswered. Without it, a write-only // controller costs a 400 ms wait on every single poll of a pass. silent bool } // New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld // convention and the usual default in the controllers' own setup screens. func New(host string, port int, maxAz int) *Client { if strings.TrimSpace(host) == "" { host = "127.0.0.1" } if port <= 0 || port > 65535 { port = 4533 } return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)} } // NewSerial builds a serial client. func NewSerial(comPort string, baud int, maxAz int) *Client { if baud <= 0 { baud = 9600 } return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)} } func normMaxAz(v int) int { if v == 450 { return 450 } return 360 } // Point commands the rotator to an azimuth and elevation. // // The azimuth is given in the rotator's own terms: on a 450° machine an // azimuth past 360 is a real, reachable position, and asking for 010 when the // rotator is sitting at 370 would send it the long way round through the whole // scale — three quarters of a turn, in the middle of a pass, with the antenna // pointing at the ground for most of it. func (c *Client) Point(az, el float64) error { az = c.wrapAz(az) el = clamp(el, 0, 180) if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil { return err } c.mu.Lock() c.lastAz, c.lastEl, c.commanded = az, el, true c.mu.Unlock() return nil } // Stop halts both axes. func (c *Client) Stop() error { return c.send("SA SE", false) } // Heading is where the rotator says it is. // // live is false when the answer is the last commanded position rather than a // reading — the caller shows that differently, because "where I told it to go" // and "where it is" are not the same claim and a stuck rotator must not be able // to hide behind the first. func (c *Client) Heading() (az, el float64, live bool, err error) { c.mu.Lock() silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded c.mu.Unlock() if silent { if !commanded { return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position") } return la, le, false, nil } line, err := c.query("AZ EL") if err != nil { // One silence is enough: a controller either answers or it does not, and // this runs every second for the length of a pass. c.mu.Lock() c.silent = true c.mu.Unlock() if commanded { return la, le, false, nil } return 0, 0, false, err } a, e, ok := parseHeading(line) if !ok { c.mu.Lock() c.silent = true c.mu.Unlock() if commanded { return la, le, false, nil } return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line) } return a, e, true, nil } // wrapAz brings an azimuth into what this rotator can reach. // // On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an // OVERLAP — 370 and 10 are the same direction — and which of the two to use is // decided by whichever is nearer where the rotator already is, so a pass // crossing north continues instead of unwinding. func (c *Client) wrapAz(az float64) float64 { az = math.Mod(az, 360) if az < 0 { az += 360 } if c.MaxAz != 450 { return az } c.mu.Lock() cur, known := c.lastAz, c.commanded c.mu.Unlock() if !known { return az } alt := az + 360 if alt > 450 { return az } if math.Abs(alt-cur) < math.Abs(az-cur) { return alt } return az } // ── Transport ─────────────────────────────────────────────────────────────── type heldPort struct { p serial.Port openedAt time.Time } var ( portsMu sync.Mutex openPorts = map[string]*heldPort{} ) // bootSettle: an Arduino-based controller resets when its serial port is // opened, and its bootloader then holds the processor for a second or more. A // command sent into that window is simply lost — which is how a controller that // answers a terminal perfectly reports nothing here. const bootSettle = 2 * time.Second 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(150 * time.Millisecond) h := &heldPort{p: sp, openedAt: time.Now()} openPorts[com] = h return h, nil } 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) } } // Close releases the serial port. TCP dials per command and holds nothing. func (c *Client) Close() { if c.ComPort != "" { drop(c.ComPort) } } func (c *Client) send(cmd string, wantReply bool) error { _, err := c.exchange(cmd, wantReply) return err } func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) } func (c *Client) exchange(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 } if wait := bootSettle - time.Since(h.openedAt); wait > 0 { time.Sleep(wait) } conn = h.p 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 rotator %s:%d: %w", c.Host, c.Port, err) } _ = nc.SetDeadline(time.Now().Add(ioTimeout)) defer nc.Close() conn = nc } // LF, not CR: EasyComm's own documents use a line feed, and the controllers // that want CR accept either. The reverse is not true of every Arduino // sketch out there. if _, err := conn.Write([]byte(cmd + "\n")); err != nil { if c.ComPort != "" { drop(c.ComPort) } return "", fmt.Errorf("send %q: %w", cmd, err) } if !wantReply { return "", nil } buf := make([]byte, 128) var sb strings.Builder deadline := time.Now().Add(replyWait) 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 } } if err != nil { break } } line := strings.TrimSpace(sb.String()) if line == "" { return "", fmt.Errorf("no reply to %q", cmd) } return line, nil } func drain(sp serial.Port) { buf := make([]byte, 256) for { n, err := sp.Read(buf) if n == 0 || err != nil { return } } } // parseHeading reads a controller's answer. // // The shapes in the wild differ more than the specification suggests — // "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so // this looks for the two labels and takes the number attached to each rather // than trying to match a whole line. func parseHeading(line string) (az, el float64, ok bool) { up := strings.ToUpper(line) az, aok := numberAfter(up, "AZ") el, eok := numberAfter(up, "EL") if !aok { return 0, 0, false } // Elevation missing is not a broken reply: an azimuth-only controller // answering an AZ EL query says what it has. if !eok { el = 0 } return az, el, true } func numberAfter(s, label string) (float64, bool) { i := strings.Index(s, label) if i < 0 { return 0, false } rest := strings.TrimLeft(s[i+len(label):], " \t=:") end := 0 for end < len(rest) { ch := rest[end] if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) { end++ continue } break } if end == 0 { return 0, false } v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64) if err != nil { return 0, false } return v, true } func clamp(v, lo, hi float64) float64 { if v < lo { return lo } if v > hi { return hi } return v }