// 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..450 on an overlap rotator) 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) // The soft limits the Genius itself is configured with, as it reports them. // Read rather than assumed: an operator with a 450° mast has told the // Genius so, and that is the authority on how far it will go — OpsLog // asking for 400° on a box configured for 360 is a command it will refuse. LimitCW int LimitCCW int } // 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, LimitCW: atoiField(string(p[base+3 : base+6])), LimitCCW: atoiField(string(p[base+6 : base+9])), } if target != 999 { st.Target = target } return st, nil } // GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F' // on reject. // // The ceiling is 450 and not 360, which is the whole point: a rotator with an // overlap can be asked for 010° as either 10 or 370, and only the second reaches // it without unwinding the cable back through north. The command carries three // digits, so the range was never the protocol's — it was ours, and it left an // operator with a 450° mast clicking "clockwise" by hand every time a bearing // crossed north. // // A Genius configured for a 360° rotator refuses a target beyond its own limit, // which is the correct place for that decision: it knows what is bolted to the // tower, and OpsLog does not. func (c *Client) GoTo(rotator, az int) error { if rotator != 1 && rotator != 2 { rotator = 1 } if az < 0 { az = 0 } if az > 450 { az = 450 } 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) }