fix(relays): the generic HTTP board never sent its URLs; cluster filters start off

buildDeviceDriver had no case for "httpgen", so the generic board fell through
to the WebSwitch driver: it polled an address it had never been given, reported
itself offline, greyed out every relay button, and sent none of the configured
URLs. Nothing in the interface said so — the board was configured, saved and
listed, and simply did nothing. deviceKey did not cover the URLs either, so once
that is fixed, correcting a typo in one would still have handed back the cached
driver holding the old address until a restart.

Two shapes of home-made switch could not be described at all:

  - a bit-mask board whose four URLs differ by one character
    (/Set0/1, /Set0/2, /Set0/4, /Set0/8) — {value} in the pattern now takes the
    number from the per-relay box, keeping the address in one place;
  - a board numbering its channels from zero — {relay-1}, since giving up the
    pattern for eight hand-typed URLs was the only alternative.

The pattern decides what the per-relay boxes hold, and the grid says which as
soon as {value} is typed: guessing per box ("does this look like a URL?") would
change meaning on a typo, which is not a thing to do to something wired to an
antenna. A URL typed without a scheme gets http:// like the named boards get
from relayBase; https:// is passed through untouched.

Host and the connection test are gone for this type. It has no address of its
own — its relays may each live on a different box — and no status to read, so a
test could only ever answer "OK, 4 relays". Save was greyed out without a host,
which made a complete configuration of four full URLs impossible to store.

Cluster filters no longer persist across launches. A band lock set weeks earlier
is invisible to whoever set it: the counter reads 76 spots live, the grid is
empty, and the search goes to the cluster instead. Nobody loses work by
re-ticking a chip. Grouping and the panel state still persist — they change how
spots look, never whether they appear.
This commit is contained in:
2026-08-15 23:53:32 +02:00
parent be0326c862
commit fe2affc72f
9 changed files with 346 additions and 73 deletions
+96 -17
View File
@@ -3,6 +3,7 @@ package relaydev
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
@@ -16,16 +17,25 @@ import (
// their boards need something specific; this one exists because most of them
// need nothing at all.
//
// TWO WAYS TO CONFIGURE IT, and the difference matters:
// THREE WAYS TO CONFIGURE IT, and the differences matter:
//
// - one URL pair with {relay} in it, used for every relay:
// http://192.168.1.9/relay?n={relay}&state=on
// - or one pair per relay, when the box has no pattern to speak of:
// - one pair per relay, when the box has no pattern to speak of:
// relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
// - one pair with {value} in it, and a VALUE per relay:
// pattern http://192.168.1.9:59/Set0/{value}, relay 1 ON "1", relay 2 ON "2",
// relay 3 ON "4", relay 4 ON "8", every OFF "0".
//
// The second is the reason this driver exists. A hand-made switch often has
// URLs with nothing in common between channels, and a template with {relay}
// cannot express that.
// The last two are the reason this driver exists. A hand-made switch often has
// URLs with nothing in common between channels, which no pattern can express;
// and a bit-mask board (qro.cz and its kin) repeats a long URL whose only
// varying part is one number, which is eight boxes of noise to type and to read.
// {value} keeps the address in one place and leaves the numbers in the grid.
//
// {relay} may carry an offset — {relay-1} for a board that counts its channels
// from zero, which is otherwise impossible to express without giving up the
// pattern entirely.
//
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
// or answer with a web page nobody can parse reliably. Status therefore returns
@@ -64,21 +74,84 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
func (h *httpGen) Count() int { return h.count }
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
// urlFor picks the per-relay URL, falling back to the pattern.
func (h *httpGen) urlFor(relay int, on bool) string {
list, pat := h.offURLs, h.offPat
// patFor returns the pattern for a direction, trimmed.
func (h *httpGen) patFor(on bool) string {
if on {
list, pat = h.onURLs, h.onPat
return strings.TrimSpace(h.onPat)
}
return strings.TrimSpace(h.offPat)
}
// entryFor returns what was typed in the per-relay box for a direction.
func (h *httpGen) entryFor(relay int, on bool) string {
list := h.offURLs
if on {
list = h.onURLs
}
if i := relay - 1; i >= 0 && i < len(list) {
if u := strings.TrimSpace(list[i]); u != "" {
return u
}
return strings.TrimSpace(list[i])
}
if pat = strings.TrimSpace(pat); pat == "" {
return ""
}
// urlFor builds the request for one relay in one direction.
//
// THE PATTERN DECIDES WHAT THE PER-RELAY BOXES HOLD. With {value} in it they
// hold values to drop into it; without, they hold whole URLs that win over it.
// One rule, and it is the pattern the operator can see while typing them — a
// per-box guess ("does this look like a URL?") would change meaning silently on
// a typo, which is not a thing to do to something wired to an antenna.
func (h *httpGen) urlFor(relay int, on bool) string {
pat, entry := h.patFor(on), h.entryFor(relay, on)
if strings.Contains(pat, "{value}") {
if entry == "" {
return ""
}
return expandRelay(strings.ReplaceAll(pat, "{value}", entry), relay)
}
if entry != "" {
return expandRelay(entry, relay)
}
if pat == "" {
return ""
}
return strings.ReplaceAll(pat, "{relay}", strconv.Itoa(relay))
return expandRelay(pat, relay)
}
// withScheme supplies http:// when none was typed, and leaves https:// alone.
//
// The same rule the named boards get from relayBase, and it has to be here too:
// this driver takes whole URLs rather than a host, and a line typed as
// "192.168.1.9/Set0/1" would otherwise fail with "unsupported protocol scheme"
// — an error about a scheme, for a field where nobody knew one was expected.
// An https:// board (a reverse proxy fronting the shack, most often) is passed
// through untouched and needs no other handling: it is the same HTTP client.
func withScheme(u string) string {
if u == "" {
return ""
}
if l := strings.ToLower(u); strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
return u
}
return "http://" + u
}
// relayToken matches {relay} and its offset forms, {relay-1} / {relay+2}.
var relayToken = regexp.MustCompile(`\{relay([+-]\d+)?\}`)
// expandRelay substitutes the relay number, honouring an offset. A board that
// numbers its channels from zero is written {relay-1}; without that the whole
// pattern has to be abandoned for four hand-typed URLs.
func expandRelay(s string, relay int) string {
return relayToken.ReplaceAllStringFunc(s, func(m string) string {
n := relay
if i := strings.IndexAny(m, "+-"); i >= 0 {
if off, err := strconv.Atoi(m[i : len(m)-1]); err == nil {
n += off
}
}
return strconv.Itoa(n)
})
}
func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
@@ -89,13 +162,19 @@ func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
if u == "" {
// Naming the direction matters: an operator who filled the ON URLs and
// left OFF empty gets a switch that latches, and "no URL configured"
// alone would not say which half is missing.
dir := "OFF"
// alone would not say which half is missing. Name what is missing too —
// with {value} in the pattern the empty box wants a number, not a URL,
// and being told to enter a URL there sends them the wrong way.
dir, what := "OFF", "URL"
if on {
dir = "ON"
}
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
if strings.Contains(h.patFor(on), "{value}") {
what = "value"
}
return fmt.Errorf("no %s %s configured for relay %d", dir, what, relay)
}
u = withScheme(u)
if _, err := get(ctx, u, h.user, h.pass); err != nil {
return err
}
+81
View File
@@ -66,6 +66,87 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
}
}
// The {value} form: one address, and the per-relay boxes hold the number that
// goes into it. A bit-mask board (qro.cz) is the case — /Set0/1, /Set0/2,
// /Set0/4, /Set0/8 — where four full URLs differ by one character.
func TestHTTPGenericValueSubstitution(t *testing.T) {
var mu sync.Mutex
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
got = append(got, r.URL.Path)
mu.Unlock()
}))
defer srv.Close()
d := NewHTTPGeneric(
[]string{"1", "2", "4", "8"},
[]string{"0", "0", "0", "0"},
srv.URL+"/Set0/{value}", srv.URL+"/Set0/{value}", "", "", 4)
_ = d.Set(context.Background(), 3, true)
_ = d.Set(context.Background(), 1, false)
mu.Lock()
defer mu.Unlock()
want := []string{"/Set0/4", "/Set0/0"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("requested %v, want %v", got, want)
}
}
// {value} and {relay-1} together: the other API of the same board, whose
// channels are numbered from zero. Without the offset the pattern has to be
// abandoned for four hand-typed URLs.
func TestHTTPGenericRelayOffset(t *testing.T) {
var mu sync.Mutex
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
got = append(got, r.URL.Path)
mu.Unlock()
}))
defer srv.Close()
d := NewHTTPGeneric(
[]string{"1", "1", "1", "1"},
[]string{"0", "0", "0", "0"},
srv.URL+"/set0/{relay-1}/{value}", srv.URL+"/set0/{relay-1}/{value}", "", "", 4)
_ = d.Set(context.Background(), 1, true)
_ = d.Set(context.Background(), 4, false)
mu.Lock()
defer mu.Unlock()
want := []string{"/set0/0/1", "/set0/3/0"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("requested %v, want %v", got, want)
}
}
// With {value} in the pattern the per-relay boxes hold values, so an empty one
// must be reported as a missing VALUE. Telling the operator to enter a URL in a
// box that wants "4" sends them to rewrite a configuration that was nearly right.
func TestHTTPGenericNamesAMissingValue(t *testing.T) {
d := NewHTTPGeneric(nil, nil, "http://x/Set0/{value}", "http://x/Set0/{value}", "", "", 2)
err := d.Set(context.Background(), 1, true)
if err == nil || !strings.Contains(err.Error(), "value") {
t.Errorf("err = %v, want it to name the missing value", err)
}
}
// A URL typed without a scheme must still be sent — the named boards take a
// bare host and add http:// themselves, and this one has to behave the same.
// https:// is left exactly as typed.
func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"192.168.1.9/Set0/1", "http://192.168.1.9/Set0/1"},
{"http://192.168.1.9/x", "http://192.168.1.9/x"},
{"https://relay.example.com/x", "https://relay.example.com/x"},
{"HTTPS://relay.example.com/x", "HTTPS://relay.example.com/x"},
} {
if got := withScheme(c.in); got != c.want {
t.Errorf("withScheme(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// A switch with the ON URLs filled and OFF left empty latches. The error has to
// name the direction, or the operator cannot tell which half is missing.
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {