Catching an RO Leak with Home Assistant
A few weeks ago I opened our water bill and noticed our usage had spiked. Normally our house uses around 330 to 350 gallons a day (GPD). But looking at the bill for August and September, we were averaging over 510 GPD.
That meant roughly 180 extra gallons every single day—about 7.5 gallons every hour—was going somewhere.
The strange thing was that there were no signs of a leak anywhere in the house:
- No wet spots on the ceiling or drywall
- No puddles under any sinks
- No hissing toilet flappers
- The water softener was not stuck in a regeneration cycle
- Outdoor hose bibbs were completely dry
Here is how I tracked down where the water was going, why software-defined radio didn't work for reading our meter, and how I built a Python pipeline and Home Assistant sensor to catch it using hourly plumbing "rest hours."
Why RTL-SDR Didn't Work
My first thought was to grab my RTL-SDR USB dongle and use rtlamr to read the water meter wirelessly, similar to how many people read their gas or electric meters.
I opened the iron meter pit cover by the curb to check the model:
- Register: Neptune E-Coder solid-state register with an LCD display.
- Transceiver: Sensus SmartPoint 510M.
- Protocol: Sensus FlexNet.
Unfortunately, rtlamr and rtl_433 only work with unencrypted "bubble-up" broadcast meters (like older Itron ERT meters) that broadcast their readings in the clear every few seconds.
Sensus FlexNet operates on a licensed 900–960 MHz frequency and uses two-way encrypted cellular/mesh communication back to the utility's tower. So sniffing packets with an SDR dongle wasn't going to work.
However, looking at the Neptune LCD screen gave me a big clue. The display wakes up if you shine a flashlight onto its optical sensor. In the top corner of the LCD, there is a small faucet icon:
- If the faucet icon is flashing, it means water was flowing intermittently.
- If the faucet icon is solid, it means water has been flowing continuously for all 96 15-minute intervals in the last 24 hours.
The icon was solid. Water had not stopped running for even a single minute.
Getting the Data into Home Assistant
Since I couldn't read the meter directly over radio, I turned to the utility portal (mywater.veolia.us). The portal displays hourly consumption charts, but scraping it automatically had a few challenges:
- Cloudflare Turnstile: Blocks simple Python
requestsorcurl. - Session timeouts: The Drupal session expires after a short period of inactivity.
- Mandatory 2FA: Every new login requires a 6-digit one-time passcode sent to email.
To solve this, I split the automation into three Python components running on a low-power Dell Wyse 5070 thin client:
gmail_otp.py: Listens for the 2FA code email over IMAP SSL and extracts the 6-digit code.veolia_auto_login.py: Uses SeleniumBase in Undetected Chrome (UC) mode to pass Cloudflare, enter credentials, and complete the 2FA prompt.veolia_sync.py: Fetches the hourly CSV using the authenticated session and writes directly to Home Assistant's MariaDB Long-Term Statistics (LTS).
1. Grabbing the 2FA Email via IMAP (gmail_otp.py)
When logging in, the portal sends a 6-digit code to email. Rather than dealing with full OAuth API setup, we can connect directly to Gmail using an App Password over IMAP SSL, poll the inbox, and pull the code using regex:
import imaplib
import email
import re
import time
def fetch_veolia_otp(username, app_password, timeout_seconds=45):
"""Polls Gmail inbox via IMAP for the newest Veolia verification code."""
mail = imaplib.IMAP4_SSL("imap.gmail.com", 993)
mail.login(username, app_password)
start_time = time.time()
try:
while time.time() - start_time < timeout_seconds:
mail.select("inbox")
status, messages = mail.search(None, '(OR (FROM "veolia") (BODY "veolia"))')
if status == "OK" and messages[0]:
msg_ids = messages[0].split()
# Check recent messages in reverse order (newest first)
for mid in reversed(msg_ids[-5:]):
res, data = mail.fetch(mid, "(RFC822)")
if res != "OK":
continue
msg = email.message_from_bytes(data[0][1])
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body += part.get_payload(decode=True).decode(errors="replace")
else:
body = msg.get_payload(decode=True).decode(errors="replace")
# Look for 6-digit verification passcode
codes = re.findall(r'\b(\d{6})\b', body)
if codes:
mail.store(mid, '+FLAGS', '\\Seen')
return codes[0]
time.sleep(2)
return None
finally:
mail.close()
mail.logout()
2. Headless Cloudflare Login (veolia_auto_login.py)
To run headless on Linux without getting blocked by Cloudflare Turnstile, we run seleniumbase in UC mode inside xvfb. Once it submits the login form and hits the 2FA page, it calls fetch_veolia_otp(), types the code, and saves the session cookies to cookies.json:
import json
import time
from seleniumbase import Driver
from gmail_otp import fetch_veolia_otp
def run_login(veolia_user, veolia_pass, gmail_user, gmail_pass):
# Initialize Undetected Chrome
driver = Driver(uc=True)
try:
driver.uc_open_with_reconnect("https://mywater.veolia.us/user/login", 6)
# Enter credentials
driver.type("#edit-name", veolia_user)
driver.type("#edit-pass", veolia_pass)
driver.js_click("#edit-submit")
time.sleep(5)
# Check for 2FA screen
if "/tfa/" in driver.current_url or "one-time passcode" in driver.page_source.lower():
# Trigger 'Send code' button if presented
send_btn = driver.find_elements("xpath", "//input[contains(@value, 'Send code')]")
if send_btn:
driver.execute_script("arguments[0].click();", send_btn[0])
# Fetch the OTP from Gmail
otp_code = fetch_veolia_otp(gmail_user, gmail_pass)
if not otp_code:
raise Exception("Failed to retrieve OTP from email.")
# Enter OTP code and submit
driver.type("input[name*='code']", otp_code)
driver.js_click("#edit-submit")
time.sleep(5)
# Export cookies for fast HTTP syncing
cookies = driver.get_cookies()
with open("cookies.json", "w") as f:
json.dump(cookies, f)
print("Login successful. Cookies saved.")
return True
finally:
driver.quit()
3. Direct MariaDB Long-Term Statistics Ingestion (veolia_sync.py)
Once cookies.json is saved, we don't need a heavy browser for everyday syncing. A standard HTTP GET request downloads the rolling hourly CSV.
To make the data show up cleanly in Home Assistant's native Energy & Water dashboard, we insert the hourly rows directly into Home Assistant's MariaDB statistics table:
import pymysql
def insert_ha_statistics(hourly_records, db_conf, metadata_id=981, start_cum=127000.0):
conn = pymysql.connect(
host=db_conf["host"],
port=db_conf["port"],
user=db_conf["user"],
password=db_conf["password"],
database=db_conf["database"],
autocommit=True,
)
cur = conn.cursor()
current_sum = start_cum
for rec in hourly_records:
dt = rec["datetime"]
usage = rec["usage"]
current_sum = round(current_sum + usage, 2)
start_ts = int(dt.timestamp())
created_ts = start_ts + 3600
# Insert or update hourly statistic row
sql = """
INSERT INTO statistics (state, sum, metadata_id, created_ts, start_ts)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE state = VALUES(state), sum = VALUES(sum)
"""
cur.execute(sql, (current_sum, current_sum, metadata_id, created_ts, start_ts))
# Keep short_term stats in sync to prevent artificial spikes in HA recorder
cur.execute(
"UPDATE statistics_short_term SET state = %s, sum = %s WHERE metadata_id = %s",
(current_sum, current_sum, metadata_id)
)
conn.close()
The Smoking Gun: Only 2 Hours of Rest in 30 Days
Once the hourly data was in MariaDB, the problem became obvious.
How the Meter Ticks
Water meters don't measure individual small cups or hand washes. The Neptune register logs usage in whole cubic feet:
1 cubic foot = 7.4805 gallons
When you wash your hands or flush a toilet, it adds to an internal accumulator. Once that total crosses the next cubic foot, the meter records a 7.48-gallon tick for that hour.
Checking the "Rest Hours"
In a normal home, between 1:00 AM and 5:00 AM, everyone is asleep. Water usage during those hours should be 0.00 gallons.
I ran a query on September's hourly data (720 total hours):
SELECT COUNT(*) FROM statistics
WHERE metadata_id = 981
AND state = 0
AND start >= '2026-09-01' AND start < '2026-10-01';
Out of 720 hours in September, only 2 hours had zero water usage.
That meant 99.7% of the time, water was actively flowing.
And if you do the math:
180 gallons / day ÷ 24 hours = 7.5 gallons / hour
7.5 gallons per hour is almost exactly one cubic foot (7.48 gal) every hour. The leak was ticking the meter like clockwork once an hour, 24 hours a day.
Finding the Leak: The Reverse Osmosis Trap
Because 7.5 gallons per hour is only about 0.12 gallons per minute, you won't hear water gushing through pipes. And since there were no puddles on the floor, the water had to be flowing straight into a drain.
I checked our under-sink 5-stage Reverse Osmosis (RO) drinking water filter.
Under-sink RO systems have an Auto Shut-Off Valve (ASOV) and a pressurized storage tank with an internal rubber bladder:
[ Cold Water Line ] ───► [ ASOV Valve ] ───► [ RO Membrane ] ───► [ Tank & Faucet ]
▲ │
│ (Backpressure shuts off feed) ▼
└─────────────────────────────────── [ Brine to Drain ]
- As pure water fills the storage tank, the air bladder inside compresses.
- When the tank pressure reaches around 60% of the incoming line pressure (usually around 35–40 PSI), the backpressure pushes a diaphragm inside the ASOV, shutting off the cold water supply.
- While the system is actively making water, it flushes reject "brine" water down the sink drain line at a roughly 3:1 or 4:1 ratio.
I unscrewed the cap on the RO tank's Schrader air valve and pressed the valve pin.
Water shot out of the air valve.
The internal rubber bladder inside the tank had ruptured. Because the air chamber was filled with water instead of air, the tank could never build up enough pressure to trigger the ASOV shut-off valve.
As a result, the ASOV stayed wide open 24/7. The RO unit was constantly running, dumping filtered water and brine directly down the drain pipe through the drain saddle—wasting 180 gallons every single day without making a sound.
The Fix and Verification
On October 6 around 7:00 PM, I closed the feed valve to the RO filter.
The next morning I checked the hourly readings in Home Assistant:
Time Usage
2026-10-06 23:00 7.48 gal
2026-10-07 00:00 7.48 gal
2026-10-07 01:00 0.00 gal (Rest)
2026-10-07 02:00 0.00 gal (Rest)
2026-10-07 03:00 0.00 gal (Rest)
2026-10-07 04:00 0.00 gal (Rest)
2026-10-07 05:00 7.48 gal (Morning routine)
For the first time in nearly two months, the meter registered 4 consecutive hours of 0.00 gallons.
Daily usage immediately dropped from ~510 GPD back to ~310 GPD. Replacing the RO storage tank and the shut-off valve completely fixed the issue.
Building an Automated Leak Alert in Home Assistant
To make sure this doesn't happen again, I added a leak detection rule into veolia_sync.py that publishes to Home Assistant over MQTT.
Instead of setting an alert on total daily usage (which fluctuates if you do laundry or have guests), the script checks for rest hours:
# 1. Count how many hours had 0 usage today
zero_hours = sum(1 for rec in hourly_records if rec["usage"] == 0.0)
# 2. Check the overnight average (1:00 AM to 5:00 AM)
night_hours = [rec["usage"] for rec in hourly_records if 1 <= rec["datetime"].hour <= 5]
night_avg = sum(night_hours) / len(night_hours) if night_hours else 0.0
# 3. Trigger alert if there was no rest or night baseline is high
leak_detected = (zero_hours < 1) or (night_avg > 4.0)
Through MQTT Discovery, Home Assistant automatically creates:
sensor.veolia_zero_usage_hours(tracks idle hours)sensor.veolia_nighttime_flow(overnight baseline flow)binary_sensor.veolia_continuous_leak_alert(alert entity withdevice_class: problem)
Here is how the MQTT Discovery configuration payload is published so Home Assistant auto-discovers the binary sensor without writing YAML:
import json
import paho.mqtt.client as mqtt
def publish_mqtt_discovery(client, prefix="homeassistant", account_id="water_meter"):
discovery_topic = f"{prefix}/binary_sensor/{account_id}_leak/config"
payload = {
"name": "Water Meter Continuous Leak Alert",
"unique_id": f"{account_id}_leak_alert",
"device_class": "problem",
"state_topic": f"{prefix}/sensor/{account_id}/state",
"value_template": "{{ 'ON' if value_json.leak_detected else 'OFF' }}",
"icon": "mdi:pipe-leak"
}
client.publish(discovery_topic, json.dumps(payload), retain=True)
If a full 24-hour day passes without at least 1 hour of zero usage, Home Assistant flags the problem and sends an actionable mobile notification.
Summary & Things to Check
If your water bill spikes unexpectedly:
- Look at plumbing rest hours, not just total gallons. In almost any home, water usage should drop to zero for at least an hour or two overnight. If your meter never stops ticking, you have a leak.
- Check appliances that connect directly to drains. Things like Reverse Osmosis systems, water softeners, and furnace humidifiers drain straight into sewer lines. A failure there will never cause a puddle on the floor.
- Test your RO tank: Unscrew the cap on the tank's air valve and press the pin lightly. If even a drop of water comes out, the internal bladder is torn and the tank needs to be replaced.