fix(antenna): Retract and Calibrate report the elements moving

An operator reported that retracting a SteppIR shows nothing, while an
Ultrabeam visibly does. The cause is not the display: both antennas feed
one `moving` flag and the widget renders it the same way.

moveCmdAt — the bridge that reports motion from the moment a move is
COMMANDED, before the controller's own poll can confirm it — was set
only by SetFrequency. Retract and Calibrate write their frame directly
and armed nothing, so three things were missing at once: nothing said
the elements were moving, the poll stayed on its two-second idle cadence
instead of speeding up to 250 ms to watch, and the transmit inhibit was
not engaged while the elements travelled. That last one is the reason
this is not cosmetic.

The Ultrabeam's Retract armed nothing either. It was less visible there
because that controller reports element lengths, which count down on
screen; a SteppIR reports none at all, so the retract looked inert. The
inhibit was equally absent.

The window becomes a deadline rather than a timestamp, so the caller can
say how long a bridge it needs: three seconds for a tune, ten for a
retract or a calibrate. Those are tens of seconds of travel, and a
retract drops the controller out of AUTOTRACK where its motor-bit
reporting is less predictable. Still bounded — an antenna that never
reports motion must not latch the inhibit on for ever — and markMoving
only ever extends, so a tune issued just after a retract cannot cut the
retract's bridge down to its own.

Four tests, including that a command which failed to reach the
controller does not claim the antenna is moving.
This commit is contained in:
2026-09-10 20:15:03 +02:00
parent 031fcbd543
commit fd93036f86
4 changed files with 125 additions and 12 deletions
+61 -2
View File
@@ -19,7 +19,7 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
}
c.statusMu.Lock()
c.moveCmdAt = time.Now()
c.moveUntil = time.Now().Add(moveOptimisticWindow)
c.statusMu.Unlock()
st, _ := c.GetStatus()
@@ -33,9 +33,68 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
// Bounded: an antenna that never reports motion must not latch the transmit
// inhibit on for ever.
c.statusMu.Lock()
c.moveCmdAt = time.Now().Add(-moveOptimisticWindow - time.Second)
c.moveUntil = time.Now().Add(-time.Second)
c.statusMu.Unlock()
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Error("the optimistic window never expires")
}
}
// Retract and Calibrate move every element, and they were the two commands that
// reported nothing at all: no "moving" on screen, the poll left on its
// two-second idle cadence instead of speeding up to watch, and no transmit
// inhibit while the elements travelled. Reported by an operator whose retract
// looked inert next to an Ultrabeam's.
func TestRetractAndCalibrateReportMotion(t *testing.T) {
for _, tc := range []struct {
name string
call func(*Client) error
}{
{"retract", (*Client).Retract},
{"calibrate", (*Client).Calibrate},
} {
c := &Client{}
c.lastStatus = &Status{Connected: true, Frequency: 14074}
// No connection, so the write fails and the command returns an error —
// which is the point: a command that did NOT reach the controller must
// not claim the antenna is moving.
if err := tc.call(c); err == nil {
t.Fatalf("%s: expected an error with no connection", tc.name)
}
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Errorf("%s: a command that failed to send reports motion", tc.name)
}
// And with the write accepted, motion is reported at once.
c.markMoving(retractOptimisticWindow)
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
t.Errorf("%s: a commanded move is not reported as motion", tc.name)
}
}
}
// The retract bridge is longer than the tune bridge: winding every element into
// its hub takes tens of seconds, and a retract drops the controller out of
// AUTOTRACK, where its motor-bit reporting is less predictable.
func TestRetractBridgeOutlastsTheTuneBridge(t *testing.T) {
if retractOptimisticWindow <= moveOptimisticWindow {
t.Errorf("retract window %v is not longer than the tune window %v",
retractOptimisticWindow, moveOptimisticWindow)
}
// Bounded all the same — see the note on the constant.
if retractOptimisticWindow > time.Minute {
t.Errorf("retract window %v could latch the transmit inhibit on", retractOptimisticWindow)
}
}
// markMoving extends, never shortens: a tune issued a moment after a retract
// must not cut the retract's bridge down to the tune's.
func TestMarkMovingOnlyExtends(t *testing.T) {
c := &Client{}
c.markMoving(retractOptimisticWindow)
far := c.moveUntil
c.markMoving(moveOptimisticWindow)
if c.moveUntil.Before(far) {
t.Error("a shorter bridge shortened a longer one")
}
}