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:
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
// The two ways a satellite station points its antenna.
|
||||
//
|
||||
// Some operators drive their az/el rotator directly — EasyComm II, what
|
||||
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
|
||||
// them and a dozen different controllers and handles az AND el; for those,
|
||||
// OpsLog talking to the controller itself would be a second program fighting
|
||||
// PstRotator over the same cable.
|
||||
//
|
||||
// So both, behind one small interface, chosen in Settings. Neither is more
|
||||
// "correct" than the other: the right one is whichever the station already has
|
||||
// working.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hamlog/internal/rotator/easycomm"
|
||||
"hamlog/internal/rotator/pst"
|
||||
)
|
||||
|
||||
// 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 rotator kinds, as stored.
|
||||
const (
|
||||
satRotEasycomm = "easycomm"
|
||||
satRotPst = "pstrotator"
|
||||
)
|
||||
|
||||
// newSatRotator builds the configured controller.
|
||||
func newSatRotator(s SatSettings) (satRotator, error) {
|
||||
switch s.RotType {
|
||||
case satRotPst:
|
||||
if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 {
|
||||
return nil, fmt.Errorf("no address for PstRotator")
|
||||
}
|
||||
return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil
|
||||
default:
|
||||
if s.RotTransport == "tcp" {
|
||||
if strings.TrimSpace(s.RotHost) == "" {
|
||||
return nil, fmt.Errorf("no address for the rotator")
|
||||
}
|
||||
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
|
||||
}
|
||||
if strings.TrimSpace(s.RotCOM) == "" {
|
||||
return nil, fmt.Errorf("no COM port for the rotator")
|
||||
}
|
||||
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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 EasyComm needs 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
|
||||
}
|
||||
if el < 0 {
|
||||
el = 0
|
||||
}
|
||||
if el > 180 {
|
||||
el = 180
|
||||
}
|
||||
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() {}
|
||||
Reference in New Issue
Block a user