Files
OpsLog/internal/rotgenius/rotgenius.go
T
rouggyandClaude Opus 5 3dad00f8ad feat(rotator): drive a Rotator Genius through the overlap
An operator with a 450° mast watched the Genius stop at 359 and had to press
"clockwise" by hand to get through north. Half of that was ours: GoTo clamped
every target to 360 before sending it, although the wire command carries three
digits and always could have said 370.

So the clamp goes to 450, the rotator-range setting is offered for the Rotator
Genius like the other backends it applies to, and when a bearing can be reached
two ways the nearer one is taken — 010° sent as 370° when the antenna is already
at 350°, which is the entire point of having an overlap.

THE GENIUS DECIDES WHAT IS REACHABLE. It reports the limits it is configured
with, and they are the truth about what is bolted to the tower. This particular
station's box says "5 to 4" — the factory 360° range — and would refuse 370,
turning a working command into a rejected one. So the overlap is used only when
the Genius itself says it has one, and when OpsLog is set to 450 while the Genius
is not, the log says so once a minute and names the dialog to change: the setting
lives in the Genius's own Rotator Configuration, and nothing here can reach past
its limits.

The |h reply always carried those limits and they were being skipped over.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 23:46:04 +02:00

215 lines
6.5 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..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)
}