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 {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
return rotgeniusGoTo(l, az)
case "arco":
return arcoClient(l).GoTo(az)
case "erc":
@@ -22517,3 +22517,77 @@ func (a *App) IcomConsolePTT(on bool) error {
}
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
}