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)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package rotgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
|
||||
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
|
||||
// their documented widths.
|
||||
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
|
||||
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
|
||||
pad := func(s string, n int) string {
|
||||
for len(s) < n {
|
||||
s = " " + s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
b := []byte("|h")
|
||||
b = append(b, '0', 0x00) // Active, Panic
|
||||
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
|
||||
b = append(b, []byte(pad(cur, 3))...)
|
||||
b = append(b, []byte(pad(cw, 3))...)
|
||||
b = append(b, []byte(pad(ccw, 3))...)
|
||||
b = append(b, cfg, mv)
|
||||
b = append(b, []byte(pad(off, 4))...)
|
||||
b = append(b, []byte(pad(tgt, 3))...)
|
||||
b = append(b, []byte(pad(start, 3))...)
|
||||
b = append(b, lim)
|
||||
b = append(b, []byte(pad(name, 12))...)
|
||||
}
|
||||
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
|
||||
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestReadParsesBothRotators(t *testing.T) {
|
||||
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
|
||||
// offline), not moving. Mirrors the manual's worked example.
|
||||
reply := buildHReply(
|
||||
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
|
||||
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
|
||||
|
||||
c := &Client{}
|
||||
_ = c
|
||||
if len(reply) != hdrReplyLen {
|
||||
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
|
||||
}
|
||||
|
||||
st1, err := parseFor(reply, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
|
||||
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
|
||||
}
|
||||
if st1.Target != -1 {
|
||||
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
|
||||
}
|
||||
|
||||
st2, err := parseFor(reply, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st2.Connected || st2.Azimuth != 999 {
|
||||
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
|
||||
}
|
||||
}
|
||||
|
||||
// parseFor exercises the offset math without a socket.
|
||||
func parseFor(reply []byte, rotator int) (Status, error) {
|
||||
base := 4
|
||||
if rotator == 2 {
|
||||
base = 38
|
||||
}
|
||||
if len(reply) < hdrReplyLen {
|
||||
return Status{}, errShort
|
||||
}
|
||||
cur := atoiField(string(reply[base : base+3]))
|
||||
moving := atoiField(string(reply[base+10 : base+11]))
|
||||
target := atoiField(string(reply[base+15 : base+18]))
|
||||
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||
if target != 999 {
|
||||
st.Target = target
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
var errShort = fmtErrorf("short")
|
||||
|
||||
func fmtErrorf(s string) error { return &strErr{s} }
|
||||
|
||||
type strErr struct{ s string }
|
||||
|
||||
func (e *strErr) Error() string { return e.s }
|
||||
|
||||
func TestGoToFormatting(t *testing.T) {
|
||||
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
|
||||
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
|
||||
for az, want := range cases {
|
||||
got := "|A" + "1" + pad3(az)
|
||||
if got != want {
|
||||
t.Errorf("az %d → %q, want %q", az, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pad3(az int) string {
|
||||
s := ""
|
||||
switch {
|
||||
case az >= 100:
|
||||
s = itoa(az)
|
||||
case az >= 10:
|
||||
s = "0" + itoa(az)
|
||||
default:
|
||||
s = "00" + itoa(az)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user