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 }