// 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. // - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET // CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&; // status: GET /relay_cgi_load.cgi. Both answer &-separated fields. // (IOT Relay Programming Manual V1.9.8.1, §3.) // // 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 // Close releases any OS handle the driver holds (serial port, FTDI handle). // Network boards hold nothing and no-op. MUST be called when a cached driver is // discarded so the port/handle is freed for the next open — stateful boards // (Denkovi, USB-serial) can only be opened by one handle at a time. Close() error } 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) Close() error { return nil } // stateless HTTP, nothing to release 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) Close() error { return nil } // stateless HTTP, nothing to release 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 } // ── Dingtian IOT relay (DTWONDER) ────────────────────────────────────── // // The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we // use the HTTP GET CGI, which needs no connection state and matches how the // other network boards here are driven. // // Both endpoints answer &-separated fields, first one 0 on success: // // /relay_cgi_load.cgi → &0&4&1&0&1&0& // result, count, r1…rN // /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0& // result, type, relay, on, time // // NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the // Device interface is 1-based like every other board here. type dingtian struct { host string session string // optional: the board can require "Cookie: session=" pwd string // CGI password, 0–9999; "0" (or blank) when none is set count int } // NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID // when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay // password (blank or "0" when none). func NewDingtian(host, session, pwd string, count int) Device { if count <= 0 { count = 2 } if strings.TrimSpace(pwd) == "" { pwd = "0" } return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count} } func (d *dingtian) Count() int { return d.count } func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release // getCGI issues the GET with the session cookie the board may require. func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } if d.session != "" { req.Header.Set("Cookie", "session="+d.session) } 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 } // dtFields splits an &-separated CGI reply into its fields, dropping the empty // ones the leading and trailing "&" produce. func dtFields(body []byte) []string { var out []string for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") { if f = strings.TrimSpace(f); f != "" { out = append(out, f) } } return out } func (d *dingtian) Set(ctx context.Context, relay int, on bool) error { if relay < 1 || relay > d.count { return fmt.Errorf("relay %d out of range 1..%d", relay, d.count) } state := 0 if on { state = 1 } // type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle), // and time is then unused. body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&", d.host, relay-1, state, d.pwd)) if err != nil { return err } // A wrong password or session answers 200 with a non-zero result (e.g. // "&302&/&"), so the HTTP status alone does not tell us it worked. if f := dtFields(body); len(f) == 0 || f[0] != "0" { return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID", strings.TrimSpace(string(body))) } return nil } func (d *dingtian) Status(ctx context.Context) ([]bool, error) { body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host)) if err != nil { return nil, err } f := dtFields(body) if len(f) < 2 || f[0] != "0" { return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body))) } // The board reports its own relay count; trust it over the configured one so // a mis-set channel count doesn't silently hide relays. if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 { d.count = n } out := make([]bool, d.count) for i := 0; i < d.count && i+2 < len(f); i++ { out[i] = f[i+2] == "1" } return out, nil }