feat(sat): PstRotator can point the antenna too

It handles azimuth and elevation, and a great many stations already run
it in front of a controller OpsLog has never heard of. For those,
OpsLog talking to the controller itself would be a second program
fighting PstRotator over the same cable — so it hands over the bearing
instead, and lets PstRotator turn the mast.

Both kinds sit behind one small interface, chosen in Settings. Neither is
more correct than the other: the right one is whichever the station
already has working.

The 450° overlap is deliberately NOT applied on the PstRotator path.
PstRotator knows which machine is on the other end and does its own; two
programs each deciding to go the long way round is exactly how an antenna
unwinds in the middle of a pass.

Position queries are asked at most every three seconds rather than on
every tick. A PstRotator query binds a socket and waits up to a second
and a half, and many setups answer nothing at all — so one silence is
enough and it stops asking, reporting the commanded position instead and
saying that is what it is.
This commit is contained in:
2026-09-07 17:11:23 +02:00
parent 9dfa6f7d39
commit 2283734210
8 changed files with 327 additions and 41 deletions
+26 -24
View File
@@ -29,7 +29,6 @@ import (
"hamlog/internal/applog"
"hamlog/internal/cat"
"hamlog/internal/qso"
"hamlog/internal/rotator/easycomm"
"hamlog/internal/sat"
)
@@ -66,13 +65,14 @@ type satTracker struct {
// The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured.
rot *easycomm.Client
rotStep float64
rotMinE float64
rotPark bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rot satRotator
rotStep float64
rotMinE float64
rotPark bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{}
done chan struct{}
@@ -131,12 +131,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
if set.RotTransport == "tcp" {
t.rot = easycomm.New(set.RotHost, set.RotPort, set.RotMaxAz)
r, rerr := newSatRotator(set)
if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else {
t.rot = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz)
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
}
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
}
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
@@ -191,17 +193,9 @@ func (a *App) TestSatelliteRotator() (string, error) {
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
var c *easycomm.Client
if set.RotTransport == "tcp" {
if strings.TrimSpace(set.RotHost) == "" {
return "", fmt.Errorf("no address for the rotator")
}
c = easycomm.New(set.RotHost, set.RotPort, set.RotMaxAz)
} else {
if strings.TrimSpace(set.RotCOM) == "" {
return "", fmt.Errorf("no COM port for the rotator")
}
c = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz)
c, err := newSatRotator(set)
if err != nil {
return "", err
}
defer c.Close()
az, el, live, err := c.Heading()
@@ -209,7 +203,7 @@ func (a *App) TestSatelliteRotator() (string, error) {
return "", err
}
if !live {
return "The controller accepted the command but does not report its position — normal for many EasyComm controllers. It will still be driven.", nil
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
}
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
}
@@ -441,6 +435,14 @@ func (t *satTracker) readRotator() {
if t.rot == nil {
return
}
// Not on every tick. A PstRotator query binds a socket and waits up to a
// second and a half for an answer, and a held serial port still costs a
// round trip; three seconds is often enough to watch an antenna slew and
// rare enough not to sit in the way of the tuning.
if time.Since(t.rotReadAt) < 3*time.Second {
return
}
t.rotReadAt = time.Now()
az, el, live, err := t.rot.Heading()
t.mu.Lock()
defer t.mu.Unlock()