feat(sat): point the antenna — EasyComm II az/el rotator

EasyComm is what satellite rotator controllers agreed on, so a box that
works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its
own settings rather than the HF rotator's: an az/el pair is a different
machine on a different port, and an operator who has both must not have
to choose.

A great many EasyComm controllers — the Arduino trackers above all —
accept commands and never say a word back. That is legal and common, so a
silent controller is not treated as a broken one: it is still driven, and
the last commanded position is reported in its place, marked as commanded
rather than read. A stuck rotator must not be able to hide behind an
order it never carried out, which is why the panel shows the antenna's
position beside the satellite's.

The 450° overlap is the reason a satellite rotator is worth having, so it
is used: a pass crossing north continues past 360 instead of unwinding
three quarters of a turn with the antenna sweeping the ground. Below the
configured elevation the mast is left alone — the numbers are right all
the way round the orbit, but a rotator that chases a satellite through
the far side of the earth spends the night turning, and a mast has a
finite number of turns in it.
This commit is contained in:
2026-09-07 11:34:35 +02:00
parent 465481f8f1
commit 90e363f49e
11 changed files with 953 additions and 11 deletions
+369
View File
@@ -0,0 +1,369 @@
// 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<LF> point there
// AZ EL<LF> ask where it is — the reply is the same shape
// SA SE<LF> 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
}