chore: release v0.23.0

This commit is contained in:
2026-08-02 20:03:16 +02:00
parent f6cd6e999a
commit 72f692aa04
13 changed files with 433 additions and 88 deletions
+120
View File
@@ -7,6 +7,10 @@
// - 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
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
}
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, 09999; "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
}
+73
View File
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
// Dingtian — the wire examples come straight from the IOT Relay Programming
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
// or session) answers HTTP 200 with a non-zero first field.
func TestDingtianStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relay_cgi_load.cgi" {
t.Errorf("unexpected status path %q", r.URL.Path)
}
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
// The board said 4 relays even though 2 were configured — its count wins.
want := []bool{true, false, true, false}
if len(st) != len(want) {
t.Fatalf("got %d relays, want %d", len(st), len(want))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
if d.Count() != 4 {
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
}
}
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
var gotQuery, gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotCookie = r.Header.Get("Cookie")
_, _ = w.Write([]byte("&0&0&2&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
if err := d.Set(context.Background(), 3, true); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotQuery, "relay=2") {
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
}
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
!strings.Contains(gotQuery, "pwd=4660") {
t.Errorf("unexpected query %q", gotQuery)
}
if gotCookie != "session=12345678" {
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
}
}
func TestDingtianSetRefusalIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Manual §3.4.4: a bad session still answers 200 OK.
_, _ = w.Write([]byte("&302&/&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
if err := d.Set(context.Background(), 1, true); err == nil {
t.Fatal("a refused command answered HTTP 200 and was reported as success")
}
}