feat: New Station Control, allow to control Webswitch 1216H or KMTronic

This commit is contained in:
2026-07-16 22:01:07 +02:00
parent c9fd1379e1
commit 829c236d6c
13 changed files with 1344 additions and 48 deletions
+127
View File
@@ -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)
}