Skip to content

Instantly share code, notes, and snippets.

@gapple
Created January 28, 2025 23:47
Show Gist options
  • Save gapple/3f5952d87d1cab3b4c69e66456f77e58 to your computer and use it in GitHub Desktop.
Save gapple/3f5952d87d1cab3b4c69e66456f77e58 to your computer and use it in GitHub Desktop.
ESPHome ERV
substitutions:
exhaust_pwm_pin: GPIO26
exhaust_rpm_pin: GPIO27
exhaust_min_power: "0.2"
exhaust_max_power: "1.0"
exhaust_power_scale: "1.0"
exhaust_min_rpm: "300"
exhaust_max_rpm: "2000"
exhaust_pulse_per_rotation: "2.0"
intake_pwm_pin: GPIO14
intake_rpm_pin: GPIO13
intake_min_power: "0.2"
intake_max_power: "1.0"
intake_power_scale: "1.0"
intake_min_rpm: "300"
intake_max_rpm: "2000"
intake_pulse_per_rotation: "2.0"
status_pin: GPIO2
onewire_pin: GPIO33
esphome:
name: erv
friendly_name: Energy Recovery Ventilator
min_version: 2024.12.0
on_boot:
- priority: 500 # after sensor setup, before wifi
then:
- component.update: co2_pid_float
- script.execute: update_fan_speed
esp32:
board: esp32dev
framework:
type: arduino
logger:
level: WARN
api:
id: ha_api
encryption:
key: !secret hrv_api_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
version: 3
ota: False
include_internal: True
sorting_groups:
- id: sorting_group_control
name: "Control"
sorting_weight: 1
- id: sorting_group_fans
name: "Fans"
sorting_weight: 2
- id: sorting_group_efficiency
name: "Efficiency"
sorting_weight: 3
- id: sorting_group_other
name: "Other"
sorting_weight: 50
time:
- platform: homeassistant
id: homeassistant_time
one_wire:
platform: gpio
id: onewire_bus
pin:
number: ${onewire_pin}
output:
- platform: ledc
id: exhaust_fan_pwm
pin:
number: ${exhaust_pwm_pin}
mode: OUTPUT_OPEN_DRAIN
frequency: 25000
channel: 0
min_power: ${exhaust_min_power}
max_power: ${exhaust_max_power}
zero_means_zero: true
- platform: ledc
id: intake_fan_pwm
pin:
number: ${intake_pwm_pin}
mode: OUTPUT_OPEN_DRAIN
frequency: 25000
channel: 2
min_power: ${intake_min_power}
max_power: ${intake_max_power}
zero_means_zero: true
light:
- platform: status_led
id: status_led_light
name: Status LED
internal: True
pin:
number: ${status_pin}
ignore_strapping_warning: true
web_server:
sorting_group_id: sorting_group_other
fan:
##############################
# Fan Control
##############################
- platform: template
id: fan_control
name: Fans
restore_mode: RESTORE_DEFAULT_OFF
preset_modes:
- Exchange
- Exhaust
- Intake
on_turn_on:
then:
- script.execute: update_fan_speed
on_turn_off:
then:
- script.execute: update_fan_speed
on_preset_set:
then:
- script.execute: update_fan_speed
web_server:
sorting_group_id: sorting_group_control
sensor:
##############################
# Fan Speed
##############################
- platform: pulse_counter
pin:
number: ${exhaust_rpm_pin}
mode: INPUT_PULLUP
id: exhaust_fan_rpm
name: Exhaust Speed
icon: mdi:gauge
unit_of_measurement: 'rpm'
accuracy_decimals: 0
use_pcnt: False
internal_filter: 1ms # 2000 rpm: 1/(2*2000)*60 = 0.015ms
count_mode:
rising_edge: DISABLE
falling_edge: INCREMENT
update_interval: 5s
filters:
- lambda: "return x / ${exhaust_pulse_per_rotation};"
- clamp:
min_value: 0
- clamp:
max_value: ${exhaust_max_rpm}
ignore_out_of_range: True
- sliding_window_moving_average:
window_size: 4
send_every: 12
send_first_at: 2
- round_to_multiple_of: 5
web_server:
sorting_group_id: sorting_group_fans
- platform: pulse_counter
pin:
number: ${intake_rpm_pin}
mode: INPUT_PULLUP
id: intake_fan_rpm
name: Intake Speed
icon: mdi:gauge
unit_of_measurement: 'rpm'
accuracy_decimals: 0
use_pcnt: False
internal_filter: 1ms
count_mode:
rising_edge: DISABLE
falling_edge: INCREMENT
update_interval: 5s
filters:
- lambda: "return x / ${intake_pulse_per_rotation};"
- clamp:
min_value: 0
- clamp:
max_value: ${intake_max_rpm}
ignore_out_of_range: True
- sliding_window_moving_average:
window_size: 4
send_every: 12
send_first_at: 2
- round_to_multiple_of: 5
web_server:
sorting_group_id: sorting_group_fans
##############################
# C02
##############################
- platform: homeassistant
id: co2_pid
name: CO2 PID
icon: mdi:molecule-co2
internal: True
entity_id: sensor.co2_pid
accuracy_decimals: 1
filters:
- debounce: 3s
- clamp:
min_value: 1
max_value: 100
web_server:
sorting_group_id: sorting_group_fans
- platform: template
id: co2_pid_float
name: C02 PID float
icon: mdi:molecule-co2
internal: True
accuracy_decimals: 3
lambda: |-
if (isnan(id(co2_pid).state)) {
// Ignore 'unknown' state if connected to Home Assistant.
if ( id(ha_api).is_connected() ) {
return {};
}
return 0.5;
}
return id(co2_pid).state / 100.0;
update_interval: 5s
filters:
# - filter_out: NAN
- sliding_window_moving_average:
window_size: 30
send_every: 1
send_first_at: 1
- round: 5
- clamp:
min_value: 0
max_value: 1
on_value:
then:
script.execute: update_fan_speed
web_server:
sorting_group_id: sorting_group_fans
##############################
# Temperature & Efficiency #
##############################
- platform: dallas_temp
one_wire_id: onewire_bus
id: exhaust_input_temp
name: "Exhaust Input Temperature"
icon: mdi:thermometer
address: 0xF0F0F0FF0F0F0FF0
accuracy_decimals: 2
update_interval: 7s
filters:
- filter_out: nan
- offset: 0.3
- clamp:
min_value: 10
max_value: 30
ignore_out_of_range: True
- throttle_average: 60s
on_value:
then:
component.update: efficiency
web_server:
sorting_group_id: sorting_group_efficiency
- platform: dallas_temp
one_wire_id: onewire_bus
id: intake_input_temp
name: "Intake Input Temperature"
icon: mdi:thermometer
address: 0xFF00FF00FF00FF00
accuracy_decimals: 2
update_interval: 10s
filters:
- filter_out: nan
- clamp:
min_value: -20
max_value: 40
ignore_out_of_range: True
- throttle_average: 60s
on_value:
then:
component.update: efficiency
web_server:
sorting_group_id: sorting_group_efficiency
- platform: dallas_temp
one_wire_id: onewire_bus
id: intake_output_temp
name: "Intake Output Temperature"
icon: mdi:thermometer
address: 0xFFFF0000FFFF0000
accuracy_decimals: 2
update_interval: 8s
filters:
- filter_out: nan
- offset: 0.5
- clamp:
min_value: 0
max_value: 30
ignore_out_of_range: True
- throttle_average: 60s
on_value:
then:
component.update: efficiency
web_server:
sorting_group_id: sorting_group_efficiency
- platform: template
id: efficiency
name: "Efficiency"
icon: mdi:home-percent-outline
state_class: measurement
unit_of_measurement: "%"
accuracy_decimals: 1
update_interval: never
lambda: |-
if (
isnan(id(intake_output_temp).state)
|| isnan(id(intake_input_temp).state)
|| isnan(id(exhaust_input_temp).state)
) {
return NAN;
}
// When cold outside,
if (
id(intake_input_temp).state <= id(exhaust_input_temp).state
&&
(
// fresh air must be colder
id(exhaust_input_temp).state <= id(intake_output_temp).state
||
// outside must be at least a degree colder
id(exhaust_input_temp).state - id(intake_input_temp).state < 1
)
) {
return NAN;
}
// When warm outside,
if (
id(intake_input_temp).state > id(exhaust_input_temp).state
&&
(
// fresh air must be warmer
id(exhaust_input_temp).state >= id(intake_output_temp).state
||
// outside must be at least a degree warmer
id(intake_input_temp).state - id(exhaust_input_temp).state < 1
)
) {
return NAN;
}
return
( id(intake_output_temp).state - id(intake_input_temp).state )
/
( id(exhaust_input_temp).state - id(intake_input_temp).state )
* 100.0;
filters:
- debounce: 5s
- lambda: |-
if (
id(exhaust_fan_rpm).state > ${exhaust_min_rpm}
|| id(intake_fan_rpm).state > ${intake_min_rpm}
) {
return x;
}
return NAN;
- round: 1
- clamp:
min_value: 0
max_value: 100
ignore_out_of_range: True
- throttle_average: 60s
web_server:
sorting_group_id: sorting_group_efficiency
script:
- id: update_fan_speed
mode: restart
then:
- script.execute:
id: set_fan_speed
mode: !lambda |-
return id(fan_control).preset_mode;
value: !lambda |-
return id(co2_pid_float).state;
- id: set_fan_speed
mode: restart
parameters:
mode: string
value: float
then:
- if:
condition:
- fan.is_on: fan_control
then:
- if:
condition:
- lambda: return mode == "Intake";
then:
- output.turn_off: exhaust_fan_pwm
else:
- if:
condition:
- lambda: return mode == "Exhaust";
then:
- output.set_level:
id: exhaust_fan_pwm
level: 1.0
else:
- output.set_level:
id: exhaust_fan_pwm
level: !lambda |-
return fminf(1.0, value * ${exhaust_power_scale} );
- if:
condition:
- lambda: return mode == "Exhaust";
then:
- output.turn_off: intake_fan_pwm
else:
- if:
condition:
- lambda: return mode == "Intake";
then:
- output.set_level:
id: intake_fan_pwm
level: 1.0
else:
- output.set_level:
id: intake_fan_pwm
level: !lambda
return fminf(1.0, value * ${intake_power_scale} );
else:
- output.turn_off: exhaust_fan_pwm
- output.turn_off: intake_fan_pwm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment