// 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 // .. (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: 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 }