fix(ultrabeam): flush stale replies (not seq-match) + instant moving flag

The antenna does NOT echo our sequence number — its replies carry their own
counter — so the previous seq-matching drained every reply and stalled status
updates. Revert to reading one reply per command, but flush any bytes left in
the stream before each command (drainStale): a reply left by a timed-out command
is discarded so the next read stays 1:1. readPacket also resyncs to the next STX.
This fixes the intermittent disconnects, phantom frequency jumps and wrong
element-length readings without depending on the seq.

Also: report motion for a short window right after a commanded move, so the
"moving" indicator and the Flex TX-inhibit fire the instant a band/pattern is
clicked instead of a poll (~2 s) later; the real motor state takes over once
polled.
This commit is contained in:
2026-08-05 00:13:53 +02:00
parent ef087492cc
commit 821883dfd3
3 changed files with 115 additions and 69 deletions
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "", "date": "",
"en": [ "en": [
"Log viewer: the window now keeps twice as much history (512 KB instead of 256 KB, ~3200 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them.", "Log viewer: the window now keeps twice as much history (512 KB instead of 256 KB, ~3200 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them.",
"Ultrabeam over a remote link: fixed intermittent connection drops, phantom frequency jumps and wrong element-length readings. Replies weren't matched to the command that asked for them, so on a slow link a status query could pick up a lingering reply meant for another command reading, say, the antenna's status where element lengths were expected. Every reply is now matched to its request by sequence number, and stale replies are discarded." "Ultrabeam over a remote link: fixed intermittent connection drops, phantom frequency jumps and wrong element-length readings. On a slow link a command that timed out left its late reply in the stream, and the next command read it as its own — crossing a status query with an element-length one, for instance. The stream is now flushed of any stale reply before each command, keeping the exchange in step.",
"Ultrabeam: the 'moving' indicator and the Flex TX-inhibit now react the instant you click a band or pattern, instead of up to a status poll (~2 s) later. A commanded move reports motion immediately; the real motor state takes over once the next poll reads it."
], ],
"fr": [ "fr": [
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.", "Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
"Ultrabeam en remote : coupures de connexion intermittentes, sauts de fréquence fantômes et longueurs d'éléments erronées corrigés. Les réponses n'étaient pas associées à la commande qui les demandait : sur un lien lent, une requête de statut pouvait récupérer une réponse traînante destinée à une autre commande — lisant par exemple le statut de l'antenne là où on attendait les longueurs d'éléments. Chaque réponse est désormais appariée à sa requête par numéro de séquence, et les réponses périmées sont ignorées." "Ultrabeam en remote : coupures de connexion intermittentes, sauts de fréquence fantômes et longueurs d'éléments erronées corrigés. Sur un lien lent, une commande qui expirait laissait sa réponse tardive dans le flux, et la commande suivante la lisait comme la sienne — croisant par exemple une requête de statut avec une requête de longueurs d'éléments. Le flux est désormais vidé de toute réponse périmée avant chaque commande, gardant l'échange synchronisé.",
"Ultrabeam : l'indicateur « en mouvement » et l'inhibition d'émission Flex réagissent désormais dès que vous cliquez sur une bande ou un diagramme, au lieu d'attendre jusqu'à un poll de statut (~2 s). Un mouvement commandé signale le déplacement immédiatement ; l'état réel des moteurs prend le relais dès le poll suivant."
] ]
}, },
{ {
+78 -48
View File
@@ -88,8 +88,22 @@ type Client struct {
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY // yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors. // re-tunes the motors.
lastSetKHz int lastSetKHz int
// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
// status poll runs every couple of seconds, so without this the "moving" flag
// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
// poll late even though the elements start moving at once. GetStatus reports
// motion during a short window after a command so both react immediately.
moveCmdAt time.Time
} }
// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
// "moving" before the status poll has had a chance to read the real motor state.
// It only needs to bridge one poll interval; once a poll sees real motion, that
// takes over. Bounded, so if the antenna never reports motion the flag still
// clears rather than latching the TX-inhibit on for ever.
const ubMoveOptimisticWindow = 3 * time.Second
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna, // LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet. // or 0 if none yet.
func (c *Client) LastSetKHz() int { func (c *Client) LastSetKHz() int {
@@ -270,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
return &Status{Connected: false}, nil return &Status{Connected: false}, nil
} }
return c.lastStatus, nil // Copy so the optimistic-motion tweak below never mutates the cached status
// the poll goroutine owns.
st := *c.lastStatus
// Optimistic motion: right after a commanded move, report "moving" until a
// status poll can read the real motor state (~one poll interval). The elements
// start moving the instant the operator clicks a band/pattern, so this makes
// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
// later. Real polled motion takes over once seen; the window is bounded so the
// flag can't latch the inhibit on for ever.
if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
}
return &st, nil
} }
// getNextSeq returns the next sequence number // getNextSeq returns the next sequence number
@@ -388,64 +414,67 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("not connected") return nil, fmt.Errorf("not connected")
} }
// Build and send packet, remembering the seq so the reply can be matched to it. // Flush anything already sitting in the stream before we send. The antenna
// does NOT echo our sequence number — its replies carry their own counter — so
// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
// a reply left behind by an earlier timed-out command is discarded here, so
// the reply we read next belongs to THIS command. Reading a stale reply as the
// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
// spurious follow-loop re-tune), dropped connections and wrong element lengths.
c.drainStale()
seq := c.getNextSeq() seq := c.getNextSeq()
packet := c.buildPacket(seq, cmd, data) packet := c.buildPacket(seq, cmd, data)
if _, err := c.conn.Write(packet); err != nil {
_, err := c.conn.Write(packet)
if err != nil {
return nil, fmt.Errorf("failed to write: %w", err) return nil, fmt.Errorf("failed to write: %w", err)
} }
// Read reply with timeout (generous — tolerates remote-link latency). The // Read the reply with a timeout generous enough for a remote link.
// deadline bounds the WHOLE exchange, including draining any stale replies.
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout)) c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
buffer, err := c.readPacket()
if err != nil {
return nil, err
}
_, replyCmd, payload, err := parsePacket(buffer)
if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err)
}
// Match the reply to our seq. A command that timed out earlier can leave its // Log for debugging unknown codes
// late reply sitting in the stream; without this check THIS command would read if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
// that stale reply as its own — crossing STATUS with READ_BANDS/PROGRESS, which log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
// showed up as phantom frequencies (a spurious follow-loop re-tune) and dropped }
// connections. So read frames until one carries our seq, discarding the rest.
// Bounded, so a stream that never yields our reply errors instead of looping.
const maxDrain = 16
for attempts := 0; attempts < maxDrain; attempts++ {
buffer, rerr := c.readPacket()
if rerr != nil {
return nil, rerr
}
replySeq, replyCmd, payload, perr := parsePacket(buffer)
if perr != nil {
log.Printf("Ultrabeam: discarding malformed reply: %v", perr)
continue
}
if replySeq != seq {
log.Printf("Ultrabeam: draining stale reply (seq %d, want %d) — likely a prior timed-out command", replySeq, seq)
continue
}
// Log for debugging unknown codes // Check for errors
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR { switch replyCmd {
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer) case UB_BAD:
} return nil, fmt.Errorf("invalid command")
case UB_PAR:
return nil, fmt.Errorf("bad parameters")
case UB_ERR:
return nil, fmt.Errorf("execution error")
case UB_OK:
return payload, nil
default:
// Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
}
}
// Check for errors // drainStale discards any bytes already waiting in the stream — a reply left
switch replyCmd { // behind by a command that timed out. A short read deadline lets it consume what
case UB_BAD: // is there and stop quickly when the stream is clean. Caller holds connMu.
return nil, fmt.Errorf("invalid command") func (c *Client) drainStale() {
case UB_PAR: c.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
return nil, fmt.Errorf("bad parameters") buf := make([]byte, 256)
case UB_ERR: for {
return nil, fmt.Errorf("execution error") n, err := c.reader.Read(buf)
case UB_OK: if n == 0 || err != nil {
return payload, nil return
default:
// Unknown codes might indicate "busy" or "in progress"
// Treat as non-fatal, return empty payload
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
return []byte{}, nil
} }
} }
return nil, fmt.Errorf("no reply matching seq %d after draining %d frames", seq, maxDrain)
} }
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until // readPacket reads one complete STX…ETX frame, skipping any leading bytes until
@@ -560,6 +589,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
c.statusMu.Lock() c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz c.lastSetKHz = freqKhz
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately c.lastStatus.Direction = direction // reflect immediately
} }
+33 -19
View File
@@ -5,14 +5,14 @@ import (
"bytes" "bytes"
"net" "net"
"testing" "testing"
"time"
) )
// A stale reply left in the stream by an earlier timed-out command must be // sendCommand must flush any bytes already in the stream (a reply left by an
// DRAINED, not taken for this command's reply. Without seq matching, a STATUS // earlier timed-out command) before reading, then return the reply to THIS
// query would return a lingering READ_BANDS/PROGRESS payload — which showed up // command. The antenna does not echo our sequence number, so keeping the stream
// on a remote link as phantom frequencies (a spurious follow-loop re-tune) and // clean is the only way to stay in sync.
// dropped connections. func TestSendCommandFlushesStaleThenReadsReply(t *testing.T) {
func TestSendCommandDrainsStaleReplyAndMatchesSeq(t *testing.T) {
srvConn, cliConn := net.Pipe() srvConn, cliConn := net.Pipe()
defer srvConn.Close() defer srvConn.Close()
defer cliConn.Close() defer cliConn.Close()
@@ -22,8 +22,14 @@ func TestSendCommandDrainsStaleReplyAndMatchesSeq(t *testing.T) {
errc := make(chan error, 1) errc := make(chan error, 1)
go func() { go func() {
// Read the request, learn its seq, then answer with a stale reply (wrong // Leftover from a "previous" command still sitting in the stream. The Write
// seq) FIRST, then the real one. // blocks until drainStale consumes it, so it is guaranteed flushed before
// the real exchange.
if _, err := srvConn.Write(c.buildPacket(9, UB_OK, []byte{0xDE, 0xAD})); err != nil {
errc <- err
return
}
// Now serve the actual request.
req, err := srv.readPacket() req, err := srv.readPacket()
if err != nil { if err != nil {
errc <- err errc <- err
@@ -34,16 +40,8 @@ func TestSendCommandDrainsStaleReplyAndMatchesSeq(t *testing.T) {
errc <- err errc <- err
return return
} }
staleSeq := (seq + 1) % 128 _, err = srvConn.Write(c.buildPacket(seq, UB_OK, []byte{0x11, 0x22, 0x33}))
if _, err := srvConn.Write(c.buildPacket(staleSeq, UB_OK, []byte{0xAA, 0xBB})); err != nil { errc <- err
errc <- err
return
}
if _, err := srvConn.Write(c.buildPacket(seq, UB_OK, []byte{0x11, 0x22, 0x33})); err != nil {
errc <- err
return
}
errc <- nil
}() }()
payload, err := c.sendCommand(CMD_STATUS, nil) payload, err := c.sendCommand(CMD_STATUS, nil)
@@ -51,7 +49,7 @@ func TestSendCommandDrainsStaleReplyAndMatchesSeq(t *testing.T) {
t.Fatalf("sendCommand: %v", err) t.Fatalf("sendCommand: %v", err)
} }
if !bytes.Equal(payload, []byte{0x11, 0x22, 0x33}) { if !bytes.Equal(payload, []byte{0x11, 0x22, 0x33}) {
t.Fatalf("payload = % X, want 11 22 33 — stale reply not drained or seq not matched", payload) t.Fatalf("payload = % X, want 11 22 33 — stale reply not flushed", payload)
} }
if err := <-errc; err != nil { if err := <-errc; err != nil {
t.Fatalf("server: %v", err) t.Fatalf("server: %v", err)
@@ -78,3 +76,19 @@ func TestReadPacketResyncsToSTX(t *testing.T) {
t.Fatalf("seq=%d cmd=%d payload=% X, want 5 / OK / 01 02", seq, cmd, payload) t.Fatalf("seq=%d cmd=%d payload=% X, want 5 / OK / 01 02", seq, cmd, payload)
} }
} }
// A commanded move must report motion immediately (before the next status poll),
// then fall back to the real motor state once the window elapses.
func TestOptimisticMotionAfterCommand(t *testing.T) {
c := &Client{lastStatus: &Status{Connected: true, MotorsMoving: 0}}
c.moveCmdAt = time.Now()
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
t.Fatal("just after a move command, GetStatus should report motion")
}
c.moveCmdAt = time.Now().Add(-ubMoveOptimisticWindow - time.Second)
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Fatal("past the window with motors idle, GetStatus must report no motion")
}
}