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.
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
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() {}
|