// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over // its "Transceiver Interface" serial protocol, reached either directly on a COM // port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to // an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can // drive either behind one interface. // // Protocol (cross-checked against the SteppIR "Transceiver Interface Operation" // note, the we7u/steppir library, and the la1k.no write-up — three independent // sources that agree, which is what makes the byte layout trustworthy): // // SET : "@A" 00 00 0x0D (11 bytes) // = int32 big-endian of (Hz / 10) // = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave // = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF // 'S' home/retract · 'V' calibrate // STATUS: "?A" 0x0D → 11 bytes back: // [2:6] int32 big-endian frequency (× 10 = Hz) // [6] active-motor bitmask (0xFF = command received / setup) // [7] & 0xE0 direction // // Timing: the controller needs ≥100 ms between commands and dislikes status // polls faster than ~10/s. The poll loop runs at 2 s, well inside that. package steppir import ( "bytes" "encoding/binary" "errors" "fmt" "io" "log" "net" "sync" "time" "go.bug.st/serial" ) // errBadFrame marks a reply that isn't a well-formed status frame. It means // "ignore this poll", not "the link is down". var errBadFrame = errors.New("steppir: malformed status frame") // Direction values, matching the app-wide convention (also used by Ultrabeam): // 0 normal, 1 reverse (180°), 2 bidirectional. const ( DirNormal = 0 Dir180 = 1 DirBi = 2 ) // SteppIR direction bytes on the wire. const ( wireNormal = 0x00 wire180 = 0x40 wireBi = 0x80 ) // pendingDirTTL is how long a commanded direction is trusted over the // controller's own report. The elements physically re-tune to swap director and // reflector, and the SDA only reports the new pattern once it starts that move, // so a few seconds is not enough — 4 s (the original value) had the UI snapping // back to "normal" while the antenna was on its way to 180°. Long enough to // cover a real move, short enough that a command the controller never received // self-corrects instead of lying forever. const pendingDirTTL = 45 * time.Second // Transport says how to reach the controller. type Transport struct { Mode string // "tcp" | "serial" Host string // tcp Port int // tcp COM string // serial device (COM3, /dev/ttyUSB0) Baud int // serial baud (controller default 9600; 1200-19200 valid) } // Status is the antenna state, in the same shape the app reads from the // Ultrabeam so the two are interchangeable at the UI. type Status struct { Connected bool `json:"connected"` Frequency int `json:"frequency"` // kHz Band int `json:"band"` // 0 (SteppIR does not report a band index) Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional MotorsMoving int `json:"motors_moving"` } type Client struct { tr Transport connMu sync.Mutex conn io.ReadWriteCloser // ioMu serialises EVERY exchange on the shared connection — a status query // (write "?A" then read 11 bytes) and a command write must never interleave, // or their bytes mix on the wire and both frames are corrupted. The status // poll runs on one goroutine, tuning on another, so this is essential. ioMu sync.Mutex statusMu sync.RWMutex lastStatus *Status lastSetKHz int // lastRaw holds the previous raw status frame so we only log a status line // when the controller's reply actually changes — enough to diagnose a stuck // "motors moving" read (which drives the app's TX-inhibit interlock) without // spamming the log every 2 s poll. lastRaw []byte // A just-commanded direction is held until the controller's poll reports it — // the motors take a second or two, and a stale poll would otherwise snap the // UI back. Same trick as the Ultrabeam client. // // The hold is deliberately long (pendingDirTTL). It is not just a UI nicety: // the follow loop re-tunes with the direction it reads back from this status, // so a single stale poll reading "normal" would make OpsLog command the // antenna out of 180° all by itself. pendingDir int pendingDirAt time.Time pendingDirSet bool stopChan chan struct{} running bool } func New(tr Transport) *Client { if tr.Baud <= 0 { tr.Baud = 9600 } return &Client{tr: tr, stopChan: make(chan struct{})} } func (c *Client) Start() error { c.running = true go c.pollLoop() return nil } func (c *Client) Stop() { if !c.running { return } c.running = false close(c.stopChan) c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.connMu.Unlock() } // LastSetKHz returns the frequency last commanded, or 0. func (c *Client) LastSetKHz() int { c.statusMu.RLock() defer c.statusMu.RUnlock() return c.lastSetKHz } func (c *Client) GetStatus() (*Status, error) { c.statusMu.RLock() defer c.statusMu.RUnlock() if c.lastStatus == nil { return &Status{Connected: false}, nil } return c.lastStatus, nil } // open dials the transport. Callers hold connMu. func (c *Client) open() (io.ReadWriteCloser, error) { switch c.tr.Mode { case "serial": if c.tr.COM == "" { return nil, fmt.Errorf("steppir: no serial port configured") } p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud}) if err != nil { return nil, err } // A finite read timeout so a silent controller doesn't wedge the poll loop. _ = p.SetReadTimeout(2 * time.Second) return p, nil default: // tcp if c.tr.Host == "" { return nil, fmt.Errorf("steppir: no host configured") } d := net.Dialer{Timeout: 5 * time.Second} return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port))) } } func (c *Client) pollLoop() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { select { case <-c.stopChan: return case <-ticker.C: c.connMu.Lock() if c.conn == nil { conn, err := c.open() if err != nil { c.connMu.Unlock() c.setDisconnected() continue } c.conn = conn } c.connMu.Unlock() st, err := c.queryStatus() if errors.Is(err, errBadFrame) { // Framing glitch, not a dead link: skip this tick and keep the // previous status. Dropping the connection here would blink the // UI to "disconnected" over one garbled reply. continue } if err != nil { log.Printf("steppir: status query failed, reconnecting: %v", err) c.closeConn() c.setDisconnected() continue } st.Connected = true c.statusMu.Lock() c.applyPendingDir(st) c.lastStatus = st c.statusMu.Unlock() } } } // applyPendingDir replaces a freshly polled direction with the one the operator // last commanded, until the controller confirms it (or the hold expires). The // caller holds statusMu. func (c *Client) applyPendingDir(st *Status) { if !c.pendingDirSet { return } switch { case st.Direction == c.pendingDir: c.pendingDirSet = false // confirmed — trust the controller's reports again case time.Since(c.pendingDirAt) > pendingDirTTL: c.pendingDirSet = false log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold", c.pendingDir, st.Direction) default: st.Direction = c.pendingDir } } func (c *Client) setDisconnected() { c.statusMu.Lock() c.lastStatus = &Status{Connected: false} c.statusMu.Unlock() } func (c *Client) closeConn() { c.connMu.Lock() if c.conn != nil { c.conn.Close() c.conn = nil } c.connMu.Unlock() } // setDeadline applies a read/write deadline on TCP; serial uses its own timeout. func setDeadline(conn io.ReadWriteCloser, d time.Duration) { if nc, ok := conn.(net.Conn); ok { _ = nc.SetDeadline(time.Now().Add(d)) } } // setReadTimeout bounds a single read on either transport, so a drain can tell // "nothing more queued" from "still arriving" without blocking. func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) { switch t := conn.(type) { case net.Conn: _ = t.SetReadDeadline(time.Now().Add(d)) case serial.Port: _ = t.SetReadTimeout(d) } } // restoreTimeouts puts the normal exchange timeouts back after a drain shortened // them. func restoreTimeouts(conn io.ReadWriteCloser) { setDeadline(conn, 3*time.Second) // TCP: read + write setReadTimeout(conn, 2*time.Second) } // drain throws away everything already sitting in the input buffer and returns // how many bytes it discarded. // // This is the fix for the antenna's state appearing tens of seconds out of date. // The SDA controller does not only answer "?A" — it also pushes status frames on // its own (front-panel changes, autotrack moves, each command it processes). We // consume exactly one frame per poll, so every unsolicited frame adds one to a // backlog that only ever grows: reading 11 bytes then returns a frame from // minutes ago. The field log showed it plainly — two consecutive polls 4 s apart // reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a // 180° command not showing up in the status for ~40 s (long after the UI had // given up waiting and snapped the button back to "normal"). Emptying the buffer // immediately before each query means the frame we then read is the answer to // THIS query. func drain(conn io.ReadWriteCloser) int { buf := make([]byte, 512) total := 0 // Bounded so a controller that streams continuously can't hold the poll // goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than // any real link builds up, and it only costs one 30 ms timeout when the // buffer is already empty (reads return immediately while data is queued). for i := 0; i < 32; i++ { setReadTimeout(conn, 30*time.Millisecond) n, err := conn.Read(buf) total += n if err != nil || n == 0 { // timeout / nothing left break } } return total } func (c *Client) queryStatus() (*Status, error) { c.connMu.Lock() conn := c.conn c.connMu.Unlock() if conn == nil { return nil, fmt.Errorf("steppir: not connected") } c.ioMu.Lock() defer c.ioMu.Unlock() // Discard any frame the controller pushed on its own since the last poll, so // what we read below is this query's answer and not a stale backlog entry. if n := drain(conn); n > 0 { log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n) } restoreTimeouts(conn) if _, err := conn.Write([]byte("?A\r")); err != nil { return nil, fmt.Errorf("write status cmd: %w", err) } buf := make([]byte, 11) if _, err := io.ReadFull(conn, buf); err != nil { return nil, fmt.Errorf("read status: %w", err) } // Reject anything that isn't a framed reply rather than decoding garbage into // a frequency and a direction the app would then act on. if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D { log.Printf("steppir: ignoring malformed status frame % X", buf) drain(conn) // resync: drop the rest of whatever we landed mid-way through restoreTimeouts(conn) return nil, errBadFrame } st, err := parseStatus(buf) // Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is // what decides st.MotorsMoving, and that in turn drives the app's "block TX // while moving" interlock — so if a controller (e.g. with its INHIBIT engaged) // reports a byte we misread as perpetual motion, this line makes it visible. if err == nil && !bytes.Equal(buf, c.lastRaw) { c.lastRaw = append(c.lastRaw[:0], buf...) log.Printf("steppir: status ← % X (freq=%d kHz dir=%d moving=%d motorByte=0x%02X dirByte=0x%02X)", buf, st.Frequency, st.Direction, st.MotorsMoving, buf[6], buf[7]) } return st, err } // parseStatus decodes an 11-byte status frame. func parseStatus(b []byte) (*Status, error) { if len(b) < 11 { return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b)) } freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10 active := b[6] dir := decodeDir(b[7]) // active-motors byte: one bit per element that is currently moving. // 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C) // Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the // controller just received a command, not motion. So "moving" is precisely // "any real motor bit set", ignoring the always-on bit and the ack value. const motorBits = 0x3C moving := 0 if active != 0xFF && active&motorBits != 0 { moving = 1 } return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil } func decodeDir(b byte) int { switch b & 0xE0 { case wireBi: return DirBi case wire180: return Dir180 default: return DirNormal } } func dirWireByte(dir int) byte { switch dir { case Dir180: return wire180 case DirBi: return wireBi default: return wireNormal } } // buildSet frames a SET command: "@A" 00 00 CR. func buildSet(freqHz int, dir int, cmd byte) []byte { var f [4]byte binary.BigEndian.PutUint32(f[:], uint32(freqHz/10)) out := make([]byte, 0, 11) out = append(out, '@', 'A') out = append(out, f[:]...) out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D) return out } func (c *Client) writeCmd(pkt []byte) error { c.connMu.Lock() conn := c.conn c.connMu.Unlock() if conn == nil { return fmt.Errorf("steppir: not connected") } c.ioMu.Lock() defer c.ioMu.Unlock() log.Printf("steppir: → % X", pkt) setDeadline(conn, 3*time.Second) if _, err := conn.Write(pkt); err != nil { c.closeConn() return err } // The controller needs breathing room between commands. time.Sleep(120 * time.Millisecond) return nil } // SetFrequency tunes the elements to freqKhz with the given direction. // // AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency // sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and // RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if // switched off on the front panel. Sending the 'R' command each tune is cheap and // makes tuning work regardless of the controller's current mode — which is what // was silently failing before. func (c *Client) SetFrequency(freqKhz int, direction int) error { if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON return err } if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir return err } c.statusMu.Lock() c.lastSetKHz = freqKhz c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.statusMu.Unlock() return nil } // SetDirection changes the pattern. SteppIR has no standalone direction command — // it is a SET with the current frequency and the new direction byte. func (c *Client) SetDirection(direction int) error { khz := c.LastSetKHz() if khz <= 0 { if st, _ := c.GetStatus(); st != nil { khz = st.Frequency } } if khz <= 0 { return fmt.Errorf("steppir: no frequency known yet — cannot set direction") } return c.SetFrequency(khz, direction) } // Retract homes the elements into the hubs (storage). This drops the controller // out of AUTOTRACK, but that is handled transparently: the next SetFrequency // re-issues AUTOTRACK ON before tuning. func (c *Client) Retract() error { // A valid frequency must accompany the command; reuse the last one. khz := c.LastSetKHz() if khz <= 0 { if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 { khz = st.Frequency } else { khz = 14000 // any in-range value; the controller just homes } } return c.writeCmd(buildSet(khz*1000, DirNormal, 'S')) }