194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
|
|
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
|
|
// there is no sequence/framing wrapper — you send a short command and read back a
|
|
// fixed-length reply.
|
|
//
|
|
// Commands used here:
|
|
//
|
|
// |h read heading + full state (both rotators)
|
|
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
|
|
// |P<rot> / |M<rot> rotate CW / CCW
|
|
// |S stop all movement
|
|
//
|
|
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
|
|
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
|
|
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
|
|
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
|
|
package rotgenius
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultPort = 9006
|
|
dialTimeout = 4 * time.Second
|
|
ioTimeout = 4 * time.Second
|
|
hdrReplyLen = 72 // fixed length of the |h reply
|
|
)
|
|
|
|
// Status is one rotator's live state parsed from a |h reply.
|
|
type Status struct {
|
|
Azimuth int // current heading in degrees (0..360)
|
|
Connected bool // false when the sensor reports 999 (not connected)
|
|
Moving int // 0 not moving, 1 CW, 2 CCW
|
|
Target int // target azimuth when moving (else -1)
|
|
}
|
|
|
|
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
|
// mirroring how the PstRotator client works, so there is no socket to manage.
|
|
type Client struct {
|
|
host string
|
|
port int
|
|
}
|
|
|
|
func New(host string, port int) *Client {
|
|
if port <= 0 {
|
|
port = defaultPort
|
|
}
|
|
return &Client{host: host, port: port}
|
|
}
|
|
|
|
func (c *Client) dial() (net.Conn, error) {
|
|
d := net.Dialer{Timeout: dialTimeout}
|
|
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
|
|
return conn, nil
|
|
}
|
|
|
|
// exchange sends cmd and returns up to max bytes of the reply.
|
|
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
|
|
conn, err := c.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
if _, err := conn.Write([]byte(cmd)); err != nil {
|
|
return nil, fmt.Errorf("write %q: %w", cmd, err)
|
|
}
|
|
buf := make([]byte, 0, max)
|
|
tmp := make([]byte, max)
|
|
for len(buf) < max {
|
|
n, rerr := conn.Read(tmp)
|
|
if n > 0 {
|
|
buf = append(buf, tmp[:n]...)
|
|
}
|
|
if rerr != nil {
|
|
break // deadline or EOF — return what we have and let the parser judge
|
|
}
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
// atoiField trims the space-padding a Rotator Genius field may carry and parses
|
|
// it. An empty or non-numeric field yields 0.
|
|
func atoiField(s string) int {
|
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
|
return n
|
|
}
|
|
|
|
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
|
|
// decoded field for diagnostics.
|
|
func (c *Client) Heading(rotator int) (Status, string, error) {
|
|
st, err := c.Read(rotator)
|
|
if err != nil {
|
|
return Status{}, "", err
|
|
}
|
|
return st, strconv.Itoa(st.Azimuth), nil
|
|
}
|
|
|
|
// Read fetches and parses the full |h reply for one rotator (1 or 2).
|
|
func (c *Client) Read(rotator int) (Status, error) {
|
|
if rotator != 1 && rotator != 2 {
|
|
rotator = 1
|
|
}
|
|
reply, err := c.exchange("|h", hdrReplyLen)
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
i := strings.Index(string(reply), "|h")
|
|
if i < 0 || len(reply)-i < hdrReplyLen {
|
|
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
|
|
}
|
|
p := reply[i:]
|
|
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
|
|
base := 4
|
|
if rotator == 2 {
|
|
base = 38
|
|
}
|
|
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
|
|
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
|
|
cur := atoiField(string(p[base : base+3]))
|
|
moving := atoiField(string(p[base+10 : base+11]))
|
|
target := atoiField(string(p[base+15 : base+18]))
|
|
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
|
if target != 999 {
|
|
st.Target = target
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
|
// accept, 'F' on reject.
|
|
func (c *Client) GoTo(rotator, az int) error {
|
|
if rotator != 1 && rotator != 2 {
|
|
rotator = 1
|
|
}
|
|
if az < 0 {
|
|
az = 0
|
|
}
|
|
if az > 360 {
|
|
az = 360
|
|
}
|
|
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkKF(reply, "GoTo")
|
|
}
|
|
|
|
// Stop halts all movement.
|
|
func (c *Client) Stop() error {
|
|
reply, err := c.exchange("|S", 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkKF(reply, "Stop")
|
|
}
|
|
|
|
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
|
|
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
|
|
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
|
|
|
|
func (c *Client) rotate(cmd byte, rotator int) error {
|
|
if rotator != 1 && rotator != 2 {
|
|
rotator = 1
|
|
}
|
|
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkKF(reply, string(cmd))
|
|
}
|
|
|
|
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
|
|
// no other letters (the rest is the header + digits), so scanning for them is
|
|
// unambiguous.
|
|
func checkKF(reply []byte, what string) error {
|
|
s := string(reply)
|
|
if strings.ContainsRune(s, 'K') {
|
|
return nil
|
|
}
|
|
if strings.ContainsRune(s, 'F') {
|
|
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
|
|
}
|
|
return fmt.Errorf("rotgenius: no reply to %s", what)
|
|
}
|