Last active
June 23, 2026 03:45
-
-
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 charset=utf-8>" | |
| "<meta name=viewport content='width=device-width,initial-scale=1'>" | |
| "<title>ICWS</title><style>" | |
| "*{box-sizing:border-box}" | |
| "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" | |
| "margin:0;background:#0b0f14;color:#e6edf3;-webkit-font-smoothing:antialiased}" | |
| ".wrap{max-width:480px;margin:0 auto;padding:18px 14px 40px}" | |
| "header{display:flex;align-items:baseline;gap:10px;margin:6px 2px 16px}" | |
| ".logo{font-size:22px;font-weight:800;letter-spacing:.5px;" | |
| "background:linear-gradient(90deg,#22d3ee,#38bdf8);-webkit-background-clip:text;" | |
| "background-clip:text;-webkit-text-fill-color:transparent}" | |
| ".sub{font-size:11px;color:#7d8da1;text-transform:uppercase;letter-spacing:2px}" | |
| ".card{background:#11171f;border:1px solid #1e2733;border-radius:16px;padding:16px;margin-bottom:14px}" | |
| ".card h3{margin:0 0 10px;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#7d8da1;font-weight:600}" | |
| ".live{text-align:center}" | |
| ".status{display:inline-block;font-size:11px;font-weight:700;letter-spacing:2px;padding:4px 14px;border-radius:999px;margin-bottom:4px}" | |
| ".s-idle{background:#1b2430;color:#7d8da1}" | |
| ".s-armed{background:#3a2a06;color:#fbbf24}" | |
| ".s-spray{background:#06363f;color:#22d3ee;animation:pulse 1s infinite}" | |
| "@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}" | |
| ".boost{font-size:54px;font-weight:800;line-height:1;margin:6px 0;font-variant-numeric:tabular-nums}" | |
| ".boost .unit{font-size:18px;color:#7d8da1;font-weight:600;margin-left:6px}" | |
| ".bar{position:relative;height:10px;background:#1b2430;border-radius:999px;margin:14px 0 10px;overflow:hidden}" | |
| ".fill{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:999px;transition:width .25s ease}" | |
| ".fill.s-idle{background:#475569}.fill.s-armed{background:#f59e0b}" | |
| ".fill.s-spray{background:linear-gradient(90deg,#22d3ee,#38bdf8)}" | |
| ".mark{position:absolute;top:-3px;width:2px;height:16px;background:#e6edf3;opacity:.55}" | |
| ".meta{display:flex;justify-content:space-between;font-size:13px;color:#9fb0c3}" | |
| ".meta b{color:#e6edf3}" | |
| "label{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:14px;padding:9px 0;border-bottom:1px solid #161d27}" | |
| "label:last-child{border-bottom:0}" | |
| ".ig{display:flex;align-items:center;gap:8px}" | |
| ".u{color:#7d8da1;font-size:12px}" | |
| "input{background:#0b0f14;border:1px solid #28323f;color:#e6edf3;border-radius:10px;padding:9px 11px;width:96px;font-size:15px;text-align:right;font-variant-numeric:tabular-nums}" | |
| "input:focus{outline:none;border-color:#22d3ee;box-shadow:0 0 0 3px rgba(34,211,238,.15)}" | |
| "button{font-family:inherit;cursor:pointer;border:0;border-radius:11px;padding:11px 14px;font-size:14px;font-weight:600}" | |
| ".primary{width:100%;background:linear-gradient(90deg,#22d3ee,#38bdf8);color:#04121a;font-weight:800;letter-spacing:.5px}" | |
| ".ghost{background:transparent;border:1px solid #28323f;color:#9fb0c3;margin-top:12px}" | |
| ".cap{background:#1b2430;color:#e6edf3;border:1px solid #28323f}" | |
| ".full{width:100%}" | |
| ".row{display:flex;gap:8px;align-items:center}" | |
| ".row input{width:64px}.row button{flex:1}" | |
| "form{margin:0}" | |
| "</style></head><body><div class=wrap>" | |
| "<header><div class=logo>⚡ ICWS</div><div class=sub>ZC33S Water Spray</div></header>" | |
| "<section class='card live'>" | |
| "<div class=status id=status>--</div>" | |
| "<div class=boost><span id=boost>--</span><span class=unit>bar</span></div>" | |
| "<div class=bar><div class=fill id=fill></div><div class=mark id=mark></div></div>" | |
| "<div class=meta><span id=mv>-- mV</span><span>Peak <b id=peak>--</b> mV</span></div>" | |
| "<form method=POST action=/resetpk><button class=ghost>reset peak</button></form>" | |
| "</section>" | |
| "<form method=POST action=/set>" | |
| "<section class=card><h3>Thresholds</h3>"); | |
| s += "<label><span>Boost ON</span><span class=ig><input name=boostOn value=" + String(cfg.boostOn,2) + "><span class=u>bar</span></span></label>"; | |
| s += "<label><span>Boost OFF</span><span class=ig><input name=boostOff value=" + String(cfg.boostOff,2) + "><span class=u>bar</span></span></label>"; | |
| s += "<label><span>Spray ON</span><span class=ig><input name=onMs value=" + String(cfg.onMs) + "><span class=u>ms</span></span></label>"; | |
| s += "<label><span>Spray OFF</span><span class=ig><input name=offMs value=" + String(cfg.offMs) + "><span class=u>ms</span></span></label>"; | |
| s += F("</section><section class=card><h3>Calibration</h3>"); | |
| s += "<label><span>v0 @0 bar</span><span class=ig><input name=v0mV value=" + String(cfg.v0mV,1) + "><span class=u>mV</span></span></label>"; | |
| s += "<label><span>slope</span><span class=ig><input name=slope value=" + String(cfg.slopeMvBar,1) + "><span class=u>mV/bar</span></span></label>"; | |
| s += F("</section><button class=primary type=submit>Save</button></form>" | |
| "<section class=card><h3>Quick Calibrate</h3>" | |
| "<form method=POST action=/zero><button class='cap full'>Capture 0 bar · set v0 = now</button></form>" | |
| "<form method=POST action=/cal class=row style='margin-top:10px'>" | |
| "<span class=u>known</span><input name=kb value=1.0><span class=u>bar</span>" | |
| "<button class=cap>Capture slope</button></form>" | |
| "</section>" | |
| "<script>var ON="); | |
| s += String(cfg.boostOn,2); | |
| s += F(";function pct(b){var lo=-1.0,hi=2.0,p=(b-lo)/(hi-lo)*100;return Math.max(0,Math.min(100,p));}" | |
| "document.getElementById('mark').style.left=pct(ON)+'%';" | |
| "setInterval(function(){fetch('/live').then(r=>r.json()).then(j=>{" | |
| "document.getElementById('boost').textContent=j.boost.toFixed(2);" | |
| "document.getElementById('mv').textContent=j.mv.toFixed(0)+' mV';" | |
| "document.getElementById('peak').textContent=j.peak.toFixed(0);" | |
| "var c=j.spray?'s-spray':(j.armed?'s-armed':'s-idle');" | |
| "var st=document.getElementById('status');" | |
| "st.textContent=j.spray?'SPRAY':(j.armed?'ARMED':'IDLE');st.className='status '+c;" | |
| "var f=document.getElementById('fill');f.style.width=pct(j.boost)+'%';f.className='fill '+c;" | |
| "}).catch(e=>{});},250);" | |
| "</script></div></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); | |
| WiFi.setTxPower(WIFI_POWER_8_5dBm); // 約8.5dBm ≈ 最大出力の約10% | |
| 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