chore: release v0.21.3
This commit is contained in:
+109
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user