fix(rotator): the Rotator Genius is a 360° controller, and OpsLog says so

Correcting the previous commit, which offered a 450° rotator range for the
Rotator Genius. It cannot do it.

The evidence is the operator's own box and 4O3A's manual together. His Rotator
Configuration reads "Limits: 5 to 4" — that is where the mechanical stop sits
within ONE turn, a dead zone at four and a half degrees, not a range of travel.
The manual is unambiguous about what happens past it: "you will not be able to
give it a target beyond the limits". A 450° mast on a Rotator Genius is a 450°
mast used as a 360° one, and that limit belongs to the controller.

So the setting goes. Offering an operator a 450° option that the box can only
ever refuse is worse than not offering one — it spends their evening proving
that the software was wrong about their station.

What stays is the half that was genuinely ours: GoTo no longer clamps to 360
before sending, and the limits the Genius reports on every heading query are now
read instead of skipped over. The overlap branch is driven entirely by what the
device answers — no setting, no assumption — so a controller that one day
reports a range past 360 is driven through it without a line changing here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 23:58:49 +02:00
co-authored by Claude Opus 5
parent 3dad00f8ad
commit 3c93684b2b
3 changed files with 32 additions and 59 deletions
+21 -52
View File
@@ -22518,71 +22518,40 @@ 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.
// rotgeniusGoTo picks which way round to reach a bearing when the controller
// has an overlap to offer.
//
// 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, and it needs no setting from us. It reports the limits it
// is configured with on every heading query, and those are the truth about what
// is bolted to the tower: if the far side of an overlap is reachable it says so,
// and if it is not, asking anyway turns a working command into a rejected one.
// Its manual is unambiguous — "you will not be able to give it a target beyond
// the limits".
//
// 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.
// In practice today that means the plain bearing, every time. A Rotator Genius
// is a 360° controller: its Limits fields say where the mechanical stop sits
// within one turn ("5 to 4" is a dead zone at four and a half degrees), not how
// far the mast can travel, and an operator with a 450° rotator gets 360° of it.
// The overlap branch stays because the decision is made from what the device
// reports rather than from an assumption about it — a controller that one day
// answers 450 will be driven through the overlap without a line changing 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.
// Which of the two forms is right depends on where the antenna IS, so the
// heading is read first and the nearer one wins: 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.
if err != nil || !st.Connected || st.LimitCW <= 360 {
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, alt)
}
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)
return c.GoTo(l.Num, a)
}
func absInt(v int) int {