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.
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
// Package pst sends commands to PstRotator over its UDP listener.
|
|
//
|
|
// PstRotator (Codrut Buda YO3DMU) exposes a simple text/XML protocol on
|
|
// a configurable UDP port (default 12000 on localhost). Each command is a
|
|
// single fire-and-forget datagram — no handshake, no response. This keeps
|
|
// us connectionless and means a misconfigured port silently no-ops rather
|
|
// than hanging the UI. Run the matching "Test" action to confirm the link.
|
|
package pst
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is a stateless UDP sender. Safe to construct cheaply per call —
|
|
// the underlying socket only lives for the length of one Write.
|
|
type Client struct {
|
|
Host string // hostname or IP of the PstRotator host (usually "127.0.0.1")
|
|
Port int // UDP port (PstRotator default = 12000)
|
|
}
|
|
|
|
// New returns a Client with sane defaults applied for empty fields.
|
|
func New(host string, port int) *Client {
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
if port <= 0 || port > 65535 {
|
|
port = 12000
|
|
}
|
|
return &Client{Host: host, Port: port}
|
|
}
|
|
|
|
// GoTo points the antenna at azimuth (0-359°). If hasElevation is true
|
|
// and el >= 0 the elevation field is included too (VHF/satellite setups);
|
|
// otherwise PstRotator just turns in azimuth.
|
|
func (c *Client) GoTo(az int, hasElevation bool, el int) error {
|
|
az = ((az % 360) + 360) % 360 // normalise to [0,360)
|
|
if hasElevation && el >= 0 && el <= 180 {
|
|
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH><ELEVATION>%d</ELEVATION></PST>", az, el))
|
|
}
|
|
return c.send(fmt.Sprintf("<PST><AZIMUTH>%d</AZIMUTH></PST>", az))
|
|
}
|
|
|
|
// Stop interrupts any in-progress rotation.
|
|
func (c *Client) Stop() error {
|
|
return c.send("<PST><STOP>1</STOP></PST>")
|
|
}
|
|
|
|
// Park sends the rotator to its parked position (configured inside
|
|
// PstRotator itself — we just trigger it).
|
|
func (c *Client) Park() error {
|
|
return c.send("<PST><PARK>1</PARK></PST>")
|
|
}
|
|
|
|
// Heading queries PstRotator for the current azimuth. PstRotator's protocol:
|
|
// send "<PST>AZ?</PST>" to the command port, and it reports the azimuth back
|
|
// on UDP port+1. So we bind a listener on port+1 first, send the query, then
|
|
// read the reply. Returns the raw reply too, for diagnostics. err is non-nil
|
|
// on timeout (no reply) or an unparseable response.
|
|
func (c *Client) Heading() (az int, raw string, err error) {
|
|
// Listen on port+1 where PstRotator sends its position report.
|
|
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>AZ?</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])
|
|
a, ok := parseAzimuth(raw)
|
|
if !ok {
|
|
return 0, raw, fmt.Errorf("no azimuth in reply %q", raw)
|
|
}
|
|
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).
|
|
func parseAzimuth(s string) (int, bool) {
|
|
i := 0
|
|
for i < len(s) && (s[i] < '0' || s[i] > '9') {
|
|
i++
|
|
}
|
|
if i >= len(s) {
|
|
return 0, false
|
|
}
|
|
j := i
|
|
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
|
|
j++
|
|
}
|
|
n, err := strconv.Atoi(s[i:j])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return ((n % 360) + 360) % 360, true
|
|
}
|
|
|
|
func (c *Client) send(payload string) error {
|
|
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port)) // IPv6-safe
|
|
conn, err := net.DialTimeout("udp", addr, 2*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("dial PstRotator at %s: %w", addr, err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
|
if _, err := conn.Write([]byte(payload)); err != nil {
|
|
return fmt.Errorf("send to PstRotator: %w", err)
|
|
}
|
|
return nil
|
|
}
|