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]>
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package main
|
|
|
|
// How a satellite station points its antenna.
|
|
//
|
|
// It does NOT configure a rotator. Every rotator interface OpsLog knows lives in
|
|
// Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them.
|
|
// The two used to be separate: EasyComm and PstRotator were described inside the
|
|
// satellite settings while five other backends were described in the rotator
|
|
// list, so an operator with one mast described it twice — and could describe it
|
|
// differently the second time, which is a station that works on HF and not on a
|
|
// pass, for no reason anyone can see.
|
|
//
|
|
// What remains here is the adapter: turning whichever backend the operator
|
|
// picked into the three things a pass needs — point it, ask where it is, let go
|
|
// of it at the end.
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
|
|
"hamlog/internal/rotator/gs232"
|
|
"hamlog/internal/rotator/pst"
|
|
"hamlog/internal/rotator/spid"
|
|
)
|
|
|
|
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
|
// is, and let go of it at the end of the pass.
|
|
type satRotator interface {
|
|
Point(az, el float64) error
|
|
// Heading reports where the antenna is. live is false when the answer is
|
|
// the last commanded position rather than a reading — a stuck rotator must
|
|
// not be able to hide behind an order it never carried out.
|
|
Heading() (az, el float64, live bool, err error)
|
|
Close()
|
|
}
|
|
|
|
// The legacy satellite-only rotator kinds. They are no longer stored; they
|
|
// survive only so migrateSatRotator can read what an operator configured before
|
|
// the rotator list existed.
|
|
const (
|
|
satRotEasycomm = "easycomm"
|
|
satRotPst = "pstrotator"
|
|
)
|
|
|
|
// newSatRotator builds a controller for the rotor the satellite page selected.
|
|
func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
|
|
if strings.TrimSpace(s.RotID) == "" {
|
|
return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite")
|
|
}
|
|
lr, ok := a.rotorByKey(s.RotID)
|
|
if !ok {
|
|
// The rotor was deleted from the list after being chosen here. Say that,
|
|
// rather than failing to connect to an address nobody can see any more.
|
|
return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator")
|
|
}
|
|
// Azimuth only: any rotor will do, including the tower the operator already
|
|
// turns for HF. See SatSettings.RotAzOnly for why this is the common case
|
|
// rather than a fallback.
|
|
if s.RotAzOnly {
|
|
return &azOnlySatRotator{link: lr.Link}, nil
|
|
}
|
|
if !lr.HasEl {
|
|
name := strings.TrimSpace(lr.Name)
|
|
if name == "" {
|
|
name = "this rotator"
|
|
}
|
|
return nil, fmt.Errorf("%s has no elevation axis — tick \"follow the azimuth only\" in Settings ▸ Satellite, or pick an az/el rotator", name)
|
|
}
|
|
l := lr.Link
|
|
switch l.Type {
|
|
case "pst":
|
|
return &pstSatRotator{c: pst.New(l.Host, l.Port), maxAz: l.MaxAz}, nil
|
|
case "easycomm":
|
|
return easycommClient(l), nil
|
|
case "erc":
|
|
return &gs232SatRotator{c: ercClient(l), maxAz: l.MaxAz}, nil
|
|
case "spid":
|
|
return &spidSatRotator{c: spidClient(l)}, nil
|
|
default:
|
|
return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type)
|
|
}
|
|
}
|
|
|
|
// SatelliteRotorChoice is one entry in the satellite page's rotator dropdown.
|
|
type SatelliteRotorChoice struct {
|
|
Key string `json:"key"`
|
|
// Name is the operator's label; Type is the backend's, for the rotors left
|
|
// unnamed (a list of three blank rows is a list of one rotor as far as
|
|
// anybody can tell).
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
HasEl bool `json:"has_el"`
|
|
}
|
|
|
|
// ListSatelliteRotors returns every configured rotor, elevation-capable or not.
|
|
//
|
|
// Never filtered. Which of them can be USED depends on the azimuth-only switch,
|
|
// and that is a question for the panel: with it off an azimuth rotor is shown
|
|
// greyed and says why, with it on every rotor is fair game. Hiding them
|
|
// outright would only teach an operator with one mast that OpsLog cannot find
|
|
// it.
|
|
func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) {
|
|
devs, err := a.GetRotators()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []SatelliteRotorChoice{}
|
|
for _, r := range flattenRotors(devs) {
|
|
out = append(out, SatelliteRotorChoice{
|
|
Key: r.Key, Name: r.Name, Type: rotorTypeInfo(r.Link.Type).Label, HasEl: r.HasEl,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// gs232SatRotator points an ERC-M (or any GS-232 az/el controller) through the
|
|
// W command.
|
|
//
|
|
// The 450° overlap is handled HERE and not in the package, the same way the
|
|
// EasyComm client does it: a controller reports 0-450 and takes 0-450, but the
|
|
// tracker works in true bearings, and which of the two ways round to reach 010°
|
|
// depends on where the mast currently is.
|
|
type gs232SatRotator struct {
|
|
c *gs232.Client
|
|
maxAz int
|
|
}
|
|
|
|
func (g *gs232SatRotator) Point(az, el float64) error {
|
|
return g.c.GoToAzEl(int(math.Round(satWrapAz(az, g.maxAz))), int(math.Round(clampEl(el))))
|
|
}
|
|
|
|
func (g *gs232SatRotator) Heading() (float64, float64, bool, error) {
|
|
az, el, _, err := g.c.Position()
|
|
if err != nil {
|
|
return 0, 0, false, err
|
|
}
|
|
return float64(az), float64(el), true, nil
|
|
}
|
|
|
|
// Close: nothing to release. The serial port is held by the gs232 package, which
|
|
// keeps it open across the whole session on purpose — an Arduino-based
|
|
// controller reboots every time its port is opened.
|
|
func (g *gs232SatRotator) Close() {}
|
|
|
|
// spidSatRotator points a SPID Rot2Prog. Its protocol is absolute and binary,
|
|
// with no overlap notion to manage: the controller is told a bearing and a
|
|
// resolution and works out its own path.
|
|
type spidSatRotator struct{ c *spid.Client }
|
|
|
|
func (s *spidSatRotator) Point(az, el float64) error {
|
|
a := math.Mod(az, 360)
|
|
if a < 0 {
|
|
a += 360
|
|
}
|
|
return s.c.GoTo(int(math.Round(a)), int(math.Round(clampEl(el))))
|
|
}
|
|
|
|
func (s *spidSatRotator) Heading() (float64, float64, bool, error) {
|
|
az, el, err := s.c.Heading()
|
|
if err != nil {
|
|
return 0, 0, false, err
|
|
}
|
|
return float64(az), float64(el), true, nil
|
|
}
|
|
|
|
func (s *spidSatRotator) Close() {}
|
|
|
|
// satWrapAz maps a true bearing onto what the controller accepts. On a 450°
|
|
// mast the far end of the overlap is reachable two ways and the higher number is
|
|
// chosen for the last 90°, which is what keeps a pass crossing north from
|
|
// unwinding the cable in the middle of it.
|
|
func satWrapAz(az float64, maxAz int) float64 {
|
|
a := math.Mod(az, 360)
|
|
if a < 0 {
|
|
a += 360
|
|
}
|
|
if maxAz == 450 && a < 90 {
|
|
return a + 360
|
|
}
|
|
return a
|
|
}
|
|
|
|
// clampEl keeps the elevation inside what a mast will accept. 180 and not 90: a
|
|
// G-5500 goes past the zenith and keeps counting, which is how an overhead pass
|
|
// is followed without swinging the azimuth 180° through the middle of it.
|
|
func clampEl(el float64) float64 {
|
|
if el < 0 {
|
|
return 0
|
|
}
|
|
if el > 180 {
|
|
return 180
|
|
}
|
|
return el
|
|
}
|
|
|
|
// pstSatRotator points the antenna through PstRotator.
|
|
//
|
|
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
|
// rotator — it knows which controller is on the other end, and OpsLog does not.
|
|
// So the azimuth is sent plainly, and the 450° logic that the direct backends
|
|
// need is deliberately NOT applied here: two programs each deciding to go the
|
|
// long way round is how an antenna ends up unwinding in the middle of a pass.
|
|
type pstSatRotator struct {
|
|
c *pst.Client
|
|
maxAz int
|
|
|
|
mu sync.Mutex
|
|
// lastAz/lastEl are what was commanded, for the display when PstRotator
|
|
// does not answer a position query — which is the usual case for the many
|
|
// setups whose controller reports nothing back to it either.
|
|
lastAz, lastEl float64
|
|
commanded bool
|
|
azSilent bool // the azimuth query went unanswered; stop asking
|
|
elSilent bool // likewise for elevation, and far more common
|
|
}
|
|
|
|
func (p *pstSatRotator) Point(az, el float64) error {
|
|
a := math.Mod(az, 360)
|
|
if a < 0 {
|
|
a += 360
|
|
}
|
|
el = clampEl(el)
|
|
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
p.lastAz, p.lastEl, p.commanded = a, el, true
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
|
|
p.mu.Lock()
|
|
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
|
|
p.mu.Unlock()
|
|
|
|
az, el, live := la, le, false
|
|
if !azSilent {
|
|
if v, _, err := p.c.Heading(); err == nil {
|
|
az, live = float64(v), true
|
|
} else {
|
|
// One silence is enough. Each query binds a socket and waits a second
|
|
// and a half; repeating that every few seconds for a setup that will
|
|
// never answer is a stall per poll for nothing.
|
|
p.mu.Lock()
|
|
p.azSilent = true
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
if !elSilent {
|
|
if v, _, err := p.c.Elevation(); err == nil {
|
|
el = float64(v)
|
|
} else {
|
|
p.mu.Lock()
|
|
p.elSilent = true
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
if !live && !commanded {
|
|
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
|
|
}
|
|
return az, el, live, nil
|
|
}
|
|
|
|
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
|
// socket lives for the length of a single write.
|
|
func (p *pstSatRotator) Close() {}
|
|
|
|
// azOnlySatRotator follows the satellite in azimuth and never touches the
|
|
// elevation axis, whatever the rotor happens to have.
|
|
//
|
|
// It works because of the geometry, not in spite of it: a pass at the far edge
|
|
// of the footprint stays between the horizon and about fifteen degrees for its
|
|
// whole length, and a yagi's beamwidth swallows that. What it costs is the high
|
|
// passes — a bird straight overhead is a moving azimuth and a useless bearing —
|
|
// and that is the operator's trade to make, which is why it is a switch and not
|
|
// a silent fallback.
|
|
//
|
|
// It drives whichever rotor was chosen through the same per-backend dispatch the
|
|
// compass uses, so a PstRotator, a Rotator Genius, an ARCO, a DCU-1, a SPID and
|
|
// the az/el ones all work here without a second implementation of each.
|
|
type azOnlySatRotator struct{ link rotorLink }
|
|
|
|
// Point sends the azimuth alone. The elevation is passed as -1, the callers'
|
|
// "no opinion", so a rotor that HAS an elevation axis is left where it is rather
|
|
// than being driven to the horizon.
|
|
func (r *azOnlySatRotator) Point(az, _ float64) error {
|
|
a := math.Mod(az, 360)
|
|
if a < 0 {
|
|
a += 360
|
|
}
|
|
return linkGoTo(r.link, int(math.Round(a)), -1)
|
|
}
|
|
|
|
// Heading reports the azimuth. The elevation comes back as whatever the
|
|
// controller said, which for an azimuth rotor is zero — the panel is told
|
|
// separately not to draw it (SatTrackStatus.RotAzOnly), because zero is a real
|
|
// bearing and not the absence of one.
|
|
//
|
|
// live stays true when the AZIMUTH was genuinely read: it means "this is a
|
|
// reading and not the last command", and that answer is honest whatever the
|
|
// other axis does or does not do.
|
|
func (r *azOnlySatRotator) Heading() (float64, float64, bool, error) {
|
|
az, el, _, _, err := linkHeading(r.link)
|
|
if err != nil {
|
|
return 0, 0, false, err
|
|
}
|
|
return az, el, true, nil
|
|
}
|
|
|
|
func (r *azOnlySatRotator) Close() {}
|