chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+109 -8
View File
@@ -25,6 +25,7 @@ package steppir
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
@@ -35,6 +36,10 @@ import (
"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 (
@@ -50,6 +55,15 @@ const (
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"
@@ -94,6 +108,11 @@ type Client struct {
// 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
@@ -189,6 +208,12 @@ func (c *Client) pollLoop() {
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()
@@ -197,19 +222,32 @@ func (c *Client) pollLoop() {
}
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.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}
@@ -232,6 +270,56 @@ func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
}
}
// 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
@@ -241,7 +329,12 @@ func (c *Client) queryStatus() (*Status, error) {
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second)
// 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)
}
@@ -249,6 +342,14 @@ func (c *Client) queryStatus() (*Status, error) {
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