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
+9
View File
@@ -1713,7 +1713,16 @@ func (f *Flex) SetTune(on bool) error {
// the operator from keying while a motorized antenna's elements are moving —
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
// blocked. The reason is best-effort (a separate interlock object); the inhibit
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
func (f *Flex) SetTXInhibit(on bool) error {
if on {
f.send("interlock set reason=OpsLog")
} else {
f.send("interlock set reason=")
}
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
}
+168
View File
@@ -0,0 +1,168 @@
// Package relaydev drives network relay boards used for station control (power
// sequencing, switching accessories). Two devices are supported, both over HTTP:
//
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
// (Protocol taken from the operator's own working ShackMaster driver.)
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Device is one relay board.
type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
}
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
// get issues a GET with optional basic auth and returns the body on 2xx.
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// ── WebSwitch 1216H ────────────────────────────────────────────────────
type webswitch struct {
host string
count int
}
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
}
action := "off"
if on {
action = "on"
}
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
return err
}
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
// Build the "1$2$3$..." selector the device expects.
var sel strings.Builder
for i := 1; i <= w.count; i++ {
sel.WriteString(strconv.Itoa(i))
sel.WriteByte('$')
}
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
if err != nil {
return nil, err
}
out := make([]bool, w.count)
// Lines "n,state" — "1,1", "2,0", …
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
parts := strings.Split(strings.TrimSpace(line), ",")
if len(parts) != 2 {
continue
}
n, e1 := strconv.Atoi(parts[0])
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if e1 != nil || e2 != nil || n < 1 || n > w.count {
continue
}
out[n-1] = st == 1
}
return out, nil
}
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
type kmtronic struct {
host string
user, pass string
count int
}
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
// blank unless the board's HTTP authentication is enabled.
func NewKMTronic(host, user, pass string) Device {
return &kmtronic{host: host, user: user, pass: pass, count: 8}
}
func (k *kmtronic) Count() int { return k.count }
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > k.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
}
state := "00"
if on {
state = "01"
}
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
return err
}
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
type kmStatus struct {
XMLName xml.Name `xml:"response"`
Relays []struct {
XMLName xml.Name
Value string `xml:",chardata"`
} `xml:",any"`
}
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
if err != nil {
return nil, err
}
var s kmStatus
if err := xml.Unmarshal(body, &s); err != nil {
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
}
out := make([]bool, k.count)
for _, r := range s.Relays {
// Element names are relay0..relay8; relay0 is reserved.
name := r.XMLName.Local
if !strings.HasPrefix(name, "relay") {
continue
}
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
if e != nil || n < 1 || n > k.count {
continue
}
out[n-1] = strings.TrimSpace(r.Value) == "1"
}
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package relaydev
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The status parsers are the risky part; pin them against the documented wire
// formats using a stub HTTP server.
func TestWebswitchStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
t.Errorf("unexpected status path %q", r.URL.Path)
}
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
want := []bool{true, false, true, false, true}
if len(st) != 5 {
t.Fatalf("got %d relays, want 5", len(st))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
}
func TestWebswitchSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
if err := d.Set(context.Background(), 4, true); err != nil {
t.Fatal(err)
}
if gotPath != "/relaycontrol/on/4" {
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
}
_ = d.Set(context.Background(), 4, false)
}
func TestKMTronicStatus(t *testing.T) {
// relay0 reserved (ignored); relay7 + relay8 ON.
xmlBody := `<?xml version="1.0"?><response>` +
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
`</response>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status.xml" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(xmlBody))
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if len(st) != 8 {
t.Fatalf("got %d relays, want 8", len(st))
}
if st[6] != true || st[7] != true {
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
}
for i := 0; i < 6; i++ {
if st[i] {
t.Errorf("relay %d unexpectedly on", i+1)
}
}
}
func TestKMTronicSetURL(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
}))
defer srv.Close()
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
if err := d.Set(context.Background(), 8, true); err != nil {
t.Fatal(err)
}
if gotPath != "/FF0801" {
t.Errorf("set path = %q, want /FF0801", gotPath)
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
+193
View File
@@ -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)
}
+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)
}