feat(sat): point the antenna — EasyComm II az/el rotator
EasyComm is what satellite rotator controllers agreed on, so a box that works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its own settings rather than the HF rotator's: an az/el pair is a different machine on a different port, and an operator who has both must not have to choose. A great many EasyComm controllers — the Arduino trackers above all — accept commands and never say a word back. That is legal and common, so a silent controller is not treated as a broken one: it is still driven, and the last commanded position is reported in its place, marked as commanded rather than read. A stuck rotator must not be able to hide behind an order it never carried out, which is why the panel shows the antenna's position beside the satellite's. The 450° overlap is the reason a satellite rotator is worth having, so it is used: a pass crossing north continues past 360 instead of unwinding three quarters of a turn with the antenna sweeping the ground. Below the configured elevation the mast is left alone — the numbers are right all the way round the orbit, but a rotator that chases a satellite through the far side of the earth spends the night turning, and a mast has a finite number of turns in it.
This commit is contained in:
+146
-1
@@ -29,6 +29,7 @@ import (
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rotator/easycomm"
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
@@ -63,6 +64,16 @@ type satTracker struct {
|
||||
status SatTrackStatus
|
||||
fails int
|
||||
|
||||
// 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
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
@@ -82,6 +93,14 @@ type SatTrackStatus struct {
|
||||
Visible bool `json:"visible"`
|
||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||
Error string `json:"error"`
|
||||
|
||||
// Where the antenna is. RotLive distinguishes a reading from the controller
|
||||
// from the last position it was TOLD to go to — a stuck rotator must not be
|
||||
// able to hide behind a command it never carried out.
|
||||
RotOn bool `json:"rot_on"`
|
||||
RotAz float64 `json:"rot_az"`
|
||||
RotEl float64 `json:"rot_el"`
|
||||
RotLive bool `json:"rot_live"`
|
||||
}
|
||||
|
||||
// StartSatelliteTracking arms the radio and starts following the satellite.
|
||||
@@ -108,6 +127,18 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||
}
|
||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||
|
||||
// The rotator, if there is one. A geostationary bird is pointed at once and
|
||||
// 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)
|
||||
} else {
|
||||
t.rot = easycomm.NewSerial(set.RotCOM, set.RotBaud, set.RotMaxAz)
|
||||
}
|
||||
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:
|
||||
// it can still be tuned to the downlink, which is most of a receive-heavy
|
||||
// pass, and saying so beats refusing to track at all.
|
||||
@@ -150,6 +181,39 @@ func (a *App) StopSatelliteTracking() {
|
||||
a.emitSatTrack(SatTrackStatus{})
|
||||
}
|
||||
|
||||
// TestSatelliteRotator opens the configured controller and asks it where it is.
|
||||
//
|
||||
// The one question worth asking before a pass: is this port the rotator, and
|
||||
// does it talk back? A controller that accepts commands silently is a normal,
|
||||
// working one — so that answer is a success with a caveat, not a failure.
|
||||
func (a *App) TestSatelliteRotator() (string, error) {
|
||||
set := a.satSettings()
|
||||
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)
|
||||
}
|
||||
defer c.Close()
|
||||
az, el, live, err := c.Heading()
|
||||
if err != nil {
|
||||
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 fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
|
||||
}
|
||||
|
||||
// GetSatelliteTracking reports what the tracker is doing.
|
||||
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
||||
a.satTrackMu.Lock()
|
||||
@@ -185,6 +249,7 @@ func (a *App) emitSatTrack(s SatTrackStatus) {
|
||||
|
||||
func (a *App) satTrackLoop(t *satTracker) {
|
||||
defer close(t.done)
|
||||
defer t.releaseRotator()
|
||||
tick := time.NewTicker(satTickEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
@@ -273,7 +338,14 @@ func (a *App) satTrackStep(t *satTracker) {
|
||||
Radio: t.status.Radio, Error: t.status.Error,
|
||||
}
|
||||
t.mu.Unlock()
|
||||
a.emitSatTrack(t.status)
|
||||
|
||||
t.pointRotator(pos, b.Geostationary)
|
||||
t.readRotator()
|
||||
|
||||
t.mu.Lock()
|
||||
st := t.status
|
||||
t.mu.Unlock()
|
||||
a.emitSatTrack(st)
|
||||
|
||||
// Only send what has actually moved. The step is the smallest change worth a
|
||||
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||
@@ -322,6 +394,79 @@ func satNominalFromDial(heardHz int64, factor float64) int64 {
|
||||
return int64(math.Round(float64(heardHz) / (1 + factor)))
|
||||
}
|
||||
|
||||
// pointRotator keeps the antenna on the satellite.
|
||||
//
|
||||
// Below the configured elevation the rotator is left alone. Not because the
|
||||
// numbers stop being right — they are right all the way round the orbit — but
|
||||
// because a rotator that chases a satellite through the far side of the earth
|
||||
// spends the whole night turning, and a mast is a mechanical thing with a
|
||||
// finite number of turns in it.
|
||||
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
if !geostationary && pos.El < t.rotMinE {
|
||||
return
|
||||
}
|
||||
// A step below the beamwidth is a command for nothing. Compared against what
|
||||
// was last COMMANDED rather than where the rotator says it is: a rotator in
|
||||
// motion is always somewhere between the two, and comparing against that
|
||||
// would order a fresh move on every tick of a slew.
|
||||
az, el := pos.Az, pos.El
|
||||
if geostationary {
|
||||
// A satellite that does not move needs pointing once. Its own az/el were
|
||||
// not computed (there is nothing to compute), so leave the rotator where
|
||||
// the operator put it.
|
||||
if t.rotSent {
|
||||
return
|
||||
}
|
||||
}
|
||||
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
|
||||
return
|
||||
}
|
||||
if err := t.rot.Point(az, el); err != nil {
|
||||
t.setError(err.Error())
|
||||
return
|
||||
}
|
||||
t.rotAz, t.rotEl, t.rotSent = az, el, true
|
||||
}
|
||||
|
||||
// readRotator asks the controller where it actually is, for the display.
|
||||
//
|
||||
// Separate from the pointing, and it runs on every tick rather than only when a
|
||||
// command was sent: watching the antenna crawl towards the bearing is how an
|
||||
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
|
||||
// controller that does not answer says so once and is not asked again.
|
||||
func (t *satTracker) readRotator() {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
az, el, live, err := t.rot.Heading()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if err != nil {
|
||||
t.status.RotOn = true
|
||||
return
|
||||
}
|
||||
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
|
||||
}
|
||||
|
||||
// releaseRotator hands the mast back at the end of a pass.
|
||||
func (t *satTracker) releaseRotator() {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
if t.rotPark && t.rotSent {
|
||||
// Elevation down first and azimuth to north: a dish or a pair of yagis
|
||||
// left pointing at the sky is what a gale takes away.
|
||||
if err := t.rot.Point(0, 0); err != nil {
|
||||
applog.Printf("sat: could not park the rotator: %v", err)
|
||||
}
|
||||
}
|
||||
t.rot.Close()
|
||||
t.rot = nil
|
||||
}
|
||||
|
||||
func (t *satTracker) setError(msg string) {
|
||||
t.mu.Lock()
|
||||
t.status.Error = msg
|
||||
|
||||
Reference in New Issue
Block a user