feat(rotator): native SPID / AlfaSpid, so PstRotator can go

An operator with a tower at each end and an AlfaSpid on both wanted OpsLog to
talk to them directly. Multiple rotors were already there; the missing half was
the protocol.

Rot2Prog and Rot1Prog, over the controller's own COM port. The byte layout is
in the package doc and pinned by table tests against Hamlib's spid.c, the
reference implementation — including the one trap this protocol has: digits go
out as ASCII and come back as raw bytes. Send raw and the controller ignores
you; read as ASCII and every heading is wrong by a constant nobody would
recognise as such.

Two things the UI has to get right because they cannot be detected: the dialect
(different reply length AND baud rate) and the baud list, which for a SPID is
600 or 1200 — offering the usual 4800-and-up would have left the controller
permanently mute. Both are handled: picking SPID sets serial transport, 600
baud and Rot2Prog, and the baud dropdown changes to the rates these use.

The connection test reads a status rather than moving anything, so a wrong
dialect shows up there as a reply of the wrong length instead of as an antenna
that behaves oddly an hour later.

Untested against real hardware — I have none. The frames are pinned; the
controller is the only thing that can confirm the rest.
This commit is contained in:
2026-08-15 09:22:51 +02:00
parent 09f3ddaacb
commit 5bf7eb45d7
7 changed files with 448 additions and 9 deletions
+257
View File
@@ -0,0 +1,257 @@
// Package spid drives a SPID (AlfaSpid) rotator over its own serial protocol,
// Rot1Prog or Rot2Prog — the controllers sold as RAS, RAK, BIG-RAS/HR, MD-01
// and MD-02.
//
// It exists so an operator with SPID rotators does not need PstRotator running
// just to turn an antenna. Two towers with a controller each is the ordinary
// case; each one is a separate serial port and a separate rotor in OpsLog.
//
// WIRE FORMAT
//
// Every command is 13 bytes:
//
// 0 1 2 3 4 5 6 7 8 9 10 11 12
// 0x57 H1 H2 H3 H4 PH V1 V2 V3 V4 PV K 0x20
//
// K is the command: 0x0F stop, 0x1F status, 0x2F set.
//
// The DIGITS ARE ASCII in a command ('0'+d) and RAW BYTES in a reply (0..9).
// That asymmetry is the whole trap in this protocol: send raw digits and the
// controller ignores you, read them as ASCII and every heading is 48 degrees
// times a hundred out. It is pinned by the tests beside this file.
//
// PH and PV are the resolution in pulses per degree — 1, 2 or 4 — and are raw
// in both directions. The target is scaled by it:
//
// u_az = PH × (360 + az) and the four decimal digits of u_az are sent
//
// A reply is 12 bytes for Rot2Prog (azimuth and elevation) or 5 for Rot1Prog
// (azimuth only, three digits):
//
// az = H1×100 + H2×10 + H3 + H4/10 360
//
// The 360 offset is what lets the controller report a rotator that has turned
// past north in either direction, which is the point of a pulse-counting
// rotator: 180…540 rather than 0…359.
//
// Serial is 8N1 at 600 baud for Rot2Prog and 1200 for Rot1Prog. Those are not
// typos — a pulse controller has nothing to say quickly.
//
// Verified against Hamlib's spid.c (rotators/spid/spid.c), which is the
// reference implementation, and SPID's published protocol note. NOT yet run
// against real hardware here; the tests pin the frames, the controller is the
// only thing that can confirm the rest.
package spid
import (
"fmt"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// Model selects the dialect.
type Model string
const (
Rot1Prog Model = "rot1prog" // azimuth only, 5-byte reply, 1200 baud
Rot2Prog Model = "rot2prog" // azimuth + elevation, 12-byte reply, 600 baud
)
const (
cmdStop = 0x0F
cmdStatus = 0x1F
cmdSet = 0x2F
frameStart = 0x57
frameEnd = 0x20
)
// Client is one controller on one serial port.
//
// The port is opened per exchange rather than held: a rotator is polled every
// few seconds at most, and holding a COM port open for the life of the program
// is what stops an operator from using their controller's own software
// alongside — which they will want while they are still trusting this.
type Client struct {
mu sync.Mutex
port string
baud int
model Model
// resolution is pulses per degree: 1, 2 or 4. The controller is configured
// for one of them and answers with it, so a wrong value here corrects itself
// on the first status read.
resolution byte
}
// New builds a client. baud 0 takes the model's documented default.
func New(comPort string, baud int, model Model) *Client {
if model != Rot1Prog {
model = Rot2Prog
}
if baud <= 0 {
baud = 600
if model == Rot1Prog {
baud = 1200
}
}
return &Client{port: strings.TrimSpace(comPort), baud: baud, model: model, resolution: 1}
}
// BuildStatus frames the "where are you" command.
func BuildStatus() []byte { return buildCmd(0, 0, 0, 0, cmdStatus) }
// BuildStop frames the "stop now" command.
func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) }
// BuildSet frames a target. resolution is the controller's pulses per degree.
//
// Azimuth is offset by 360 before scaling, so a target of 10° and one of 350°
// are different instructions: the first turns anticlockwise past north, the
// second does not. Feeding a 0…359 heading in is therefore always safe.
func BuildSet(az, el float64, resolution byte) []byte {
if resolution == 0 {
resolution = 1
}
uaz := int(float64(resolution)*(360+az) + 0.5)
uel := int(float64(resolution)*(360+el) + 0.5)
return buildCmd(uaz, uel, resolution, resolution, cmdSet)
}
func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
c := make([]byte, 13)
c[0] = frameStart
if k == cmdSet {
c[1] = '0' + byte(uaz/1000%10)
c[2] = '0' + byte(uaz/100%10)
c[3] = '0' + byte(uaz/10%10)
c[4] = '0' + byte(uaz%10)
c[5] = ph
c[6] = '0' + byte(uel/1000%10)
c[7] = '0' + byte(uel/100%10)
c[8] = '0' + byte(uel/10%10)
c[9] = '0' + byte(uel%10)
c[10] = pv
}
c[11] = k
c[12] = frameEnd
return c
}
// ParseStatus decodes a reply. Returns the azimuth, the elevation (0 for
// Rot1Prog) and the resolution the controller reported.
func ParseStatus(buf []byte, model Model) (az, el float64, resolution byte, err error) {
want := 12
if model == Rot1Prog {
want = 5
}
if len(buf) < want {
return 0, 0, 0, fmt.Errorf("spid: short reply (%d bytes, want %d)", len(buf), want)
}
if buf[0] != frameStart || buf[want-1] != frameEnd {
return 0, 0, 0, fmt.Errorf("spid: not a reply frame: % X", buf[:want])
}
az = float64(buf[1])*100 + float64(buf[2])*10 + float64(buf[3])
if model == Rot1Prog {
return az - 360, 0, 1, nil
}
az += float64(buf[4]) / 10
el = float64(buf[6])*100 + float64(buf[7])*10 + float64(buf[8]) + float64(buf[9])/10
resolution = buf[5]
if resolution == 0 {
resolution = 1
}
return az - 360, el - 360, resolution, nil
}
// GoTo points the rotator at az (and el, on a Rot2Prog with elevation).
func (c *Client) GoTo(az int, el int) error {
c.mu.Lock()
res := c.resolution
c.mu.Unlock()
e := 0.0
if el >= 0 && c.model == Rot2Prog {
e = float64(el)
}
_, err := c.exchange(BuildSet(float64(az), e, res), 0)
return err
}
// Stop interrupts a rotation in progress.
func (c *Client) Stop() error {
// The controller answers a stop with its position, like a status — read it
// so the reply does not sit in the buffer and get taken for the ANSWER to
// the next poll, which would report a heading one command stale for ever.
_, err := c.exchange(BuildStop(), c.replyLen())
return err
}
// Heading reads the current position.
func (c *Client) Heading() (az int, el int, err error) {
buf, err := c.exchange(BuildStatus(), c.replyLen())
if err != nil {
return 0, 0, err
}
a, e, res, err := ParseStatus(buf, c.model)
if err != nil {
return 0, 0, err
}
// Believe the controller about its own resolution: it is configured on the
// front panel, and a wrong guess here would scale every target we send.
c.mu.Lock()
c.resolution = res
c.mu.Unlock()
return int(a + 0.5), int(e + 0.5), nil
}
func (c *Client) replyLen() int {
if c.model == Rot1Prog {
return 5
}
return 12
}
// exchange opens the port, writes one frame and reads the expected reply.
func (c *Client) exchange(cmd []byte, wantBytes int) ([]byte, error) {
if c.port == "" {
return nil, fmt.Errorf("spid: no serial port configured")
}
c.mu.Lock()
defer c.mu.Unlock()
p, err := serial.Open(c.port, &serial.Mode{
BaudRate: c.baud, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit,
})
if err != nil {
return nil, fmt.Errorf("spid: open %s: %w", c.port, err)
}
defer p.Close()
// 600 baud is 60 bytes a second: a 12-byte reply takes a fifth of a second
// to arrive on the wire alone, before the controller has thought about it.
_ = p.SetReadTimeout(2 * time.Second)
if _, err := p.Write(cmd); err != nil {
return nil, fmt.Errorf("spid: write: %w", err)
}
if wantBytes == 0 {
return nil, nil
}
buf := make([]byte, 0, wantBytes)
tmp := make([]byte, wantBytes)
deadline := time.Now().Add(3 * time.Second)
for len(buf) < wantBytes && time.Now().Before(deadline) {
n, err := p.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
continue
}
if err != nil {
break
}
}
if len(buf) < wantBytes {
return nil, fmt.Errorf("spid: no reply from %s (%d of %d bytes) — check the port, the baud rate (%d) and that nothing else holds the controller",
c.port, len(buf), wantBytes, c.baud)
}
return buf, nil
}