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 }