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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package spid
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The frames are pinned against Hamlib's spid.c, the reference implementation.
|
||||
// This protocol has one trap and it is here: digits go out as ASCII and come
|
||||
// back RAW. Getting that backwards points an antenna at a heading nobody asked
|
||||
// for, and nothing in the app would notice.
|
||||
func TestSetFrameMatchesTheReference(t *testing.T) {
|
||||
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
|
||||
// PH and PV raw; K = 0x2F.
|
||||
got := BuildSet(0, 0, 1) // 360 → "0360"
|
||||
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
|
||||
assertBytes(t, "az 0 res 1", got, want)
|
||||
|
||||
// 90° at half-degree resolution: 2 × 450 = 900 → "0900".
|
||||
got = BuildSet(90, 0, 2)
|
||||
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
|
||||
assertBytes(t, "az 90 res 2", got, want)
|
||||
|
||||
// A quarter-degree controller, 359°: 4 × 719 = 2876.
|
||||
got = BuildSet(359, 0, 4)
|
||||
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
|
||||
assertBytes(t, "az 359 res 4", got, want)
|
||||
}
|
||||
|
||||
// Status and stop carry no position: every data byte is zero, only K differs.
|
||||
func TestStatusAndStopFrames(t *testing.T) {
|
||||
assertBytes(t, "status", BuildStatus(),
|
||||
[]byte{0x57, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x1F, 0x20})
|
||||
assertBytes(t, "stop", BuildStop(),
|
||||
[]byte{0x57, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0F, 0x20})
|
||||
}
|
||||
|
||||
// A reply's digits are RAW, and the 360 offset is what lets a pulse-counting
|
||||
// controller report a rotator that has turned past north — the whole reason
|
||||
// these rotators exist.
|
||||
func TestParseStatusRot2Prog(t *testing.T) {
|
||||
// 0x57 H1 H2 H3 H4 PH V1 V2 V3 V4 PV 0x20
|
||||
// az digits 4,5,1,5 → 451.5 − 360 = 91.5
|
||||
frame := []byte{0x57, 4, 5, 1, 5, 0x02, 3, 6, 0, 0, 0x02, 0x20}
|
||||
az, el, res, err := ParseStatus(frame, Rot2Prog)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if math.Abs(az-91.5) > 0.001 {
|
||||
t.Errorf("az = %v, want 91.5", az)
|
||||
}
|
||||
if math.Abs(el-0) > 0.001 {
|
||||
t.Errorf("el = %v, want 0", el)
|
||||
}
|
||||
if res != 2 {
|
||||
t.Errorf("resolution = %d, want 2 — the controller's own value must win", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Rot1Prog: five bytes, three digits, no elevation.
|
||||
func TestParseStatusRot1Prog(t *testing.T) {
|
||||
az, el, res, err := ParseStatus([]byte{0x57, 4, 5, 1, 0x20}, Rot1Prog)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStatus: %v", err)
|
||||
}
|
||||
if math.Abs(az-91) > 0.001 {
|
||||
t.Errorf("az = %v, want 91", az)
|
||||
}
|
||||
if el != 0 || res != 1 {
|
||||
t.Errorf("el = %v, res = %d — Rot1Prog has neither", el, res)
|
||||
}
|
||||
}
|
||||
|
||||
// A truncated or foreign frame must be refused rather than decoded into a
|
||||
// heading: half a reply read as a position turns an antenna somewhere real.
|
||||
func TestParseStatusRefusesRubbish(t *testing.T) {
|
||||
for name, frame := range map[string][]byte{
|
||||
"short": {0x57, 4, 5, 1},
|
||||
"no start": {0x00, 4, 5, 1, 5, 1, 3, 6, 0, 0, 1, 0x20},
|
||||
"no end": {0x57, 4, 5, 1, 5, 1, 3, 6, 0, 0, 1, 0x00},
|
||||
"empty": {},
|
||||
} {
|
||||
if _, _, _, err := ParseStatus(frame, Rot2Prog); err == nil {
|
||||
t.Errorf("%s: decoded without complaint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBytes(t *testing.T, what string, got, want []byte) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: % X\nwant % X", what, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("%s: % X\nwant % X", what, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user