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") } // Azimuth only: any rotor will do, including the tower the operator already // turns for HF. See SatSettings.RotAzOnly for why this is the common case // rather than a fallback. if s.RotAzOnly { return &azOnlySatRotator{link: lr.Link}, nil } if !lr.HasEl { name := strings.TrimSpace(lr.Name) if name == "" { name = "this rotator" } return nil, fmt.Errorf("%s has no elevation axis — tick \"follow the azimuth only\" in Settings ▸ Satellite, or pick an az/el rotator", 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. // // Never filtered. Which of them can be USED depends on the azimuth-only switch, // and that is a question for the panel: with it off an azimuth rotor is shown // greyed and says why, with it on every rotor is fair game. Hiding them // outright would only teach an operator with one mast that OpsLog cannot find // it. 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() {} // azOnlySatRotator follows the satellite in azimuth and never touches the // elevation axis, whatever the rotor happens to have. // // It works because of the geometry, not in spite of it: a pass at the far edge // of the footprint stays between the horizon and about fifteen degrees for its // whole length, and a yagi's beamwidth swallows that. What it costs is the high // passes — a bird straight overhead is a moving azimuth and a useless bearing — // and that is the operator's trade to make, which is why it is a switch and not // a silent fallback. // // It drives whichever rotor was chosen through the same per-backend dispatch the // compass uses, so a PstRotator, a Rotator Genius, an ARCO, a DCU-1, a SPID and // the az/el ones all work here without a second implementation of each. type azOnlySatRotator struct{ link rotorLink } // Point sends the azimuth alone. The elevation is passed as -1, the callers' // "no opinion", so a rotor that HAS an elevation axis is left where it is rather // than being driven to the horizon. func (r *azOnlySatRotator) Point(az, _ float64) error { a := math.Mod(az, 360) if a < 0 { a += 360 } return linkGoTo(r.link, int(math.Round(a)), -1) } // Heading reports the azimuth. The elevation comes back as whatever the // controller said, which for an azimuth rotor is zero — the panel is told // separately not to draw it (SatTrackStatus.RotAzOnly), because zero is a real // bearing and not the absence of one. // // live stays true when the AZIMUTH was genuinely read: it means "this is a // reading and not the last command", and that answer is honest whatever the // other axis does or does not do. func (r *azOnlySatRotator) Heading() (float64, float64, bool, error) { az, el, _, _, err := linkHeading(r.link) if err != nil { return 0, 0, false, err } return az, el, true, nil } func (r *azOnlySatRotator) Close() {}