IoT Automation

Water Leak Detection with IoT: ESP32 Sensors, MQTT Alarms and an Automatic Shut-Off Valve

Build ESP32 leak sensors that publish retained MQTT alarms, and a valve controller that shuts off the water supply automatically and stays latched until someone resets it.

Water leak detection IoT setup with an ESP32 probe under a sink and a motorized shut-off valve on the main pipe
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

Water leak detection with IoT turns a silent drip under the sink into an alarm on your phone and, better still, a closed valve before the floor is ruined. In this tutorial you will build low-cost ESP32 leak sensors that publish alarms over MQTT, and a separate valve controller that subscribes to every sensor and shuts off the water supply automatically, staying closed until a person resets it.

System Overview

The design deliberately splits detection from action:

  1. Leak sensor nodes sit wherever water can appear: under sinks, behind the washing machine, next to the water heater. Each publishes a retained dry or wet state.
  2. The broker distributes alarms to everyone interested.
  3. A valve controller near the main supply subscribes to all leak topics with one wildcard and closes a motorized ball valve on the first wet.
  4. Your phone, Home Assistant or Node-RED subscribes too, for notifications and history.

Splitting the roles means you can add sensors without touching the valve code, and any number of apps can watch the same alarm. It also means the valve controller must fail safe, which we cover below.

Where to Place Leak Sensors

Leaks start in predictable places. Put sensors at the lowest point near each of these, because water runs downhill and pools before it spreads:

Location Typical cause Placement tip
Under the kitchen sink Loose trap, failed flexible hose At the back of the cabinet floor, under the supply valves
Behind the washing machine Burst inlet hose, blocked drain On the floor directly under the hose connections
Next to the water heater Corroded tank, relief valve discharge Inside the drip pan or at its lowest edge
Dishwasher Door seal, inlet valve Under the kick plate if accessible
Bathroom toilets and vanity units Fill valve, cracked supply line Behind the toilet base, inside the vanity
Basement or utility room Main supply, sump pump failure Near the floor drain, at the lowest corner

Name each sensor by its location rather than a number. When an alarm arrives at 3 a.m., laundry tells you where to run; sensor-4 does not.

Parts List

Part Quantity Purpose
ESP32 DevKit 1 per sensor + 1 for the valve Wi-Fi and MQTT
Two stainless steel screws or a PCB probe 1 pair per sensor Electrodes that water bridges
100 kΩ resistor 1 per sensor Pull-down for the analog reading
Motorized ball valve, 12 V, with end stops 1 Shuts off the main supply
2-channel relay module 1 Drives the valve open/close wires
12 V power supply 1 Valve power
Optional buzzer 1 per sensor Local alarm without network

Ready-made leak probes with two contact pads work just as well as screws through a small plastic box. What matters is that the electrodes sit a millimetre or two above the floor, so a thin film of water bridges them.

Wiring a Leak Sensor

Water conducts poorly compared with a wire, and a constant DC voltage on the electrodes causes corrosion over months. The fix is to power the probe only while measuring:

Connection ESP32 pin
Electrode A GPIO 25 (excite, driven HIGH only when sampling)
Electrode B GPIO 34 (ADC input)
100 kΩ resistor GPIO 34 to GND
Optional buzzer GPIO 26

Dry, the ADC reads near zero. Wet, current flows from GPIO 25 through the water into GPIO 34 and the reading climbs. Tap water typically gives a clear jump; distilled water barely conducts, but real leaks are rarely distilled.

Leak Sensor Sketch for Water Leak Detection IoT

The node samples every two seconds, requires three consecutive wet readings before alarming (to ignore a splash from mopping), and publishes a retained state so new subscribers see it instantly.

#include <WiFi.h>
#include <PubSubClient.h>

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* BROKER = "broker.connectmq.com";
const char* BASE = "connectmq/demo/w5n8c1/home1";   // your own random prefix
const char* LOCATION = "kitchen-sink";
const char* CLIENT_ID = "leak-kitchen-sink-w5n8c1";

const int EXCITE_PIN = 25;
const int SENSE_PIN = 34;
const int BUZZER_PIN = 26;
const int WET_THRESHOLD = 600;      // 0..4095, calibrate with a wet cloth
const int CONFIRM_SAMPLES = 3;

WiFiClient net;
PubSubClient mqtt(net);
char tState[96], tAvail[96], tRaw[96];
bool wet = false;
int wetCount = 0, dryCount = 0;

int readProbe() {
  digitalWrite(EXCITE_PIN, HIGH);
  delay(5);
  int v = analogRead(SENSE_PIN);
  digitalWrite(EXCITE_PIN, LOW);
  return v;
}

void connectMqtt() {
  while (!mqtt.connected()) {
    if (mqtt.connect(CLIENT_ID, nullptr, nullptr, tAvail, 1, true, "offline")) {
      mqtt.publish(tAvail, "online", true);
      mqtt.publish(tState, wet ? "wet" : "dry", true);
    } else {
      delay(3000);
    }
  }
}

void setup() {
  pinMode(EXCITE_PIN, OUTPUT);
  digitalWrite(EXCITE_PIN, LOW);
  pinMode(BUZZER_PIN, OUTPUT);
  snprintf(tState, sizeof tState, "%s/%s/leak/state", BASE, LOCATION);
  snprintf(tAvail, sizeof tAvail, "%s/%s/leak/availability", BASE, LOCATION);
  snprintf(tRaw, sizeof tRaw, "%s/%s/leak/raw", BASE, LOCATION);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  mqtt.setServer(BROKER, 1883);
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  static unsigned long last = 0;
  if (millis() - last < 2000) return;
  last = millis();

  int v = readProbe();
  if (v > WET_THRESHOLD) { wetCount++; dryCount = 0; }
  else { dryCount++; wetCount = 0; }

  if (!wet && wetCount >= CONFIRM_SAMPLES) {
    wet = true;
    mqtt.publish(tState, "wet", true);
  } else if (wet && dryCount >= 15) {   // 30 s dry before clearing
    wet = false;
    mqtt.publish(tState, "dry", true);
  }
  digitalWrite(BUZZER_PIN, wet ? HIGH : LOW);   // local alarm works offline

  static unsigned long lastRaw = 0;
  if (millis() - lastRaw > 60000) {
    lastRaw = millis();
    char buf[8];
    snprintf(buf, sizeof buf, "%d", v);
    mqtt.publish(tRaw, buf);   // useful for calibration and drift
  }
}

The buzzer is driven locally, so a leak is audible even when Wi-Fi is down. That kind of local fallback is a good habit in any safety-related project.

The Valve Controller

The valve node subscribes to <base>/+/leak/state, which matches every sensor in every room. On the first wet it closes the valve and latches: the valve stays closed until someone sends OPEN and no sensor is still reporting wet.

#include <WiFi.h>
#include <PubSubClient.h>
#include <set>
#include <string>

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* BROKER = "broker.connectmq.com";
const char* BASE = "connectmq/demo/w5n8c1/home1";
const char* CLIENT_ID = "valve-main-w5n8c1";

const int RELAY_OPEN = 32;
const int RELAY_CLOSE = 33;
const unsigned long TRAVEL_MS = 8000;   // check your valve's datasheet

WiFiClient net;
PubSubClient mqtt(net);
char tLeakAll[96], tValve[96], tValveSet[96], tAvail[96];
std::set<std::string> wetSensors;
bool closedLatched = false;

void driveValve(bool close) {
  digitalWrite(close ? RELAY_OPEN : RELAY_CLOSE, LOW);
  digitalWrite(close ? RELAY_CLOSE : RELAY_OPEN, HIGH);
  delay(TRAVEL_MS);
  digitalWrite(RELAY_OPEN, LOW);
  digitalWrite(RELAY_CLOSE, LOW);
  mqtt.publish(tValve, close ? "closed" : "open", true);
}

void onMessage(char* topic, byte* payload, unsigned int len) {
  std::string t(topic), p((char*)payload, len);
  if (t == tValveSet) {
    if (p == "CLOSE") { closedLatched = true; driveValve(true); }
    else if (p == "OPEN") {
      if (wetSensors.empty()) { closedLatched = false; driveValve(false); }
      else mqtt.publish(tValve, "closed (sensor still wet)", true);
    }
    return;
  }
  if (p == "wet") {
    wetSensors.insert(t);
    if (!closedLatched) { closedLatched = true; driveValve(true); }
  } else if (p == "dry") {
    wetSensors.erase(t);
  }
}

void connectMqtt() {
  while (!mqtt.connected()) {
    if (mqtt.connect(CLIENT_ID, nullptr, nullptr, tAvail, 1, true, "offline")) {
      mqtt.publish(tAvail, "online", true);
      mqtt.subscribe(tLeakAll, 1);
      mqtt.subscribe(tValveSet, 1);
    } else {
      delay(3000);
    }
  }
}

void setup() {
  pinMode(RELAY_OPEN, OUTPUT);
  pinMode(RELAY_CLOSE, OUTPUT);
  snprintf(tLeakAll, sizeof tLeakAll, "%s/+/leak/state", BASE);
  snprintf(tValve, sizeof tValve, "%s/utility/main-valve/state", BASE);
  snprintf(tValveSet, sizeof tValveSet, "%s/utility/main-valve/state/set", BASE);
  snprintf(tAvail, sizeof tAvail, "%s/utility/main-valve/availability", BASE);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  mqtt.setServer(BROKER, 1883);
  mqtt.setCallback(onMessage);
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();
}

Because sensor states are retained, the valve controller learns about any leak that happened while it was rebooting the moment it resubscribes. That is the main reason to retain alarm states rather than send one-off events.

Testing the System

Watch everything, then simulate a leak with a wet cloth or by publishing manually:

B=connectmq/demo/w5n8c1/home1
mosquitto_sub -h broker.connectmq.com -p 1883 -i mon-w5n8c1 -t "$B/#" -v
mosquitto_pub -h broker.connectmq.com -p 1883 -i sim-w5n8c1 -r -q 1 -t $B/laundry/leak/state -m wet
mosquitto_pub -h broker.connectmq.com -p 1883 -i sim-w5n8c1 -r -q 1 -t $B/laundry/leak/state -m dry
mosquitto_pub -h broker.connectmq.com -p 1883 -i sim-w5n8c1 -q 1 -t $B/utility/main-valve/state/set -m OPEN

After the test, clear the simulated retained topic with an empty retained message (-r -n). The public MQTT test broker is ideal for this bench stage, but its topics are readable by anyone, so do not leave a real valve listening there.

Failure Modes to Plan For

  1. Sensor loses power. Its last will publishes offline. Alert on that too; a dead sensor protects nothing.
  2. Valve controller loses Wi-Fi. It cannot hear alarms. Consider wiring the most critical sensor (water heater) directly to the valve controller's GPIO as a hard-wired backup.
  3. Valve sticks. Ball valves that never move can seize. Exercise the valve monthly with a scheduled CLOSE/OPEN during the day.
  4. False alarms. Calibrate WET_THRESHOLD using the raw topic, and keep the confirmation count.
  5. Mains power cut. A small UPS keeps the router and valve controller alive; battery sensors can use deep sleep with an ext0 wake-up on the probe.

Alert Escalation That People Actually Notice

An alarm nobody sees is not an alarm. A simple escalation ladder works well:

  1. Local buzzer on the sensor, immediately and without any network.
  2. Push notification from Home Assistant, Node-RED or a small Python subscriber the moment a wet state arrives.
  3. Valve closes automatically, and the closed state is pushed as a second notification so you know the supply is off.
  4. Repeat reminders every fifteen minutes while any sensor is wet or the valve is latched.
  5. Offline alerts when a sensor or the valve controller publishes offline, because silence can hide a problem.

Keep notification logic outside the sensors. The devices publish facts; subscribers decide who to tell and how often.

Moving to a Private Broker

For a real installation, create a broker in the portal, then switch both sketches to WiFiClientSecure, port 8883, the ISRG Root X1 CA and your generated YOUR_USERNAME, YOUR_PASSWORD and YOUR_CLIENT_ID. Each device needs its own client ID. In the ConnectMQ portal you can watch every leak topic in the live topic explorer and create threshold alert rules, for example on the raw probe value.

For phone notifications, Home Assistant can subscribe to the same wildcard; our guide to Home Assistant MQTT automations shows the trigger syntax. If you also want to spot hidden leaks from consumption at night, pair this with a water meter over MQTT. The smart home MQTT topic structure guide explains the /set and retained-state conventions used here.

Protect Your Home with ConnectMQ

A leak system has to be online when it matters, which makes a hosted MQTT broker a sensible foundation. ConnectMQ gives you TLS, generated credentials and alert rules in one portal. Create a free Serverless broker at app.connectmq.com, no credit card required, and move your sensors over in minutes. More home projects are collected in the IoT automation guide.

Frequently Asked Questions

Why does the valve stay closed after the floor is dry?

That is deliberate. A leak usually means something is broken, so the valve latches until a person checks and sends OPEN, which only works once every sensor reports dry.

Can I run the leak sensors on batteries?

Yes. Use deep sleep with a periodic wake-up, or wake on the probe through ext0, and connect only when the state changes. Expect to tune the circuit, because the analog probe needs to be powered to detect water.

Why use retained messages for leak alarms?

A retained wet state is delivered to any client the moment it subscribes, so a valve controller or app that restarts during a leak still learns about it immediately.

Will the probe corrode?

Keeping DC on the electrodes constantly speeds corrosion. Powering the probe only for a few milliseconds per sample, as the sketch does, and using stainless steel electrodes keeps it working for a long time.

Related articles

Zigbee2MQTT Setup Guide: From Coordinator to Dashboard

Install Zigbee2MQTT with Docker, configure the coordinator and MQTT connection, pair and rename devices, control them over MQTT and view their states on a dashboard, testing first on a free public broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.