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.
225 lines
7.8 KiB
Go
225 lines
7.8 KiB
Go
package relaydev
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// A generic HTTP relay board: one URL to switch a relay on, one to switch it off.
|
|
//
|
|
// This is for the home-made switch — an ESP8266 with a web page, a Shelly, a
|
|
// Sonoff running third-party firmware, any of the boxes that answer a GET and
|
|
// have no protocol worth naming. The named drivers beside it exist because
|
|
// 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:
|
|
//
|
|
// - 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
|
|
//
|
|
// 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.
|
|
//
|
|
// 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
|
|
// what we last commanded — see the method for what that costs.
|
|
type httpGen struct {
|
|
onURLs []string // index 0 = relay 1; "" falls back to the pattern
|
|
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
|
|
|
|
mu sync.Mutex
|
|
state []bool
|
|
}
|
|
|
|
// 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; 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)
|
|
}
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
return &httpGen{
|
|
onURLs: onURLs, offURLs: offURLs,
|
|
onPat: onPat, offPat: offPat, labels: labels,
|
|
user: user, pass: pass, count: count,
|
|
state: make([]bool, count),
|
|
}
|
|
}
|
|
|
|
func (h *httpGen) Count() int { return h.count }
|
|
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
|
|
|
|
// patFor returns the pattern for a direction, trimmed.
|
|
func (h *httpGen) patFor(on bool) string {
|
|
if on {
|
|
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) {
|
|
return strings.TrimSpace(list[i])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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 {
|
|
u := h.entryFor(relay, on)
|
|
if u == "" {
|
|
u = h.patFor(on)
|
|
}
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
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.
|
|
//
|
|
// 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+)?\}`)
|
|
|
|
// 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 {
|
|
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 {
|
|
if relay < 1 || relay > h.count {
|
|
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
|
|
}
|
|
// 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
|
|
}
|
|
h.mu.Lock()
|
|
if i := relay - 1; i < len(h.state) {
|
|
h.state[i] = on
|
|
}
|
|
h.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Status returns what was last commanded, not what the board is doing.
|
|
//
|
|
// These boxes rarely have a status endpoint worth trusting, so there is nothing
|
|
// to read. The cost is real and worth stating: after OpsLog restarts, every
|
|
// relay reads as off until something switches it, so the automatic control
|
|
// re-commands each one once. That is a harmless extra command on a board with
|
|
// no memory of its own — and far better than guessing a state and leaving an
|
|
// antenna disconnected because we assumed it was already selected.
|
|
func (h *httpGen) Status(ctx context.Context) ([]bool, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
out := make([]bool, h.count)
|
|
copy(out, h.state)
|
|
return out, nil
|
|
}
|