DIY Well Water Tank Monitor

Growing up off the grid, we’d run out of water yearly, and the recharge time of the well slowed more and more as the dry season progressed. Sometimes we’d run out of water from normal use, but many times it was because there was a leak in the complex system of irrigation and household water and the tank would drain overnight.
My grandparents have checked the water level religiously, by going out and knocking on the side of the tank multiple times per day. This doesn’t work when they’re asleep or not home. Further, the periodic trip to the tank is getting more difficult as they get older.
I wanted to build a real-time water level sensor for them that I could hook up in Home Assistant and it would be accessible via their phone and tablets that I placed around the house. And that could alert them if water level was low or falling too quickly.
After many attempts over the years I finally built something that works: a submersible pressure sensor hanging at the bottom of the tank, wired to an ESP32 that sends readings over WiFi to a small server in my grandmother’s sewing room running homeassistant. Total parts cost was around $100.
Table of Contents
- Table of Contents
- Parts
- How It Works
- Wiring
- ESPHome
- Home Assistant
- Dashboard
- Alerts
- Mounting
- Some notes and resources
Parts
| Item | Notes | |
|---|---|---|
| DFRobot KIT0139 (GL-136 sensor + SEN0262 converter) | ![]() | Submersible, 0–5m range, 4–20mA, 316L stainless steel, 5m cable |
| SEN0262 current-to-voltage converter | ![]() | Included in KIT0139. Converts the 4–20mA loop to a 0–3.3V analog signal. |
| ESP32 DevKit V1 (38-pin DOIT style) | ![]() | Any ESP32 dev board works. |
| LM2596 buck converter | ![]() | Steps 12V down to 5V. Set the output with a multimeter before wiring it to the ESP32. |
| 12V 2A wall adapter | One power source for the whole system. | |
| a small server (raspberry pi) running homeassistant | Hosts ESPHome and Home Assistant. See this example tutorial. | |
| Jumper wires (female-to-male) | For connecting the SEN0262, ESP32, and LM2596. |
A note on sensor range: the KIT0139 is rated 0–5m. An 11.5ft tank uses about 70% of that range, which is plenty of headroom. Don’t substitute a 0–1m or 0–2m sensor. The reading will saturate before the tank is full.
How It Works
The GL-136 uses a 4–20mA current loop. With the sensor in air it draws 4mA, and at its maximum depth of 5 meters of water it draws 20mA. The SEN0262 converter sits in the loop, measures that current across a 120Ω sense resistor, and outputs a proportional voltage. In practice that’s roughly 0.5V with the sensor in air and about 2V at the depth of our tank. The wiring diagram below shows how all of this connects.
ESPHome’s job is minimal. It reads the raw ADC voltage and publishes it. Home Assistant handles all the calibration math. You tell HA what voltage you see when the tank is empty and when it’s full, and it calculates level, percentage, and volume from that. This way calibration is a UI adjustment rather than a firmware reflash.
Note: This was a long, iterative project outside my usual expertise, so there’s a chance my description here isn’t exactly how it ended up. The YAML is exactly what I used, but my description of the wiring may be a bit off — I’ll double-check the next time I’m at my grandparents’. That said, this gets just about all of it, and I believe any trouble should be easy enough to resolve.
Wiring
Because I deviated from the manufacturers wiring so that I could power the ESP32 and GL-136 sensor with one power supply, I had to create a bespoke wiring diagram. This part was out of my comfort zone, so I relied on some help from LLMs, which in general I’m hesitant to do outside of my area of expertise. But it worked, and I suppose that’s the best verification I could get.
12V side (current loop)
At the SEN0262 green screw terminal:
- 12V adapter positive → green terminal + (top screw)
- Green terminal - (bottom screw) → sensor red wire
- Sensor black wire → 12V adapter negative directly (bypasses SEN0262)
The current path: 12V+ → terminal + → terminal - → sensor red → through sensor → sensor black → 12V-
ESP32 power (LM2596)
Tap from the same 12V supply to the LM2596. Set its output to 5V with a multimeter before wiring it to the ESP32.
- 12V positive tap → LM2596 IN+
- 12V negative tap → LM2596 IN-
- LM2596 OUT+ (5V) → ESP32 VIN
- LM2596 OUT- → ESP32 GND
Signal side (SEN0262 → ESP32)
The SEN0262 has a 3-wire JST output connector. Use female-to-male jumper wires:
- SEN0262 red → ESP32 3V3
- SEN0262 gray → ESP32 GND (shared with LM2596 OUT-)
- SEN0262 blue → ESP32 VP (GPIO36, analog input)
A note on output voltage: the SEN0262 V1.0 board has no voltage jumper. The output range is set by whatever you feed the VCC pin on the Gravity connector. Since we wire the red wire to ESP32 3V3, the signal output scales to the 0–3.3V range automatically. No configuration needed, but it does mean the ESP32’s 3.3V rail needs to be stable.
ESPHome
Install ESPHome as a Home Assistant add-on (Settings → Add-ons → Add-on Store → ESPHome), or via pip for first-time flashing from a laptop:
pip install esphome
The config is intentionally minimal. ESPHome only publishes the raw voltage and WiFi signal strength. All the level math lives in Home Assistant where it’s easy to tune.
The !secret references pull from a secrets.yaml file in the same directory:
secrets.yaml:
wifi_ssid: "YourNetworkName"
wifi_password: "YourWiFiPassword"
api_encryption_key: "base64-encoded-32-byte-key"
ota_password: "something-secret"
fallback_ap_password: "something-secret"
Generate the API encryption key with:
python3 -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
View water-tank-level-monitor.yaml (click to expand)
esphome:
name: tank-level-monitor
friendly_name: "Water Tank Level Monitor"
esp32:
board: esp32dev
logger:
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Tank-Monitor-Fallback"
password: !secret fallback_ap_password
captive_portal:
sensor:
- platform: adc
pin: GPIO36
id: tank_voltage
name: "Tank Sensor Voltage"
attenuation: 11db
update_interval: 10s
accuracy_decimals: 3
unit_of_measurement: "V"
filters:
- sliding_window_moving_average:
window_size: 6
send_every: 6
internal: false
- platform: wifi_signal
name: "Tank Monitor WiFi Signal"
update_interval: 60s
Connect the ESP32 over USB and flash:
esphome run water-tank-level-monitor.yaml
You should see it flash properly in the terminal and start reporting voltage values for the sensor. After the first flash you can do all future updates wirelessly from the ESPHome dashboard in Home Assistant.
Home Assistant
Once the device shows up under Settings → Devices & Services → ESPHome, it exposes the raw voltage sensor. The rest of the logic lives in a package file.
Add the package reference to configuration.yaml:
homeassistant:
packages:
water_tank: !include packages/water_tank.yaml
Then create config/packages/water_tank.yaml. This file does a few things worth understanding:
input_numberhelpers for calibration. The empty voltage, full voltage, tank height, and capacity are all adjustable from the HA dashboard, no reflash needed.- Template sensors that override the ESPHome voltage reading with calibrated level, percentage, and volume values.
- A derivative sensor that calculates instantaneous drain rate in gal/h.
- An integration sensor that only accumulates drain events. When the tank is filling, the rate is clamped to zero, so the “water used” counter never decreases during a refill. This means “used last 12hr” reflects actual consumption regardless of how many times the pump ran.
- Statistics sensors for rolling 10-minute, 1-hour, and 12-hour consumption windows.
Here’s the full water_tank.yaml:
View water_tank.yaml (click to expand)
# ============================================================================
# Water tank: calibration, level/volume templates, net change, usage (refill-safe)
# ============================================================================
input_number:
tank_calibration_voltage_empty:
name: Tank calibration voltage (empty)
min: 0
max: 3.5
step: 0.001
mode: box
unit_of_measurement: "V"
initial: 0.718
tank_calibration_voltage_full:
name: Tank calibration voltage (full)
min: 0
max: 3.5
step: 0.001
mode: box
unit_of_measurement: "V"
initial: 1.993
tank_height_ft:
name: Tank height
min: 1
max: 50
step: 0.1
mode: box
unit_of_measurement: "ft"
initial: 11.5
tank_capacity_gal:
name: Tank capacity
min: 100
max: 100000
step: 1
mode: box
unit_of_measurement: "gal"
initial: 5184
sensor:
- platform: statistics
name: "Tank Volume Stats 1h"
entity_id: sensor.water_tank_level_monitor_tank_volume
state_characteristic: average_linear
max_age:
hours: 1
- platform: statistics
name: "Tank Volume Stats 12h"
entity_id: sensor.water_tank_level_monitor_tank_volume
state_characteristic: average_linear
max_age:
hours: 12
- platform: statistics
name: "Tank Volume Stats 24h"
entity_id: sensor.water_tank_level_monitor_tank_volume
state_characteristic: average_linear
max_age:
hours: 24
# Instantaneous rate of volume change (gal/h), smoothed over 10 min
- platform: derivative
name: "Tank Volume Rate"
source: sensor.water_tank_level_monitor_tank_volume
unit_time: h
time_window: "00:10:00"
# Cumulative consumption: integrates only drain rate, never decreases on refill
- platform: integration
name: "Water Used Cumulative"
source: sensor.water_consumption_rate
unit_time: h
method: trapezoidal
- platform: statistics
name: "Water Used Stats 10m"
entity_id: sensor.water_used_cumulative
state_characteristic: change
max_age:
minutes: 10
- platform: statistics
name: "Water Used Stats 1h"
entity_id: sensor.water_used_cumulative
state_characteristic: change
max_age:
hours: 1
- platform: statistics
name: "Water Used Stats 12h"
entity_id: sensor.water_used_cumulative
state_characteristic: change
max_age:
hours: 12
template:
- sensor:
- name: "Water Tank Level Monitor Water Level"
unique_id: ha_tank_water_level_calibrated
default_entity_id: sensor.water_tank_level_monitor_water_level
unit_of_measurement: "ft"
state_class: measurement
icon: mdi:water
availability: >
{{ states('sensor.water_tank_level_monitor_tank_sensor_voltage') not in ['unknown', 'unavailable', 'none']
and (states('input_number.tank_calibration_voltage_full') | float(0)
- states('input_number.tank_calibration_voltage_empty') | float(0)) > 0.001 }}
state: >
{% set v = states('sensor.water_tank_level_monitor_tank_sensor_voltage') | float %}
{% set v0 = states('input_number.tank_calibration_voltage_empty') | float(0.66) %}
{% set v1 = states('input_number.tank_calibration_voltage_full') | float(1.982) %}
{% set h = states('input_number.tank_height_ft') | float(11) %}
{% set span = v1 - v0 %}
{% set raw = (v - v0) / span * h %}
{% if raw < 0 %}{{ 0.0 | round(1) }}
{% elif raw > h %}{{ h | round(1) }}
{% else %}{{ raw | round(1) }}
{% endif %}
- name: "Water Tank Level Monitor Tank Percentage"
unique_id: ha_tank_percentage_calibrated
default_entity_id: sensor.water_tank_level_monitor_tank_percentage
unit_of_measurement: "%"
state_class: measurement
icon: mdi:percent
availability: >
{{ states('sensor.water_tank_level_monitor_tank_sensor_voltage') not in ['unknown', 'unavailable', 'none']
and (states('input_number.tank_calibration_voltage_full') | float(0)
- states('input_number.tank_calibration_voltage_empty') | float(0)) > 0.001 }}
state: >
{% set v = states('sensor.water_tank_level_monitor_tank_sensor_voltage') | float %}
{% set v0 = states('input_number.tank_calibration_voltage_empty') | float(0.66) %}
{% set v1 = states('input_number.tank_calibration_voltage_full') | float(1.982) %}
{% set h = states('input_number.tank_height_ft') | float(11) %}
{% set span = v1 - v0 %}
{% set raw = (v - v0) / span * h %}
{% set level = 0 if raw < 0 else (h if raw > h else raw) %}
{% if h < 0.001 %}0{% else %}{{ ((level / h) * 100) | round(0) }}{% endif %}
- name: "Water Tank Level Monitor Tank Volume"
unique_id: ha_tank_volume_calibrated
default_entity_id: sensor.water_tank_level_monitor_tank_volume
unit_of_measurement: "gal"
state_class: measurement
icon: mdi:water-pump
availability: >
{{ states('sensor.water_tank_level_monitor_tank_sensor_voltage') not in ['unknown', 'unavailable', 'none']
and (states('input_number.tank_calibration_voltage_full') | float(0)
- states('input_number.tank_calibration_voltage_empty') | float(0)) > 0.001 }}
state: >
{% set v = states('sensor.water_tank_level_monitor_tank_sensor_voltage') | float %}
{% set v0 = states('input_number.tank_calibration_voltage_empty') | float(0.66) %}
{% set v1 = states('input_number.tank_calibration_voltage_full') | float(1.982) %}
{% set h = states('input_number.tank_height_ft') | float(11) %}
{% set cap = states('input_number.tank_capacity_gal') | float(5000) %}
{% set span = v1 - v0 %}
{% set raw = (v - v0) / span * h %}
{% set level = 0 if raw < 0 else (h if raw > h else raw) %}
{% if h < 0.001 %}0{% else %}
{% set vol = (level / h) * cap %}
{% if vol < 0 %}0{% elif vol > cap %}{{ cap | round(0) }}{% else %}{{ vol | round(0) }}{% endif %}
{% endif %}
- name: "Tank Volume Change 1h"
unique_id: tank_volume_change_1h
unit_of_measurement: "gal"
state: >
{% set current = states('sensor.water_tank_level_monitor_tank_volume') | float(0) %}
{% set avg = state_attr('sensor.tank_volume_stats_1h', 'value') %}
{% if avg is not none %}
{{ (current - (avg | float(current))) | round(0) }}
{% else %}
0
{% endif %}
- name: "Tank Volume Change 12h"
unique_id: tank_volume_change_12h
unit_of_measurement: "gal"
state: >
{% set current = states('sensor.water_tank_level_monitor_tank_volume') | float(0) %}
{% set avg = state_attr('sensor.tank_volume_stats_12h', 'value') %}
{% if avg is not none %}
{{ (current - (avg | float(current))) | round(0) }}
{% else %}
0
{% endif %}
- name: "Tank Volume Change 24h"
unique_id: tank_volume_change_24h
unit_of_measurement: "gal"
state: >
{% set current = states('sensor.water_tank_level_monitor_tank_volume') | float(0) %}
{% set avg = state_attr('sensor.tank_volume_stats_24h', 'value') %}
{% if avg is not none %}
{{ (current - (avg | float(current))) | round(0) }}
{% else %}
0
{% endif %}
# Consumption rate: positive gal/h when draining, 0 during refills
- name: "Water Consumption Rate"
unique_id: water_consumption_rate
unit_of_measurement: "gal/h"
state_class: measurement
icon: mdi:water-minus
state: >
{% set rate = states('sensor.tank_volume_rate') | float(0) %}
{{ [0, -rate] | max | round(2) }}
- name: "Water Used 10m"
unique_id: water_used_display_10m
default_entity_id: sensor.water_used_10m
unit_of_measurement: "gal"
state_class: measurement
icon: mdi:water-minus
availability: "{{ states('sensor.water_used_stats_10m') not in ['unknown', 'unavailable', 'none'] }}"
state: "{{ states('sensor.water_used_stats_10m') | float(0) | round(0) }}"
- name: "Water Used 1h"
unique_id: water_used_display_1h
default_entity_id: sensor.water_used_1h
unit_of_measurement: "gal"
state_class: measurement
icon: mdi:water-minus
availability: "{{ states('sensor.water_used_stats_1h') not in ['unknown', 'unavailable', 'none'] }}"
state: "{{ states('sensor.water_used_stats_1h') | float(0) | round(0) }}"
- name: "Water Used 12h"
unique_id: water_used_display_12h
default_entity_id: sensor.water_used_12h
unit_of_measurement: "gal"
state_class: measurement
icon: mdi:water-minus
availability: "{{ states('sensor.water_used_stats_12h') not in ['unknown', 'unavailable', 'none'] }}"
state: "{{ states('sensor.water_used_stats_12h') | float(0) | round(0) }}"
- binary_sensor:
- name: "Water Tank Level Monitor Tank Low Water Alert"
unique_id: ha_tank_low_water_calibrated
default_entity_id: binary_sensor.water_tank_level_monitor_tank_low_water_alert
icon: mdi:water-alert
delay_on:
seconds: 60
delay_off:
seconds: 5
state: >
{% set v = states('sensor.water_tank_level_monitor_tank_sensor_voltage') %}
{% if v in ['unknown', 'unavailable', 'none'] %}{{ false }}{% else %}
{% set vf = v | float %}
{% set v0 = states('input_number.tank_calibration_voltage_empty') | float(0.66) %}
{% set v1 = states('input_number.tank_calibration_voltage_full') | float(1.982) %}
{% set h = states('input_number.tank_height_ft') | float(11) %}
{% set span = v1 - v0 %}
{% if span < 0.001 %}{{ false }}{% else %}
{% set raw = (vf - v0) / span * h %}
{% set level = 0 if raw < 0 else (h if raw > h else raw) %}
{% set pct = (level / h * 100) if h > 0.001 else 0 %}
{{ pct < 10 }}
{% endif %}
{% endif %}
Restart Home Assistant after adding this. You’ll get entities like sensor.water_tank_level_monitor_tank_percentage, sensor.water_used_1h, and so on.
Calibration
The input_number defaults in the package are from our tank, so you’ll need to set your own. The good news is that only the full calibration voltage actually requires any effort.
Empty voltage: hold the sensor in air and check Developer Tools → States for tank_sensor_voltage. The sensor reads about 0.48–0.51V out of water (4mA × 120Ω sense resistor). Set that as your empty calibration value before dropping the sensor in.
Full voltage: after a full pump cycle with the tank full, check the same entity and note the reading. That’s your full calibration voltage. We measured about 1.99V at our 11.5ft tank, well below the theoretical 2.4V maximum since the tank only uses about 70% of the sensor’s 5m range.
Update the tank height and capacity to match your actual tank.
Dashboard
In HA go to Edit dashboard → Add Card → Manual and paste:
View dashboard YAML (click to expand)
type: vertical-stack
cards:
- type: gauge
entity: sensor.water_tank_level_monitor_tank_percentage
name: Water tank
unit: "%"
needle: true
min: 0
max: 100
segments:
- from: 0
color: "#E24B4A"
- from: 25
color: "#EF9F27"
- from: 50
color: "#378ADD"
- from: 75
color: "#1D9E75"
- type: entities
entities:
- entity: sensor.water_tank_level_monitor_tank_volume
name: Volume
icon: mdi:water
- entity: sensor.water_tank_level_monitor_water_level
name: Water level
icon: mdi:ruler
- entity: sensor.water_tank_level_monitor_tank_sensor_voltage
name: Sensor voltage (raw)
icon: mdi:sine-wave
- entity: sensor.water_tank_level_monitor_tank_monitor_wifi_signal
name: WiFi signal
icon: mdi:wifi
- type: entities
title: Calibration
entities:
- entity: input_number.tank_calibration_voltage_empty
name: Voltage at empty
- entity: input_number.tank_calibration_voltage_full
name: Voltage at full
- entity: input_number.tank_height_ft
name: Tank height
- entity: input_number.tank_capacity_gal
name: Tank capacity
- type: horizontal-stack
cards:
- type: entity
entity: sensor.water_used_10m
name: Used (10m)
icon: mdi:water-minus
- type: entity
entity: sensor.water_used_1h
name: Last 1 hr
icon: mdi:water-minus
- type: entity
entity: sensor.water_used_12h
name: Last 12 hr
icon: mdi:water-minus
- type: history-graph
entities:
- entity: sensor.water_tank_level_monitor_tank_volume
name: Volume
hours_to_show: 12


Alerts
Add to automations.yaml. Replace notify.family with your own notification service.
View automations YAML (click to expand)
- id: tank_low_water_25_percent
alias: Tank low water alert (25%)
trigger:
- platform: numeric_state
entity_id: sensor.water_tank_level_monitor_tank_percentage
below: 25
for:
minutes: 5
condition:
- condition: template
value_template: >
{{ (as_timestamp(now()) -
as_timestamp(state_attr('automation.tank_low_water_alert_25',
'last_triggered') | default(0))) > 3600 }}
action:
- service: notify.family
data:
title: "Low water alert"
message: >
Tank is at {{ states('sensor.water_tank_level_monitor_tank_percentage') }}%
({{ states('sensor.water_tank_level_monitor_tank_volume') }} gal).
- id: tank_rapid_loss_10_percent
alias: Tank rapid loss alert (100 gal in 1hr)
trigger:
- platform: template
value_template: >
{{ states('sensor.tank_volume_change_1h') | float(0) <= -100 }}
for:
minutes: 2
action:
- service: notify.family
data:
title: "Rapid water loss"
message: >
Tank has lost {{ states('sensor.tank_volume_change_1h') | float(0) | abs | round(0) }}
gallons in the last hour. Currently at
{{ states('sensor.water_tank_level_monitor_tank_percentage') }}%
({{ states('sensor.water_tank_level_monitor_tank_volume') }} gal).
The low-water automation has a 1-hour cooldown so it doesn’t spam while the tank is sitting low. The cooldown references the automation’s entity ID. If it doesn’t seem to work, check Developer Tools → States for the actual entity ID HA assigned to the automation and update the state_attr(...) call to match.
Mounting
Lower the sensor through the tank hatch so it hangs vertically from its cable, a few inches above the floor. Resting on the bottom can block the diaphragm with sediment. Secure the cable at the hatch with a zip tie or cable clamp.

One thing to know: the sensor cable has a thin vent tube running through it for atmospheric pressure compensation. Don’t clamp down so hard on the cable gland that you crush it. The small white filter cap that came with the kit stays attached to the cable end and keeps the tube open to air.
The 12V wall adapter is not weatherproof. Run it into a pump house or garage. The sensor and its cable are fine submerged, but everything else should stay under cover.

Some notes and resources
Set the LM2596 before wiring it to the ESP32. Put a multimeter on the output terminals with the 12V supply connected, and turn the trim pot until you read 5V. An untuned buck converter can deliver too much voltage to the ESP32 VIN and fry the board.
The sensor manufacturer’s wiring notes and specs live here: https://wiki.dfrobot.com/kit0139/#tech_specs. They don’t include the ESP32 or the particular power supply setup we used, but I referenced it at the start of this build.



