package main // How a satellite station points its antenna. // // It does NOT configure a rotator. Every rotator interface OpsLog knows lives in // Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them. // The two used to be separate: EasyComm and PstRotator were described inside the // satellite settings while five other backends were described in the rotator // list, so an operator with one mast described it twice — and could describe it // differently the second time, which is a station that works on HF and not on a // pass, for no reason anyone can see. // // What remains here is the adapter: turning whichever backend the operator // picked into the three things a pass needs — point it, ask where it is, let go // of it at the end. import ( "fmt" "math" "strings" "sync" "hamlog/internal/rotator/gs232" "hamlog/internal/rotator/pst" "hamlog/internal/rotator/spid" ) // 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 legacy satellite-only rotator kinds. They are no longer stored; they // survive only so migrateSatRotator can read what an operator configured before // the rotator list existed. const ( satRotEasycomm = "easycomm" satRotPst = "pstrotator" ) // newSatRotator builds a controller for the rotor the satellite page selected. func (a *App) newSatRotator(s SatSettings) (satRotator, error) { if strings.TrimSpace(s.RotID) == "" { return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite") } lr, ok := a.rotorByKey(s.RotID) if !ok { // The rotor was deleted from the list after being chosen here. Say that, // rather than failing to connect to an address nobody can see any more. return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator") } if !lr.HasEl { name := strings.TrimSpace(lr.Name) if name == "" { name = "this rotator" } return nil, fmt.Errorf("%s has no elevation axis — a satellite pass needs one", name) } l := lr.Link switch l.Type { case "pst": return &pstSatRotator{c: pst.New(l.Host, l.Port), maxAz: l.MaxAz}, nil case "easycomm": return easycommClient(l), nil case "erc": return &gs232SatRotator{c: ercClient(l), maxAz: l.MaxAz}, nil case "spid": return &spidSatRotator{c: spidClient(l)}, nil default: return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type) } } // SatelliteRotorChoice is one entry in the satellite page's rotator dropdown. type SatelliteRotorChoice struct { Key string `json:"key"` // Name is the operator's label; Type is the backend's, for the rotors left // unnamed (a list of three blank rows is a list of one rotor as far as // anybody can tell). Name string `json:"name"` Type string `json:"type"` HasEl bool `json:"has_el"` } // ListSatelliteRotors returns every configured rotor, elevation-capable or not. // // Not filtered to the az/el ones, deliberately. An operator who owns exactly one // rotator and does not see it in this list concludes OpsLog cannot find it; shown // with "azimuth only" beside it, they learn the actual thing — that the tracker // needs an elevation axis and this mast has none. func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) { devs, err := a.GetRotators() if err != nil { return nil, err } out := []SatelliteRotorChoice{} for _, r := range flattenRotors(devs) { out = append(out, SatelliteRotorChoice{ Key: r.Key, Name: r.Name, Type: rotorTypeInfo(r.Link.Type).Label, HasEl: r.HasEl, }) } return out, nil } // gs232SatRotator points an ERC-M (or any GS-232 az/el controller) through the // W command. // // The 450° overlap is handled HERE and not in the package, the same way the // EasyComm client does it: a controller reports 0-450 and takes 0-450, but the // tracker works in true bearings, and which of the two ways round to reach 010° // depends on where the mast currently is. type gs232SatRotator struct { c *gs232.Client maxAz int } func (g *gs232SatRotator) Point(az, el float64) error { return g.c.GoToAzEl(int(math.Round(satWrapAz(az, g.maxAz))), int(math.Round(clampEl(el)))) } func (g *gs232SatRotator) Heading() (float64, float64, bool, error) { az, el, _, err := g.c.Position() if err != nil { return 0, 0, false, err } return float64(az), float64(el), true, nil } // Close: nothing to release. The serial port is held by the gs232 package, which // keeps it open across the whole session on purpose — an Arduino-based // controller reboots every time its port is opened. func (g *gs232SatRotator) Close() {} // spidSatRotator points a SPID Rot2Prog. Its protocol is absolute and binary, // with no overlap notion to manage: the controller is told a bearing and a // resolution and works out its own path. type spidSatRotator struct{ c *spid.Client } func (s *spidSatRotator) Point(az, el float64) error { a := math.Mod(az, 360) if a < 0 { a += 360 } return s.c.GoTo(int(math.Round(a)), int(math.Round(clampEl(el)))) } func (s *spidSatRotator) Heading() (float64, float64, bool, error) { az, el, err := s.c.Heading() if err != nil { return 0, 0, false, err } return float64(az), float64(el), true, nil } func (s *spidSatRotator) Close() {} // satWrapAz maps a true bearing onto what the controller accepts. On a 450° // mast the far end of the overlap is reachable two ways and the higher number is // chosen for the last 90°, which is what keeps a pass crossing north from // unwinding the cable in the middle of it. func satWrapAz(az float64, maxAz int) float64 { a := math.Mod(az, 360) if a < 0 { a += 360 } if maxAz == 450 && a < 90 { return a + 360 } return a } // clampEl keeps the elevation inside what a mast will accept. 180 and not 90: a // G-5500 goes past the zenith and keeps counting, which is how an overhead pass // is followed without swinging the azimuth 180° through the middle of it. func clampEl(el float64) float64 { if el < 0 { return 0 } if el > 180 { return 180 } return el } // 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 the direct backends // need 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 } el = clampEl(el) 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() {}