// 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("%d%d", az, el)) } return c.send(fmt.Sprintf("%d", az)) } // Stop interrupts any in-progress rotation. func (c *Client) Stop() error { return c.send("1") } // Park sends the rotator to its parked position (configured inside // PstRotator itself — we just trigger it). func (c *Client) Park() error { return c.send("1") } // Heading queries PstRotator for the current azimuth. PstRotator's protocol: // send "AZ?" 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("AZ?"); 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("EL?"); 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", // "45". // // 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", "123", …) 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 }