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) } }