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.
This commit is contained in:
2026-09-06 20:10:39 +02:00
parent c8c48d408f
commit 421bc372ee
3 changed files with 100 additions and 4 deletions
+2 -2
View File
@@ -12,7 +12,7 @@
"Audio: the Listening device now says when it cannot be opened. A device unplugged, renamed by Windows or unable to run at 16 kHz failed silently while everything upstream reported success — the stream up, the packets arriving, the monitor started — which is the whole of “I turned the sound on and nothing comes out”. The log also says, once, whether the network RX audio is reaching the speakers or arriving with nobody listening.",
"Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening.",
"Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again.",
"Motorised antenna: the transmitter is released as soon as the elements stop. Three delays were stacked between the antenna finishing and the operator being allowed to call — the antenna polled every two seconds, the transmit gag held for three after the command whatever the antenna said, and the screen refreshed every three. The antenna is now polled four times a second WHILE IT MOVES (and left at two seconds when it is still, where nothing changes), the gag only bridges the command itself, and the widget follows at half a second.",
"Motorised antenna: the transmitter is released as soon as the elements stop. Three delays were stacked between the antenna finishing and the operator being allowed to call — the antenna polled every two seconds, the transmit gag held for three after the command whatever the antenna said, and the screen refreshed every three. Both the Ultrabeam and the SteppIR are now polled four times a second WHILE IT MOVES (and left at two seconds when it is still, where nothing changes), the gag only bridges the command itself, and the widget follows at half a second. The SteppIR also reports a commanded move at once, as the Ultrabeam already did: it says nothing until its own poll comes round, so the shortened gag would otherwise have released the transmitter in the middle of a move.",
"[NEW] The WSJT-X / JTDX highlight colours are yours to choose (Settings → UDP), one per verdict — watch list, new DXCC, new band, worked. Only the background is set: the text colour is worked out from it, so a chosen colour cannot come back unreadable in the decoders window. The “grey out stations already worked” switch keeps its purpose — it decides WHETHER dupes are marked, not what colour they are — and is now called “Mark stations already worked”.",
"WSJT-X highlighting: a watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked everything — including the log — so a station already worked stayed pink for the session with no way to tell it from one still needed.",
"Motorised antenna: the indicator turns amber the instant the move is ORDERED, from a button or from an automatic band change with tracking on, instead of waiting for the antenna to say it is moving. The transmit gag already started there; the screen did not, so the two disagreed by a second or more — and on a follow there was no warning at all until a poll landed."
@@ -27,7 +27,7 @@
"Audio : le périphérique d’écoute signale désormais quand il ne peut pas souvrir. Un périphérique débranché, renommé par Windows ou incapable de fonctionner en 16 kHz échouait en silence pendant que tout en amont annonçait le succès — flux ouvert, paquets reçus, moniteur démarré — ce qui est exactement le « jai remis le son et rien ne sort ». Le journal dit aussi, une fois, si laudio réseau atteint les haut-parleurs ou arrive sans que personne n’écoute.",
"Laudio réseau Icom démarre tout de suite au lieu dune demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio nexiste : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait quau tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute.",
"Cadran rotor : échelle circulaire et faisceau au lieu dune flèche. Lanneau carré plaçait un repère à 45° plus loin du centre quun repère au nord — un cadran se lit par langle, donc lanneau qui sert de référence est désormais à la même distance tout autour. Lantenne est dessinée comme un secteur qui sestompe vers lextérieur, ce qui est la forme de ce quil représente ; là où la souris lenverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant quon vise. Vert pour où lantenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe dun Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design dEC1KD, encore.",
"Antenne motorisée : l’émission est rendue dès que les éléments sarrêtent. Trois délais sajoutaient entre la fin du mouvement et le droit dappeler — lantenne interrogée toutes les deux secondes, le blocage d’émission maintenu trois secondes après la commande quoi quen dise lantenne, et l’écran rafraîchi toutes les trois. Lantenne est désormais interrogée quatre fois par seconde PENDANT quelle bouge (et laissée à deux secondes à larrêt, où rien ne change), le blocage ne couvre plus que la commande elle-même, et le widget suit à la demi-seconde.",
"Antenne motorisée : l’émission est rendue dès que les éléments sarrêtent. Trois délais sajoutaient entre la fin du mouvement et le droit dappeler — lantenne interrogée toutes les deux secondes, le blocage d’émission maintenu trois secondes après la commande quoi quen dise lantenne, et l’écran rafraîchi toutes les trois. LUltrabeam comme la SteppIR sont désormais interrogées quatre fois par seconde PENDANT quelle bouge (et laissée à deux secondes à larrêt, où rien ne change), le blocage ne couvre plus que la commande elle-même, et le widget suit à la demi-seconde. La SteppIR signale aussi un mouvement dès quil est commandé, comme le faisait déjà lUltrabeam : elle ne dit rien avant sa propre lecture, et le blocage raccourci aurait sinon rendu l’émission en plein mouvement.",
"[NEW] Les couleurs de mise en évidence WSJT-X / JTDX sont au choix (Réglages → UDP), une par verdict — watchlist, nouveau DXCC, nouvelle bande, contactée. Seul le fond se règle : la couleur du texte en est déduite, pour quune couleur choisie ne revienne jamais illisible dans la fenêtre du décodeur. Loption « griser les stations déjà contactées » garde sa raison d’être — elle décide SI les doublons sont marqués, pas de quelle couleur — et sappelle désormais « Marquer les stations déjà contactées ».",
"Mise en évidence WSJT-X : une station de la watchlist déjà contactée sur cette bande et ce mode nest plus peinte comme une station à appeler. La liste dit une intention, pas ce quil reste à faire, et son rose passait devant tout — y compris le carnet — si bien quune station déjà faite restait rose toute la session, sans moyen de la distinguer dune station encore à faire.",
"Antenne motorisée : lindicateur passe à lambre à linstant où le mouvement est COMMANDÉ, par un bouton comme par un changement de bande automatique quand le suivi est actif, au lieu dattendre que lantenne dise quelle bouge. Le blocage d’émission démarrait déjà là ; l’écran non, et les deux se contredisaient dune seconde ou plus — sur un suivi, il ny avait aucun signe avant larrivée dune lecture."
+41
View File
@@ -0,0 +1,41 @@
package steppir
import (
"testing"
"time"
)
// The controller says nothing until its own poll comes round, so a move has to
// be reported from the moment it is COMMANDED. Without it the app's "block TX
// while the elements travel" had a hole: the grace covering 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.
func TestAJustCommandedMoveReportsMotion(t *testing.T) {
c := &Client{}
c.lastStatus = &Status{Connected: true} // idle, as the controller last said
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Fatal("an idle antenna reports motion")
}
c.statusMu.Lock()
c.moveCmdAt = time.Now()
c.statusMu.Unlock()
st, _ := c.GetStatus()
if st.MotorsMoving == 0 {
t.Error("a move commanded a moment ago is not reported as motion")
}
if c.lastStatus.MotorsMoving != 0 {
t.Error("the optimistic flag leaked into the cached status the poll owns")
}
// 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.statusMu.Unlock()
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Error("the optimistic window never expires")
}
}
+57 -2
View File
@@ -65,6 +65,25 @@ const (
// 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"
@@ -103,6 +122,8 @@ type Client struct {
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.
@@ -191,7 +212,28 @@ func (c *Client) GetStatus() (*Status, error) {
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
return c.lastStatus, 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.
@@ -266,8 +308,9 @@ func (c *Client) pollLoop() {
close(c.done)
}
}()
ticker := time.NewTicker(2 * time.Second)
ticker := time.NewTicker(pollIdle)
defer ticker.Stop()
fast := false
for {
select {
case <-c.stopChan:
@@ -315,6 +358,17 @@ func (c *Client) pollLoop() {
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)
}
}
}
}
}
@@ -639,6 +693,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
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
}