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.
370 lines
9.9 KiB
Go
370 lines
9.9 KiB
Go
// 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
|
|
}
|