feat: New Station Control, allow to control Webswitch 1216H or KMTronic
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user