An operator was about to build band→relay mapping into the custom URL rows.
It already exists, and better: relay auto-control has a per-relay "band" rule
driving webswitch, KMTronic, Dingtian, Denkovi and USB boards. It holds state
so a relay is only commanded when its wanted position changed, reads the
boards live before the first apply so it does not re-command one already in
place, and carries per-relay labels. A URL fired on a band change has none of
that, and would have been a second definition of "which antenna on which band"
— the drift that cost us a week on counties.
So the gap was only the hardware: a hand-made switch is none of the five named
types. It is now the sixth, "HTTP relay": an ON URL and an OFF URL, either as
one pattern with {relay} substituted, or one pair per relay. The per-relay
form is the reason it exists — these boxes often have URLs with nothing in
common between channels (…/FF0101 and …/FF0201), which no pattern can express.
Status is remembered rather than read: most have no endpoint worth trusting.
The cost is stated in the code and the panel — after a restart every relay is
re-commanded once, which is harmless on a board with no memory and far better
than assuming an antenna is already selected.
And the Connections panel now says so, exactly where the wrong choice is made:
picking a band change with a URL transport shows a note pointing at Station
Control. The URL transport stays — a lookup pushed to a webhook or a QSO to a
dashboard is an event, not a state, and Station Control has no place for it.
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
package relaydev
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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
|
|
// - or 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.
|
|
//
|
|
// 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
|
|
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.
|
|
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int) Device {
|
|
if count <= 0 {
|
|
count = len(onURLs)
|
|
}
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
return &httpGen{
|
|
onURLs: onURLs, offURLs: offURLs,
|
|
onPat: onPat, offPat: offPat,
|
|
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
|
|
|
|
// 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
|
|
if on {
|
|
list, pat = h.onURLs, h.onPat
|
|
}
|
|
if i := relay - 1; i >= 0 && i < len(list) {
|
|
if u := strings.TrimSpace(list[i]); u != "" {
|
|
return u
|
|
}
|
|
}
|
|
if pat = strings.TrimSpace(pat); pat == "" {
|
|
return ""
|
|
}
|
|
return strings.ReplaceAll(pat, "{relay}", strconv.Itoa(relay))
|
|
}
|
|
|
|
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.
|
|
dir := "OFF"
|
|
if on {
|
|
dir = "ON"
|
|
}
|
|
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
|
|
}
|
|
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
|
|
}
|