// 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" "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 } // 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 := fmt.Sprintf("%s:%d", c.Host, c.Port) 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 }