feat(rotator): drive a Rotator Genius through the overlap

An operator with a 450° mast watched the Genius stop at 359 and had to press
"clockwise" by hand to get through north. Half of that was ours: GoTo clamped
every target to 360 before sending it, although the wire command carries three
digits and always could have said 370.

So the clamp goes to 450, the rotator-range setting is offered for the Rotator
Genius like the other backends it applies to, and when a bearing can be reached
two ways the nearer one is taken — 010° sent as 370° when the antenna is already
at 350°, which is the entire point of having an overlap.

THE GENIUS DECIDES WHAT IS REACHABLE. It reports the limits it is configured
with, and they are the truth about what is bolted to the tower. This particular
station's box says "5 to 4" — the factory 360° range — and would refuse 370,
turning a working command into a rejected one. So the overlap is used only when
the Genius itself says it has one, and when OpsLog is set to 450 while the Genius
is not, the log says so once a minute and names the dialog to change: the setting
lives in the Genius's own Rotator Configuration, and nothing here can reach past
its limits.

The |h reply always carried those limits and they were being skipped over.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 23:46:04 +02:00
co-authored by Claude Opus 5
parent b8491f3038
commit 3dad00f8ad
4 changed files with 112 additions and 10 deletions
+75 -1
View File
@@ -17486,7 +17486,7 @@ func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error
func linkGoTo(l rotorLink, az, el int) error { func linkGoTo(l rotorLink, az, el int) error {
switch l.Type { switch l.Type {
case "rotgenius": case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az) return rotgeniusGoTo(l, az)
case "arco": case "arco":
return arcoClient(l).GoTo(az) return arcoClient(l).GoTo(az)
case "erc": case "erc":
@@ -22517,3 +22517,77 @@ func (a *App) IcomConsolePTT(on bool) error {
} }
return a.cat.SetPTT(on) return a.cat.SetPTT(on)
} }
// rotgeniusGoTo picks which way round to reach a bearing on a mast with an
// overlap.
//
// A 450° rotator can be at 010° twice over: once at 10 and once at 370, and
// only the second reaches it without unwinding the cable back through north.
// OpsLog used to clamp every target to 360, so an operator with such a mast
// watched the Rotator Genius stop dead at 359 and had to press "clockwise" by
// hand to get through north.
//
// THE GENIUS DECIDES WHAT IS REACHABLE, not us and not a setting. It reports the
// limits it is configured with, and they are the truth about what is bolted to
// the tower: a box configured "5 to 4" — the factory 360° range — refuses a
// target of 370, and asking anyway turns a working command into a rejected one.
// So the overlap is used only when the Genius itself says it has one, and when
// it does not, the operator is told, because the setting to change is in the
// Genius and not here.
//
// Which of the two forms is right then depends on where the antenna IS, so the
// heading is read first and the nearer one wins. That is the point of an
// overlap: a beam at 350° heading for 010° should cross north, not travel the
// other 340 degrees.
func rotgeniusGoTo(l rotorLink, az int) error {
c := rotgenius.New(l.Host, l.Port)
a := ((az % 360) + 360) % 360
if l.MaxAz <= 360 {
return c.GoTo(l.Num, a)
}
st, _, err := c.Heading(l.Num)
if err != nil || !st.Connected {
// No reading to compare against. The plain bearing is always reachable;
// the overlap is an optimisation, not a requirement.
return c.GoTo(l.Num, a)
}
if st.LimitCW <= 360 {
// OpsLog is set to 450 and the Genius is not. Said once per move rather
// than silently doing the wrong thing — the fix is in the Genius's own
// rotator configuration, and nothing here can reach past its limits.
rotgeniusRangeWarn(l, st)
return c.GoTo(l.Num, a)
}
target := a
if alt := a + 360; alt <= st.LimitCW && absInt(alt-st.Azimuth) < absInt(a-st.Azimuth) {
target = alt
applog.Printf("rotator: %d° is nearer as %d° from the antenna's %d° (Genius limit %d)",
a, alt, st.Azimuth, st.LimitCW)
}
return c.GoTo(l.Num, target)
}
// rotgeniusRangeWarn says, at most once a minute, that the two ends disagree
// about the mast.
var rotgeniusWarnedAt sync.Map // host:port → time.Time
func rotgeniusRangeWarn(l rotorLink, st rotgenius.Status) {
key := fmt.Sprintf("%s:%d/%d", l.Host, l.Port, l.Num)
if v, ok := rotgeniusWarnedAt.Load(key); ok {
if t, _ := v.(time.Time); time.Since(t) < time.Minute {
return
}
}
rotgeniusWarnedAt.Store(key, time.Now())
applog.Printf("rotator: OpsLog is set to a 450° mast but the Rotator Genius is configured %d° to %d° — "+
"a range it will not go past, whatever is asked. Change the limits in the Genius's own Rotator "+
"Configuration; until then the antenna takes the long way round through north.",
st.LimitCCW, st.LimitCW)
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}
+4 -2
View File
@@ -5,12 +5,14 @@
"en": [ "en": [
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.", "The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.", "While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\"." "Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
"Rotator Genius: a 450° mast can now be pointed through the overlap instead of the long way round. OpsLog clamped every target to 360°, so a bearing just past north meant the rotator stopped at 359 and the operator pressed \"clockwise\" by hand to get through it. A rotator range now appears for the Rotator Genius like the other backends it applies to, and the nearer of the two ways to a bearing is taken — 010° reached as 370° when the antenna is already at 350°. The Genius's own limits are read and they win: a box configured for 360° refuses a target beyond it, so OpsLog does not ask, and says in the log that the range has to be changed in the Genius's own Rotator Configuration."
], ],
"fr": [ "fr": [
"Laffichage de lantenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — lantenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.", "Laffichage de lantenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — lantenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
"Pendant le suivi, les deux fréquences et le cap de lantenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et lantenne, pas une colonne à lautre bout de la fenêtre — et cest la première chose quon masque pour avoir la carte en pleine largeur. La boussole tourne tant que lantenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.", "Pendant le suivi, les deux fréquences et le cap de lantenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et lantenne, pas une colonne à lautre bout de la fenêtre — et cest la première chose quon masque pour avoir la carte en pleine largeur. La boussole tourne tant que lantenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz »." "Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace denviron soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et laffichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne sagit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
"Rotator Genius : un pylône 450° peut désormais être pointé à travers le recouvrement au lieu de faire le tour. OpsLog écrêtait toute consigne à 360°, donc un cap juste après le nord arrêtait le rotor à 359 et il fallait cliquer « clockwise » à la main pour le franchir. Lamplitude du rotor apparaît maintenant pour le Rotator Genius comme pour les autres pilotes concernés, et le plus court des deux chemins est pris — 010° atteint comme 370° quand lantenne est déjà à 350°. Les limites propres au Genius sont lues et priment : un boîtier configuré en 360° refuse une consigne au-delà, donc OpsLog ne la lui envoie pas et écrit dans le journal que lamplitude est à changer dans la configuration du Genius lui-même."
] ]
}, },
{ {
+6 -1
View File
@@ -5135,7 +5135,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// controller. PstRotator knows which machine is on the other end and // controller. PstRotator knows which machine is on the other end and
// does its own overlap; two programs each deciding to go the long // does its own overlap; two programs each deciding to go the long
// way round is how an antenna unwinds mid-pass. // way round is how an antenna unwinds mid-pass.
const ownsOverlap = isERC || isEasycomm; //
// A Rotator Genius is in between: it has its OWN limits, and they
// win — this setting only tells OpsLog it may ask for the far side
// of the overlap at all. If the Genius is configured 360°, it says
// so in the log rather than sending commands the box refuses.
const ownsOverlap = isERC || isEasycomm || isRG;
return ( return (
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3"> <div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+27 -6
View File
@@ -33,10 +33,16 @@ const (
// Status is one rotator's live state parsed from a |h reply. // Status is one rotator's live state parsed from a |h reply.
type Status struct { type Status struct {
Azimuth int // current heading in degrees (0..360) Azimuth int // current heading in degrees (0..450 on an overlap rotator)
Connected bool // false when the sensor reports 999 (not connected) Connected bool // false when the sensor reports 999 (not connected)
Moving int // 0 not moving, 1 CW, 2 CCW Moving int // 0 not moving, 1 CW, 2 CCW
Target int // target azimuth when moving (else -1) Target int // target azimuth when moving (else -1)
// The soft limits the Genius itself is configured with, as it reports them.
// Read rather than assumed: an operator with a 450° mast has told the
// Genius so, and that is the authority on how far it will go — OpsLog
// asking for 400° on a box configured for 360 is a command it will refuse.
LimitCW int
LimitCCW int
} }
// Client is a stateless connector: each call opens a short-lived TCP connection, // Client is a stateless connector: each call opens a short-lived TCP connection,
@@ -128,15 +134,30 @@ func (c *Client) Read(rotator int) (Status, error) {
cur := atoiField(string(p[base : base+3])) cur := atoiField(string(p[base : base+3]))
moving := atoiField(string(p[base+10 : base+11])) moving := atoiField(string(p[base+10 : base+11]))
target := atoiField(string(p[base+15 : base+18])) target := atoiField(string(p[base+15 : base+18]))
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1} st := Status{
Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1,
LimitCW: atoiField(string(p[base+3 : base+6])),
LimitCCW: atoiField(string(p[base+6 : base+9])),
}
if target != 999 { if target != 999 {
st.Target = target st.Target = target
} }
return st, nil return st, nil
} }
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on // GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F'
// accept, 'F' on reject. // on reject.
//
// The ceiling is 450 and not 360, which is the whole point: a rotator with an
// overlap can be asked for 010° as either 10 or 370, and only the second reaches
// it without unwinding the cable back through north. The command carries three
// digits, so the range was never the protocol's — it was ours, and it left an
// operator with a 450° mast clicking "clockwise" by hand every time a bearing
// crossed north.
//
// A Genius configured for a 360° rotator refuses a target beyond its own limit,
// which is the correct place for that decision: it knows what is bolted to the
// tower, and OpsLog does not.
func (c *Client) GoTo(rotator, az int) error { func (c *Client) GoTo(rotator, az int) error {
if rotator != 1 && rotator != 2 { if rotator != 1 && rotator != 2 {
rotator = 1 rotator = 1
@@ -144,8 +165,8 @@ func (c *Client) GoTo(rotator, az int) error {
if az < 0 { if az < 0 {
az = 0 az = 0
} }
if az > 360 { if az > 450 {
az = 360 az = 450
} }
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8) reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
if err != nil { if err != nil {