feat: While closing OpsLog will keep the same size and position for next launch
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// 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" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
|
||||
// <freq> = int32 big-endian of (Hz / 10)
|
||||
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
|
||||
// <cmd> = '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
|
||||
|
||||
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
|
||||
|
||||
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
|
||||
// frequency sets until it is turned back ON. Set on Retract, cleared by
|
||||
// re-enabling on the next SetFrequency.
|
||||
needAutotrack 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")
|
||||
}
|
||||
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" <freq be32 of Hz/10> 00 <dir> <cmd> 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")
|
||||
}
|
||||
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. If a prior
|
||||
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
if c.needAutotrack {
|
||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
||||
return err
|
||||
}
|
||||
c.needAutotrack = false
|
||||
}
|
||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
|
||||
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 leaves AUTOTRACK off,
|
||||
// so the next SetFrequency re-enables it.
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
||||
return err
|
||||
}
|
||||
c.needAutotrack = true
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user