Rainwater tank level measurement with Esphome

Here is an example of how to measure the level of a rainwater tank using a pressure sensor.

My inspiration comes from here.

For my 5000L tank with a height of 160cm I ordered a pressure sensor that measures 2m height, with a cable of 10m. It transmits the data with a 4-20mA current loop. The cable contains 2 wires and also a small tube to compensate the atmospheric pressure.

I use an ESP32 board, because it is cheap, fast and easy to program with Esphome. The Esphome binding from @Seime makes it very easy to communicate with OH.

Here is the layout of the 8 x 12 cm PCB board I made:


The module on the right bottom is a voltage booster that transforms the 5V to 24V. This has to be adjusted with the potentiometer.

The module on the left bottom provides the current loop.

  • The two jumpers must be removed to provide a measuring range from 0 to 3,3V.
  • The zero potentiometer must be adjusted so that the measured voltage (green wire) is 0V when the pressure sensor is out of the water (empty rainwater tank).
  • I adjusted the span potentiometer to maximum (turn clockwise): if the tank is full (160cm), then I want to have as much volts as possible.

The module in the middle is a 16 bit ADC converter. It measures the voltage precisely and with low noise. The result is sent to the ESP32 via a I2C bus. The default address of the module is 0X48. The ESP32 can also measure voltages, but the linearity is not good and there is a lot of noise (variations of the measured level).

The small module on top left has nothing to do with this project. It is a BME280 board that can measure air pressure, temperature and humidity in my cellar. It is also connected with the I2C bus on address 0x76.

Here is the Esphome code:

esphome:
  name: esp3
  comment: esp32 controller for rainwater tank

esp32:
  board: az-delivery-devkit-v4
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API (to be able to send commands; not necessary here)
api:
  encryption: 
    key: !secret encryption_key

ota:
  # to send over the air updates to the esp32 
  - platform: esphome
    password: !secret ota_pw

time:
  # synchronise time from OH
  - platform: homeassistant
    id: openhab_time
    
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_pw
  fast_connect: true    # necessary for hidden networks, 
                        # faster for other networks as there is no scanning
  # esp3 with a manual ip address
  manual_ip:
    static_ip: 192.168.1.20
    gateway: 192.168.1.1
    subnet: 255.255.255.0  
    
# i2c initialisation for BME280 and ADS1115
i2c:
  id:   bus_a
  sda:  GPIO21
  scl:  GPIO22
  scan: True

# ADC initialization, ADDR pin is not connected, so address is 0x48
ads1115:
  - address: 0x48
  
sensor:

  # ADC channel A0 voltage measurement
  - platform: ads1115
    multiplexer: 'A0_GND'   # measure between pin A0 and GND
    gain: 4.096             # measure maximum 4.096V
    name: "niveauV"
    id: niveau_V
    update_interval: 5s
    filters:
      - median:                    
          window_size: 61          # mediaan of 61 measurements
          send_every: 61           # every 5*61 = 365s 

  # convert voltage to cm
  - platform: copy
    source_id: niveau_V
    name: "niveauCm"
    id: niveau_cm
    filters:
      - calibrate_linear:
          - 0.115 -> 6       # the sensor is at about 6cm above the bottom = 200L
          - 2.415 -> 160     # the height of the tank is 160cm
      - round: 1
    accuracy_decimals: 0
    unit_of_measurement: cm
            
  # convert voltage to liter
  - platform: copy
    source_id: niveau_V
    name: "niveauL"
    filters:
      - calibrate_linear:
          - 0.115   -> 200
          - 2.415   -> 5000
      - round: 0
    accuracy_decimals: 0
    unit_of_measurement: l


  # BME280 air pressure and humidity measurement
  - platform: bme280_i2c
    address: 0x76                 
    pressure:
      name: "luchtdruk"
      filters:
        - offset: 6.5             
        - round: 1
    humidity:
      name: "kelderVochtigheid"  
      filters:
        - round: 0
    update_interval: 60s

The measurement was still a bit noisy, so in the code I added a median filter:

  • Every 5s a measurement is made.
  • After 61 measurements the median value is taken (has to be uneven otherwise there is no median).
  • This is sent every 61 measurements or about 5 minutes.

Now the noise is less than 1mm or about 3L. This gives a stable reading:

The channels used for the rainwater tank are:

  • niveauV: the measured voltage
  • niveauCm: the corresponding water level in cm
  • niveauL: the level in liters

I used a widget to show the level:

uid: regenwaterput
tags: []
props:
  parameters:
    - default: Regenwaterput
      description: Title
      label: Title
      name: title
      required: false
      type: TEXT
    - context: item
      default: regenwaterL
      description: Item that contains the level in %
      label: Level Item
      name: levelItem
      required: true
      type: TEXT
    - default: blue
      description: "Fill color: red, blue,... or rgb(200,10,65) or '#ff0066'"
      label: Fill color
      name: fillColor
      required: false
      type: TEXT
    - default: Regenwater
      description: Text label abover the tank
      label: tankLabel
      name: tankLabel
      required: false
      type: TEXT
    - default: 110px
      description: "Height in px (needed for responsive layout)"
      label: Height
      name: height
      required: false
      type: TEXT
    - default: "160"
      description: "Width (no units)"
      label: Width
      name: width
      required: false
      type: TEXT
  parameterGroups: []
timestamp: Mar 2, 2025, 6:31:10 PM
component: f7-card
config:
  outline: true
  style:
    background-color: "#1c1c1d"
    margin-left: 0px
    margin-right: 0px
    noShadow: true
    padding: 0px
  title: =props.title
slots:
  default:
    - component: f7-block
      config:
        style:
          display: flex
          height: =props.height
          justify-content: center
          margin-left: 0px
          margin-right: 0px
          padding: 0px
      slots:
        default:
          - component: svg
            config:
              preserveAspectRatio: xMidYMin meet
              viewBox: ='0 0 180 160'
              xlmns: http://www.w3.org/2000/svg
            slots:
              default:
                - component: defs
                  slots:
                    default:
                      - component: linearGradient
                        config:
                          comment: the id can only be used once in a page; so we have to make it unique if
                            we want to use the widget more than once
                          id: ='Grad'+props.levelItem
                          x1: 0%
                          x2: 0%
                          y1: 100%
                          y2: 0%
                        slots:
                          default:
                            - component: stop
                              config:
                                comment: in order to have a hard line, both stops must use the same offset
                                offset: =items[props.levelItem].numericState / 50 + '%'
                                stop-color: =items[props.levelItem].numericState>500?'blue':'red'
                            - component: stop
                              config:
                                comment: the upper part is transparent
                                offset: =items[props.levelItem].numericState / 50 + '%'
                                stop-color: rgba(0,0,0,0)
                            - component: animate
                              config:
                                attributeName: y2
                                dur: 1s
                                from: 100%
                                repeatCount: 1
                                to: 0%
                - component: path
                  config:
                    d: ='M 30 20 q -10 0 -10 10 l 0 100 q 0 10 10 10 l 100 0 q 10 0 10 -10 l 0 -100
                      q 0 -10 -10 -10 l -100 0 m -10 24 l 10 0 m -10 24 l 10 0 m
                      -10 24 l 10 0 m -10 24 l 10 0'
                    fill: ='url(#Grad'+props.levelItem+')'
                    stroke: =themeOptions.dark=='dark'?'white':'black'
                    stroke-width: 2
          - component: oh-label-card
            config:
              action: navigate
              actionPage: page:regenwaterL
              fontSize: 18px
              label: =items[props.levelItem].numericState + ' L'
              stylesheet: |
                .item-inner {
                  margin-top: 30px;
                  margin-bottom: 0px;
                  }
                .card-content-padding {
                  padding: 0px;
                  }

The only drawback is, that you need to do some digging and drilling to install the pressure sensor in the tank. There is no OH binding yet for this…

3 Likes

I did something similar and used a shelly uni device flashed with tasmota.
I based mine from this video:

I did a video on how to get the litres reading from the tasmota as there was nothing really out there to get this information.

I also have sensors for my LPG gas tank (which is nearly empty at the moment) based on this video:

Here is my gas and water tank in openHab:

Hi Greg,

Compared to mine, your water tank is quite big. Your video about the weight sensor is interesting.

Do you have an idea of the measured noise or how much the water level fluctuates?

Don’t know about noise? Don’t know how I would measure it.
The water level is quiet steady and does fluctuate on windy days.
I think it is fairly accurate. You can see on the graph when it rained.

I have just done WLED project which I am sending the solar power reading to the WLED and it is green when running on solar and red when getting power from the grid.
I also have it to show if the front gate is open as it is about 200 metres away and I cannot see it from the house.


Here is what I based it off:

1 Like

I did something similar to measure the differential pressure on the pool filter.

I used pressure transmitter 0-5V connected directly to the ADC pin of the ESP32 board flashed with tasmota.

Since the analogue input of the ESP32 is 3.3V, I had to make simple voltage divider using resistors of few kohms to lower the output voltage of the transmitter to suitable valuefor the esp. It came out that the signal is noisy, so I made simple low pass filter by adding a capacitor. Quite happy with the results.
I calibrated the transmitter by using the local pressure gauge for few speeds of my pump and linearizing the values as a trendline in excel.

Hi, why 4-20mA? I’m using same sensor but 0-3.3v directly connected to ESP32 ADC pin and it’s works without any problem past 2 years.

A current loop has some advantages over measuring a voltage directly: https://en.wikipedia.org/wiki/Current_loop

I don’t use any other electronic parts and just plug straight onto the tasmota device and it works as far as I can tell. :grinning_face:

I am not an electronics engineer. I just follow the youTube videos.

1 Like

If you look at the graph you see this:


When you enable ‘Do Not Force Scale to Include Zero’ on the Y axis, this is what you see:

The small ripple that is now visible, is caused by noise. For my configuration it corresponds to about 3L on a tank of 5000L, which means a noise level of 0,06%.

I must admit that this is not important at all as the main goal is to see when the tank gets empty.

I used to work as an electronics engineer :wink:

I’m no electronics engineer either, but one thing I read in the initial linked post is that current loop design allows for considerable distance between the sensor and the electronics (tens of meters is apparently no problem).

This allows at least me to keep the electronics in a weather safe place close to WiFi while measuring a tank level 20m away in a harsh environment.

Another option could of course be to go for the sensor variant using RS485 which is digital and also work well over long distances.

I think the easiest way to use the current loop is to just use an INA219 or an INA232 and replace the sense resistor with a higher resistor (e.g. a 2Ohm or 4Ohm sense resistor).
That way one can directly measure the current without the need for additional circuits and there is wide support for reading the INA through i2c e.g. in esphome.
There is also built in averaging available to reduce noise.

I saw that too but I always think external a-d should be more precise than the internal one as long as there is no information about internal precision. I always think they will only use the cheapest parts available :wink: :smiley:

That would indeed replace the current loop and the ADS1115 modules with one module. Soldering the smd shunt resistor is not for those who have shaky hands.

That is a point.
The distance in my case is about 2m and I use shielded cable (shield is grounded on one side). For this, simple voltage divider (if your sensor output is on higher voltage than the esp) and/or low pass filter (i.e 1uF capacitor) directly to the ESP ADC pin works fine

I know difference, but for long distance is better to use RS485 or RF module, then two times analog - digital conversion