Skip to content

Instantly share code, notes, and snippets.

@matsubo
Last active June 23, 2026 03:45
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 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>&#9889; ICWS</div><div class=sub>ZC33S&nbsp;Water&nbsp;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&nbsp;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&nbsp;bar &middot; 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