Files
OpsLog/internal/steppir/steppir.go
T
rouggy 421bc372ee fix(steppir): the same fast poll, and motion reported at the command
The shortened transmit gag was written for an Ultrabeam that is now polled
four times a second while it moves. The SteppIR was still on two seconds
and reported nothing at all until its own poll came round, so the gap
between the 900 ms grace and the first poll that would have seen the
movement was a hole in "block TX while the elements travel" — the
transmitter released in the middle of a move.

It now reports a commanded move at once (bounded, so an antenna that
never answers cannot latch the inhibit on), on a COPY of the cached
status so the flag cannot leak into what the poll goroutine owns, and
follows the motors with its poll rate exactly as the Ultrabeam does.
2026-09-06 20:10:39 +02:00

744 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"net"
"strings"
"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
// The same two cadences the Ultrabeam uses, and for the same reason: transmit
// is inhibited while the elements travel, so every poll interval between the
// motors stopping and this client noticing is a second the operator cannot call
// with the antenna already in place.
const (
pollIdle = 2 * time.Second
pollMoving = 250 * time.Millisecond
)
// moveOptimisticWindow is how long after a commanded move GetStatus reports
// motion before the controller has had a chance to say so.
//
// The SteppIR reports nothing until its own poll comes round, so without this
// the app's "block TX while moving" had a hole in it: the grace that covers the
// command ran out before the first poll that would have seen the movement, and
// the transmitter was released in the middle of a move. Bounded, so an antenna
// that never reports motion cannot latch the inhibit on for ever.
const moveOptimisticWindow = 3 * 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
// openFails counts consecutive failures to reopen the port, so the retry
// reports the first one and the recovery, and stays quiet in between.
// Guarded by connMu.
openFails int
// 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
// moveCmdAt is when a move was last COMMANDED — see moveOptimisticWindow.
moveCmdAt time.Time
// lastDriftKHz is the frequency last reported for a controller that had gone
// somewhere other than where it was told, so the disagreement is stated once
// and not on every poll. Zero when it is where it should be.
lastDriftKHz 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{}
// done is closed by the poll loop on its way out, so Stop can wait for the
// serial port to be genuinely released — see Stop.
done 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
c.done = make(chan struct{})
go c.pollLoop()
return nil
}
// Stop closes the link and WAITS for the poll loop to leave.
//
// Closing the port from another goroutine does not undo an open() the loop is
// already inside: that open returns a fresh handle which the loop then stores,
// and the stopped client goes on owning the serial port. The next client — a
// settings save, a profile switch — cannot open it, and the operator sees
// "Serial port busy" with no other program running.
//
// Bounded, so a device stuck in the driver cannot freeze a settings save.
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()
if c.done == nil {
return
}
select {
case <-c.done:
case <-time.After(6 * time.Second):
log.Printf("steppir: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target())
}
}
// 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
}
// A COPY, so the optimistic flag below can never leak into the status the
// poll goroutine owns — and so a caller holding the value cannot see it
// change under them mid-read.
st := *c.lastStatus
if st.Connected && st.MotorsMoving == 0 && c.movingOptimisticallyLocked() {
st.MotorsMoving = 1 // sentinel: commanded, not yet reported (read as != 0)
}
return &st, nil
}
// movingOptimisticallyLocked reports a move commanded too recently for the
// controller to have answered. Callers hold statusMu.
func (c *Client) movingOptimisticallyLocked() bool {
return !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < moveOptimisticWindow
}
// MovingOptimistically is the same question from outside the lock — the poll
// loop asks it to decide its cadence.
func (c *Client) MovingOptimistically() bool {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.movingOptimisticallyLocked()
}
// 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")
}
// 8N1 spelled out rather than left to the library defaults: a controller
// that answers nothing must not have a line format that depends on them.
p, err := serial.Open(c.tr.COM, &serial.Mode{
BaudRate: c.tr.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
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)))
}
}
// openFailQuiet is how many consecutive failed reopens are reported before the
// loop goes quiet about them. A port that has gone (adapter unplugged, another
// program holding it) stays gone, and one line every two seconds would be the
// entire log.
const openFailQuiet = 3
// noteOpenFailure logs a failure to reopen the port, throttled. Caller must NOT
// hold connMu — it is taken here.
func (c *Client) noteOpenFailure(err error) {
c.connMu.Lock()
c.openFails++
n := c.openFails
c.connMu.Unlock()
switch {
case n <= openFailQuiet:
log.Printf("steppir: cannot open %s: %v%s (attempt %d)", c.target(), err, portBusyHint(c.tr.Mode, c.tr.COM, err), n)
case n == openFailQuiet+1:
log.Printf("steppir: still cannot open %s — retrying every 2 s, further attempts will not be logged until it comes back", c.target())
}
}
// target names what the client is trying to reach, for the log.
func (c *Client) target() string {
if c.tr.Mode == "serial" {
return fmt.Sprintf("%s @ %d baud", c.tr.COM, c.tr.Baud)
}
return fmt.Sprintf("%s:%d", c.tr.Host, c.tr.Port)
}
func (c *Client) pollLoop() {
// Signals Stop that the port is genuinely released.
defer func() {
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
if c.done != nil {
close(c.done)
}
}()
ticker := time.NewTicker(pollIdle)
defer ticker.Stop()
fast := false
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()
// SAY SO. This retried every two seconds in complete silence,
// so an antenna that lost its port stopped answering and the
// log had nothing at all after the disconnection — which is
// exactly what an operator reports as "it worked for a while
// and then it didn't". Throttled, because a port that is gone
// stays gone and this would otherwise be the whole log.
c.noteOpenFailure(err)
continue
}
if c.openFails > 0 {
log.Printf("steppir: reconnected after %d failed attempt(s)", c.openFails)
c.openFails = 0
}
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()
c.checkDrift(st)
// Follow the motors with the poll rate; changed only when it changes,
// since the antenna is polled for hours on end.
if moving := st.MotorsMoving != 0 || c.MovingOptimistically(); moving != fast {
fast = moving
if fast {
ticker.Reset(pollMoving)
} else {
ticker.Reset(pollIdle)
}
}
}
}
}
// driftToleranceKHz is how far the controller's reported frequency may sit from
// the one we commanded before it is worth saying so. A SteppIR quantises to its
// own grid, so a few kHz is normal and must not be reported as a fault.
const driftToleranceKHz = 10
// checkDrift reports a controller that settled somewhere other than where it was
// told to go.
//
// This exists because of a log that read as "the antenna stops responding": every
// tune was commanded, acknowledged on the next poll with the requested frequency,
// and then REPLACED on the poll after by a different one — 21075 asked, 21075
// confirmed, 21050 reported, over and over, with the operator tuning again each
// time. Nothing in the log named that, so it looked like a dead link rather than
// a controller with a mind of its own (its own radio interface tracking the rig,
// or a front-panel mode that overrides the host).
//
// Only when the motors have stopped: a frequency read mid-travel is not a
// disagreement, it is an antenna on its way. And only when the value changes, so
// a controller parked somewhere else does not fill the log.
func (c *Client) checkDrift(st *Status) {
if st.MotorsMoving != 0 {
return
}
c.statusMu.Lock()
want := c.lastSetKHz
last := c.lastDriftKHz
if want <= 0 || st.Frequency <= 0 {
c.statusMu.Unlock()
return
}
diff := st.Frequency - want
if diff < 0 {
diff = -diff
}
if diff <= driftToleranceKHz {
c.lastDriftKHz = 0 // back where it was asked to be
c.statusMu.Unlock()
return
}
if last == st.Frequency {
c.statusMu.Unlock()
return // already said, and it has not moved since
}
c.lastDriftKHz = st.Frequency
c.statusMu.Unlock()
log.Printf("steppir: commanded %d kHz but the controller settled on %d kHz — "+
"something else is driving it (its own radio interface, or a front-panel mode overriding the host)",
want, st.Frequency)
}
// 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
}
// frameTimeout bounds the wait for one 11-byte status reply. At 4800 baud the
// frame itself takes ~23 ms; three seconds is a controller that is not going to
// answer this query.
const frameTimeout = 3 * time.Second
// readFrame reads exactly len(buf) bytes, or gives up.
//
// io.ReadFull CANNOT be used on a serial port, and using it here is what made an
// antenna "stop responding after a while" with nothing whatsoever in the log.
//
// On Windows a serial read that times out returns (0, nil) — a timeout is not an
// error on this transport. io.ReadFull loops while err == nil, so a controller
// that goes quiet, or sends a truncated frame, spins it forever. It holds ioMu
// the whole time, and that is the part the operator sees: the poll goroutine
// never returns to report a fault, so the last status stays on screen and the
// link still looks connected — while every command blocks on the same mutex.
// The trace line used to sit AFTER that lock, so even the attempt went unlogged.
// One dropped reply on a 4800-baud link wedged the driver until OpsLog restarted.
//
// Giving up returns an error, which the poll loop already knows how to handle:
// it says so in the log and reconnects.
func readFrame(conn io.ReadWriteCloser, buf []byte, d time.Duration) error {
deadline := time.Now().Add(d)
for n := 0; n < len(buf); {
m, err := conn.Read(buf[n:])
if err != nil {
return err
}
n += m
if n >= len(buf) {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s with %d of %d bytes", d, n, len(buf))
}
}
return nil
}
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 := readFrame(conn, buf, frameTimeout); 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" <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")
}
// Traced BEFORE taking the lock, not after. A command waits here for the poll
// in flight, and when that wait was unbounded the log showed no sign the
// operator had asked for anything at all — the one fact that would have named
// the fault. The line now means "asked for"; a failure to write is reported
// by the caller.
log.Printf("steppir: → % X", pkt)
c.ioMu.Lock()
defer c.ioMu.Unlock()
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.moveCmdAt = time.Now() // report motion at once — see moveOptimisticWindow
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'))
}
// portBusyHint turns "Serial port busy" into something actionable — see the
// identical note in internal/ultrabeam.
func portBusyHint(mode, com string, err error) string {
if mode != "serial" || err == nil {
return ""
}
msg := strings.ToLower(err.Error())
if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") {
return ""
}
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
}