diy-a-smart-reverse-osmosis-tds-flow-meter-with-esphome-and-esp32c6

DIY a Smart Reverse Osmosis TDS & Flow Meter with ESPHome and ESP32‑C6

For my home reverse osmosis (RO) system I wanted real‑time visibility into three things:

  • TDS (Total Dissolved Solids) before the RO membrane (tap water / inlet)
  • TDS after the membrane (permeate / filtered water)
  • How much water actually flows through the system over time (liters per day, month, etc.)

Commercial inline TDS meters usually come as closed black boxes with a tiny display and no way to integrate them into Home Assistant. And while there are quite a few hobby TDS probes for Arduino/ESP, I couldn’t find ready‑made probes that combine both TDS and temperature in a single housing that would look clean in a drinking water installation.

So I decided to hack a commercial RO TDS monitor, salvage its probes, and connect everything to an ESP32‑C6 running ESPHome. 😈


Hardware Overview 🧩

1. Donor device: commercial RO TDS monitor

Since I couldn’t find decent standalone TDS+temperature probes, I bought this ready‑made TDS monitor:

Link to the AliExpress device

This device has two probes (IN and OUT) in compact plastic housings designed for 1/4″ RO tubing. Inside each probe there is:

  • A pair of electrodes for measuring conductivity (TDS)
  • A built‑in NTC temperature sensor

The original electronics and LCD were useless for integration, but the probes themselves were exactly what I needed. So I kept the probes and ditched the board.


2. Flow sensor 🚿

To measure how much water passes through the system, I added a Hall‑effect flow sensor (typical YF‑S402B style). It outputs a pulse train proportional to flow rate — more pulses = more liters per minute.

Link to the AliExpress device

This sensor sits in the inlet line, so I can count the total amount of water processed by the RO system.


3. Plumbing fittings: two T‑connectors 🔧

To install the TDS probes inline, I used two T‑fittings for 1/4″ RO tubing:

  • One T‑connector on the inlet line → TDS IN probe
  • One T‑connector on the permeate/clean water line → TDS OUT probe

The probes screw into these fittings so the water flows around them, just like in the original commercial TDS monitor.

I didn’t buy these fittings separately — they came bundled together with the probes in the original commercial TDS monitor.


4. TDS amplifier boards 🔌

The probes themselves are just passive components. To read TDS with an ESP32, you need an analog front‑end that excites the probe and outputs a voltage proportional to conductivity.

For that I used TDS meter boards (the classic DFRobot‑style modules):

Link to the AliExpress device

Each board has:

  • A connector for the probe
  • A BNC or JST interface (depending on the model)
  • An analog voltage output (0–3.3 V range)

I use two boards:

  • One for TDS IN (raw water)
  • One for TDS OUT (filtered water)

Both analog outputs go to different ADC pins of the ESP32‑C6.


5. ESP32‑C6 as the brain 🧠

The controller is:

  • An ESP32‑C6 devkit
  • Running ESPHome with the esp-idf framework
  • Connected to Wi‑Fi and Home Assistant via the native ESPHome API

This single board reads:

  • Two TDS voltages (tds_in_voltage, tds_out_voltage)
  • Two NTC temperature sensors (inside the probes)
  • One pulse flow sensor

And exposes:

  • TDS IN / TDS OUT (ppm, temperature‑compensated)
  • Temperature IN / OUT (°C)
  • Instantaneous flow rate (L/min)
  • Total volume (L), persisted across reboots

Reverse Engineering the NTC Sensors 🧪

The tricky part with the probes was figuring out what kind of NTC thermistor is inside and how to turn its resistance into temperature.

Step 1: Identify the wiring

From the donor device and a bit of continuity testing it became clear that:

  • The TDS electrodes go to the original meter’s excitation circuitry (colored wires)
  • The NTC sensor is a separate pair of leads (white wires)

I wired the NTC from each probe as:

  • One side to GND
  • The other side to an ADC pin via a fixed 10 kΩ resistor to 3.3 V

So the schematic is:

3.3 V → 10 kΩ resistor → ADC node → NTC → GND

In ESPHome terms this is a DOWNSTREAM configuration (fixed resistor on the high side, NTC to ground).


Step 2: Measuring resistance at a known temperature

With the probes sitting at room temperature, I measured:

  • Ambient temp: 26.5 °C (using a trusted thermometer)
  • NTC resistance: ≈ 9.06 kΩ at that temperature

That gave me one reference point for the thermistor:

  • T₀ = 26.5 °C
  • R₀ = 9.06 kΩ

Step 3: Choosing a B‑constant

Most water‑grade epoxy‑potted NTCs are in the 10 kΩ / 3950–4200 K range. I experimentally tested a few B values in ESPHome and observed which curve best matched reality when the probe was:

  • In room‑temperature water
  • In slightly warmed water
  • In cooled water

After some experiments, a B‑constant of 4200 K with R₀ = 9.06 kΩ @ 26.5 °C produced very reasonable readings, so I baked those values into ESPHome:

- platform: ntc
  id: tds_in_temperature
  sensor: ntc_in_resistance
  name: "TDS IN Temperature"
  unit_of_measurement: "°C"
  icon: mdi:thermometer-water
  accuracy_decimals: 0   # Expose integer °C to Home Assistant
  calibration:
    b_constant: 4200
    reference_temperature: 26.5°C
    reference_resistance: 9.06kOhm
  filters:
    - sliding_window_moving_average:
        window_size: 10
        send_every: 1

The same logic is used for tds_out_temperature.

Internally ESPHome still uses floating‑point precision for all calculations; accuracy_decimals: 0 only affects how values are published to Home Assistant.


ESPHome Configuration Breakdown 🧾

Let’s walk through the most important parts of the ESPHome YAML and what each block does.

1. ADC sensors for raw TDS voltages

Each TDS board outputs a voltage proportional to conductivity. These are read with two ADC channels:

- platform: adc
  id: tds_in_voltage
  pin: 3
  attenuation: 11db      # up to ~3.3 V
  update_interval: 200ms
  internal: true
  accuracy_decimals: 3
  filters:
    - median:
        window_size: 7
        send_every: 1
        send_first_at: 1
    - sliding_window_moving_average:
        window_size: 20
        send_every: 5

- platform: adc
  id: tds_out_voltage
  pin: 2
  attenuation: 11db
  update_interval: 200ms
  internal: false
  accuracy_decimals: 3
  filters:
    - median:
        window_size: 7
        send_every: 1
        send_first_at: 1
    - sliding_window_moving_average:
        window_size: 20
        send_every: 5

Key points:

  • median cleanup reduces spikes from electrical noise.
  • sliding_window_moving_average smooths the signal over ~4 seconds.
  • For now, only OUT is exposed directly to HA (internal: false), but you can make IN visible as well if you want.

2. NTC chain: ADC → resistance → temperature 🌡️

Each probe gets three steps in ESPHome:

  1. Raw ADC read from the NTC voltage divider
  2. Convert voltage → resistance using the known fixed resistor
  3. Convert resistance → temperature using the NTC model

Example for TDS IN:

# 1) ADC
- platform: adc
  id: ntc_in_adc
  pin: 4
  attenuation: 6db
  update_interval: 1s
  internal: true
  accuracy_decimals: 4

# 2) Voltage → resistance
- platform: resistance
  id: ntc_in_resistance
  sensor: ntc_in_adc
  configuration: DOWNSTREAM
  resistor: 10kOhm
  internal: true

# 3) Resistance → temperature
- platform: ntc
  id: tds_in_temperature
  sensor: ntc_in_resistance
  name: "TDS IN Temperature"
  unit_of_measurement: "°C"
  icon: mdi:thermometer-water
  accuracy_decimals: 0
  calibration:
    b_constant: 4200
    reference_temperature: 26.5°C
    reference_resistance: 9.06kOhm
  filters:
    - sliding_window_moving_average:
        window_size: 10
        send_every: 1

The same pattern is used for the OUT probe.


3. TDS computation with temperature compensation 💧

Raw voltage is not linearly equal to TDS, and TDS itself depends strongly on temperature. To get reasonably accurate numbers, I used the DFRobot TDS formula with temperature compensation.

The template sensor for TDS IN looks like this:

- platform: template
  id: tds_in_ppm
  name: "TDS IN"
  unit_of_measurement: "ppm"
  icon: "mdi:water-opacity"
  accuracy_decimals: 0
  update_interval: 1s
  lambda: |-
    // 1) Read water temperature (fallback to 25 °C if sensor not ready)
    float waterTemperatureC = id(tds_in_temperature).state;
    if (isnan(waterTemperatureC)) {
      waterTemperatureC = 25.0f;
    }

    // 2) Read compensated voltage from the TDS board
    float voltage = id(tds_in_voltage).state;

    // 3) Apply temperature compensation (2% per °C from 25 °C)
    float compensationCoefficient = 1.0f + 0.02f * (waterTemperatureC - 25.0f);
    float compensatedVoltage = voltage / compensationCoefficient;

    // 4) Apply DFRobot TDS polynomial
    float tds = (133.42f * compensatedVoltage * compensatedVoltage * compensatedVoltage
                 - 255.86f * compensatedVoltage * compensatedVoltage
                 + 857.39f * compensatedVoltage) * 0.5f;

    // 5) Apply calibration factor (found experimentally)
    const float CAL_FACTOR_IN = 0.727f;

    return tds * CAL_FACTOR_IN;
  filters:
    - sliding_window_moving_average:
        window_size: 10
        send_every: 1

For TDS OUT the logic is identical, with its own calibration factor (in my case I ended up using the same 0.727, but you can calibrate them independently).


Calibrating the TDS Sensors 🧪📏

To get meaningful ppm values, you need to calibrate the TDS calculation against a known reference solution.

I used a TDS calibration solution with a known ppm value.

I don’t have a link to buy it online because I purchased it in a local store, but you can Google “TDS calibration solution.”

Calibration procedure

  1. Prepare the calibration solution according to the manufacturer’s instructions (correct dilution, temperature, etc.).
  2. Put the IN probe into the solution (or whichever one you want to calibrate first).
  3. Let it sit for a few minutes so both the probe and the solution reach thermal equilibrium.
  4. In ESPHome logs / Home Assistant, monitor the raw TDS value before applying CAL_FACTOR. For example, temporarily set:const float CAL_FACTOR_IN = 1.0f;and note what tds value your code calculates at that point.
  5. Compute the required calibration factor:CAL_FACTOR = TARGET_PPM / MEASURED_PPMExample: if the solution is 1413 ppm and your raw calculation gives 1943 ppm, then:CAL_FACTOR = 1413 / 1943 ≈ 0.727
  6. Put that factor into the code:const float CAL_FACTOR_IN = 0.727f;
  7. Re‑upload the firmware and verify that the reading now matches the calibration solution.

Repeat the same process for the OUT probe if you want separate calibration.


Flow Sensor and Volume Integration 🚿📊

The flow sensor produces pulses at a rate proportional to flow. The spec usually gives a relation like:

F(Hz) = K × Q(L/min)

or in terms of pulses per liter.

In my case, the theoretical spec was around 2280 pulses per liter, but real‑world measurements showed a significant discrepancy (for 1 liter of water, the system was reporting only about 0.718 L).

So I calibrated it empirically.

Flow calibration procedure

  1. Connect the flow sensor in series with your RO inlet line.
  2. Configure a temporary ESPHome sensor that simply counts pulses and computes liters based on your initial assumption (pulses_per_liter = 2280).
  3. Run exactly 1 liter of water through the system into a measuring container.
  4. Check what ESPHome says:
    • If it shows e.g. 0.718 L instead of 1.000 L, then your pulses_per_liter is too high.
  5. Compute the actual pulses per liter:pulses_per_liter_real = pulses_per_liter_initial × (measured_volume / real_volume) pulses_per_liter_real = 2280 × (0.718 / 1.0) ≈ 1637
  6. Update the ESPHome config to use this new factor.

In the final version, my pulse_counter sensor looks like this:

- platform: pulse_counter
  id: water_in_flow_l_min
  name: "Water In Flow Rate"
  pin:
    number: 6
    mode:
      input: true
      pullup: true
  unit_of_measurement: "L/min"
  icon: "mdi:water-pump"
  update_interval: 1s
  accuracy_decimals: 2
  filters:
    # pulses_per_min / 1637 ≈ L/min
    - multiply: 0.00061086   # 1 / 1637

Now the instantaneous flow reading in L/min matches reality much better.


Integrating total volume over time 🧮

To track how many liters have passed through the RO system, I use ESPHome’s integration sensor:

- platform: integration
  id: water_in_total_l
  name: "Water In Total"
  sensor: water_in_flow_l_min
  time_unit: min
  unit_of_measurement: "L"
  device_class: water
  state_class: total_increasing
  accuracy_decimals: 2
  integration_method: trapezoid
  restore: true

Important details:

  • The input is in L/min, and we integrate over minutes, so the result is in liters.
  • state_class: total_increasing makes this sensor compatible with Home Assistant’s energy/utility dashboards.
  • restore: true ensures the total volume survives reboots (saved in flash).

In Home Assistant, I then create Utility Meter entities for daily, monthly, and yearly water consumption using this Water In Total sensor as the source.


Integration with Home Assistant 🏠💙

With ESPHome’s native API, the ESP32‑C6 shows up in Home Assistant as a device with multiple entities:

  • TDS IN (ppm)
  • TDS OUT (ppm)
  • TDS IN Temperature (°C)
  • TDS OUT Temperature (°C)
  • Water In Flow Rate (L/min)
  • Water In Total (L)

On top of that you can define in Home Assistant:

  • Daily/Monthly/Yearly water usage via utility_meter
  • Automations that:
    • Alert you when TDS OUT rises above a threshold (e.g. filter needs replacement)
    • Notify you when you’ve used more than X liters per day
    • Track long‑term stats and visualize TDS and usage trends

All of this works over Wi‑Fi with no cloud dependencies.


Lessons Learned & Final Thoughts 💡

This project started because I couldn’t find nice, compact probes that combine TDS + temperature in a way that fits into standard RO plumbing and exposes data to my smart home.

By salvaging probes from a commercial TDS monitor and pairing them with:

  • TDS amplifier boards
  • A Hall‑effect flow sensor
  • ESP32‑C6 + ESPHome

…I ended up with a fully local, highly customizable RO monitoring system that:

  • Measures both input and output water quality
  • Compensates TDS readings for temperature
  • Tracks water consumption over time
  • Integrates cleanly with Home Assistant

If you’re comfortable with basic electronics and plumbing, this is a very satisfying project that gives you deep insight into how your RO system performs over time — and when it’s really time to change those filters instead of just guessing. 😉

You can easily extend this setup further:

  • Add leak sensors for safety 🔔
  • Control RO pump via a smart relay 💡
  • Log all data to InfluxDB and build Grafana dashboards 📊

For me, the combination of ESPHome + ESP32‑C6 + a bit of hardware hacking turned an opaque black‑box water filter into a transparent, observable system that I can trust and analyze.

The full source code is also available on GitHub as well.

esphome:
  name: reverse_osmosis_meter
  friendly_name: Esp32C6 Reverse Osmosis TDS and Flow Meter

esp32:
  board: esp32-c6-devkitc-1
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: !secret reverse_osmosis_meter_encryption_key 

ota:
  - platform: esphome
    password: !secret reverse_osmosis_meter_encryption_key 

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Esp32C6-Tds-Meter"
    password: "uhv4aaRh8c3Q"

captive_portal:

debug:
  update_interval: 5s

text_sensor:
  - platform: debug
    device:
      name: "Device Info"
    reset_reason:
      name: "Reset Reason"

sensor:
  # ---------- TDS VOLTAGE SENSORS (IN / OUT) ----------

  # Raw ADC for TDS IN (voltage)
  - platform: adc
    id: tds_in_voltage
    pin: 3                # TDS IN analog output
    attenuation: 11db     # full range up to ~3.3V
    update_interval: 200ms
    internal: true       # temporary visible in HA for diagnostics
    accuracy_decimals: 3
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1
      - sliding_window_moving_average:
          window_size: 20   # ~4 seconds smoothing (20 * 0.2s)
          send_every: 5     # send to HA roughly once per second

  # Raw ADC for TDS OUT (voltage)
  - platform: adc
    id: tds_out_voltage
    pin: 2                # TDS OUT analog output
    attenuation: 11db
    update_interval: 200ms
    internal: true
    accuracy_decimals: 3
    filters:
      - median:
          window_size: 7
          send_every: 1
          send_first_at: 1
      - sliding_window_moving_average:
          window_size: 20
          send_every: 5

  # ---------- NTC FOR TDS IN ----------

  # ADC for NTC in TDS IN probe
  - platform: adc
    id: ntc_in_adc
    pin: 4                # GPIO for NTC (IN)
    attenuation: 6db
    update_interval: 1s
    internal: true
    accuracy_decimals: 4

  # Convert ADC -> resistance (NTC downstream: 3.3V -> 10k -> node -> NTC -> GND)
  - platform: resistance
    id: ntc_in_resistance
    sensor: ntc_in_adc
    configuration: DOWNSTREAM
    resistor: 10kOhm       # fixed resistor from 3.3V to node
    internal: true

  # NTC temperature for TDS IN
  - platform: ntc
    id: tds_in_temperature
    sensor: ntc_in_resistance
    name: "TDS IN Temperature"
    unit_of_measurement: "°C"
    icon: mdi:thermometer-water
    accuracy_decimals: 0   # Send integer °C to Home Assistant
    calibration:
      b_constant: 4200
      reference_temperature: 26.5°C
      reference_resistance: 9.06kOhm
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  # ---------- NTC FOR TDS OUT ----------

  # ADC for NTC in TDS OUT probe
  - platform: adc
    id: ntc_out_adc
    pin: 5                # GPIO for NTC (OUT)
    attenuation: 6db
    update_interval: 1s
    internal: true
    accuracy_decimals: 4

  - platform: resistance
    id: ntc_out_resistance
    sensor: ntc_out_adc
    configuration: DOWNSTREAM
    resistor: 10kOhm
    internal: true

  - platform: ntc
    id: tds_out_temperature
    sensor: ntc_out_resistance
    name: "TDS OUT Temperature"
    unit_of_measurement: "°C"
    icon: mdi:thermometer-water
    accuracy_decimals: 0   # Send integer °C to Home Assistant
    calibration:
      b_constant: 4200
      reference_temperature: 26.5°C
      reference_resistance: 9.06kOhm
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  # ---------- TDS CALCULATIONS (IN / OUT) ----------

  # Calculated TDS for IN (ppm)
  - platform: template
    id: tds_in_ppm
    name: "TDS IN"
    unit_of_measurement: "ppm"
    icon: "mdi:water-opacity"
    accuracy_decimals: 0
    update_interval: 1s
    lambda: |-
      // Water temperature from NTC (IN). Fallback to 25°C if not ready.
      float waterTemperatureC = id(tds_in_temperature).state;
      if (isnan(waterTemperatureC)) {
        waterTemperatureC = 25.0f;
      }

      // Voltage from ADC (already in Volts)
      float voltage = id(tds_in_voltage).state;

      // Temperature compensation (2% per °C from 25°C)
      float compensationCoefficient = 1.0f + 0.02f * (waterTemperatureC - 25.0f);
      float compensatedVoltage = voltage / compensationCoefficient;

      // Base TDS formula (DFRobot polynomial)
      float tds = (133.42f * compensatedVoltage * compensatedVoltage * compensatedVoltage
                   - 255.86f * compensatedVoltage * compensatedVoltage
                   + 857.39f * compensatedVoltage) * 0.5f;

      // Calibration factor for IN
      const float CAL_FACTOR_IN = 0.727f;

      return tds * CAL_FACTOR_IN;
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  # Calculated TDS for OUT (ppm)
  - platform: template
    id: tds_out_ppm
    name: "TDS OUT"
    unit_of_measurement: "ppm"
    icon: "mdi:water-opacity"
    accuracy_decimals: 0
    update_interval: 1s
    lambda: |-
      float waterTemperatureC = id(tds_out_temperature).state;
      if (isnan(waterTemperatureC)) {
        waterTemperatureC = 25.0f;
      }

      float voltage = id(tds_out_voltage).state;

      float compensationCoefficient = 1.0f + 0.02f * (waterTemperatureC - 25.0f);
      float compensatedVoltage = voltage / compensationCoefficient;

      float tds = (133.42f * compensatedVoltage * compensatedVoltage * compensatedVoltage
                   - 255.86f * compensatedVoltage * compensatedVoltage
                   + 857.39f * compensatedVoltage) * 0.5f;

      const float CAL_FACTOR_OUT = 0.727f;

      return tds * CAL_FACTOR_OUT;
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  # ---------- WATER FLOW SENSOR (YF-S402B) ----------

  # Flow rate in L/min
  - platform: pulse_counter
    id: water_in_flow_l_min
    name: "Water In Flow Rate"
    pin:
      number: 6              # GPIO for yellow wire from flow sensor
      mode:
        input: true
        pullup: true         # use internal pull-up to 3.3V
    unit_of_measurement: "L/min"
    icon: "mdi:water-pump"
    update_interval: 1s
    accuracy_decimals: 2
    filters:
      # Spec: F(Hz) = 38 * Q(L/min)  =>  ~2280 pulses per liter
      # pulse_counter дає pulses/min, тому:
      #   Q(L/min) = pulses_per_min / 2280
      # Calibrated: ~1637 pulses per liter  =>  L/min = pulses_per_min / 1637
      - multiply: 0.00061086

  # Integrated total volume in liters, stored across reboots
  - platform: integration
    id: water_in_total_l
    name: "Water In Total"
    sensor: water_in_flow_l_min
    time_unit: min           # integrate L/min over minutes => liters
    unit_of_measurement: "L"
    device_class: water
    state_class: total_increasing
    accuracy_decimals: 2
    integration_method: trapezoid
    restore: true            # store value in flash so it survives reboot

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.