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.
100 lines
3.3 KiB
Go
100 lines
3.3 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|