Created
June 14, 2026 12:07
-
-
Save matsubo/f2806c5010ebd7a4d8197ce317050ded to your computer and use it in GitHub Desktop.
Intercooler water spray for ESP-32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| Intercooler Water Spray Controller / ZC33S | |
| ------------------------------------------------------------ | |
| MCU : ESP32 (Arduino core "ESP32 Dev Module") | |
| Boost in : AutoGauge 548 boost sensor WHITE wire (signal, ~1V @0bar) | |
| tapped via 1k series -> ADS1115 A0. Sensor BLACK -> common GND. | |
| RED is left alone (the gauge powers the sensor). READ-ONLY: never | |
| drive voltage onto WHITE. | |
| Pump out : logic-level MOSFET low-side. Gate needs an external 10k pulldown | |
| to GND so the pump stays OFF whenever the MCU is unpowered. | |
| Flyback diode across the pump. Fuse (5-7.5A) on the pump 12V. | |
| LEDs : RED = hardware only, 5V rail -> 330ohm -> LED -> GND (power on/off) | |
| GREEN= GPIO -> 220ohm -> LED -> GND (lit while spraying; case A: | |
| mirrors the pump, so it pulses with interval bursts) | |
| Master : a toggle on the 12V(ACC) feed to the buck cuts MCU power = OFF. | |
| No software involvement; gate pulldown guarantees no spray. | |
| Config : phone -> WiFi AP "ICWS-ZC33S" -> http://192.168.4.1 | |
| Thresholds / interval / calibration are editable and persisted | |
| to NVS (survive power cycles). No logging. | |
| Libraries: Adafruit ADS1X15 (+ Adafruit BusIO). WiFi/WebServer/Preferences | |
| are part of the ESP32 core. | |
| ------------------------------------------------------------ | |
| CALIBRATION (boost_bar = (mV - v0mV) / slopeMvBar): | |
| 1) Key ON, engine OFF (manifold = atmosphere = 0 bar). Open the page, | |
| tap "Capture 0 bar" -> sets v0mV. | |
| 2) Do a pull to a boost the AutoGauge gauge clearly shows (use its | |
| peak-recall). The page holds Peak mV. Then set slope by hand: | |
| slopeMvBar = (peakMv - v0mV) / peakBoost, type it into "slope", | |
| or while sitting at a known steady boost use "Capture slope point". | |
| */ | |
| #include <Wire.h> | |
| #include <Adafruit_ADS1X15.h> | |
| #include <WiFi.h> | |
| #include <WebServer.h> | |
| #include <Preferences.h> | |
| // ---------- Pin map (ESP32) ---------- | |
| const int PIN_MOSFET = 25; // -> gate (EXTERNAL 10k pulldown to GND required) | |
| const int PIN_LED_GREEN = 26; // -> 220ohm -> green LED -> GND (spray active) | |
| const int PIN_BTN = 27; // momentary push to GND (INPUT_PULLUP) | |
| // I2C: SDA=21, SCL=22 -> ADS1115 (addr 0x48) | |
| // ---------- WiFi AP ---------- | |
| const char* AP_SSID = "ICWS-ZC33S"; | |
| const char* AP_PASS = "spray12345"; // must be >= 8 chars | |
| Adafruit_ADS1115 ads; | |
| WebServer server(80); | |
| Preferences prefs; | |
| // ---------- Config (persisted in NVS) ---------- | |
| struct Config { | |
| float boostOn; // bar, arm threshold | |
| float boostOff; // bar, disarm threshold (hysteresis) | |
| uint32_t onMs; // interval spray ON (set offMs=0 for continuous) | |
| uint32_t offMs; // interval spray OFF | |
| float v0mV; // white-wire mV at 0 bar (gauge) | |
| float slopeMvBar; // mV per bar | |
| } cfg; | |
| void loadCfg() { | |
| prefs.begin("icws", false); | |
| cfg.boostOn = prefs.getFloat("boostOn", 0.40f); | |
| cfg.boostOff = prefs.getFloat("boostOff", 0.20f); | |
| cfg.onMs = prefs.getUInt ("onMs", 3000); | |
| cfg.offMs = prefs.getUInt ("offMs", 1000); | |
| cfg.v0mV = prefs.getFloat("v0mV", 456.2f); // from screenshot; recalibrate with real sensor in car | |
| cfg.slopeMvBar = prefs.getFloat("slope", 456.2f); // from screenshot; recalibrate with real sensor in car | |
| prefs.end(); | |
| } | |
| void saveCfg() { | |
| prefs.begin("icws", false); | |
| prefs.putFloat("boostOn", cfg.boostOn); | |
| prefs.putFloat("boostOff", cfg.boostOff); | |
| prefs.putUInt ("onMs", cfg.onMs); | |
| prefs.putUInt ("offMs", cfg.offMs); | |
| prefs.putFloat("v0mV", cfg.v0mV); | |
| prefs.putFloat("slope", cfg.slopeMvBar); | |
| prefs.end(); | |
| } | |
| // ---------- Runtime state ---------- | |
| bool adsOk = false; | |
| float lastMv = 0, lastBoost = 0, peakMv = 0; | |
| bool armed = false, sprayPhaseOn = true, spraying = false; | |
| uint32_t lastPhase = 0, lastSample = 0; | |
| float readMv() { | |
| if (!adsOk) return 0; | |
| int16_t raw = ads.readADC_SingleEnded(0); | |
| return ads.computeVolts(raw) * 1000.0f; | |
| } | |
| float toBoost(float mv) { | |
| if (cfg.slopeMvBar < 1.0f) return 0; | |
| return (mv - cfg.v0mV) / cfg.slopeMvBar; | |
| } | |
| // ---------- Web ---------- | |
| String page() { | |
| String s = F("<!doctype html><html><head><meta name=viewport " | |
| "content='width=device-width,initial-scale=1'><title>ICWS</title>" | |
| "<style>body{font-family:sans-serif;margin:16px;max-width:480px}" | |
| "input{width:90px}fieldset{margin-bottom:12px}" | |
| ".b{font-size:20px;font-weight:bold}</style></head><body>"); | |
| s += F("<h2>IC Water Spray</h2><div>Live: <span class=b id=live>--</span></div>"); | |
| s += F("<div>Peak: <span id=peak>--</span> mV " | |
| "<form style='display:inline' method=POST action=/resetpk>" | |
| "<button>reset peak</button></form></div>"); | |
| s += F("<form method=POST action=/set><fieldset><legend>Thresholds</legend>"); | |
| s += "ON (bar) <input name=boostOn value=" + String(cfg.boostOn,2) + "><br>"; | |
| s += "OFF (bar) <input name=boostOff value=" + String(cfg.boostOff,2) + "><br>"; | |
| s += "Spray ON (ms) <input name=onMs value=" + String(cfg.onMs) + "><br>"; | |
| s += "Spray OFF (ms) <input name=offMs value=" + String(cfg.offMs) + "></fieldset>"; | |
| s += F("<fieldset><legend>Calibration</legend>"); | |
| s += "v0 (mV @0bar) <input name=v0mV value=" + String(cfg.v0mV,1) + "><br>"; | |
| s += "slope (mV/bar) <input name=slope value=" + String(cfg.slopeMvBar,1) + "></fieldset>"; | |
| s += F("<button type=submit>Save</button></form>"); | |
| s += F("<form method=POST action=/zero><button>Capture 0 bar (v0 = now)</button></form>"); | |
| s += F("<form method=POST action=/cal>known boost (bar) " | |
| "<input name=kb value=1.0><button>Capture slope point</button></form>"); | |
| s += F("<script>setInterval(function(){fetch('/live').then(r=>r.json()).then(j=>{" | |
| "document.getElementById('live').textContent=" | |
| "j.boost.toFixed(2)+' bar ('+j.mv.toFixed(0)+' mV) '+" | |
| "(j.spray?'SPRAY':(j.armed?'armed':'idle'));" | |
| "document.getElementById('peak').textContent=j.peak.toFixed(0);});},500);" | |
| "</script></body></html>"); | |
| return s; | |
| } | |
| void handleRoot() { server.send(200, "text/html", page()); } | |
| void handleLive() { | |
| String j = "{\"mv\":" + String(lastMv,1) + ",\"boost\":" + String(lastBoost,3) | |
| + ",\"peak\":" + String(peakMv,1) | |
| + ",\"armed\":" + (armed ? "true" : "false") | |
| + ",\"spray\":" + (spraying ? "true" : "false") + "}"; | |
| server.send(200, "application/json", j); | |
| } | |
| float argF(const char* n, float d) { return server.hasArg(n) ? server.arg(n).toFloat() : d; } | |
| void handleSet() { | |
| cfg.boostOn = argF("boostOn", cfg.boostOn); | |
| cfg.boostOff = argF("boostOff", cfg.boostOff); | |
| cfg.onMs = (uint32_t)argF("onMs", cfg.onMs); | |
| cfg.offMs = (uint32_t)argF("offMs", cfg.offMs); | |
| cfg.v0mV = argF("v0mV", cfg.v0mV); | |
| cfg.slopeMvBar = argF("slope", cfg.slopeMvBar); | |
| if (cfg.boostOff > cfg.boostOn) cfg.boostOff = cfg.boostOn; // keep hysteresis sane | |
| saveCfg(); | |
| server.sendHeader("Location", "/"); server.send(303, "text/plain", ""); | |
| } | |
| void handleZero() { | |
| cfg.v0mV = readMv(); saveCfg(); | |
| server.sendHeader("Location", "/"); server.send(303, "text/plain", ""); | |
| } | |
| void handleCal() { | |
| float kb = argF("kb", 0); | |
| if (kb > 0.05f) { | |
| float sl = (readMv() - cfg.v0mV) / kb; | |
| if (sl > 100.0f) { cfg.slopeMvBar = sl; saveCfg(); } | |
| } | |
| server.sendHeader("Location", "/"); server.send(303, "text/plain", ""); | |
| } | |
| void handleResetPk() { | |
| peakMv = lastMv; | |
| server.sendHeader("Location", "/"); server.send(303, "text/plain", ""); | |
| } | |
| void setup() { | |
| Serial.begin(115200); | |
| pinMode(PIN_MOSFET, OUTPUT); digitalWrite(PIN_MOSFET, LOW); | |
| pinMode(PIN_LED_GREEN, OUTPUT); digitalWrite(PIN_LED_GREEN, LOW); | |
| pinMode(PIN_BTN, INPUT_PULLUP); | |
| loadCfg(); | |
| Wire.begin(21, 22); | |
| adsOk = ads.begin(); // ADS1115 @ 0x48 | |
| if (adsOk) ads.setGain(GAIN_TWOTHIRDS); // +-6.144V (headroom; add divider if signal nears 5V) | |
| else Serial.println("ADS1115 not found"); | |
| WiFi.mode(WIFI_AP); | |
| WiFi.softAP(AP_SSID, AP_PASS); | |
| server.on("/", handleRoot); | |
| server.on("/live", handleLive); | |
| server.on("/set", HTTP_POST, handleSet); | |
| server.on("/zero", HTTP_POST, handleZero); | |
| server.on("/cal", HTTP_POST, handleCal); | |
| server.on("/resetpk", HTTP_POST, handleResetPk); | |
| server.begin(); | |
| } | |
| void loop() { | |
| server.handleClient(); | |
| uint32_t now = millis(); | |
| if (now - lastSample >= 20) { // sample ~50 Hz | |
| lastSample = now; | |
| lastMv = readMv(); | |
| lastBoost = toBoost(lastMv); | |
| if (lastMv > peakMv) peakMv = lastMv; | |
| if (lastBoost >= cfg.boostOn) armed = true; // hysteresis | |
| else if (lastBoost <= cfg.boostOff) armed = false; | |
| } | |
| if (armed) { // interval duty machine | |
| if ( sprayPhaseOn && (now - lastPhase >= cfg.onMs)) { sprayPhaseOn = false; lastPhase = now; } | |
| if (!sprayPhaseOn && (now - lastPhase >= cfg.offMs)) { sprayPhaseOn = true; lastPhase = now; } | |
| } else { | |
| sprayPhaseOn = true; lastPhase = now; // next arm starts on a spray burst | |
| } | |
| bool autoSpray = armed && sprayPhaseOn; | |
| bool manual = (digitalRead(PIN_BTN) == LOW); // held = force spray, ignores boost | |
| spraying = autoSpray || manual; | |
| digitalWrite(PIN_MOSFET, spraying ? HIGH : LOW); | |
| digitalWrite(PIN_LED_GREEN, spraying ? HIGH : LOW); // case A: green mirrors the pump | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment