Files
OpsLog/internal/rotator/spid/spid_test.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

146 lines
5.5 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
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, Rot2Prog) // 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, Rot2Prog)
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, Rot2Prog)
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
assertBytes(t, "az 359 res 4", got, want)
}
// A Rot1Prog reads its azimuth from offsets 1, 2 and 3 — three digits, the same
// field its five-byte replies use.
//
// This is field evidence, not a reading of the reference: sending the
// four-digit Rot2Prog form to a RAK/RAU made every command turn the antenna
// nearly a full circle ANTICLOCKWISE. "0450" for 90° was read as 045, which is
// 45 360 = 315°, and the same shift made 0°, 180° and every other target go
// the wrong way round too. The three cases below are the ones that were tried
// on the tower.
func TestRot1ProgSetFrameIsThreeDigits(t *testing.T) {
// 0° → 360 → "360". The "point to 0°" test that did nothing.
assertBytes(t, "rot1prog az 0", BuildSet(0, 0, 1, Rot1Prog),
[]byte{0x57, '3', '6', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
// 90° → 450 → "450". Sent as "0450" it read as 45 360 = 315°, which from
// 45° is a full turn the wrong way.
assertBytes(t, "rot1prog az 90", BuildSet(90, 0, 1, Rot1Prog),
[]byte{0x57, '4', '5', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
// 359° → 719 → "719": three digits still, at the top of the range.
assertBytes(t, "rot1prog az 359", BuildSet(359, 0, 1, Rot1Prog),
[]byte{0x57, '7', '1', '9', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
}
// What a Rot1Prog is SENT and what it REPORTS have to be the same number, or
// the antenna goes somewhere nobody asked for. Round-tripping every degree of
// the circle through the command builder and the reply parser is the cheapest
// way to say that, and it is the check that would have caught the four-digit
// frame before it reached a tower.
func TestRot1ProgCommandAndReplyAgree(t *testing.T) {
for deg := 0; deg < 360; deg++ {
cmd := BuildSet(float64(deg), 0, 1, Rot1Prog)
// The controller reads three ASCII digits and answers with the same value
// in raw bytes — the asymmetry this protocol is built on.
reply := []byte{0x57, cmd[1] - '0', cmd[2] - '0', cmd[3] - '0', 0x20}
az, _, _, err := ParseStatus(reply, Rot1Prog)
if err != nil {
t.Fatalf("%d°: %v", deg, err)
}
if int(az+0.5) != deg {
t.Fatalf("commanded %d°, the controller would report %v° — a %v° error",
deg, az, az-float64(deg))
}
}
}
// 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)
}
}
}