Solar-Powered Water Tank Monitor using Home Assistant

If you’re on tank water, the “how much is left?” question isn’t just curiosity. In summer especially, running low can turn into a very annoying problem very quickly, and water deliveries are something you want to organise early, not after the pressure drops mid-shower.

For ages, checking our tank meant walking outside and climbing a ladder to look into a dark tank. It’s awkward, it’s not safe, and it’s the sort of job you avoid until you really have to.

So I built a solar powered water tank monitor that reports the level straight into Home Assistant and is designed to run long-term with no maintenance. No battery swaps, no topping up, no pulling it down every few weeks to recharge.

This post breaks down the approach, the hardware, the power system, and the key software choices that make it reliable.

Some of the links in this post may be affiliate links. If you buy through them, I may earn a small commission at no extra cost to you.

Project goals

Here’s what I wanted this setup to do:

  • Show the tank level accurately (not “roughly”)
  • Send the data into Home Assistant
  • Run on solar power
  • Use ultra-low power so it can keep going for years

The tank is about 15 m from the house and roughly 40 m from the Wi-Fi router with walls in the way, so connectivity matters too. I also wanted a system I can trust, not something that needs constant babysitting.

Why I switched from ultrasonic to a pressure sensor

I’ve measured tank level with ultrasonic sensors before. They can work well, but inside a tank they deal with things like:

  • Condensation and moisture on the sensor face
  • Temperature swings affecting readings
  • The occasional insect or debris causing random noise

This time I went with a pressure sensor mounted at the bottom of the tank. With a roughly 2 m high tank, the pressure at the bottom maps directly to how much water is above it. In practice, a pressure sensor tends to be more consistent over time in a sealed environment like this.

The trade-off is cost. This sensor is more expensive than the ultrasonic options I’ve used. But for something I want running unattended long-term, consistency is worth it.

How the system works

The overall logic is simple:

  1. ESP32 wakes up (every 15 minutes)
  2. Powers the pressure sensor
  3. Takes multiple readings
  4. Converts those readings into a real tank level
  5. Sends the data to Home Assistant via ESPHome
  6. Goes straight back to deep sleep

Most of the time, the ESP32 is asleep. The sensor is also unpowered most of the time. That’s the whole game when you’re trying to build something that runs on a small panel.

Hardware used

This is the core hardware in my build:

That MAX17048 fuel gauge is especially useful in a solar build because it removes the guesswork. You can actually see if the panel is keeping up, what the battery voltage is doing, and whether the system is trending up or down over time.

Calibration (this is where accuracy comes from)

A pressure sensor gives you a signal, not a meaningful “litres remaining” number on its own. Calibration is what turns it into a real water level reading.

I calibrated using three known points:

  • Tank empty
  • Tank full
  • A known midpoint

With those three reference points, you can map raw voltage (or raw ADC readings) to an actual tank level. Once the sensor was wired correctly, the readings became stable and repeatable, and the calibration held nicely.

If you skip this part or rush it, you’ll end up with a dashboard number that looks impressive but isn’t trustworthy.

ESPHome + Home Assistant integration

The ESP32-C6 connects over Wi-Fi and pushes values into Home Assistant via ESPHome. I like this approach because:

  • Everything stays local
  • Dashboards and automations are easy
  • Tweaks are quick when you learn something new

Power-focused Wi-Fi tweaks

Wi-Fi is often the biggest power hit in a battery device, especially when it struggles to connect.

To reduce wasted energy, I made a few small changes:

  • Reduced transmit power
  • Tweaked connection behaviour so it connects efficiently
  • Assigned a static IP address

None of these change what you see day-to-day, but they reduce the time the device spends fighting Wi-Fi, which matters a lot when you want long battery life.

Deep sleep strategy

The ESP32 spends almost all its time in deep sleep. Every 15 minutes it wakes, does the job, and sleeps again. This keeps the average current draw extremely low, which is what makes a small solar panel practical.

ESPHome Code
water_tank_sensor.yml
esphome:
  name: water-tank-sensor
  friendly_name: Water_Tank_Sensor

  on_boot:
    priority: 700
    then:
      # Enable RF switch function and select external antenna
      - output.turn_off: gpio3_rf_enable      # GPIO3 = LOW (enable antenna-select)
      - delay: 100ms
      - output.turn_on: gpio14_ant_ext        # GPIO14 = HIGH (external antenna)

      # --- Power 5V tank sensor rail and take readings (unconditional) ---
      - output.turn_on: boost_5v_en
      - delay: 500ms   # let tank sensor stabilise

      # Take single reading from fuel gauge
      - component.update: fuel_gauge

      # ---- Take multiple raw readings from tank sensor and average them ----
      - lambda: |-
          id(tank_v_sum) = 0.0f;
          id(tank_v_count) = 0;

      - repeat:
          count: 10
          then:
            - component.update: tank_raw_voltage
            - delay: 150ms
            - lambda: |-
                id(tank_v_sum) += id(tank_raw_voltage).state;
                id(tank_v_count) += 1;

      - sensor.template.publish:
          id: tank_raw_avg
          state: !lambda |-
            if (id(tank_v_count) == 0) return NAN;
            return id(tank_v_sum) / id(tank_v_count);

      # Now compute depth and % from the averaged voltage
      - component.update: tank_depth_m
      - component.update: tank_level_pct

      # Log the values once per wake so you can see calibration
      - logger.log:
          level: INFO
          format: "Battery: %.3f V (%.1f %%), Tank raw(avg): %.3f V, Depth: %.2f m, Tank: %.1f %%"
          args:
            - 'id(batt_v).state'
            - 'id(batt_pct).state'
            - 'id(tank_raw_avg).state'
            - 'id(tank_depth_m).state'
            - 'id(tank_level_pct).state'

      # Turn off 5V rail before sleep to avoid leakage
      - output.turn_off: boost_5v_en

      # Try to bring up Wi-Fi so values can be sent to HA
      - wait_until:
          condition:
            wifi.connected:
          timeout: 10s

      # If Wi-Fi connected, try API and keep node awake for OTA
      - if:
          condition:
            wifi.connected
          then:
            # Try API for 5 seconds
            - wait_until:
                condition:
                  api.connected
                timeout: 5s

            # Keep node awake ~60s so you can catch it for OTA (change later)
            - delay: 60s

      # Whether Wi-Fi/API worked or not, go to sleep after this boot
      - deep_sleep.enter: deep_sleep_ctrl

esp32:
  board: esp32-c6-devkitm-1
  variant: esp32c6
  framework:
    type: esp-idf
    sdkconfig_options:
      CONFIG_ESPTOOLPY_FLASHSIZE_8MB: y


logger:
  hardware_uart: USB_SERIAL_JTAG
  level: DEBUG


api:
  encryption:
    key: "<YOUR API KEY>" # From ESPHome


ota:
  - platform: esphome
    password: "<YOUR PASSWORD>"  # Choose your own


wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    static_ip: 192.168.X.X    # Set your own static IP
    gateway: 192.168.1.1
    subnet: 255.255.255.0

  output_power: 8.5db     # Adjust as needed 8.5 - 20.5dB
  power_save_mode: HIGH
  fast_connect: on
  reboot_timeout: 0s     # important for battery nodes


# --- Antenna & 5V boost control pins (XIAO ESP32-C6) ---
output:
  - platform: gpio
    id: gpio3_rf_enable
    pin: GPIO3

  - platform: gpio
    id: gpio14_ant_ext
    pin: GPIO14

  # 5V boost enable for water tank sensor (EN pin)
  - platform: gpio
    id: boost_5v_en
    pin: GPIO16
    inverted: false


# Globals used for averaging the tank sensor voltage
globals:
  - id: tank_v_sum
    type: float
    restore_value: no
    initial_value: '0.0'
  - id: tank_v_count
    type: int
    restore_value: no
    initial_value: '0'


# MAX17048 FUEL GAUGE USES I2C
i2c:
  id: bus_a
  sda: GPIO22     # XIAO D4
  scl: GPIO23     # XIAO D5
  frequency: 200kHz
  scan: true


external_components:
  # MAX17048 external component
  - source: github://Option-Zero/esphome-components@max17048
    components: [max17048]

  # ESP32-C6 ADC workaround
  - source: github://PhracturedBlue/c6_adc
    components: [c6_adc]


sensor:
  # --- Battery fuel gauge ---
  - platform: max17048
    id: fuel_gauge
    i2c_id: bus_a
    address: 0x36
    update_interval: never
    battery_voltage:
      name: Battery Voltage
      id: batt_v
      unit_of_measurement: "V"
      accuracy_decimals: 3
    battery_level:
      name: Battery Level
      id: batt_pct
      unit_of_measurement: "%"
      accuracy_decimals: 1
    rate:
      name: Battery Discharge Rate
      id: batt_rate
      unit_of_measurement: "V/h"
      accuracy_decimals: 3

  # --- Raw water tank sensor voltage (0-3.3 V) ---
  - platform: c6_adc
    id: tank_raw_voltage
    pin: GPIO0
    attenuation: 12db        # full 0-3.3 V range
    update_interval: never   # one-shot, triggered at boot
    name: "Tank Raw Voltage (Single)"
    unit_of_measurement: "V"
    accuracy_decimals: 3

  # --- Averaged tank raw voltage (published from on_boot) ---
  - platform: template
    id: tank_raw_avg
    name: "Tank Raw Voltage (Avg)"
    unit_of_measurement: "V"
    accuracy_decimals: 3
    update_interval: never

  # --- Depth in metres (using calibrate_linear) ---
  #
  # Calibration steps:
  # 1) Lift sensor so the sensing part is at the water surface (0 m depth).
  #    Note the Tank Raw Voltage in logs -> call this V0.
  # 2) Put sensor back in its normal position.
  #    Measure water depth at the sensor (in metres) -> call this H1.
  #    Note the Tank Raw Voltage again -> call this V1.
  #
  # Replace the example numbers below with your real V0, V1, H1.
  - platform: template
    id: tank_depth_m
    name: "Tank Depth"
    unit_of_measurement: "m"
    accuracy_decimals: 2
    update_interval: never
    lambda: |-
      return id(tank_raw_avg).state;
    filters:
      - calibrate_linear:
          # Replace these with your real calibration points:
          # V0 (sensor at water surface) -> 0.00 m
          # V1 (sensor at known depth H1) -> H1 m
          - 0.025 -> 0.00
          - 0.634 -> 1.09
      # Clamp to your tank height of 2.00 m
      - lambda: |-
          if (x < 0.0) return 0.0;
          if (x > 2.00) return 2.00;
          return x;

  # --- Tank level as percentage, based on depth ---
  - platform: template
    name: "Tank Level"
    id: tank_level_pct
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: never
    lambda: |-
      const float tank_height_m = 2.00f;  // total usable height of the tank in metres
      float d = id(tank_depth_m).state;   // depth at sensor in metres

      if (d <= 0.0f) return 0.0f;
      if (d >= tank_height_m) return 100.0f;

      return (d / tank_height_m) * 100.0f;


# Deep sleep controller: 15 minute between wakeups
deep_sleep:
  id: deep_sleep_ctrl
  sleep_duration: 15min


# Optional: extra I2C scan logging at boot
# debug:
#   update_interval: 0s

Before you use the code, make sure you:

  • Put in your API key
  • Set your OTA password
  • Update the IP addresses for your network
  • Do the sensor calibration (see below)

Sensor power switching (saving power where it actually counts)

The pressure sensor runs at 5 V. The battery is a single-cell lithium battery, so I used a boost converter to step up to 5 V.

The important part is that the boost converter is disabled most of the time. The ESP32 only turns it on right before taking readings, then turns it off again immediately after.

That one design choice saves a huge amount of energy compared to leaving the sensor powered 24/7.

External antenna for better reliability

With the tank outside and the router inside behind multiple walls, I didn’t want to rely on a weak internal antenna. I used an external Wi-Fi antenna and made the changes needed so the ESP32-C6 uses the correct antenna path.

The result is a more stable connection and less time wasted on reconnect attempts, which helps both reliability and battery life.

ESP32-C6 notes (what was annoying, and how I got around it)

The ESP32-C6 is newer, and ESPHome support is improving, but it’s not as friction-free as older ESP32 variants yet.

The main pain points for me were:

  • ADC behaviour being less straightforward than expected
  • Battery monitoring needing more work than usual

In the end, I pulled in external components from GitHub to get things solid:

  • MAX17048 fuel gauge component
  • An ADC workaround component to handle C6 limitations

Once that was done, everything behaved properly. Just be aware that if you choose an ESP32-C6 for a low power sensor build, you may do a bit more troubleshooting than you would with older boards.

Filtering the readings: average vs median

At first I averaged multiple readings. It worked, but every now and then I’d see weird spikes.

Instead, I’m switching to using the median of multiple samples. Median filtering is much better at ignoring outliers, which makes the long-term graph cleaner and the level reading more stable.

This is one of those small changes that turns a “prototype that mostly works” into something you can leave running and stop thinking about.

Enclosure and mounting

All electronics live in a 3D-printed enclosure mounted to the tank.

I printed it in white PETG because:

  • PETG handles heat better than PLA
  • White reflects sunlight instead of soaking it up

You could absolutely use an off-the-shelf weatherproof enclosure too. The key requirements are:

  • Weatherproofing
  • Secure mounting
  • Easy access if you ever need to service it

Real-world results so far

At the time of writing, the system has been running for about two weeks continuously.

  • No manual charging
  • No intervention
  • Battery staying healthy
  • Solar panel keeping up comfortably
  • Tank readings getting more trustworthy as data builds up

I also added a Home Assistant alert so I get notified if the tank drops below 20%. That’s the real payoff: less checking, more confidence, and enough warning to organise delivery before it becomes urgent.

What’s next: a long-range version with LoRa

Wi-Fi works for this tank because it’s close enough to the house.

I’ve got a second tank much further away, so the next step is building a LoRa version using the same low power approach, but with a connection that’s designed for distance.

Calibrating the pressure sensor

1. Turn off deep sleep (for now)

In the YAML, comment this out:

YAML
# deep_sleep:
#   id: deep_sleep_ctrl
#   sleep_duration: 15min

Upload the config so the board stays awake.

2. Open the logs

In ESPHome → click your device → LOGS.
You’ll see lines like:

Log
Battery: 3.819 V, Tank raw(avg): 0.842 V, Depth: 0.95 m, Tank: 48%

We care about Tank raw (volts) and the real depth (metres).

3. Take 3 readings

Do this 3 times at different water levels (empty / middle / full):

For each level:

  1. Measure depth from water surface down to the sensor (in metres).
  2. Note the Tank raw voltage from the logs.

Write them down like this:

  • 0.00 m → 0.12 V
  • 1.00 m → 0.75 V
  • 2.00 m → 1.40 V

4. Put your numbers into calibrate_linear

Find this part in the YAML:

YAML
filters:
  - calibrate_linear:
      - 0.12 -> 0.0
      - 1.24 -> 1.66

Replace it with your 3 points:

YAML
filters:
  - calibrate_linear:
      # Voltage -> Depth (m)  *** use your own values ***
      - 0.12 -> 0.00
      - 0.75 -> 1.00
      - 1.40 -> 2.00
  - lambda: |-
      if (x < 0.0) return 0.0;
      if (x > 2.00) return 2.00;
      return x;

Upload again and check:

  • Tank Depth looks close to reality
  • Tank Level looks sensible

5. Turn deep sleep back on

When you’re happy:

YAML
deep_sleep:
  id: deep_sleep_ctrl
  sleep_duration: 30min  # Or however long you want

Upload one last time. Done.

MAX17048 fuel gauge reference

Here’s the Home Assistant Community thread I used for the MAX17048 ESPHome component:

https://community.home-assistant.io/t/adafruit-max17048-lipo-battery-gauge-with-esphome/524005/11

Disclaimer: This project is shared for educational purposes only. If you choose to build it, you do so at your own risk. Double-check wiring, follow safety guidelines, and never work on live circuits if you’re unsure.

Leave a Reply

Your email address will not be published. Required fields are marked *