Sensiron Differential Pressure Sensor with ESP32 to Provide HRV CFM/Balancing Data

I’ve been working on an HRV project for a few years now. It is based on a Venmar 200 CFM HRV, with internals gutted, leaving only the heat exchange core, and defrost damper setup. It now has with two external ECM inline 6" fans, controlled by 0-10V which control fresh and stale air flows. The system ramps 50,60,75, 90, 100 and 110 CFM depending on input from IAQ sensors on each floor of our 110 year old (heavily retrofitted) home.

I had based the motor settings for each CFM range on manual balancing via the HRV balance ports and a manual differential magnehelic gauge. You use the pressure data to derive the CFM values for each air stream based on the manufacturer’s provided data table. I’ve wanted to add the abiity to measure CFM and send it to automation. I recently fired up my first HA installation to go along with existing Hubitat hosted sensors and was immediately intriqued by the ESPHome addin. After some research, I could see that ESPHome supported some Sensirion pressure sensors. These electronic differential pressure sensors in particular matched the pressure range in the Venmar balancing tables (Sensirion SDP810-125Pa differential-pressure sensors). I’m using $7 ESP32 boards (WiFi) programmed using ESPHome. I’ll be adding some code so that the HRV system will auto balance as the interior IAQ sensors drive the system ramping between 50 and 110 CFM. The idea is to both stale and fresh air streams dynamically adjust to filtration, particularly during fire smoke season (right now!).

Sensors are about $30 each. Tubing runs to the HRV balance ports on the HRV door. Housing is magnetically attached for each removal if I need to open the HRV door.

I’ll add images as the forum rules will allow…

One ESP32 for each sensor, and a 12V to 5V power buck to power both.

I used ChatGPT pretty much for all setup and coding…

For stale air sensor here is the YAML code. A table is used to convert manufacturer pressure readings to CFM values for this HRV. You could edit these tables to reflect your own unit if doing a similar project. Most units have a this data on a sticker attached to the unit, or in the manual.:slight_smile:

# Board: DOIT ESP32 DEVKIT V1 / ELEGOO ESP32 CP2102
# Device: HRV pressure sensor #1
# Sensor: Sensirion SDP810 / SDP8xx I2C differential pressure sensor
# Port use: STALE / VICIÉ air ports
# I2C address detected: 0x25
#
# ESPHome sdp3x publishes pressure in hPa.
# 1 hPa = 100 Pa
# 1 hPa = 0.401463 inH2O
#
# Model 45720 table:
# Sensor 1 uses STALE / VICIÉ CFM column.

esphome:
  name: hrv-pressure-1
  friendly_name: HRV-Pressure-1

esp32:
  variant: esp32
  flash_size: 4MB
  framework:
    type: esp-idf
    advanced:
      minimum_chip_revision: "3.1"
      sram1_as_iram: true

logger:

api:
  encryption:
    key: "Use your own key API Key!"

ota:
  - platform: esphome

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

  ap:
    ssid: "HRV-Pressure-1 Fallback Hotspot"
    password: "Use your own password!"

captive_portal:

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true

sensor:
  # Raw SDP sensor value.
  # ESPHome sdp3x publishes this in hPa.
  - platform: sdp3x
    name: "HRV Pressure 1 Differential Raw hPa"
    id: hrv_pressure_1_hpa
    address: 0x25
    measurement_mode: differential_pressure
    update_interval: 1s
    unit_of_measurement: "hPa"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 4
    filters:
      - sliding_window_moving_average:
          window_size: 5
          send_every: 1
          send_first_at: 1

  # Converted to Pa.
  - platform: template
    name: "HRV Pressure 1 Differential Pa"
    id: hrv_pressure_1_pa
    unit_of_measurement: "Pa"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 2
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_1_hpa).state)) {
        return NAN;
      }
      return id(hrv_pressure_1_hpa).state * 100.0;

  # Converted to inches of water column.
  - platform: template
    name: "HRV Pressure 1 Differential inH2O"
    id: hrv_pressure_1_inh2o
    unit_of_measurement: "inH2O"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 4
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_1_hpa).state)) {
        return NAN;
      }
      return id(hrv_pressure_1_hpa).state * 0.401463;

  # Model 45720 STALE / VICIÉ CFM lookup table.
  - platform: template
    name: "HRV Stale Air CFM"
    id: hrv_stale_air_cfm
    unit_of_measurement: "CFM"
    state_class: measurement
    accuracy_decimals: 0
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_1_inh2o).state)) {
        return NAN;
      }

      float x = fabs(id(hrv_pressure_1_inh2o).state);

      static const float p[] = {
        0.01, 0.02, 0.03, 0.04, 0.05,
        0.06, 0.07, 0.08, 0.09, 0.10,
        0.11, 0.12, 0.13, 0.14, 0.15,
        0.16, 0.17, 0.18, 0.19, 0.20,
        0.21, 0.22, 0.23, 0.24, 0.25,
        0.26, 0.27, 0.28, 0.29, 0.30,
        0.31, 0.32, 0.33, 0.34, 0.35,
        0.36, 0.37, 0.38, 0.39, 0.40,
        0.41, 0.42, 0.43, 0.44, 0.45,
        0.46, 0.47, 0.48, 0.49, 0.50
      };

      static const float cfm[] = {
        17, 32, 47, 61, 75,
        89, 102, 116, 129, 142,
        155, 168, 181, 194, 207,
        220, 233, 245, 258, 270,
        283, 295, 308, 320, 332,
        344, 357, 369, 381, 393,
        405, 417, 429, 441, 453,
        465, 477, 489, 501, 513,
        525, 537, 549, 560, 572,
        584, 596, 607, 619, 631
      };

      const int n = sizeof(p) / sizeof(p[0]);

      if (x <= 0.0) {
        return 0.0;
      }

      if (x <= p[0]) {
        return cfm[0] * (x / p[0]);
      }

      if (x >= p[n - 1]) {
        return cfm[n - 1];
      }

      for (int i = 0; i < n - 1; i++) {
        if (x >= p[i] && x <= p[i + 1]) {
          float ratio = (x - p[i]) / (p[i + 1] - p[i]);
          return cfm[i] + ratio * (cfm[i + 1] - cfm[i]);
        }
      }

      return NAN;

And for the 2nd (Fresh Air stream) sensor, similar:

# Board: DOIT ESP32 DEVKIT V1 / ELEGOO ESP32 CP2102
# Device: HRV pressure sensor #2
# Sensor: Sensirion SDP810 / SDP8xx I2C differential pressure sensor
# Port use: FRESH / FRAIS air ports
# I2C address detected: 0x25
#
# ESPHome sdp3x publishes pressure in hPa.
# 1 hPa = 100 Pa
# 1 hPa = 0.401463 inH2O
#
# Model 45720 table:
# Sensor 2 uses FRESH / FRAIS CFM column.

esphome:
  name: hrv-pressure-2
  friendly_name: HRV-Pressure-2

esp32:
  variant: esp32
  flash_size: 4MB
  framework:
    type: esp-idf
    advanced:
      minimum_chip_revision: "3.1"

logger:

api:
  encryption:
    key: "insert your own key!"

ota:
  - platform: esphome

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

  ap:
    ssid: "HRV-Pressure-2 Fallback Hotspot"
    password: "Use your own password!"

captive_portal:

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true

sensor:
  # Raw SDP sensor value.
  # ESPHome sdp3x publishes this in hPa.
  - platform: sdp3x
    name: "HRV Pressure 2 Differential Raw hPa"
    id: hrv_pressure_2_hpa
    address: 0x25
    measurement_mode: differential_pressure
    update_interval: 1s
    unit_of_measurement: "hPa"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 4
    filters:
      - sliding_window_moving_average:
          window_size: 5
          send_every: 1
          send_first_at: 1

  # Converted to Pa.
  - platform: template
    name: "HRV Pressure 2 Differential Pa"
    id: hrv_pressure_2_pa
    unit_of_measurement: "Pa"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 2
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_2_hpa).state)) {
        return NAN;
      }
      return id(hrv_pressure_2_hpa).state * 100.0;

  # Converted to inches of water column.
  - platform: template
    name: "HRV Pressure 2 Differential inH2O"
    id: hrv_pressure_2_inh2o
    unit_of_measurement: "inH2O"
    device_class: pressure
    state_class: measurement
    accuracy_decimals: 4
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_2_hpa).state)) {
        return NAN;
      }
      return id(hrv_pressure_2_hpa).state * 0.401463;

  # Model 45720 FRESH / FRAIS CFM lookup table.
  - platform: template
    name: "HRV Fresh Air CFM"
    id: hrv_fresh_air_cfm
    unit_of_measurement: "CFM"
    state_class: measurement
    accuracy_decimals: 0
    update_interval: 1s
    lambda: |-
      if (isnan(id(hrv_pressure_2_inh2o).state)) {
        return NAN;
      }

      float x = fabs(id(hrv_pressure_2_inh2o).state);

      static const float p[] = {
        0.01, 0.02, 0.03, 0.04, 0.05,
        0.06, 0.07, 0.08, 0.09, 0.10,
        0.11, 0.12, 0.13, 0.14, 0.15,
        0.16, 0.17, 0.18, 0.19, 0.20,
        0.21, 0.22, 0.23, 0.24, 0.25,
        0.26, 0.27, 0.28, 0.29, 0.30,
        0.31, 0.32, 0.33, 0.34, 0.35,
        0.36, 0.37, 0.38, 0.39, 0.40,
        0.41, 0.42, 0.43, 0.44, 0.45,
        0.46, 0.47, 0.48, 0.49, 0.50
      };

      static const float cfm[] = {
        11, 19, 27, 34, 41,
        48, 55, 61, 67, 73,
        80, 86, 92, 97, 103,
        109, 115, 120, 126, 131,
        137, 142, 148, 153, 158,
        164, 169, 174, 179, 184,
        189, 195, 200, 205, 210,
        215, 220, 225, 230, 235,
        239, 244, 249, 254, 259,
        264, 268, 273, 278, 283
      };

      const int n = sizeof(p) / sizeof(p[0]);

      if (x <= 0.0) {
        return 0.0;
      }

      if (x <= p[0]) {
        return cfm[0] * (x / p[0]);
      }

      if (x >= p[n - 1]) {
        return cfm[n - 1];
      }

      for (int i = 0; i < n - 1; i++) {
        if (x >= p[i] && x <= p[i + 1]) {
          float ratio = (x - p[i]) / (p[i + 1] - p[i]);
          return cfm[i] + ratio * (cfm[i + 1] - cfm[i]);
        }
      }

      return NAN;

HA’s configuration.yaml needed these bits added:

input_boolean:
  hrv_auto_balance_enabled:
    name: HRV Auto Balance Enabled
    icon: mdi:air-filter

input_select:
  hrv_target_stage:
    name: HRV Target Airflow
    options:
      - "50"
      - "60"
      - "75"
      - "90"
      - "100"
      - "110"
    initial: "50"
    icon: mdi:fan

input_number:
  hrv_balance_deadband_cfm:
    name: HRV Balance Deadband
    min: 0
    max: 20
    step: 1
    initial: 3
    unit_of_measurement: CFM
    mode: box

template:
  - sensor:
      - name: "HRV CFM Difference"
        unique_id: hrv_cfm_difference
        unit_of_measurement: "CFM"
        state_class: measurement
        state: >
          {% set stale = states('sensor.hrv_pressure_1_stale_air_cfm') | float(0) %}
          {% set fresh = states('sensor.hrv_pressure_2_fresh_air_cfm') | float(0) %}
          {{ (stale - fresh) | round(1) }}

      - name: "HRV Balance Percent"
        unique_id: hrv_balance_percent
        unit_of_measurement: "%"
        state_class: measurement
        state: >
          {% set stale = states('sensor.hrv_pressure_1_stale_air_cfm') | float(0) %}
          {% set fresh = states('sensor.hrv_pressure_2_fresh_air_cfm') | float(0) %}
          {% if fresh > 0 %}
            {{ (((stale - fresh) / fresh) * 100) | round(1) }}
          {% else %}
            0
          {% endif %}

      - name: "HRV Balance Status"
        unique_id: hrv_balance_status
        state: >
          {% set difference = states('sensor.hrv_cfm_difference') | float(0) %}
          {% set deadband = states('input_number.hrv_balance_deadband_cfm') | float(3) %}
          {% if difference > deadband %}
            Stale Air High
          {% elif difference < (deadband * -1) %}
            Fresh Air High
          {% else %}
            Balanced
          {% endif %}

External filtration unit on HRV fresh air intake. It uses a MERV16 5"x20"x24" filter, along with a 15lb carbon canister (AC infinity) to filter particulates and address smoke smell.

Sorry for all the posts but can only attach one pic per post. I experimented for a few years to arrive at this solution. In winter I use the cheaper MPR100 filter, and in winter the MERV16 version. The carbon canister is replaceable and an internal damper allows me to bypass the carbon completely, or for days like today (intense smoke, PM2.5 over 79 ug/m3) force the air stream through the carbon. Having this unit outside means you don’t need to insulate for cold weather. It’s been working amazingly well for 2 years now, including winters down to -35C with zero issues.

This is a project I’ve wanted to tackle for years.. so I get you saying about it taking a while. I also have an 80 year old house that I air sealed and have been dealing with stagnant air in individual rooms. Do you have that issue? With the house being air sealed and insulated the HVAC doesn’t have to run that frequently which is the point but then individual rooms get stuffy. I saw you were using IAQ sensors, is that per room or just the house?