feat(sat): PstRotator can point the antenna too

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.
This commit is contained in:
2026-09-07 17:11:23 +02:00
parent 9dfa6f7d39
commit 2283734210
8 changed files with 327 additions and 41 deletions
+71
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"net"
"strconv"
"strings"
"time"
)
@@ -85,6 +86,76 @@ func (c *Client) Heading() (az int, raw string, err error) {
return a, raw, nil
}
// Elevation queries PstRotator for the current elevation.
//
// Same shape as Heading, and the same port+1 listener — but a great many
// PstRotator setups drive an azimuth-only rotator and answer nothing at all,
// which is why the caller is expected to ask once and stop rather than wait a
// second and a half per poll for a reply that is never coming.
//
// The reply is matched on its LABEL and not on "the first number in it": AZ?
// and EL? both report on the same port, so taking the first integer of whatever
// arrives would happily read an azimuth as an elevation.
func (c *Client) Elevation() (el int, raw string, err error) {
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
if err != nil {
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
}
defer pc.Close()
if err := c.send("<PST>EL?</PST>"); err != nil {
return 0, "", fmt.Errorf("query PstRotator: %w", err)
}
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
buf := make([]byte, 512)
n, _, rerr := pc.ReadFrom(buf)
if rerr != nil {
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
}
raw = string(buf[:n])
v, ok := parseLabelled(raw, "EL", "AZ")
if !ok {
return 0, raw, fmt.Errorf("no elevation in reply %q", raw)
}
return v, raw, nil
}
// parseLabelled reads the number attached to a label — "EL:45", "EL 45",
// "<PST><ELEVATION>45</ELEVATION></PST>".
//
// The number is the first one AFTER the label, and false is returned when the
// label is absent — which is how an answer to the other question gets refused
// rather than read as this one.
func parseLabelled(s, label, other string) (int, bool) {
up := strings.ToUpper(s)
i := strings.Index(up, label)
if i < 0 {
return 0, false
}
// A reply carrying BOTH labels is answering the other question first; only
// what follows our own label counts.
rest := up[i+len(label):]
if j := strings.Index(rest, other); j >= 0 {
rest = rest[:j]
}
j := 0
for j < len(rest) && (rest[j] < '0' || rest[j] > '9') {
j++
}
k := j
for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' {
k++
}
if k == j {
return 0, false
}
n, err := strconv.Atoi(rest[j:k])
if err != nil {
return 0, false
}
return n, true
}
// parseAzimuth extracts the first integer found in a PstRotator reply
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
// it to [0,360).