feat(sat): follow the azimuth only

A satellite tracker that insists on an elevation motor is a tracker switched off
for nearly everybody. A pass at the edge of the footprint — which is most of
them — never climbs above ten or fifteen degrees for its whole length, and a
yagi's beamwidth swallows that: the bearing alone is enough, and it is how most
stations that work satellites are actually built. The same switch rescues an
az/el station whose elevation motor has failed.

So it is an option, not a silent fallback, because it does cost something: a
bird straight overhead is a moving azimuth and a bearing that means nothing, and
whether to accept that is the operator's call. With it on, any rotor in the list
can be chosen — the PstRotator, the Rotator Genius, the ARCO, the tower already
turned for HF.

That works because the per-backend command dispatch moved out of the three
RotatorGoTo/Stop/Heading methods into linkGoTo/linkStop/linkHeading, so the
satellite tracker drives any of the seven backends through the same code the
compass uses instead of a second implementation of each. GetRotatorHeading loses
sixty lines of near-duplicate switch in the process, and a rotor with no
elevation axis now says so (HasElevation) rather than reporting a zero that
looks like a real bearing.

One trap, with a test on it: the step check compared both axes, so with the
elevation never commanded its difference stayed above the step for the whole
pass and every tick ordered the antenna to the bearing it was already on. A mast
has a finite number of turns in it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-09 11:35:23 +02:00
co-authored by Claude Opus 5
parent 8f17416eca
commit b15055ba4a
10 changed files with 337 additions and 160 deletions
+131 -131
View File
@@ -17412,6 +17412,126 @@ func (a *App) activeRotor() (lr logicalRotor, rotors []logicalRotor, idx int, ok
return rotors[idx], rotors, idx, true
}
// linkHeading asks one rotor where it is.
//
// The per-backend switch lives here, once, so the compass and the satellite
// tracker read a rotor the same way. hasEl says whether the elevation returned
// means anything: an azimuth controller answers the azimuth question perfectly
// well and has nothing to say about the other axis, and reporting a zero there
// would draw an antenna lying on the horizon.
//
// raw is the controller's own reply, kept for the log — it is what tells a
// baffled operator whether the port is silent or answering something we did not
// expect.
func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error) {
switch l.Type {
case "rotgenius":
st, r, herr := rotgenius.New(l.Host, l.Port).Heading(l.Num)
if herr != nil {
return 0, 0, false, "", herr
}
if !st.Connected {
return 0, 0, false, "sensor not connected (999)", fmt.Errorf("sensor not connected")
}
return float64(st.Azimuth), 0, false, r, nil
case "arco":
v, r, herr := arcoClient(l).Heading()
return float64(v), 0, false, r, herr
case "erc":
aa, ee, r, herr := ercClient(l).Position()
return float64(aa), float64(ee), herr == nil, r, herr
case "easycomm":
aa, ee, live, herr := easycommClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
r := fmt.Sprintf("AZ %.0f° EL %.0f°", aa, ee)
if !live {
// The controller answered nothing and this is the last COMMANDED
// position. Say so: a stuck rotator must not be able to hide behind
// an order it never carried out.
r += " (commanded)"
}
return aa, ee, true, r, nil
case "spid":
aa, ee, herr := spidClient(l).Heading()
if herr != nil {
return 0, 0, false, "", herr
}
if l.HasElevation {
return float64(aa), float64(ee), true, fmt.Sprintf("AZ %d° EL %d°", aa, ee), nil
}
return float64(aa), 0, false, fmt.Sprintf("%d°", aa), nil
case "dcu1":
v, r, herr := dcu1Client(l).Heading()
return float64(v), 0, false, r, herr
default:
v, r, herr := pst.New(l.Host, l.Port).Heading()
if herr != nil {
// PstRotator's own text is more useful than the transport error.
return 0, 0, false, r, herr
}
if l.HasElevation {
if e, _, eerr := pst.New(l.Host, l.Port).Elevation(); eerr == nil {
return float64(v), float64(e), true, r, nil
}
}
return float64(v), 0, false, r, nil
}
}
// linkGoTo points one rotor. An elevation below zero is the callers' "no
// opinion" — a spot click, a compass drag — and leaves the elevation axis where
// it is rather than swinging a dish to the horizon.
func linkGoTo(l rotorLink, az, el int) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
case "arco":
return arcoClient(l).GoTo(az)
case "erc":
if el < 0 {
return ercClient(l).GoTo(az)
}
return ercClient(l).GoToAzEl(az, el)
case "easycomm":
if el < 0 {
if _, cur, _, err := easycommClient(l).Heading(); err == nil {
el = int(math.Round(cur))
} else {
el = 0
}
}
return easycommClient(l).Point(float64(az), float64(el))
case "spid":
return spidClient(l).GoTo(az, el)
case "dcu1":
return dcu1Client(l).GoTo(az)
default:
return pst.New(l.Host, l.Port).GoTo(az, l.HasElevation, el)
}
}
// linkStop interrupts one rotor.
func linkStop(l rotorLink) error {
switch l.Type {
case "rotgenius":
return rotgenius.New(l.Host, l.Port).Stop()
case "arco":
return arcoClient(l).Stop()
case "erc":
return ercClient(l).Stop()
case "easycomm":
return easycommClient(l).Stop()
case "spid":
return spidClient(l).Stop()
case "dcu1":
return dcu1Client(l).Stop()
default:
return pst.New(l.Host, l.Port).Stop()
}
}
// GetRotatorHeading queries the active rotor for its azimuth. Returns
// Enabled=false when no rotator is configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
@@ -17424,94 +17544,19 @@ func (a *App) GetRotatorHeading() RotatorHeading {
names[i] = r.Name
}
base := RotatorHeading{Enabled: true, Rotors: names, Active: idx, Motorized: lr.Motorized}
link := lr.Link
switch link.Type {
case "rotgenius":
st, raw, herr := rotgenius.New(link.Host, link.Port).Heading(link.Num)
if herr != nil {
base.Raw = herr.Error()
return base
}
if !st.Connected {
base.Raw = "sensor not connected (999)"
return base
}
base.OK = true
base.Azimuth = st.Azimuth
az, el, hasEl, raw, err := linkHeading(lr.Link)
if err != nil {
base.Raw = raw
return base
case "arco":
az, raw, herr := arcoClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
if base.Raw == "" {
base.Raw = err.Error()
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
case "erc":
az, el, raw, herr := ercClient(link).Position()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth, base.Elevation, base.HasElevation = az, el, true
base.Raw = raw
return base
case "easycomm":
az, el, live, herr := easycommClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth, base.Elevation, base.HasElevation = int(math.Round(az)), int(math.Round(el)), true
if live {
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f°", az, el)
} else {
// The controller answered nothing and this is the last COMMANDED
// position. Say so: a stuck rotator must not be able to hide behind
// an order it never carried out.
base.Raw = fmt.Sprintf("AZ %.0f° EL %.0f° (commanded)", az, el)
}
return base
case "spid":
az, el, herr := spidClient(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = fmt.Sprintf("%d°", az)
if link.HasElevation {
base.Elevation, base.HasElevation = el, true
base.Raw = fmt.Sprintf("AZ %d° EL %d°", az, el)
}
return base
case "dcu1":
az, raw, herr := dcu1Client(link).Heading()
if herr != nil {
base.Raw = herr.Error()
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
default:
az, raw, herr := pst.New(link.Host, link.Port).Heading()
if herr != nil {
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = az
base.Raw = raw
return base
}
base.OK = true
base.Azimuth = int(math.Round(az))
base.Elevation, base.HasElevation = int(math.Round(el)), hasEl
base.Raw = raw
return base
}
// RotatorGoTo points the active rotor at the given azimuth (and optional
@@ -17535,36 +17580,7 @@ func (a *App) RotatorGoToPath(az int, el int, path string) error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
case "arco":
return arcoClient(link).GoTo(az)
case "erc":
// An elevation of -1 is the callers' "no opinion" (a spot click, a
// compass drag). Leaving the elevation where it is beats swinging the
// dish to the horizon because somebody clicked a DX spot.
if el < 0 {
return ercClient(link).GoTo(az)
}
return ercClient(link).GoToAzEl(az, el)
case "easycomm":
if el < 0 {
if _, cur, _, err := easycommClient(link).Heading(); err == nil {
el = int(math.Round(cur))
} else {
el = 0
}
}
return easycommClient(link).Point(float64(az), float64(el))
case "spid":
return spidClient(link).GoTo(az, el)
case "dcu1":
return dcu1Client(link).GoTo(az)
default:
return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el)
}
return linkGoTo(lr.Link, az, el)
}
// RotatorStop interrupts any in-progress rotation of the active rotor.
@@ -17573,23 +17589,7 @@ func (a *App) RotatorStop() error {
if !ok {
return fmt.Errorf("no rotator configured")
}
link := lr.Link
switch link.Type {
case "rotgenius":
return rotgenius.New(link.Host, link.Port).Stop()
case "arco":
return arcoClient(link).Stop()
case "erc":
return ercClient(link).Stop()
case "easycomm":
return easycommClient(link).Stop()
case "spid":
return spidClient(link).Stop()
case "dcu1":
return dcu1Client(link).Stop()
default:
return pst.New(link.Host, link.Port).Stop()
}
return linkStop(lr.Link)
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and