228 lines
7.8 KiB
Go
228 lines
7.8 KiB
Go
package steppir
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
|
||
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
|
||
// antenna, far worse than a compile error.
|
||
func TestBuildSetLayout(t *testing.T) {
|
||
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
|
||
pkt := buildSet(14_074_000, DirNormal, '1')
|
||
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
|
||
if len(pkt) != 11 {
|
||
t.Fatalf("packet is %d bytes, want 11", len(pkt))
|
||
}
|
||
for i := range want {
|
||
if pkt[i] != want[i] {
|
||
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
|
||
}
|
||
}
|
||
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
|
||
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
|
||
t.Fatalf("freq round-trip = %d, want 14074000", got)
|
||
}
|
||
}
|
||
|
||
func TestBuildSetDirectionAndCommand(t *testing.T) {
|
||
cases := []struct {
|
||
dir int
|
||
cmd byte
|
||
wantDir byte
|
||
wantCmd byte
|
||
}{
|
||
{DirNormal, '1', 0x00, '1'},
|
||
{Dir180, '1', 0x40, '1'},
|
||
{DirBi, '1', 0x80, '1'},
|
||
{DirNormal, 'S', 0x00, 'S'}, // retract / home
|
||
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
|
||
}
|
||
for _, c := range cases {
|
||
pkt := buildSet(21_000_000, c.dir, c.cmd)
|
||
if pkt[7] != c.wantDir {
|
||
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
|
||
}
|
||
if pkt[8] != c.wantCmd {
|
||
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||
// version "08". Using the actual device output (rather than hand-built bytes)
|
||
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||
func TestParseStatusRealFrame(t *testing.T) {
|
||
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||
st, err := parseStatus(frame)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.Frequency != 50313 {
|
||
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||
}
|
||
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||
}
|
||
if st.MotorsMoving != 0 {
|
||
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||
}
|
||
}
|
||
|
||
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||
// frequency field, plus the direction nibble and the motors-busy byte.
|
||
func TestParseStatus(t *testing.T) {
|
||
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||
st, err := parseStatus(frame)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.Frequency != 14074 {
|
||
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
|
||
}
|
||
if st.Direction != Dir180 {
|
||
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||
}
|
||
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
|
||
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
|
||
}
|
||
|
||
// Motors busy: a bit beyond 0x01 is set.
|
||
frame[6] = 0x07
|
||
st, _ = parseStatus(frame)
|
||
if st.MotorsMoving == 0 {
|
||
t.Error("active-motors 0x07 should read as moving")
|
||
}
|
||
|
||
// 0xFF is "command received", not motion.
|
||
frame[6] = 0xFF
|
||
st, _ = parseStatus(frame)
|
||
if st.MotorsMoving != 0 {
|
||
t.Error("active-motors 0xFF (command received) must not read as moving")
|
||
}
|
||
}
|
||
|
||
// fakeConn stands in for the controller link. rx holds bytes the "controller"
|
||
// has already sent (what the client will read), tx collects what the client
|
||
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
|
||
//
|
||
// Reads never block: an empty rx returns (0, nil), which is exactly how
|
||
// go.bug.st/serial reports a read timeout, so drain() sees the same
|
||
// nothing-left signal it gets from real hardware.
|
||
type fakeConn struct {
|
||
mu sync.Mutex
|
||
rx bytes.Buffer
|
||
tx bytes.Buffer
|
||
reply []byte
|
||
}
|
||
|
||
func (f *fakeConn) Read(p []byte) (int, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
if f.rx.Len() == 0 {
|
||
return 0, nil
|
||
}
|
||
return f.rx.Read(p)
|
||
}
|
||
|
||
func (f *fakeConn) Write(p []byte) (int, error) {
|
||
f.mu.Lock()
|
||
defer f.mu.Unlock()
|
||
f.tx.Write(p)
|
||
if bytes.Contains(p, []byte("?A")) {
|
||
f.rx.Write(f.reply)
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
func (f *fakeConn) Close() error { return nil }
|
||
|
||
var (
|
||
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
|
||
// F4BPO's friend's log long after the rig had left the band.
|
||
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
|
||
// 14.250 MHz, 180° — what the controller actually reports right now.
|
||
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
|
||
)
|
||
|
||
// The controller pushes status frames unsolicited, so they pile up between polls.
|
||
// Reading one frame per poll then returns state from minutes ago — which is how a
|
||
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
|
||
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
|
||
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
|
||
fc := &fakeConn{reply: frame20m180}
|
||
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
|
||
fc.rx.Write(frame6mNormal)
|
||
|
||
c := &Client{conn: fc}
|
||
st, err := c.queryStatus()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if st.Frequency != 14250 {
|
||
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
|
||
}
|
||
if st.Direction != Dir180 {
|
||
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||
}
|
||
}
|
||
|
||
// A reply we land on mid-frame must be rejected, not decoded into a bogus
|
||
// frequency and direction the follow loop would then act on — and it must not
|
||
// look like a dead link either (errBadFrame keeps the connection).
|
||
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
|
||
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
|
||
c := &Client{conn: &fakeConn{reply: shifted}}
|
||
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
|
||
t.Fatalf("err = %v, want errBadFrame", err)
|
||
}
|
||
}
|
||
|
||
// The direction the operator just commanded is shown until the controller
|
||
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
|
||
// to Normal while the elements were still swapping over to 180°.
|
||
func TestApplyPendingDirHold(t *testing.T) {
|
||
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
|
||
|
||
// Controller still reports the old pattern: keep showing what was commanded.
|
||
st := &Status{Direction: DirNormal}
|
||
c.applyPendingDir(st)
|
||
if st.Direction != Dir180 {
|
||
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
|
||
}
|
||
if !c.pendingDirSet {
|
||
t.Fatal("hold released before the controller confirmed")
|
||
}
|
||
|
||
// Still holding well past the old 4 s window — a SteppIR takes longer than
|
||
// that to report a pattern change.
|
||
c.pendingDirAt = time.Now().Add(-10 * time.Second)
|
||
st = &Status{Direction: DirNormal}
|
||
c.applyPendingDir(st)
|
||
if st.Direction != Dir180 {
|
||
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
|
||
}
|
||
|
||
// Controller confirms: release the hold and trust its reports again.
|
||
st = &Status{Direction: Dir180}
|
||
c.applyPendingDir(st)
|
||
if c.pendingDirSet {
|
||
t.Fatal("hold should be released once the controller reports the commanded direction")
|
||
}
|
||
|
||
// A command the controller never acted on must not lie forever.
|
||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
|
||
st = &Status{Direction: DirNormal}
|
||
c.applyPendingDir(st)
|
||
if st.Direction != DirNormal || c.pendingDirSet {
|
||
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
|
||
}
|
||
}
|