// Generic Jarvis relay board. // // One of these runs on every ESP32 that switches household things -- // bedroom light, hall light, fan, geyser. The board itself is // deliberately dumb and knows nothing about what its relays are // wired to: it exposes "relay 0..N-1, on or off" over HTTP and the // Jetson holds every human-facing name in // ~/.config/jarvis/relay_devices.json. Renaming "Relay 2" to // "Bedroom fan" is therefore a dashboard edit, never a reflash -- // the same split that keeps the wall light's animations in Python // (see esp32_firmware/light_test/light_test.ino). // // The whole config block below is REGENERATED by // dashboard/relay_admin.py at flash time. In particular RELAY_PINS is // written from RELAY_PIN_ORDER in dashboard/relay_admin.py, so the // pin numbers printed on the dashboard's wiring card and the pin // numbers this firmware actually drives come from one single source // and cannot drift apart. Don't hand-edit the block; change the // Python list and reflash. #include #include #include #include // ==== JARVIS-CONFIG-BEGIN (regenerated by dashboard/relay_admin.py) ==== const char *WIFI_SSID = "YOUR_WIFI"; const char *WIFI_PASSWORD = "YOUR_PASSWORD"; const char *BOARD_ID = "relay-dev"; const char *BOARD_LABEL = "Relay board"; const int RELAY_PINS[] = {4, 5, 6, 7}; // Nearly every cheap opto-isolated relay module sold for the ESP32 is // active LOW: the coil energises when the IN pin is pulled to ground, // and the module's own pull-up holds IN high (relay open) while the // ESP32 is still booting and its GPIOs are floating. That boot // behaviour is exactly why this defaults to true -- an active-HIGH // module can click every relay closed for the ~200ms before setup() // runs. const bool RELAY_ACTIVE_LOW = true; // false (the default) means every relay comes up OFF after a power // cut. Deliberate: restoring state would turn the whole house's // lights back on by itself whenever the mains blips at night. The // dashboard exposes this per board for anyone who wants the other // behaviour. const bool RESTORE_ON_BOOT = false; // ==== JARVIS-CONFIG-END ==== const int RELAY_COUNT = sizeof(RELAY_PINS) / sizeof(RELAY_PINS[0]); WebServer server(80); Preferences prefs; bool relayOn[sizeof(RELAY_PINS) / sizeof(RELAY_PINS[0])]; unsigned long bootMillis = 0; unsigned long lastWifiCheckMs = 0; const unsigned long WIFI_CHECK_INTERVAL_MS = 10000; // --------------------------------------------------------------------- // Relay driving // --------------------------------------------------------------------- // Translates "should this thing be on" into the voltage this // particular relay module wants to see. Every write to a relay pin in // this file goes through here so the active-low/active-high decision // exists in exactly one place. void driveRelay(int index, bool on) { digitalWrite(RELAY_PINS[index], (on != RELAY_ACTIVE_LOW) ? HIGH : LOW); } void setRelay(int index, bool on, bool persist) { if (index < 0 || index >= RELAY_COUNT) { return; } relayOn[index] = on; driveRelay(index, on); if (persist) { char key[12]; snprintf(key, sizeof(key), "r%d", index); prefs.putBool(key, on); } } // --------------------------------------------------------------------- // HTTP // --------------------------------------------------------------------- String relayStateJson() { String json = "{"; json += "\"ok\":true"; json += ",\"board_id\":\"" + String(BOARD_ID) + "\""; json += ",\"label\":\"" + String(BOARD_LABEL) + "\""; json += ",\"firmware\":\"relay_board/1\""; json += ",\"ip\":\"" + WiFi.localIP().toString() + "\""; json += ",\"rssi\":" + String(WiFi.RSSI()); json += ",\"uptime_s\":" + String((millis() - bootMillis) / 1000); json += ",\"active_low\":" + String(RELAY_ACTIVE_LOW ? "true" : "false"); json += ",\"restore_on_boot\":" + String(RESTORE_ON_BOOT ? "true" : "false"); json += ",\"relay_count\":" + String(RELAY_COUNT); json += ",\"relays\":["; for (int i = 0; i < RELAY_COUNT; i++) { if (i > 0) { json += ","; } json += "{\"index\":" + String(i); json += ",\"pin\":" + String(RELAY_PINS[i]); json += ",\"on\":" + String(relayOn[i] ? "true" : "false") + "}"; } json += "]}"; return json; } void sendJson(int code, const String &body) { server.sendHeader("Access-Control-Allow-Origin", "*"); server.send(code, "application/json", body); } void handleStatus() { sendJson(200, relayStateJson()); } void handleRelay() { if (!server.hasArg("index")) { sendJson(400, "{\"ok\":false,\"error\":\"missing index\"}"); return; } int index = server.arg("index").toInt(); if (index < 0 || index >= RELAY_COUNT) { sendJson(404, "{\"ok\":false,\"error\":\"no such relay\"}"); return; } String state = server.hasArg("state") ? server.arg("state") : "toggle"; bool on; if (state == "on" || state == "1" || state == "true") { on = true; } else if (state == "off" || state == "0" || state == "false") { on = false; } else if (state == "toggle") { on = !relayOn[index]; } else { sendJson(400, "{\"ok\":false,\"error\":\"state must be on, off or toggle\"}"); return; } setRelay(index, on, true); sendJson(200, relayStateJson()); } void handleAll() { bool on = server.hasArg("state") && (server.arg("state") == "on"); for (int i = 0; i < RELAY_COUNT; i++) { setRelay(i, on, true); } sendJson(200, relayStateJson()); } void handleReboot() { sendJson(200, "{\"ok\":true,\"rebooting\":true}"); delay(200); ESP.restart(); } void handleNotFound() { sendJson(404, "{\"ok\":false,\"error\":\"not found\"}"); } // --------------------------------------------------------------------- // Setup / loop // --------------------------------------------------------------------- void connectWifi() { WiFi.mode(WIFI_STA); WiFi.setSleep(false); WiFi.setHostname(BOARD_ID); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); unsigned long startedAt = millis(); while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 20000) { delay(250); Serial.print("."); } Serial.println(); // These two lines are parsed by dashboard/relay_admin.py, which is // still holding the USB cable for a few seconds after upload. It is // the only place the WiFi password can actually be *verified* -- // the Jetson itself is on a different (5GHz) SSID and cannot test // these credentials without dropping its own connection, and a // board that silently never joins is otherwise indistinguishable // from one that was flashed wrong. Keep the prefixes stable. if (WiFi.status() == WL_CONNECTED) { Serial.print("JARVIS-WIFI-OK ip="); Serial.println(WiFi.localIP()); Serial.print("WiFi connected, IP: "); Serial.println(WiFi.localIP()); // Best effort. The Jetson does not depend on mDNS resolving -- // relay_client.py falls back to a cached IP and then a subnet // sweep, because mDNS on this network has been unreliable enough // in practice to cost a whole debugging session once already. if (MDNS.begin(BOARD_ID)) { MDNS.addService("http", "tcp", 80); MDNS.addServiceTxt("http", "tcp", "board_id", BOARD_ID); } } else { // wl_status_t distinguishes the two mistakes that actually happen // during onboarding: a name that no 2.4GHz radio can see, and a // password that is simply wrong. switch (WiFi.status()) { case WL_NO_SSID_AVAIL: Serial.println("JARVIS-WIFI-FAIL reason=no_such_network"); break; case WL_CONNECT_FAILED: Serial.println("JARVIS-WIFI-FAIL reason=wrong_password"); break; default: Serial.println("JARVIS-WIFI-FAIL reason=timeout"); break; } Serial.println("WiFi failed; will keep retrying in loop()"); } } void setup() { // Relay pins first, before anything slow like Serial or WiFi -- the // window between power-on and this line is the one moment the // relays are at the mercy of whatever the GPIOs float to. for (int i = 0; i < RELAY_COUNT; i++) { relayOn[i] = false; pinMode(RELAY_PINS[i], OUTPUT); driveRelay(i, false); } Serial.begin(115200); delay(100); Serial.println(); Serial.print("Jarvis relay board: "); Serial.print(BOARD_LABEL); Serial.print(" ("); Serial.print(BOARD_ID); Serial.print("), "); Serial.print(RELAY_COUNT); Serial.println(" relays"); prefs.begin("relayboard", false); if (RESTORE_ON_BOOT) { for (int i = 0; i < RELAY_COUNT; i++) { char key[12]; snprintf(key, sizeof(key), "r%d", i); setRelay(i, prefs.getBool(key, false), false); } } bootMillis = millis(); connectWifi(); server.on("/status", handleStatus); server.on("/relay", handleRelay); server.on("/all", handleAll); server.on("/reboot", handleReboot); server.onNotFound(handleNotFound); server.begin(); Serial.println("HTTP server ready on port 80"); } void loop() { server.handleClient(); // A board that switches the hall light has to survive the router // rebooting without someone walking over to power-cycle it. if (millis() - lastWifiCheckMs > WIFI_CHECK_INTERVAL_MS) { lastWifiCheckMs = millis(); if (WiFi.status() != WL_CONNECTED) { Serial.println("WiFi dropped, reconnecting..."); WiFi.disconnect(); connectWifi(); } } delay(2); }