fix(relays): {value} is the relay's label, not a per-relay value
It was built the other way round on a misreading of two screenshots: the pattern
held {value} and the per-relay boxes held numbers to drop into it. The ask was
simpler and better — {value} is the name typed in Relay labels, so a switch
addressed by antenna name is one pattern instead of eight URLs:
http://10.10.10.100/relay?on={value} relay 1 named Ant1 → ?on=Ant1
Renaming the antenna re-addresses it, and the name on the button and the name on
the wire cannot drift apart because they are the same string. It works in the
per-relay URLs and in the patterns alike, so the per-relay boxes go back to
holding URLs and nothing about them changes meaning any more.
The label is percent-encoded with %20 rather than "+" for a space: "+" is a
space only in a query string and a literal plus in a path, and this can land in
either half of a URL.
{value} on a relay with no label would send "?on=", an empty parameter that most
boards answer with a cheerful 200 and no movement. The driver refuses it and
names the label as what is missing; the editor warns while it is being typed,
beside the empty box rather than after an antenna fails to switch. The labels
also join the driver's cache key — they are part of the wire format now.
This commit is contained in:
@@ -3,6 +3,7 @@ package relaydev
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,25 +18,31 @@ import (
|
||||
// their boards need something specific; this one exists because most of them
|
||||
// need nothing at all.
|
||||
//
|
||||
// THREE WAYS TO CONFIGURE IT, and the differences matter:
|
||||
// TWO WAYS TO CONFIGURE IT, and the difference matters:
|
||||
//
|
||||
// - one URL pair with {relay} in it, used for every relay:
|
||||
// http://192.168.1.9/relay?n={relay}&state=on
|
||||
// - 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 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.
|
||||
// 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.
|
||||
//
|
||||
// {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.
|
||||
// TWO SUBSTITUTIONS are available in either form:
|
||||
//
|
||||
// {relay} the relay number, 1-based. {relay-1} for a board that counts its
|
||||
// channels from zero — otherwise the whole pattern has to be given
|
||||
// up for eight hand-typed URLs over one missing offset.
|
||||
// {value} that relay's LABEL, the name given to it in Relay labels. A switch
|
||||
// addressed by antenna name rather than by channel number
|
||||
// (…/relay?on=Ant1) is then one pattern instead of eight URLs, and
|
||||
// renaming the antenna re-addresses it — the name the operator reads
|
||||
// on the button and the name on the wire cannot drift apart because
|
||||
// they are the same string.
|
||||
//
|
||||
// The label is percent-encoded, so a name with a space or an accent goes out as
|
||||
// a valid URL rather than a request the board rejects without saying why.
|
||||
//
|
||||
// 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
|
||||
@@ -45,6 +52,7 @@ type httpGen struct {
|
||||
offURLs []string
|
||||
onPat string // pattern with {relay}, used when the per-relay URL is empty
|
||||
offPat string
|
||||
labels []string // index 0 = relay 1; what {value} resolves to
|
||||
user string
|
||||
pass string
|
||||
count int
|
||||
@@ -55,8 +63,8 @@ type httpGen struct {
|
||||
|
||||
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
|
||||
// relay 1) and may be short or hold empty entries; onPat/offPat are the
|
||||
// fallback patterns.
|
||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int) Device {
|
||||
// fallback patterns; labels are the relay names {value} substitutes.
|
||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string) Device {
|
||||
if count <= 0 {
|
||||
count = len(onURLs)
|
||||
}
|
||||
@@ -65,7 +73,7 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
||||
}
|
||||
return &httpGen{
|
||||
onURLs: onURLs, offURLs: offURLs,
|
||||
onPat: onPat, offPat: offPat,
|
||||
onPat: onPat, offPat: offPat, labels: labels,
|
||||
user: user, pass: pass, count: count,
|
||||
state: make([]bool, count),
|
||||
}
|
||||
@@ -94,28 +102,34 @@ func (h *httpGen) entryFor(relay int, on bool) string {
|
||||
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.
|
||||
// labelFor returns the relay's name, as typed in Relay labels.
|
||||
func (h *httpGen) labelFor(relay int) string {
|
||||
if i := relay - 1; i >= 0 && i < len(h.labels) {
|
||||
return strings.TrimSpace(h.labels[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// urlFor builds the request for one relay in one direction: the per-relay URL
|
||||
// if there is one, the pattern otherwise, with both substitutions applied.
|
||||
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)
|
||||
u := h.entryFor(relay, on)
|
||||
if u == "" {
|
||||
u = h.patFor(on)
|
||||
}
|
||||
if entry != "" {
|
||||
return expandRelay(entry, relay)
|
||||
}
|
||||
if pat == "" {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
return expandRelay(pat, relay)
|
||||
return expand(u, relay, h.labelFor(relay))
|
||||
}
|
||||
|
||||
// escapeValue percent-encodes a relay label for use anywhere in a URL.
|
||||
//
|
||||
// url.QueryEscape alone is wrong: it writes a space as "+", which is a space
|
||||
// only in a query string and a literal plus sign in a path. Encoding it as %20
|
||||
// instead is correct in both, and {value} may land in either.
|
||||
func escapeValue(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
|
||||
// withScheme supplies http:// when none was typed, and leaves https:// alone.
|
||||
@@ -139,10 +153,12 @@ func withScheme(u string) string {
|
||||
// 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 {
|
||||
// expand substitutes {value} with the relay's label and {relay} with its
|
||||
// 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 expand(s string, relay int, label string) string {
|
||||
s = strings.ReplaceAll(s, "{value}", escapeValue(label))
|
||||
return relayToken.ReplaceAllStringFunc(s, func(m string) string {
|
||||
n := relay
|
||||
if i := strings.IndexAny(m, "+-"); i >= 0 {
|
||||
@@ -158,22 +174,27 @@ func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > h.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
|
||||
}
|
||||
u := h.urlFor(relay, on)
|
||||
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. 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"
|
||||
}
|
||||
if strings.Contains(h.patFor(on), "{value}") {
|
||||
what = "value"
|
||||
}
|
||||
return fmt.Errorf("no %s %s configured for relay %d", dir, what, relay)
|
||||
// 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"
|
||||
if on {
|
||||
dir = "ON"
|
||||
}
|
||||
tmpl := h.entryFor(relay, on)
|
||||
if tmpl == "" {
|
||||
tmpl = h.patFor(on)
|
||||
}
|
||||
if tmpl == "" {
|
||||
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
|
||||
}
|
||||
// {value} with no label would send "?on=" — an empty parameter to an antenna
|
||||
// switch, which most boards answer with a cheerful 200 and no movement. Say
|
||||
// what is missing instead of firing it.
|
||||
if strings.Contains(tmpl, "{value}") && h.labelFor(relay) == "" {
|
||||
return fmt.Errorf("the %s URL for relay %d uses {value}, but relay %d has no label to put there", dir, relay, relay)
|
||||
}
|
||||
u := h.urlFor(relay, on)
|
||||
u = withScheme(u)
|
||||
if _, err := get(ctx, u, h.user, h.pass); err != nil {
|
||||
return err
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
|
||||
|
||||
d := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/relay?n={relay}&state=on",
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4)
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil)
|
||||
if err := d.Set(context.Background(), 2, true); err != nil {
|
||||
t.Fatalf("Set on: %v", err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
||||
d := NewHTTPGeneric(
|
||||
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
||||
[]string{srv.URL + "/FF0100", "", ""},
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3)
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil)
|
||||
|
||||
_ = d.Set(context.Background(), 1, true) // its own URL
|
||||
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
||||
@@ -66,36 +66,36 @@ 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) {
|
||||
// {value} is the relay's LABEL: a switch addressed by antenna name rather than
|
||||
// by channel number is one pattern instead of eight URLs.
|
||||
func TestHTTPGenericValueIsTheRelayLabel(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)
|
||||
got = append(got, r.URL.String())
|
||||
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)
|
||||
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
|
||||
nil,
|
||||
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
|
||||
"", "", 3, []string{"Ant1", "Beam 20m", ""})
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 2, false)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
want := []string{"/Set0/4", "/Set0/0"}
|
||||
// The space in "Beam 20m" must go out as %20 — a "+" would be a literal plus
|
||||
// in a path, and this substitution can land in either half of a URL.
|
||||
want := []string{"/relay?on=Ant1", "/relay?off=Beam%2020m"}
|
||||
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.
|
||||
// {relay-1} for a board whose channels are numbered from zero.
|
||||
func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
@@ -106,10 +106,8 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
}))
|
||||
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 := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil)
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 4, false)
|
||||
mu.Lock()
|
||||
@@ -120,14 +118,15 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// A URL that uses {value} on an unlabelled relay would go out as "?on=" — an
|
||||
// empty parameter, which most boards answer with a cheerful 200 and no
|
||||
// movement. It must be refused, and the message must say the label is what is
|
||||
// missing.
|
||||
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
|
||||
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""})
|
||||
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)
|
||||
if err == nil || !strings.Contains(err.Error(), "label") {
|
||||
t.Errorf("err = %v, want it to name the missing label", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +149,7 @@ func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
|
||||
// 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) {
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1)
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil)
|
||||
err := d.Set(context.Background(), 1, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
||||
t.Errorf("err = %v, want it to name the OFF direction", err)
|
||||
@@ -161,7 +160,7 @@ func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer srv.Close()
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil)
|
||||
_ = d.Set(context.Background(), 2, true)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user