Monitoring Water Usage with Home Assistant and ESPHome

Posted on Tue 19 March 2024 in Home Automation

I've been on a bit of a kick, adding devices and automations to my Home Assisant installation. I recently started looking for ways to monitor my water usage from Toronto Water.

Unfortunately, their water meter of choice, the Neptune T-10 with a magnetically-coupled Neptune E-CODER, only has one output and it's not a pulse output but a data stream connected to an Aclara MTU for transmission to Toronto Water (see details in Jim Williams' excellent blog). Jim's project looked too involved for my taste, but in the comments I spotted a link to Ed Cheung's blog where he used a Vernier MG-BTA magnetic field sensor attached to the side of his meter to detect the magnetic field of the spinning meter.

Some web searches later looking for ways to interface with either my Brultech GreenEye Monitor or directly to Home Assistant, I stumbled on the following thread in the Home Assistant Community: Can this gas meter be made smart in any way?

This led me to using Home Assistant's sister project, ESPHome. Home Assisant leverages ESPHome to control low-cost ESP8266, ESP32, RP2040 and other microcontrollers.

I'm a big fan of wired devices in general, and PoE in particular, so I decided on the Olimex ESP32-POE-ISO platform (in particular the ESP32-POE-ISO-IND). I also appreciate solutions that don't require soldering, so I opted for one of their UEXT magnetometers, the MOD-MAG. It's equipped with a ribbon cable with a connector to connect to the ESP32 board, and uses an NXP MAG3110 3-axis magnetometer.

Unfortunately I realized too late that ESPHome doesn't support this particular chip (see supported magnetic sensors), and my C++ skills being non-existent, I looked for other options and found that Olimex also offers the MOD-QMC5883L. It's based on the popular QST QMC5883L chip, which itself is a clone of the discontinued Honeywell HMC5883L. It's supported by ESPHome.

After waiting for my second Olimex shipment to arrive, I set out to configure everything, which turned out to be very straightforward.

esphome-web-6a1574.yaml:

esphome:
  name: esphome-water-meter-6a1574
  friendly_name: ESPHome Water Meter 6a1574

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "LnsJ9vEC1+wGzbAkWzdM7zuMNGLhVs3NaE/MHv9L5FM="

i2c:
  sda: GPIO13
  scl: GPIO16
  scan: True

ota:
  - platform: esphome

web_server:


ethernet:
  type: LAN8720
  mdc_pin: GPIO23
  mdio_pin: GPIO18
  clk_mode: GPIO17_OUT
  phy_addr: 0
  power_pin: GPIO12

number:
  - platform: template
    name: "ESPHome Water Meter 6a1574 Set Reading"
    optimistic: True
    min_value: 0
    max_value: 999999
    step: 0.001
    id: act_reading
    entity_category: config
    mode: box
    icon: 'mdi:counter'
    set_action:
      then:
        - lambda: |-
            int d;
            d = floor(x);
            id(water_counter_total) = d ;

# QMC5883L Sensor Configuration #
# https://esphome.io/components/sensor/qmc5883l.html

globals:
  - id: water_counter_total # increasing count
    type: long
    restore_value: yes
    initial_value: '0'
  - id: water_counter  # current count
    type: long
    restore_value: no
    initial_value: '0'
  - id: water_high # high/low threshold for count
    type: bool
    restore_value: no
    initial_value: 'false'

interval: # set both the high and low thresholds below for a trigger.  Y-Axiz range -69 to -121 when cycling slowly.  Using -65 to -55.
  - interval: 10ms # this is why the water meter was slipping, 250 ms was way too slow.
    then:
    - lambda: |-
       if (id(qmc5883l_y).state > -60 && !id(water_high)) {
          id(water_counter_total) += 1;
          id(water_counter) += 1; 
          id(water_high) = true;
        } else if (id(qmc5883l_y).state < -75 && id(water_high)) {
          id(water_high) = false;
        } 

sensor:
  - platform: qmc5883l
    address: 0x0D
#    field_strength_x:
#      name: "QMC5883L X-axis" # Not using.
#      id: qmc5883l_x
    field_strength_y:
      name: "QMC5883L Y-axis" # Using this one, hide others.
      id: qmc5883l_y
      internal: true # Change to false if you need to see readings.  This will log a ton of data.
#    field_strength_z:
#      name: "QMC5883L Z-axis" # Not using.
#      id: qmc5883l_z
  # heading:
   #  name: "qMC5883L Heading" # once I have the best X, Y, or Z sensor for reading the meter, I can comment out the other two sensors as well.
    oversampling: 128x # 512x (default), 256x, 128x, 64x (give other settings a try, see if it reduces noise)
    range: 200uT # 200 uT, 800 uT
    update_interval: 10ms # Make sure to also set calculation interval above too!

# Oversampling rate is tied to refresh:
# 512x = 10 Hz
# 256x = 50 Hz
# 128x = 100 Hz
# 64x = 200 Hz

# QMC5883L has a maximum output of 200 Hz.
# Can read at 10, 50, 100 or 200 Hz:
# 200 Hz = 5ms
# 100 Hz = 10ms
# 50 Hz = 20ms
# 10 Hz = 100ms

# “maximum 75 pulses/second on 5/8” T10" (another source below claims 77 pulses/sec)
# The reed switch in the meter generates 2X pulses per rotation.
# My magnetometer generates 1X waveform per rotation.  I get half the pulses of a reed switch.
# 77 pulses per second /2 x 60 = 2310 rpm.
# 75 pulses per second /2 x 60 = 2250 rpm.

# Sampling a waveform should be 2X or more, so at least 77 Hz which is 12.98ms
# The next compatible HARDWARE sample rate above 77 Hz is 100 Hz or 10ms.

  - platform: template
    name: "Nutating Disc Count" # This works, doesn't miss a drop from low to high flows.
    lambda: |-
      float temp1 = id(water_counter_total);
      return temp1;
    update_interval: 1s
    state_class: 'total_increasing'
    accuracy_decimals: 0

  - platform: template
    name: "Nutating Disc RPM" # this seems to work, number is plausible.
    lambda: |-
      int temp2 = id(water_counter);
      id(water_counter) -= temp2;
      return temp2 * (6);
    update_interval: 10s
    unit_of_measurement: "rpm"
    accuracy_decimals: 0

  - platform: template
    name: "Water Flow L/min"
    lambda: |-
      int temp3 = id(water_counter);
      id(water_counter) -= temp3;
      return temp3 * (6 * 0.032736241);
    update_interval: 10s
    unit_of_measurement: "L/min"
    accuracy_decimals: 2

  - platform: template
    name: "Water Total (L)" # This works, doesn't miss a drop from low to high flows.
    lambda: |-
      return ((float)id(water_counter_total) * (0.032736241));
    update_interval: 10s
    unit_of_measurement: 'L'
    accuracy_decimals: 2
    state_class: 'total_increasing'
    device_class: 'water'

  - platform: template
    name: "Water Total (m³)" # This works, doesn't miss a drop from low to high flows.
    lambda: |-
      return ((float)id(water_counter_total) * (0.032736241 * 0.001));
    update_interval: 60s
    unit_of_measurement: 'm³'
    accuracy_decimals: 3
    state_class: 'total_increasing'
    device_class: 'water'    

# 5/8" Neptune T-10 Calibration:
# Source: https://www.riotronics.com/wp-content/uploads/2019/11/NT10-4P-WaterRead-pdf3.01.pdf
# Riotronics makes a device that fits between the meter head and the base, they say:
# 0.004324 gallons per pulse
# 231.24 pulses per gallon
# Max 20 gpm or 77 pulses/sec
# My sensor gets 1/2 the pulses of a reed switch.  See below.
# In metric:
# 0.032736241 L per rotation
# 30.54718459 rotations per litre
# 0.000032736 m3 per rotation

# A rotating magnet next to a reed switch results in 2X closes per revolution.
# When the magnet axis is parallel, the switch closes.
# When the magnet axis is perpendicular, the switch opens.
# Although the poles reverse, they still induce opposite poles in the reed switch.
# A reed switch is an omni-polar device.