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.
# 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 %}