Contents — 26 sections
Project Overview
Battery-powered floor sensors that detect water within seconds, wake from deep sleep, sound a local alarm, push a phone alert, and — optionally — close a motorised valve before the damage spreads.
Water damage is slow, silent and expensive. A washing machine hose that fails at 2 a.m. can put several hundred litres through a floor before anyone notices. The detection problem is trivial — water bridges two electrodes — so almost all the engineering here is about the parts people skip: making the node last a year on a battery, making the electrodes survive that year without corroding away, and making sure the alert actually reaches somebody.
The power design is the interesting part. A node that polls a sensor every second draws milliamps and lasts weeks. This design instead uses the ESP32's ULP-triggered external wake: the chip sits in deep sleep at about 10 µA, and the sensor itself pulls a GPIO low when water bridges it, which wakes the chip in milliseconds. Detection latency is therefore under a second while average current is measured in microamps. Two 18650 cells give well over a year.
Electrode corrosion is the failure nobody plans for. A DC-biased electrode in water electrolyses: metal migrates off the anode, and within weeks the sensor either reads permanently wet or permanently dry. The fix is to never apply continuous DC. Here the electrodes are driven only during the brief confirmation measurement after a wake, and the polarity alternates between measurements, so net metal transport is close to zero.
Finally, escalation. A local buzzer is useless if nobody is home, and a phone notification is useless if the phone is on silent. The node therefore does all three: sounds locally, publishes an MQTT alert that retries until acknowledged, and can drive a motorised ball valve to shut the supply off entirely.
What this project does
- Detects water bridging a floor-level probe within about one second of contact.
- Sleeps at roughly 10 µA between events, giving over a year on two 18650 cells.
- Sounds a 90 dB local alarm immediately, independent of any network.
- Publishes an MQTT alert and repeats it until a human acknowledges.
- Optionally drives a 12 V motorised ball valve to close the mains water supply.
- Reports battery voltage and a daily heartbeat, so a dead node is visibly dead.
- Distinguishes a genuine leak from a transient splash using a confirmation delay.
Real-World Applications
| Setting | How it is used |
|---|---|
| Under a washing machine or dishwasher | The highest-risk location in most homes, and the one where a valve shutoff pays for itself the first time it fires. |
| Water heater and boiler drip trays | A tank failure is slow at first — hours of early warning is the difference between a mop and a floor replacement. |
| Basements and sumps | Combine with a level sensor to distinguish "damp" from "rising fast". |
| Server rooms and comms cabinets | Water on a raised floor near equipment justifies an immediate power-down automation. |
| Holiday homes | The classic scenario: nobody present for weeks. Remote alerting plus automatic shutoff is the entire value proposition. |
| Aquarium and hydroponics overflow | A pump that fails on rather than off empties a tank onto the floor. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Deep sleep with external wake (
ext0) on the probe pin — microamp idle, sub-second detection. - Alternating-polarity electrode drive that eliminates electrolytic corrosion.
- Confirmation window: water must remain detected for three seconds before an alarm, filtering splashes.
- Daily heartbeat with battery voltage, so silence is unambiguous.
- Escalating alerts — local buzzer, MQTT, and repeat-until-acknowledged.
- Optional valve control with a 60-second drive timeout and position feedback.
- Multiple probes per node, each individually identified in the alert.
- Low-battery warning at 3.4 V per cell, well before the node dies.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Beginner |
| Estimated completion time | 5–8 hours |
| Indicative build cost | ₹2,400 (detect only) – ₹5,200 (with valve) |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Basic Arduino C++ and digital input reading
- Understanding of deep sleep and wake sources on the ESP32
- Voltage dividers and ADC reading for battery monitoring
- Simple soldering and waterproofing
- MQTT basics
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| Active piezo buzzer 5 V Active buzzers make tone on DC; passive ones need a PWM carrier. | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 1 | ₹25 |
| 18650 Li-ion cell 3400 mAh + holder Never charge below 0 °C; always use a protected cell or a BMS. | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 2 | ₹900 |
| TP4056 Li-ion charger + DW01 protection Buy the version *with* protection ICs — the bare charger will over-discharge your cell. | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 1 | ₹45 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| Water probe — stainless steel or gold-plated pads Gold or stainless only. Plain copper or tinned pads corrode within weeks even with polarity alternation. | 2 × electrodes, 10 mm spacing, on a floor-contact PCB | 3 | ₹270 |
| 1 MΩ + 100 kΩ resistors Probe pull-up and battery divider. | 1 % metal film | 1 | ₹20 |
| 12 V motorised ball valve (optional) | DN20, 2-wire or 5-wire with feedback, 8 W | 1 | ₹2,400 |
| Silicone sealant and heat-shrink | Neutral cure | 1 | ₹150 |
Estimated total: ₹4,580, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| Active piezo buzzer 5 V | 85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter | 3–5 V | Digital / PWM | Datasheet |
| 18650 Li-ion cell 3400 mAh + holder | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 3.0–4.2 V | Holder / spot-welded tabs | Datasheet |
| TP4056 Li-ion charger + DW01 protection | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 4.5–5.5 V in | micro-USB / pads | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| Active piezo buzzer 5 V | 3–5 V | 30 | Active buzzers make tone on DC; passive ones need a PWM carrier. |
| TP4056 Li-ion charger + DW01 protection | 4.5–5.5 V in | 1000 | Buy the version *with* protection ICs — the bare charger will over-discharge your cell. |
Summed typical draw is 1190 mA. With a 1.5× design margin the supply should deliver at least 1800 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| Probe 1 sense | Electrode A | GPIO 33 | Wake source, 1 MΩ pull-up |
| Probe 1 drive | Electrode B | GPIO 32 | Driven only during measurement |
| Probe 2 / 3 sense | Electrode A | GPIO 25 / 26 | Additional zones |
| Battery divider | Mid-point | GPIO 34 | 1 MΩ / 100 kΩ to ADC |
| Piezo buzzer | + | GPIO 27 | LEDC PWM, 2.3 kHz |
| Valve open / close | Relay IN1 / IN2 | GPIO 18 / 19 | Momentary drive, 60 s timeout |
| Status LED | Anode | GPIO 2 | Flash only, never steady |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The sense electrode sits at 3.3 V through a 1 MΩ pull-up. Water bridging to the drive electrode (held at ground during sleep) pulls it low, which is what
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 0)wakes on. - A 1 MΩ pull-up is deliberately high. It means the current through the water is under 3.3 µA, which keeps sleep current low and dramatically slows electrolysis. Tap water conducts well enough to pull the pin low even through 1 MΩ.
- Use stainless steel or gold-plated electrodes. Bare copper or HASL-finished pads will corrode visibly within a month of being wet, and the failure is silent — a corroded probe reads dry.
- Mount the probe flat on the floor with the electrodes facing down but with a 1–2 mm standoff, so surface tension does not hold a permanent bridge after the water has gone.
- GPIO 34 is input-only. The battery divider needs external resistors; there is no internal pull-up available.
- Only RTC-capable GPIO can be an ext0 wake source: 0, 2, 4, 12–15, 25–27, 32–39 on a classic ESP32. Choosing a non-RTC pin means the node sleeps and never wakes.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
Water detection here is simple resistive sensing. Two electrodes are separated by air; air is an excellent insulator, so the sense pin sits at 3.3 V through its pull-up. Tap water has a resistance of roughly 1–50 kΩ across a 10 mm gap depending on its mineral content, which is far below the 1 MΩ pull-up, so the pin is pulled close to ground. Distilled water would not trigger it — but distilled water is not what leaks out of a washing machine.
The corrosion problem is electrochemistry, not electronics. Any DC potential across two metal electrodes in an electrolyte drives electrolysis: metal ions leave the anode and either plate onto the cathode or precipitate. The rate is proportional to current and time. Two mitigations are used together here: a very high pull-up resistance limits the current to microamps, and the drive electrode is only actively grounded during a measurement, alternating polarity between measurements so the net charge transfer over time tends to zero.
The power architecture is where a leak detector is won or lost. An ESP32 running normally draws around 80–160 mA; two 18650s at 6800 mAh would last about two days. In deep sleep with only the RTC domain powered it draws roughly 10 µA, which is about 78 years of battery capacity — so the real limits become self-discharge and the brief wake events. A node that wakes once a day for a five-second heartbeat at 120 mA averages under 20 µA total, and comfortably exceeds a year.
ext0 wake is the specific mechanism. It configures a single RTC GPIO to wake the chip when it reaches a chosen level, and it works with the entire digital core powered down. Detection latency is the RTC wake time plus boot, which is around 300 ms — indistinguishable from instant for this purpose.
The confirmation window exists because a single momentary bridge is not a leak. Someone mopping the floor, a dropped glass of water, condensation running off a pipe — all produce brief contact. Requiring the probe to stay wet for three continuous seconds, sampled at 200 ms intervals, removes essentially all of those without adding meaningful delay to a real flood.
Escalation follows the principle that the alert must not depend on anything that can fail silently. The buzzer sounds first and needs no network. Only then does the node join Wi-Fi — a process that takes two to five seconds and might fail entirely — and publish. And because an MQTT publish into a broker nobody is watching is not an alert, the node republishes every thirty seconds until it receives an acknowledgement on its command topic.
The maths behind it
Battery life estimate
Deep sleep current I_sleep = 10 µA
Heartbeat: 1 per day, 6 s at 120 mA
charge per day = 6 × 120 mA / 86400 s = 8.3 µA average
Total average ≈ 18.3 µA
Capacity: 2 × 3400 mAh in parallel = 6800 mAh
Usable (to 3.4 V/cell) ≈ 5800 mAh
Life = 5800 mAh / 0.0183 mA = 316 900 h ≈ 36 years
In practice self-discharge (~2 %/month) dominates:
realistic replacement interval 2–3 years.
Probe current and electrolysis
R_pullup = 1 MΩ, V = 3.3 V
Water resistance R_w ≈ 20 kΩ (typical tap water, 10 mm gap)
I = 3.3 / (1 000 000 + 20 000) = 3.24 µA
Faraday: mass transported m = (I · t · M) / (n · F)
For copper (M = 63.5 g/mol, n = 2, F = 96485 C/mol)
over one year of continuous contact:
Q = 3.24 µA × 3.15e7 s = 102 C
m = (102 × 63.5) / (2 × 96485) = 0.034 g
34 mg of copper is enough to visibly pit a small pad —
which is why polarity alternation matters even at µA.
Battery divider
V_batt max = 4.2 V (single cell) or 4.2 V (2 in parallel)
ESP32 ADC full scale ≈ 3.3 V with 11 dB attenuation
Divider 1 MΩ / 100 kΩ:
V_adc = V_batt × 100k / 1100k = V_batt × 0.0909
4.2 V → 0.382 V (poor resolution)
Better: 100 kΩ / 100 kΩ → V_adc = V_batt / 2
4.2 V → 2.10 V, 3.0 V → 1.50 V ✓
divider current = 4.2 / 200 kΩ = 21 µA ← too high!
Use 1 MΩ / 1 MΩ: 2.1 µA, and switch the divider
ground through a MOSFET so it draws nothing while asleep.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Water Leak & Flood Detector — ESP32, deep sleep, ext0 wake
Sleeps at ~10 uA. Wakes in under a second when water bridges a
probe, confirms for 3 s to reject splashes, sounds a local alarm
immediately, then joins Wi-Fi and repeats an MQTT alert until a
human acknowledges. Optionally closes a motorised valve.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <esp_sleep.h>
#include <driver/rtc_io.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "leak-utility-room"
#define PIN_PROBE_SENSE GPIO_NUM_33 // must be an RTC-capable GPIO
#define PIN_PROBE_DRIVE 32
#define PIN_BUZZER 27
#define PIN_BATT_ADC 34
#define PIN_BATT_EN 14 // MOSFET gate: enables the divider
#define PIN_VALVE_CLOSE 18
#define PIN_VALVE_OPEN 19
#define PIN_LED 2
#define CONFIRM_MS 3000
#define ALERT_REPEAT_MS 30000
#define ALERT_MAX_MIN 30
#define HEARTBEAT_S 86400ULL // 24 h
#define VALVE_DRIVE_MS 60000
/* Survives deep sleep — RTC slow memory is not cleared. */
RTC_DATA_ATTR uint32_t bootCount = 0;
RTC_DATA_ATTR uint32_t leakCount = 0;
RTC_DATA_ATTR bool drivePolarity = false;
RTC_DATA_ATTR bool valveClosed = false;
WiFiClient net;
PubSubClient mqtt(net);
volatile bool acknowledged = false;
/* ── probe ──────────────────────────────────────────────────── */
void probeIdle() {
// Drive electrode grounded so water pulls the sense pin low and
// triggers ext0. Sense pin uses its internal RTC pull-up.
pinMode(PIN_PROBE_DRIVE, OUTPUT);
digitalWrite(PIN_PROBE_DRIVE, LOW);
rtc_gpio_pullup_en(PIN_PROBE_SENSE);
rtc_gpio_pulldown_dis(PIN_PROBE_SENSE);
}
// Alternates polarity each call so net electrolytic transport ~ 0.
bool probeWet() {
drivePolarity = !drivePolarity;
pinMode(PIN_PROBE_DRIVE, OUTPUT);
digitalWrite(PIN_PROBE_DRIVE, drivePolarity ? HIGH : LOW);
pinMode((int)PIN_PROBE_SENSE, drivePolarity ? INPUT_PULLDOWN : INPUT_PULLUP);
delayMicroseconds(200);
bool bridged = digitalRead((int)PIN_PROBE_SENSE) == (drivePolarity ? HIGH : LOW);
pinMode(PIN_PROBE_DRIVE, INPUT); // float between measurements
return bridged;
}
bool confirmLeak() {
uint32_t start = millis();
int wet = 0, total = 0;
while (millis() - start < CONFIRM_MS) {
if (probeWet()) wet++;
total++;
tone(PIN_BUZZER, 3000, 40); // audible while confirming
delay(200);
}
return total && (wet * 100 / total) >= 80; // wet for 80 % of the window
}
/* ── battery ────────────────────────────────────────────────── */
float batteryVolts() {
pinMode(PIN_BATT_EN, OUTPUT);
digitalWrite(PIN_BATT_EN, HIGH); // connect the divider
delay(5);
analogSetPinAttenuation(PIN_BATT_ADC, ADC_11db);
uint32_t acc = 0;
for (int i = 0; i < 16; i++) { acc += analogRead(PIN_BATT_ADC); delay(2); }
digitalWrite(PIN_BATT_EN, LOW); // disconnect: no idle drain
float adc = acc / 16.0f;
return (adc / 4095.0f) * 3.3f * 2.0f * 1.045f; // 1:1 divider + calibration
}
/* ── alarm ──────────────────────────────────────────────────── */
void wailOnce() {
for (int f = 1800; f < 3400; f += 60) { tone(PIN_BUZZER, f, 18); delay(18); }
for (int f = 3400; f > 1800; f -= 60) { tone(PIN_BUZZER, f, 18); delay(18); }
}
/* ── valve ──────────────────────────────────────────────────── */
void valveClose() {
if (valveClosed) return;
pinMode(PIN_VALVE_CLOSE, OUTPUT);
digitalWrite(PIN_VALVE_CLOSE, HIGH);
uint32_t t0 = millis();
while (millis() - t0 < VALVE_DRIVE_MS) { wailOnce(); }
digitalWrite(PIN_VALVE_CLOSE, LOW); // motor must not stall energised
valveClosed = true;
}
/* ── MQTT ───────────────────────────────────────────────────── */
void onMessage(char *topic, byte *payload, unsigned int len) {
if (len >= 3 && !strncmp((char *)payload, "ACK", 3)) acknowledged = true;
}
bool netUp() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
if (WiFi.status() != WL_CONNECTED) return false;
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setCallback(onMessage);
if (!mqtt.connect(DEVICE_ID)) return false;
mqtt.subscribe("home/leak/" DEVICE_ID "/cmd");
return true;
}
void publish(const char *event, float batt) {
JsonDocument d;
d["device"] = DEVICE_ID;
d["event"] = event;
d["boots"] = bootCount;
d["leaks"] = leakCount;
d["batt_v"] = roundf(batt * 100) / 100.0f;
d["low_batt"] = batt < 3.4f;
d["valve_closed"] = valveClosed;
char buf[224];
size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish("home/leak/" DEVICE_ID "/state", (uint8_t *)buf, n, true);
}
/* ── sleep ──────────────────────────────────────────────────── */
void sleepNow() {
probeIdle();
esp_sleep_enable_ext0_wakeup(PIN_PROBE_SENSE, 0); // wake on LOW
esp_sleep_enable_timer_wakeup(HEARTBEAT_S * 1000000ULL);
Serial.flush();
esp_deep_sleep_start();
}
/* ── setup runs once per wake; there is no loop() work ──────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_BUZZER, OUTPUT);
pinMode(PIN_LED, OUTPUT);
bootCount++;
esp_sleep_wakeup_cause_t why = esp_sleep_get_wakeup_cause();
float batt = batteryVolts();
Serial.printf("wake=%d boots=%lu batt=%.2f V\n", why, (unsigned long)bootCount, batt);
if (why != ESP_SLEEP_WAKEUP_EXT0) {
// Timer wake or first boot: heartbeat only.
if (netUp()) { publish(bootCount == 1 ? "boot" : "heartbeat", batt); mqtt.loop(); }
delay(200);
sleepNow();
}
/* --- leak path --- */
digitalWrite(PIN_LED, HIGH);
if (!confirmLeak()) {
Serial.println("splash rejected");
digitalWrite(PIN_LED, LOW);
sleepNow();
}
leakCount++;
Serial.println("LEAK CONFIRMED");
bool online = netUp();
if (online) publish("leak", batt);
valveClose(); // sounds the alarm while driving
uint32_t start = millis();
uint32_t lastRepeat = 0;
while (millis() - start < ALERT_MAX_MIN * 60000UL && !acknowledged) {
wailOnce();
if (online) {
mqtt.loop();
if (millis() - lastRepeat > ALERT_REPEAT_MS) {
lastRepeat = millis();
publish("leak", batteryVolts());
}
}
delay(500);
}
if (online) { publish(acknowledged ? "acknowledged" : "alert-timeout", batteryVolts()); mqtt.loop(); }
digitalWrite(PIN_LED, LOW);
sleepNow();
}
void loop() { /* never reached — every wake ends in deep sleep */ }
Configuration & Calibration
Configuration steps
- Choose an RTC-capable GPIO for the probe sense pin. On a classic ESP32 those are 0, 2, 4, 12–15, 25–27 and 32–39. A non-RTC pin will not wake the chip.
- Set
HEARTBEAT_Sto match how quickly you want to notice a dead node. Daily is a reasonable balance; hourly costs about 24× more heartbeat energy and still lasts years. - Calibrate the battery reading: measure the actual pack voltage with a multimeter and adjust the 1.045 correction factor until the reported figure matches.
- Set
ALERT_MAX_MIN. Thirty minutes of siren is enough to wake a household; leaving it indefinite flattens the battery after a false positive. - If you fit the valve, test the close operation manually first and time it. Set
VALVE_DRIVE_MSto about 150 % of the measured travel time.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Measure your water's conductivity
Put a multimeter across the probe electrodes and drip your actual tap water on them. Typical readings are 5–50 kΩ. If yours exceeds 200 kΩ (very soft or filtered water) reduce the pull-up from 1 MΩ to 470 kΩ so the pin is pulled convincingly low.
Verify deep sleep current
Break the battery positive lead and put a multimeter in series on its µA range. You should read 10–20 µA. Anything above 100 µA means something is still powered — usually a permanently connected divider, an LED, or a peripheral module with its own regulator.
Test wake latency
Drop water on the probe and time to the first buzzer chirp. Under one second is expected. Substantially longer means the wake source is misconfigured and the node is waking on the timer instead.
Network Architecture & Connectivity
Communication protocol
The node only joins the network when it has something to say, because association and DHCP cost far more energy than the publish itself. That is a deliberate trade: latency to the phone is two to five seconds rather than instant, and battery life goes from weeks to years.
Alerts are published retained so a dashboard connecting after the event still sees the leak state. They are also republished every thirty seconds until acknowledged, because a single publish into a broker with no active subscriber is not an alert.
| Topic / endpoint | Direction | Payload |
|---|---|---|
home/leak/<device>/state | device → broker (retained) | JSON: event, boots, leaks, batt_v, low_batt, valve_closed |
home/leak/<device>/cmd | broker → device | "ACK" to silence, "VALVE_OPEN" to reopen |
Message contract between the device and the broker.
Mobile app integration
A Node-RED flow subscribed to home/leak/+/state that calls ntfy.sh on event == "leak" gives push notifications with no app development. Set the ntfy priority to urgent so it bypasses do-not-disturb — a flood at 3 a.m. is exactly the case that justifies it.
Add a second automation on a missed heartbeat: if a node has not reported in 36 hours, notify. A silent leak detector is worse than none, because you believe you are covered.
Security considerations
- Use broker authentication. An unauthenticated leak topic lets anyone on the network publish a fake ACK and silence a real alarm.
- Keep the valve control on a separate, mains-powered node rather than the battery sensor, so a flat battery cannot leave the valve half-driven.
- Never make the alarm depend on cloud availability. Local buzzer first, always.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Measure sleep current with a µA meter in series | 10–20 µA. Above 100 µA means something is still drawing power. |
| Bridge the electrodes with a wet finger | The buzzer chirps within about a second, confirming for three seconds before the full alarm. |
| Touch the probe briefly and remove | Confirmation fails, "splash rejected" appears on serial, and the node returns to sleep with no alarm. |
| Pour water on the probe and leave it | Full siren, MQTT alert published, valve drives closed if fitted, and the alert repeats every 30 s. |
| Publish ACK to the command topic | The siren stops, an "acknowledged" state is published, and the node sleeps. |
| Wait for the daily timer wake | A heartbeat with a plausible battery voltage, and no alarm. |
| Read battery voltage against a multimeter | Agreement within about 0.05 V after calibration. |
| Leave the probe wet for a week, then inspect | No visible pitting or discolouration on stainless or gold electrodes. Visible corrosion means the polarity alternation is not working. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Cache the Wi-Fi channel and BSSID in RTC memory. Passing them to
WiFi.begin()skips the scan and typically halves both connection time and the energy each alert costs. - Use a bare ESP32 module rather than a development board for the deployed unit. The dev board's regulator and USB chip alone are a thousand times the module's sleep current.
- Switch the battery divider through a MOSFET. A permanently connected 200 kΩ divider drains more than the sleeping ESP32 does.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- Water and mains electricity together are lethal. Keep this node battery-powered; if you must mains-power a valve controller, put it well above any possible water level and feed it through an RCD.
- A motorised valve that closes the water supply can create water hammer in a long pipe run. Use a slow-acting motorised ball valve, not a fast solenoid, on a mains supply.
- Test the valve manually every six months. A valve that has not moved in two years may be seized when you finally need it.
- Lithium cells vent and burn when abused. Only use protected cells or a proper BMS, never charge below 0 °C, and never leave a charging pack unattended on a wooden desk.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Wipe the electrodes and check for corrosion every three months.
- Replace or recharge the cells when a heartbeat reports below 3.4 V.
- Pour a cup of water on each probe twice a year as a live end-to-end test — including checking that the phone notification actually arrives.
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Log pack voltage. When resting voltage after a full charge drops below about 4.0 V, the cell is near end of life — replace it.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Move to LoRa or Zigbee instead of Wi-Fi. Association energy drops by an order of magnitude and range through concrete improves dramatically.
- Add a capacitive rather than resistive probe, which eliminates the corrosion problem entirely at the cost of a slightly more complex front end.
- Add an ultrasonic or float level sensor so the node reports how fast water is rising, not just that it is present.
- Combine with a flow meter on the main — continuous flow with no fixtures running is a leak inside the wall that a floor probe will never see.
- Add a supercapacitor so the node can send one final "battery dead" message when the cells fail.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- ESP32 Deep Sleep and wake-up sources — ESP-IDF programming guideEspressif
- ESP32 RTC GPIO reference — which pins can wake the chipEspressif
- Faraday's laws of electrolysisWikipedia
- Samsung INR18650-35E cell datasheet — capacity and discharge curvesSamsung SDI
- ntfy — simple HTTP-based push notificationsntfy.sh
- Water conductivity and total dissolved solids — backgroundUSGS