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:
@@ -0,0 +1,92 @@
|
||||
package easycomm
|
||||
|
||||
import "testing"
|
||||
|
||||
// Every one of these is a shape a real controller has been seen to answer with.
|
||||
// The point of the parser is that none of them is special-cased.
|
||||
func TestParseHeading(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
az, el float64
|
||||
ok bool
|
||||
}{
|
||||
{"AZ123.4 EL45.0", 123.4, 45, true},
|
||||
{"AZ=123.4 EL=45.0", 123.4, 45, true},
|
||||
{"az 123.4 el 45.0", 123.4, 45, true},
|
||||
{"AZ123.4\tEL45.0\r\n", 123.4, 45, true},
|
||||
{"AZ012.0 EL000.0", 12, 0, true},
|
||||
{"AZ370.5 EL05.5", 370.5, 5.5, true},
|
||||
{"AZ123.4", 123.4, 0, true}, // azimuth-only controller
|
||||
{"RPRT 0", 0, 0, false},
|
||||
{"", 0, 0, false},
|
||||
} {
|
||||
az, el, ok := parseHeading(tc.line)
|
||||
if ok != tc.ok {
|
||||
t.Errorf("%q: ok=%v, wanted %v", tc.line, ok, tc.ok)
|
||||
continue
|
||||
}
|
||||
if ok && (az != tc.az || el != tc.el) {
|
||||
t.Errorf("%q: got %.1f/%.1f, wanted %.1f/%.1f", tc.line, az, el, tc.az, tc.el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 450° overlap is the whole reason a satellite rotator is worth having: a
|
||||
// pass crossing north must continue past 360 instead of unwinding through the
|
||||
// entire scale with the antenna sweeping the ground.
|
||||
func TestWrapAz450(t *testing.T) {
|
||||
c := &Client{MaxAz: 450}
|
||||
// Nothing commanded yet: no history to be near, so the plain bearing.
|
||||
if got := c.wrapAz(10); got != 10 {
|
||||
t.Errorf("first move: got %.1f, wanted 10", got)
|
||||
}
|
||||
c.lastAz, c.commanded = 350, true
|
||||
// Crossing north: 370 is 20° away, 10 is 340° away.
|
||||
if got := c.wrapAz(10); got != 370 {
|
||||
t.Errorf("crossing north from 350: got %.1f, wanted 370", got)
|
||||
}
|
||||
// Coming back down the same way, the overlap stays the near answer.
|
||||
c.lastAz = 370
|
||||
if got := c.wrapAz(350); got != 350 {
|
||||
t.Errorf("back from 370: got %.1f, wanted 350", got)
|
||||
}
|
||||
// Beyond the rotator's reach there is no overlap to use.
|
||||
c.lastAz = 440
|
||||
if got := c.wrapAz(100); got != 100 {
|
||||
t.Errorf("past the end of the scale: got %.1f, wanted 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapAz360(t *testing.T) {
|
||||
c := &Client{MaxAz: 360}
|
||||
c.lastAz, c.commanded = 350, true
|
||||
if got := c.wrapAz(10); got != 10 {
|
||||
t.Errorf("a 360 rotator has no overlap: got %.1f, wanted 10", got)
|
||||
}
|
||||
if got := c.wrapAz(-10); got != 350 {
|
||||
t.Errorf("negative bearing: got %.1f, wanted 350", got)
|
||||
}
|
||||
if got := c.wrapAz(725); got != 5 {
|
||||
t.Errorf("two turns and five degrees: got %.1f, wanted 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A controller that never answers must not be treated as a broken one: the last
|
||||
// commanded position is reported, marked as not live.
|
||||
func TestSilentControllerReportsCommanded(t *testing.T) {
|
||||
c := &Client{MaxAz: 360, silent: true}
|
||||
if _, _, _, err := c.Heading(); err == nil {
|
||||
t.Error("a silent controller with nothing commanded should say it cannot report")
|
||||
}
|
||||
c.lastAz, c.lastEl, c.commanded = 120, 30, true
|
||||
az, el, live, err := c.Heading()
|
||||
if err != nil {
|
||||
t.Fatalf("after a command: %v", err)
|
||||
}
|
||||
if live {
|
||||
t.Error("a commanded position must not be reported as a live reading")
|
||||
}
|
||||
if az != 120 || el != 30 {
|
||||
t.Errorf("got %.1f/%.1f, wanted 120/30", az, el)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user