This ESPHome package allows reading your water meter or gas meter using the QMC5883L or HMC5883L or MMC5603, a triple-axis magnetometer.
TLDR; Add this to your ESPHome device configuration:
substitutions:
volume_unit: 'gal'
i2c_scl: GPIO5 # D1
i2c_sda: GPIO4 # D2
# Set to false only if needed during manual calibration.
# Do not keep them at false since these slow down the ESP device
# and reduce the accuracy during high flow.
hide_magnetic_field_strength_sensors: 'true'
hide_half_rotations_total_sensor: 'true'
packages:
meter:
url: https://github.com/tronikos/esphome-magnetometer-water-gas-meter
ref: main
file: esphome-water-meter.yaml
# Or for gas meter:
# file: esphome-gas-meter.yaml
# Or if you are using HMC5883L instead of QMC5883L:
# files: [esphome-water-meter.yaml, hmc5883l.yaml]
# Or if you are using MMC5603 instead of QMC5883L:
# files: [esphome-water-meter.yaml, mmc5603.yaml]
refresh: 0s

The magnetometer is used to read the rotating magnet inside your water meter.
This should be compatible will all the water meters the Flume water sensor is compatible with, which is compatible with about 95% of water meters in the United States.
To verify compatibility follow this. Alternatively, install the Sensors app on your phone, place your phone next to the meter, and see if the Geomagnetic Field sensors are changing while water is running.
Video showing the internals of a water meter.
See Figure 1: Nutating disc operation
"The metering principle, known as positive displacement, is based on the continuous filling and discharging of the measuring chamber. Controlled clearances between the disc and the chamber provide precise measurement of each volume cycle. As the disc nutates, the center spindle rotates a magnet. The movement of the magnet is sensed through the meter wall by a follower magnet or by various sensors. Each revolution of the magnet is equivalent to a fixed volume of fluid, which is converted to any engineering unit of measure for totalization, indication or process control."

The magnetometer is used to read the diaphragm that expands and contracts inside your gas meter.
This should be compatible with all diaphragm/bellows meters which are the most common type of gas meter, seen in almost all residential and small commercial installations.
To verify compatibility install the Sensors app on your phone, place your phone next to the meter, and see if the Geomagnetic Field sensors are changing while gas is running.
Video showing the internals of a gas meter.

- ESP8266 or ESP32 with power adapter
- I placed mine inside the garage
- For high flow meters a dual core ESP32 is strongly preferred
- QMC5883L or HMC5883L or MMC5603 magnetometer
- I placed mine in the water meter box 20ft away from the garage
- Ethernet cable
- I used 32.8ft or 10m direct burial CAT6. A user has reported they successfully used 75ft or 22.9m direct burial CAT6.
- CAT6 is preferred because of its lower capacitance. CAT5 50ft or 15m should work. For 100ft you will need an active terminator such as LTC4311.
- Do not use thermostat wire, bell wire, or any other low voltage wire. You will have communication errors or instability. You really need to be using twisted pair cables with proper shielding and lower capacitance such as CAT6.
- Some way to weather proof the magnetometer. Some options:
- Adhesive 4:1 heat shrink tubing (this is what I used)
- Liquid electrical tape
- Silicone sealant
- Nail polish
- Hot glue
- Some way to mount the magnetometer on the meter. Some options:
- Cable zip tie (this is what I used)
- Duct tape
- Conduit for the ethernet cable. Can be skipped if using direct burial ethernet cable.
QMC5883L | ESP8266 |
---|---|
VCC | 5V |
GND | GND |
SCL | D1 |
SDA | D2 |
The ethernet cable has 4 twisted pairs of wires. Use any solid wire color for the 4 above pins. Tie the 4 white wires together with the GND solid wire. You might need to use a header pin for the GND. If you use a header pin cut the 5 GND wires shorter to avoid the ball of wires I had...
-
Setup ESPHome, if you don't have it already, by following Getting Started with ESPHome and Home Assistant.
-
In the ESPHome Dashboard select New device, Continue, give a name: e.g. Water meter, Next, select device type based on the ESP chip used e.g. ESP8266.
-
In the Configuration created! page select Skip to skip installation for now until we make a few changes.
-
Select Edit on the created configuration e.g. water-meter.yaml.
-
Skip this step if you used an
esp32
. Changeesp8266
section to:esp8266: board: d1_mini restore_from_flash: true preferences: flash_write_interval: 60min
-
Add the following (either at the beginning or the end of the file):
substitutions: # For water one of: CCF, ft³, gal, L, m³ # For gas one of: CCF, ft³, m³ # For better accuracy avoid using large units like CCF and m³. # You can always change the unit later in Home Assistant. volume_unit: 'gal' i2c_scl: GPIO5 # D1 i2c_sda: GPIO4 # D2 # Set to false only if needed during manual calibration. # Do not keep them at false since these slow down the ESP device # and reduce the accuracy during high flow. hide_magnetic_field_strength_sensors: 'true' hide_half_rotations_total_sensor: 'true' packages: meter: url: https://github.com/tronikos/esphome-magnetometer-water-gas-meter ref: main file: esphome-water-meter.yaml # Or for gas meter: # file: esphome-gas-meter.yaml # Or if you are using HMC5883L instead of QMC5883L: # files: [esphome-water-meter.yaml, hmc5883l.yaml] # Or if you are using MMC5603 instead of QMC5883L: # files: [esphome-water-meter.yaml, mmc5603.yaml] refresh: 0s
-
Change the values in the
substitutions
section based on your setting, e.g. if you have used different pins, or if you prefer a different unit. -
Your configuration should now look something like the following:
substitutions: volume_unit: 'gal' i2c_scl: GPIO5 # D1 i2c_sda: GPIO4 # D2 # Set to false only if needed during manual calibration. # Do not keep them at false since these slow down the ESP device # and reduce the accuracy during high flow. hide_magnetic_field_strength_sensors: 'true' hide_half_rotations_total_sensor: 'true' packages: meter: url: https://github.com/tronikos/esphome-magnetometer-water-gas-meter ref: main file: esphome-water-meter.yaml # Or for gas meter: # file: esphome-gas-meter.yaml refresh: 0s esphome: name: water-meter friendly_name: Water meter esp8266: board: d1_mini restore_from_flash: true preferences: flash_write_interval: 60min # Enable logging logger: # Enable Home Assistant API api: encryption: key: "L8408egzTATPCBT1nzvFpqj4YlVERRO31+GyB/yjf4E=" ota: - platform: esphome password: "d44ed9df293facf65e288062d5c7a5e7" wifi: ssid: !secret wifi_ssid password: !secret wifi_password # Enable fallback hotspot (captive portal) in case wifi connection fails ap: ssid: "water-meter Fallback Hotspot" password: "8cSGOshkb2Rw" captive_portal:
-
Select Save and then Install.
-
Only for the first install select Plug into this computer. For subsequent updates/installs you can install Wirelessly.
-
Select Download project to save a bin file.
-
Select Open ESPHome Web, Connect, Install downloaded project.
-
In the Install your existing ESPHome project page select Choose File, select the previously downloaded bin file, and select Install.
-
Home Assistant should auto-discover your new device.
To calibrate these just run a light stream of water/gas and press the "Calibrate axis" button. After 5 seconds (configurable) the proper axis and thresholds should be set. If not, check the device logs. You might have to lower the "Calibration minimal axis range".
Alternatively:
-
Temporarily set
hide_magnetic_field_strength_sensors: 'false'
to show the Magnetic Field Strength X, Y, and Z sensors in HA. -
Run a light stream of water/gas.
-
Observe which axis changes the most and its range.
-
Set the axis and thresholds. e.g. if y axis ranges from min to max use:
Axis = y Threshold lower = min + 0.25 * (max - min) Threshold upper = max - 0.25 * (max - min)
-
Set
hide_magnetic_field_strength_sensors: 'true'
.
This depends on your specific water/gas meter model and its size.
You can search for specifications of your specific water/gas meter and its size.
If you have the Flume water sensor you can use its lowest reported value. You can find it with:
select min(min) from statistics_short_term, statistics_meta where statistics_meta.statistic_id = 'sensor.water_usage_current' and statistics_meta.id = metadata_id and min > 0;
Alternatively:
- Temporarily set
hide_half_rotations_total_sensor: 'false'
to show the "Half rotations total" sensor in HA. - Write it down and also write down the reading on your water/gas meter.
- After a few hours or even days of regular water/gas usage, write down both of them again.
- Set this to the result of: diff of readings in volume_unit divided by diff of half rotations.
- Set
hide_half_rotations_total_sensor: 'true'
.
For water meters this defaults to 0.01008156 gal
which is for my 3/4" Badge Meter Model 35.
For gas meters this defaults to 0.125 ft³
which seems to be the most common in US.
If you have modified the volume_unit
you have to manually convert this value.
Only supported if you are using a QMC5883L. Place another temperature sensor next to the QMC5883L and adjust the temperature offset so that they match.
I'm using the Alert integration to get alerted if there is a leak.
To find what thresholds and durations to use for your own water usage patterns, run this SQL query in the SQLite Web add-on with different flow_threshold
:
-- This query calculates the longest continuous period the water meter was running each day,
-- based on a defined flow rate threshold. It includes special handling for a daily
-- "irrigation" window where the flow rate can be artificially reduced.
WITH variables AS (
-- This is the main configuration block for the query.
-- All user-adjustable parameters are defined here for easy modification.
SELECT
1.5 AS flow_threshold, -- The flow rate (e.g., in GPM or L/min) above which the water is considered "running".
0.3 AS irrigation_flow_reduction, -- The value to subtract from the flow rate during the irrigation window.
'07:00' AS irrigation_start_time, -- The start time of the daily irrigation window (HH:MM).
'10:00' AS irrigation_end_time -- The end time of the daily irrigation window (HH:MM).
),
all_corrected_states AS (
-- Step 1: Get all states for the target sensor and create an "effective_state".
-- This step applies the special logic for the irrigation window.
SELECT
state_id,
old_state_id,
last_updated_ts,
CASE
-- If the state's timestamp falls within the irrigation window, reduce its value.
WHEN STRFTIME('%H:%M', last_updated_ts, 'unixepoch', 'localtime') BETWEEN (SELECT irrigation_start_time FROM variables) AND (SELECT irrigation_end_time FROM variables)
THEN MAX(0, CAST(state AS REAL) - (SELECT irrigation_flow_reduction FROM variables)) -- Subtract the reduction, ensuring it doesn't go below zero.
-- Otherwise, just use the state's normal value.
ELSE CAST(state AS REAL)
END AS effective_state
FROM states
WHERE
-- Filter the states table to only include our specific water meter sensor.
metadata_id = (
SELECT metadata_id FROM states_meta WHERE entity_id = 'sensor.water_meter_flow'
)
),
state_pairs AS (
-- Step 2: Get the current state and the immediately preceding state on the same row.
-- This is done by joining the table to itself using the old_state_id, which links each state to the previous one.
SELECT
current_state.last_updated_ts,
current_state.effective_state AS effective_current_state,
prev_state.effective_state AS effective_prev_state
FROM
all_corrected_states AS current_state
JOIN
all_corrected_states AS prev_state ON current_state.old_state_id = prev_state.state_id
),
run_events AS (
-- Step 3: Analyze the state pairs to identify the exact moments a "run" starts or stops.
-- A "run" is defined by the flow rate crossing the 'flow_threshold' defined in the variables.
SELECT
last_updated_ts,
CASE
-- A "start" event (1) is when the flow crosses *above* the threshold.
WHEN effective_current_state > (SELECT flow_threshold FROM variables) AND effective_prev_state <= (SELECT flow_threshold FROM variables) THEN 1
-- A "stop" event (-1) is when the flow crosses *below* or becomes equal to the threshold.
WHEN effective_current_state <= (SELECT flow_threshold FROM variables) AND effective_prev_state > (SELECT flow_threshold FROM variables) THEN -1
-- Otherwise, it's not a significant event.
ELSE 0
END AS event_type
FROM state_pairs
),
run_periods AS (
-- Step 4: Match up each "start" event with its corresponding "stop" event.
-- This defines a complete, continuous run period.
SELECT
last_updated_ts AS start_time,
-- For every start event, look forward in time to find the timestamp of the very next stop event.
(
SELECT MIN(e2.last_updated_ts)
FROM run_events e2
WHERE e2.last_updated_ts > e1.last_updated_ts AND e2.event_type = -1
) AS end_time
FROM run_events e1
-- We only care about the "start" events to begin our periods.
WHERE e1.event_type = 1
),
daily_ranked_runs AS (
-- Step 5: Calculate the duration of each run and rank them within each day.
SELECT
STRFTIME('%Y-%m-%d', start_time, 'unixepoch', 'localtime') AS run_day,
(end_time - start_time) AS duration_seconds,
start_time,
end_time,
-- The RANK() window function assigns a rank to each run (1 for the longest)
-- within each day (PARTITION BY run_day).
RANK() OVER (
PARTITION BY STRFTIME('%Y-%m-%d', start_time, 'unixepoch', 'localtime')
ORDER BY (end_time - start_time) DESC
) as rank_num
FROM run_periods
-- Ignore any runs that may not have a corresponding stop event (e.g., if the water is still running).
WHERE end_time IS NOT NULL
)
-- Final Step: Select the longest run for each day and format the output for readability.
SELECT
run_day,
duration_seconds / 60 AS duration_minutes,
DATETIME(start_time, 'unixepoch', 'localtime') AS run_start_time,
DATETIME(end_time, 'unixepoch', 'localtime') AS run_end_time
-- Filter for only the top-ranked (longest) run for each day.
FROM daily_ranked_runs
WHERE rank_num = 1
-- Order the results with the most recent day first.
ORDER BY run_day DESC;
In /homeassistant/configuration.yaml
I have:
alert: !include alerts.yaml
template:
- sensor:
- name: Water running for 15 minutes
unique_id: water_running_too_long
device_class: "moisture"
icon: mdi:waves
delay_on:
minutes: 15
# Subtract irrigation system that consumes 0.28 gal/min between 7 to 9 am or 8 to 10 am depending on DST
state: "{{ max(0, states('sensor.water_meter_flow') | float - (0.3 if now().hour in range(7, 10) else 0)) > 0 }}"
availability: "{{ has_value('sensor.water_meter_flow') }}"
- name: Water running for 5 minutes at more than 1.0 gallons per minute
unique_id: water_running_med_flow_too_long
device_class: "moisture"
icon: mdi:waves
delay_on:
minutes: 5
state: "{{ max(0, states('sensor.water_meter_flow') | float - (0.3 if now().hour in range(7, 10) else 0)) > 1.0 }}"
availability: "{{ has_value('sensor.water_meter_flow') }}"
- name: Water running for 3 minutes at more than 1.7 gallons per minute
unique_id: water_running_high_flow_too_long
device_class: "moisture"
icon: mdi:waves
delay_on:
minutes: 3
state: "{{ max(0, states('sensor.water_meter_flow') | float - (0.3 if now().hour in range(7, 10) else 0)) > 1.7 }}"
availability: "{{ has_value('sensor.water_meter_flow') }}"
notify:
- platform: group
name: nikos
services:
- service: persistent_notification
- service: mobile_app_pixel_7a
- service: mobile_app_le2125
- platform: group
name: nikos_mobile
services:
- service: mobile_app_pixel_7a
- service: mobile_app_le2125
- platform: group
name: wife
services:
- service: mobile_app_wife_iphone
- platform: group
name: all
services:
- service: nikos
- service: wife
- service: google_assistant_sdk
- service: alexa_media_garage_ecobee_switch
In Settings > Devices & services > Helpers
I have created a group binary_sensor.water_leak_sensors_group
with the above 2 sensors together with all my water leak sensors.
In Settings > Automations
I have created the following automation to get notified if I ever forget to add a new sensor to the group:
alias: "Notify: incomplete groups"
description: ""
trigger:
- platform: time
at: "10:01:00"
action:
- if:
- condition: template
value_template: "{{ missing_moisture_sensors != '' }}"
then:
- service: notify.nikos
data:
message: |-
binary_sensor.water_leak_sensors_group is missing:
{{missing_moisture_sensors}}
variables:
missing_moisture_sensors: |
{{ states.binary_sensor
| rejectattr('attributes.device_class', 'undefined')
| selectattr('attributes.device_class', '==', 'moisture')
| rejectattr('attributes.entity_id', 'defined')
| map(attribute='entity_id')
| reject('in', states.binary_sensor.water_leak_sensors_group.attributes.entity_id)
| join('\n') }}
mode: single
In /homeassistant/alerts.yaml
I have the following to keep alerting me every 5 minutes in case of a leak:
water_leak:
name: Water leak detected
message: "Water leak detected at {{ expand('binary_sensor.water_leak_sensors_group') | selectattr('state', '==', 'on') | map(attribute='attributes.friendly_name') | join(', ') | lower() | replace(': water leak sensor', '') | replace(' leak sensor moisture', '') }} {{ relative_time(states.binary_sensor.water_leak_sensors_group.last_changed) }} ago"
done_message: Water leak not detected anymore
entity_id: binary_sensor.water_leak_sensors_group
state: "on"
repeat: 5
can_acknowledge: true
skip_first: false
notifiers:
- all
data:
push:
sound:
name: "default"
critical: 1
volume: 1.0
ttl: 0
priority: high
media_stream: alarm_stream_max
water_leak_tts:
name: Water leak detected (TTS)
message: TTS
done_message: Water leak not detected anymore
entity_id: binary_sensor.water_leak_sensors_group
state: "on"
repeat: 5
can_acknowledge: true
skip_first: false
notifiers:
- nikos_mobile
data:
ttl: 0
priority: high
media_stream: alarm_stream_max
tts_text: "Water leak detected"
In my main dashboard I have the following auto-entities card, which is typically hidden when empty:
type: custom:auto-entities
show_empty: false
card:
title: Active Alerts
type: entities
state_color: true
filter:
include:
- domain: alert
not:
state: idle
- domain: binary_sensor
attributes:
device_class: moisture
not:
state: 'off'
In Settings > Devices & services > Helpers
I have created a Utility Meter sensor.water_meter_daily_total
to keep track of my daily water usage.
In Settings > Automations
I have created the following automation to get notified if my daily water usage is abnormal:
alias: "Notify: water usage"
description: ""
trigger:
- platform: time
at: "23:59:00"
condition: []
action:
- if:
- condition: numeric_state
entity_id: sensor.water_meter_daily_total
above: 100
then:
- service: notify.nikos
metadata: {}
data:
title: High daily water usage
message: >-
Consumed {{ states('sensor.water_meter_daily_total') }} gal today.
Is there a leak?
- if:
- condition: numeric_state
entity_id: sensor.water_meter_daily_total
below: 10
then:
- service: notify.nikos
metadata: {}
data:
title: Low daily water usage
message: >-
Consumed {{ states('sensor.water_meter_daily_total') }} gal today.
Do you need to reposition or recalibrate the sensor?
mode: single