Skip to content

Instantly share code, notes, and snippets.

@matsubo
Created June 14, 2026 12:07
Show Gist options
  • Select an option

  • Save matsubo/f2806c5010ebd7a4d8197ce317050ded to your computer and use it in GitHub Desktop.

Select an option

Save matsubo/f2806c5010ebd7a4d8197ce317050ded to your computer and use it in GitHub Desktop.
Intercooler water spray for ESP-32
/*
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