Files
OpsLog/internal/rotator/pst/pst.go
T
2026-05-30 01:35:50 +02:00

123 lines
3.9 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"
"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
}
// 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 := 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
}