296 lines
10 KiB
Go
296 lines
10 KiB
Go
// 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.
|
||
// - 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<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
|
||
}
|
||
|
||
// ── 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=<id>"
|
||
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
|
||
}
|