Files
OpsLog/internal/rotator/spid/spid.go
T
rouggy f405e71d7d fix(spid): a Rot1Prog takes three digits, so every target went the wrong way
Field report from a tower: on a RAK/RAU in Rot1Prog, every commanded heading
made the antenna want to turn nearly a full circle ANTICLOCKWISE — 0°, 90°, any
of them — while the heading readout, the stop button and everything else worked.

BuildSet framed the four-digit Rot2Prog azimuth for both dialects. A Rot1Prog
reads three: its replies are three digits in a five-byte frame, and its command
field matches. So 90° went out as "0450" and was read as 045 — 45 − 360 = −315°.
Every target landed 360° low, which is why it was always anticlockwise and
always nearly a full turn. The operator's own guess, that OpsLog was in 720°
mode, was the right instinct in the wrong place: the fault is a decimal shift,
not a range.

The round-trip test added here is the one that would have caught it without a
tower — every degree of the circle through the command builder and back through
the reply parser, which must return the degree that went in. The frame tests
pinned the Rot2Prog form against the reference and said nothing about the other
dialect.

Two more from the same report. A rotator test that only READS the heading — SPID,
ARCO, DCU-1 — said "Packet sent, the antenna should swing to north, check
PstRotator's UDP listener", naming a program not in the path for a move never
commanded; it now says the controller answered and nothing was moved. And the
compass polled every three seconds, so a turning antenna moved the needle in
steps of about thirteen degrees; the heading now has its own 700 ms tick while
the relay boards and the antenna controller stay at three seconds.
2026-08-16 13:31:00 +02:00

300 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
//
// ROT1PROG IS THREE DIGITS IN BOTH DIRECTIONS. Its reply carries three, and so
// does its command — the controller reads the azimuth from offsets 1, 2 and 3,
// with no resolution scaling (it is one pulse per degree). Sending it the
// four-digit Rot2Prog form shifts every target by a decimal place: 90° goes out
// as "0450" and is read as 045, which is 315°, so every command turns the
// antenna nearly a full circle the wrong way. Found on a tower, not here.
//
// 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.
//
// ROT1PROG SENDS THREE DIGITS, NOT FOUR, and that is the whole reason this
// takes a model. Its reply is three digits — a 5-byte frame — and its command
// field matches: the controller reads the azimuth from offsets 1, 2 and 3.
//
// Sending the four-digit Rot2Prog form to one shifts every target by a decimal
// place. "0450" for 90° was read as 045, i.e. 45 360 = 315°, so every
// command became a near-full turn ANTICLOCKWISE whatever was asked for — 0°,
// 90°, 180°, all of them. That is exactly how it was reported from a tower:
// every heading wanted to go the wrong way round, and a "point to 0°" test that
// did nothing useful.
func BuildSet(az, el float64, resolution byte, model Model) []byte {
if model == Rot1Prog {
// No scaling: a Rot1Prog is one pulse per degree and reports resolution 1.
return buildCmd3(int(360+az+0.5), int(360+el+0.5))
}
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)
}
// buildCmd3 frames a Rot1Prog target: three ASCII digits per axis at the same
// offsets its replies use, the fourth digit position left as '0'.
func buildCmd3(uaz, uel int) []byte {
c := make([]byte, 13)
c[0] = frameStart
c[1] = '0' + byte(uaz/100%10)
c[2] = '0' + byte(uaz/10%10)
c[3] = '0' + byte(uaz%10)
c[4] = '0'
c[5] = 0x01
c[6] = '0' + byte(uel/100%10)
c[7] = '0' + byte(uel/10%10)
c[8] = '0' + byte(uel%10)
c[9] = '0'
c[10] = 0x01
c[11] = cmdSet
c[12] = frameEnd
return c
}
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, c.model), 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
}