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