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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user