// 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 ( "encoding/binary" "fmt" "io" "log" "net" "sync" "time" "go.bug.st/serial" ) // 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 ) // 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 // 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. 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 err != nil { log.Printf("steppir: status query failed, reconnecting: %v", err) c.closeConn() c.setDisconnected() continue } st.Connected = true c.statusMu.Lock() if c.pendingDirSet { if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir { c.pendingDirSet = false } else { st.Direction = c.pendingDir } } c.lastStatus = st c.statusMu.Unlock() } } } 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)) } } 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() setDeadline(conn, 3*time.Second) 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) } return parseStatus(buf) } // 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==0xFF means "command just received" (not motion); the 0x01 bit is // documented as always set. Treat anything else non-zero as motors busy. moving := 0 if active != 0xFF && (active & ^byte(0x01)) != 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')) }