Siddhant Kumar
Project 006 · Smart Home

Water Leak & Flood Detector.

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.

Beginner 5–8 hours 36 min read SensorsAlertsSafety
Jump to source Bill of materials
Water Leak & Flood Detector — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Beginner
Build time
5–8 hours
Indicative cost
₹2,400 (detect only) – ₹5,200 (with valve)
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
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.

An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 module. For a battery build, use a bare module rather than a development board — the board's regulator and USB chip dominate sleep current. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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

SettingHow it is used
Under a washing machine or dishwasherThe 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 traysA tank failure is slow at first — hours of early warning is the difference between a mop and a floor replacement.
Basements and sumpsCombine with a level sensor to distinguish "damp" from "rising fast".
Server rooms and comms cabinetsWater on a raised floor near equipment justifies an immediate power-down automation.
Holiday homesThe classic scenario: nobody present for weeks. Remote alerting plus automatic shutoff is the entire value proposition.
Aquarium and hydroponics overflowA 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

AttributeValue
Difficulty levelBeginner
Estimated completion time5–8 hours
Indicative build cost₹2,400 (detect only) – ₹5,200 (with valve)
Primary disciplineSmart Home
Reference platformESP32 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.

ComponentKey specificationQtyApprox. 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 DAC1₹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 diameter1₹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 discharge2₹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 protection1₹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 grid1₹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 lugs1₹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 PCB3₹270
1 MΩ + 100 kΩ resistors
Probe pull-up and battery divider.
1 % metal film1₹20
12 V motorised ball valve (optional)DN20, 2-wire or 5-wire with feedback, 8 W1₹2,400
Silicone sealant and heat-shrinkNeutral cure1₹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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
Active piezo buzzer 5 V85 dB at 10 cm, 2.3 kHz resonance, 12 mm diameter3–5 VDigital / PWMDatasheet
18650 Li-ion cell 3400 mAh + holder3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge3.0–4.2 VHolder / spot-welded tabsDatasheet
TP4056 Li-ion charger + DW01 protection1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection4.5–5.5 V inmicro-USB / padsDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Active piezo buzzer 5 V3–5 V30Active buzzers make tone on DC; passive ones need a PWM carrier.
TP4056 Li-ion charger + DW01 protection4.5–5.5 V in1000Buy 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
Preferences (NVS) bundledWear-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.

Water Leak & Flood Detector — system block diagramFunctional block diagram of the Water Leak & Flood Detector system. SenseProbe electrodes1 MΩ pull-upBattery dividerADC on GPIO 34Wakeext0 wake~10 µA sleepConfirm 3 sreject splashesAlertLocal buzzerno network neededMQTT publishretry until ackActMotorised valveclose supplyPhone / hubnotificationpin goes lowconfirmed leakoptional
Water Leak & Flood Detector — system block diagram

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.

Water Leak & Flood Detector — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBProbe 1 senseGPIO 33Wake source, 1 MΩpull-upProbe 1 driveGPIO 32Driven only duringmeasurementProbe 2 / 3 senseGPIO 25 / 26Additional zonesBattery dividerGPIO 341 MΩ / 100 kΩ to ADCPiezo buzzerGPIO 27LEDC PWM, 2.3 kHzValve open / closeGPIO 18 / 19Momentary drive, 60s timeoutStatus LEDGPIO 2Flash only, neversteady
Water Leak & Flood Detector — wiring schematic
PeripheralPeripheral pinController pinSignal
Probe 1 senseElectrode AGPIO 33Wake source, 1 MΩ pull-up
Probe 1 driveElectrode BGPIO 32Driven only during measurement
Probe 2 / 3 senseElectrode AGPIO 25 / 26Additional zones
Battery dividerMid-pointGPIO 341 MΩ / 100 kΩ to ADC
Piezo buzzer+GPIO 27LEDC PWM, 2.3 kHz
Valve open / closeRelay IN1 / IN2GPIO 18 / 19Momentary drive, 60 s timeout
Status LEDAnodeGPIO 2Flash 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.
An 18650 lithium-ion cell
An 18650 lithium-ion cell. Two in parallel give roughly 6800 mAh, comfortably more than this node needs for several years. Photograph sourced from Wikimedia Commons — 18650 Li-ion battery.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

Water Leak & Flood Detector — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · sensors and actuatorsDriver layerwifi · pubsub · arduinojson · preferencesApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Water Leak & Flood Detector — architecture stack

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

plainBattery 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

plainProbe 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

plainBattery 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.

Water Leak & Flood Detector — firmware flowchartControl flow through the main program loop. Deep sleep at ~10 µAWake: probe pin low, or 24 htimerTimer wake?send heartbeat + batteryleak pathDrive electrodes, measure 3 sStill wet after 3 s?confirmedsplash — back to sleepSound buzzer immediatelyJoin Wi-Fi, publish alert,close valveRepeat alert untilacknowledged
Water Leak & Flood Detector — firmware flowchart

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.

cppwater-leak-detector.ino
/* ═══════════════════════════════════════════════════════════════
   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 */ }
RTC_DATA_ATTR variablesDeep sleep clears normal RAM but preserves the 8 KB RTC slow memory. Boot count, leak count, drive polarity and valve state all need to survive, and this is the only place they can.
probeWet() polarity alternationEach measurement reverses which electrode is driven and which is sensed. Over many measurements the net charge through the water averages to zero, which is what stops the electrodes dissolving.
pinMode(PIN_PROBE_DRIVE, INPUT) after measuringFloating the drive electrode between measurements means there is no potential across the water at all for most of the time — the single biggest factor in electrode life.
wet * 100 / total >= 80Requiring 80 % rather than 100 % tolerates a probe that intermittently loses contact as water sloshes, while still rejecting a single momentary splash.
digitalWrite(PIN_BATT_EN, LOW) after readingA permanently connected resistor divider draws current forever. Switching its ground through a MOSFET turns a 21 µA constant drain into a few microamp-seconds per day.
Buzzer before Wi-FiDeliberate ordering. Joining Wi-Fi takes two to five seconds and can fail entirely; the local alarm must never wait on it.
digitalWrite(PIN_VALVE_CLOSE, LOW) after 60 sA motorised ball valve that reaches its end stop and stays energised will burn out its motor. The drive must be time-limited, or better, terminated by the valve's own limit feedback.
Everything in setup(), loop() emptyThis is the deep-sleep idiom. Each wake is a fresh boot that runs setup() and then sleeps again, so there is no persistent loop at all.

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_S to 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_MS to 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.

  1. 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.

  2. 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.

  3. 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

Water Leak & Flood Detector — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsUtility roombattery nodeUnder sinkbattery nodeWater heaterbattery nodeWi-Fi, on demand onlyHome routermust be in rangeMQTT 1883Local brokerMosquittoPhone pushntfy / HA appValve nodemains powered
Water Leak & Flood Detector — network topology

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 / endpointDirectionPayload
home/leak/<device>/statedevice → broker (retained)JSON: event, boots, leaks, batt_v, low_batt, valve_closed
home/leak/<device>/cmdbroker → 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.

TestWhat you should see
Measure sleep current with a µA meter in series10–20 µA. Above 100 µA means something is still drawing power.
Bridge the electrodes with a wet fingerThe buzzer chirps within about a second, confirming for three seconds before the full alarm.
Touch the probe briefly and removeConfirmation fails, "splash rejected" appears on serial, and the node returns to sleep with no alarm.
Pour water on the probe and leave itFull siren, MQTT alert published, valve drives closed if fitted, and the alert repeats every 30 s.
Publish ACK to the command topicThe siren stops, an "acknowledged" state is published, and the node sleeps.
Wait for the daily timer wakeA heartbeat with a plausible battery voltage, and no alarm.
Read battery voltage against a multimeterAgreement within about 0.05 V after calibration.
Leave the probe wet for a week, then inspectNo 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

The node never wakes on water

Likely cause. The probe pin is not RTC-capable, or ext0 is configured for the wrong level.

Fix. Use GPIO 32–39, 25–27, 12–15, 4, 2 or 0. Confirm esp_sleep_enable_ext0_wakeup(pin, 0) — the 0 means wake on LOW, which matches a pull-up plus water-to-ground scheme. Print esp_sleep_get_wakeup_cause() at every boot to see what actually woke it.

Sleep current is milliamps, not microamps

Likely cause. A peripheral is still powered, or you are measuring a development board with its onboard regulator and USB-serial chip.

Fix. A typical ESP32 DevKit draws 8–20 mA in deep sleep because of the AMS1117 regulator quiescent current and the CP2102. For a real battery build use a bare ESP32-WROOM module with an efficient LDO, or cut the power LED and regulator from the dev board. Also disconnect any resistor divider that is permanently connected.

False alarms in a humid bathroom

Likely cause. Condensation bridging the electrodes, or too small an electrode gap.

Fix. Increase the gap to 15 mm, raise the confirmation threshold to five seconds, and mount the probe with a 2 mm standoff so a film of condensation cannot bridge it. Coating everything except the electrode faces with conformal coating also helps.

The probe stops detecting after a few weeks

Likely cause. Electrode corrosion or mineral scale.

Fix. Inspect the electrodes. Green or white deposits mean corrosion — replace with stainless or gold-plated, and verify the polarity alternation is running. Scale from hard water can be removed with vinegar; if it recurs quickly, increase the electrode gap and clean quarterly.

The MQTT alert never arrives

Likely cause. Wi-Fi association takes longer than the 10 s allowance, or the broker rejects the connection.

Fix. Print WiFi.status() at every retry. If association is slow, store the channel and BSSID from the last successful connection in RTC memory and pass them to WiFi.begin() — that typically cuts association from 4 s to under 1 s, which also saves battery.

The valve motor buzzes and gets hot

Likely cause. It has reached its end stop and is still energised.

Fix. A motorised ball valve must be de-energised once travel completes. Either use a valve with internal limit switches (most 5-wire types have them and self-terminate), or keep the firmware timeout well matched to the real travel time.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 a millis() 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_t where 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

Why not just poll the sensor every second?

Because it costs about a thousand times more energy. A polled node keeps the CPU and radio alive; an ext0 wake keeps only the RTC domain, at roughly 10 µA. The detection latency is essentially identical — under a second either way — so polling buys nothing and costs the entire battery life.

Will it detect a slow drip?

Only once the drip accumulates enough to bridge the electrodes, which for a floor-mounted probe with a 10 mm gap means a few millilitres pooling under it. Placement therefore matters enormously: put the probe at the lowest point of the floor, not next to the appliance.

Can one node cover a whole room?

No. Water goes where the floor slopes, which is rarely where you would guess. Use several cheap nodes rather than one clever one — the marginal cost of a second probe on the same ESP32 is about ₹90.

Is the valve worth fitting?

If the property is ever empty for more than a day, yes — it is the only part of the system that limits damage rather than just reporting it. If someone is always home within minutes, the alert alone is probably enough.

My dev board draws 15 mA asleep. Is that normal?

Yes, and it is why battery projects should not use dev boards. The AMS1117 regulator has a quiescent current of about 5 mA, the CP2102 USB chip a few more, and the power LED another 3 mA. A bare ESP32-WROOM-32 module with an efficient LDO gets you to 10 µA.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. ESP32 Deep Sleep and wake-up sources — ESP-IDF programming guideEspressif
  2. ESP32 RTC GPIO reference — which pins can wake the chipEspressif
  3. Faraday's laws of electrolysisWikipedia
  4. Samsung INR18650-35E cell datasheet — capacity and discharge curvesSamsung SDI
  5. ntfy — simple HTTP-based push notificationsntfy.sh
  6. Water conductivity and total dissolved solids — backgroundUSGS