// 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 move rotator ('1'|'2') to azimuth az3 (000..360) // |P / |M 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) }