package relaydev import ( "context" "fmt" "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. // // 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 // - 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. // // {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 // 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 // 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 "" } // 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 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 { 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) } 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 }