Project Wire Routing Help
Table
| Function | ESP32-S3 GPIO | Net | Direction | Notes |
|---|---|---|---|---|
| OLED SDA | GPIO8 | I2C_SDA | I/O | SSD1306 I2C data with 4.7 kΩ pull-up |
| OLED SCL | GPIO9 | I2C_SCL | Output | SSD1306 I2C clock with 4.7 kΩ pull-up |
| Flow pulse input | GPIO10 | FLOW_PULSE | Input | Dry-contact/open-collector input, active LOW |
| Tank float input | GPIO11 | TANK_FLOAT_NO | Input | Normally-open tank-full float, active LOW when closed |
| Auxiliary input | GPIO12 | AUX_INPUT | Input | Dry-run, disable, or outlet meter input, active LOW |
| Contactor output | GPIO13 | CONTACTOR_GPIO | Output | Drives external 24 VDC contactor coil driver through R8 |
| Select button | GPIO4 | BTN_SELECT | Input | Local UI button, active LOW |
| Up button | GPIO5 | BTN_UP | Input | Local UI button, active LOW |
| Down button | GPIO6 | BTN_DOWN | Input | Local UI button, active LOW |
| USB D- | GPIO19 | USB_DN | USB | Native USB programming/debug |
| USB D+ | GPIO20 | USB_DP | USB | Native USB programming/debug |
| EN reset | EN | ESP_EN | Input | 10 kΩ pull-up, 100 nF filter, reset button to GND |
| BOOT | GPIO0 | ESP_BOOT | Input | 10 kΩ pull-up, boot button to GND |
platformio.iniIni
[env:esp32-s3-devkitc-1] platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 upload_speed = 921600 lib_deps = adafruit/Adafruit SSD1306@^2.5.13 adafruit/Adafruit GFX Library@^1.11.11
src/main.cppCpp
#include <Arduino.h> #include <WiFi.h> #include <WebServer.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #include <Preferences.h> // ─── Pin Definitions from schematic ────────────────────── #define PIN_I2C_SDA 8 #define PIN_I2C_SCL 9 #define PIN_FLOW_PULSE 10 #define PIN_TANK_FLOAT 11 #define PIN_AUX_INPUT 12 #define PIN_CONTACTOR 13 #define PIN_BTN_SELECT 4 #define PIN_BTN_UP 5 #define PIN_BTN_DOWN 6 // Native USB is on GPIO19/GPIO20 internally on ESP32-S3. // ─── Display ───────────────────────────────────────────── #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // ─── WiFi / Web UI ─────────────────────────────────────── const char* WIFI_SSID = "YOUR_WIFI_SSID"; const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; WebServer server(80); // ─── Nonvolatile settings ──────────────────────────────── Preferences prefs; // Node role can be: "well1", "well2", or "tank". String nodeRole = "well1"; float targetGallonsPerHour = 7.0f; float tankCapacityGallons = 1000.0f; float meterPulsesPerGallon = 1.0f; // update after final meter selection/calibration bool pumpEnabled = true; bool tankFull = false; // ─── Runtime counters ──────────────────────────────────── volatile uint32_t flowPulseCount = 0; volatile uint32_t auxPulseCount = 0; uint32_t lastFlowPulseCount = 0; uint32_t lastAuxPulseCount = 0; unsigned long lastDisplayMs = 0; unsigned long pumpStartMs = 0; bool pumpRunning = false; // Default simple schedule: 7 gallons/hour at 0.13 GPM ≈ 53.8 min ON per hour. float assumedWellGpm = 0.13f; void IRAM_ATTR onFlowPulse() { flowPulseCount++; } void IRAM_ATTR onAuxPulse() { auxPulseCount++; } float runtimeMinutesForTarget() { if (assumedWellGpm <= 0.0f) return 0.0f; return targetGallonsPerHour / assumedWellGpm; } void setPump(bool on) { if (!pumpEnabled || tankFull) on = false; digitalWrite(PIN_CONTACTOR, on ? HIGH : LOW); if (on && !pumpRunning) { pumpStartMs = millis(); } pumpRunning = on; } void loadSettings() { prefs.begin("wellnode", false); nodeRole = prefs.getString("role", "well1"); targetGallonsPerHour = prefs.getFloat("gph", 7.0f); tankCapacityGallons = prefs.getFloat("tankgal", 1000.0f); meterPulsesPerGallon = prefs.getFloat("ppg", 1.0f); assumedWellGpm = prefs.getFloat("wellgpm", 0.13f); pumpEnabled = prefs.getBool("enabled", true); } void saveSettings() { prefs.putString("role", nodeRole); prefs.putFloat("gph", targetGallonsPerHour); prefs.putFloat("tankgal", tankCapacityGallons); prefs.putFloat("ppg", meterPulsesPerGallon); prefs.putFloat("wellgpm", assumedWellGpm); prefs.putBool("enabled", pumpEnabled); } String htmlPage() { String s; s += "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"; s += "<title>Well Node</title></head><body>"; s += "<h2>Dual Well Pump Controller Node</h2>"; s += "<p>Role: " + nodeRole + "</p>"; s += "<p>Flow pulses: " + String((uint32_t)flowPulseCount) + "</p>"; s += "<p>Aux pulses: " + String((uint32_t)auxPulseCount) + "</p>"; s += "<p>Tank float/full input: " + String(tankFull ? "FULL/ACTIVE" : "not full") + "</p>"; s += "<p>Pump output: " + String(pumpRunning ? "ON" : "OFF") + "</p>"; s += "<form action='/save' method='get'>"; s += "Role <select name='role'><option>well1</option><option>well2</option><option>tank</option></select><br>"; s += "Target gal/hour <input name='gph' value='" + String(targetGallonsPerHour, 2) + "'><br>"; s += "Assumed well GPM <input name='wellgpm' value='" + String(assumedWellGpm, 3) + "'><br>"; s += "Tank capacity gallons <input name='tankgal' value='" + String(tankCapacityGallons, 0) + "'><br>"; s += "Meter pulses/gallon <input name='ppg' value='" + String(meterPulsesPerGallon, 3) + "'><br>"; s += "<input type='submit' value='Save'>"; s += "</form>"; s += "<p><a href='/pump/on'>Pump ON</a> | <a href='/pump/off'>Pump OFF</a></p>"; s += "</body></html>"; return s; } void setupWeb() { server.on("/", []() { server.send(200, "text/html", htmlPage()); }); server.on("/save", []() { if (server.hasArg("role")) nodeRole = server.arg("role"); if (server.hasArg("gph")) targetGallonsPerHour = server.arg("gph").toFloat(); if (server.hasArg("wellgpm")) assumedWellGpm = server.arg("wellgpm").toFloat(); if (server.hasArg("tankgal")) tankCapacityGallons = server.arg("tankgal").toFloat(); if (server.hasArg("ppg")) meterPulsesPerGallon = server.arg("ppg").toFloat(); saveSettings(); server.sendHeader("Location", "/"); server.send(302, "text/plain", "Saved"); }); server.on("/pump/on", []() { setPump(true); server.sendHeader("Location", "/"); server.send(302, "text/plain", "Pump on"); }); server.on("/pump/off", []() { setPump(false); server.sendHeader("Location", "/"); server.send(302, "text/plain", "Pump off"); }); server.begin(); } void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); Serial.print("Connecting to WiFi"); for (int i = 0; i < 30 && WiFi.status() != WL_CONNECTED; i++) { delay(500); Serial.print('.'); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { Serial.print("IP address: "); Serial.println(WiFi.localIP()); } else { Serial.println("WiFi not connected; local controls still work."); } } void updateDisplay() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("Well/Tank Node"); display.print("Role: "); display.println(nodeRole); display.print("GPH target: "); display.println(targetGallonsPerHour, 1); display.print("Float: "); display.println(tankFull ? "FULL" : "OK"); display.print("Pump: "); display.println(pumpRunning ? "ON" : "OFF"); display.print("Flow pulses: "); display.println((uint32_t)flowPulseCount); display.print("IP: "); display.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "offline"); display.display(); } void handleButtons() { static bool lastSelect = HIGH; static bool lastUp = HIGH; static bool lastDown = HIGH; bool sel = digitalRead(PIN_BTN_SELECT); bool up = digitalRead(PIN_BTN_UP); bool down = digitalRead(PIN_BTN_DOWN); if (lastUp == HIGH && up == LOW) { targetGallonsPerHour += 0.5f; saveSettings(); } if (lastDown == HIGH && down == LOW) { targetGallonsPerHour -= 0.5f; if (targetGallonsPerHour < 0.0f) targetGallonsPerHour = 0.0f; saveSettings(); } if (lastSelect == HIGH && sel == LOW) { pumpEnabled = !pumpEnabled; if (!pumpEnabled) setPump(false); saveSettings(); } lastSelect = sel; lastUp = up; lastDown = down; } void simpleHourlySchedule() { if (nodeRole == "tank") { setPump(false); return; } unsigned long now = millis(); unsigned long hourMs = 3600000UL; unsigned long onMs = (unsigned long)(runtimeMinutesForTarget() * 60000.0f); if (onMs > hourMs) onMs = hourMs; unsigned long phase = now % hourMs; bool shouldRun = phase < onMs; setPump(shouldRun); } void setup() { Serial.begin(115200); delay(200); Serial.println("Dual Well Pump Controller node starting"); loadSettings(); pinMode(PIN_CONTACTOR, OUTPUT); digitalWrite(PIN_CONTACTOR, LOW); pinMode(PIN_FLOW_PULSE, INPUT_PULLUP); pinMode(PIN_TANK_FLOAT, INPUT_PULLUP); pinMode(PIN_AUX_INPUT, INPUT_PULLUP); pinMode(PIN_BTN_SELECT, INPUT_PULLUP); pinMode(PIN_BTN_UP, INPUT_PULLUP); pinMode(PIN_BTN_DOWN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIN_FLOW_PULSE), onFlowPulse, FALLING); attachInterrupt(digitalPinToInterrupt(PIN_AUX_INPUT), onAuxPulse, FALLING); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println("SSD1306 display not found at 0x3C"); } else { updateDisplay(); } connectWiFi(); setupWeb(); } void loop() { server.handleClient(); tankFull = (digitalRead(PIN_TANK_FLOAT) == LOW); // normally-open contact closed = full/active handleButtons(); simpleHourlySchedule(); if (millis() - lastDisplayMs > 1000) { lastDisplayMs = millis(); updateDisplay(); uint32_t fp = flowPulseCount; if (fp != lastFlowPulseCount) { Serial.printf("Flow pulses: %lu\n", (unsigned long)fp); lastFlowPulseCount = fp; } } if (WiFi.status() != WL_CONNECTED) { // Current starter behavior: keep local schedule working. // Final distributed firmware should fail safe: inhibit pump starts if tank/controller heartbeat is lost. } }
Bash
pio run pio run -t upload pio device monitor
meterPulsesPerGallon after final pulse-output water meter selection.Platform & Toolchain
Schematic Pin Mapping
`platformio.ini`
Complete Starter Source — `src/main.cpp`
Build & Flash
Important Firmware Safety TODOs Before Field Use