Starting Point

Our home uses a decentralized ventilation system from Ventomaxx with five reversing fans (ebmpapst VarioPro 4412 FGMPR) that switch direction every ~70 seconds to recover heat via a regenerative core. The original room unit allowed only manual program/level changes at the wall.

Original Ventomaxx Wall Unit

Original Ventomaxx Wall Unit

This is how the fans look like:

ebmpapst VarioPro 4412 FGMPR

ebmpapst VarioPro 4412 FGMPR

What needed to change

Manual mode/strength changes at the wall were impractical. The target was to expose per‑fan control and current mode visualization in Home Assistant, and to automate operation (e.g., reduce bedroom flow overnight).

Analysis

The wall controller drives fan speed and direction with a 2 kHz PWM where duty encodes both direction and magnitude:

  • 0%: clockwise, full speed
  • 50%: stop
  • 100%: counter‑clockwise, full speed

Schematic

The custom controller (ESP8266 + PWM outputs) replaces the wall unit’s fan drive, one PWM channel per room. ESPHome exposes a native API to Home Assistant; HA inputs drive setpoints per room; a script enforces the pendulum timing

Schema/Flow

Schema/Flow

Hardware

Prototype

Verified PWM behavior and isolation on perfboard to validate signal levels and noise immunity before committing to PCB.

Prototype on Perfboard

Prototype on Perfboard

Custom PCB

A KiCad board consolidates power, five PWM channels at ~1.92 kHz, headers to the fan harness, and the ESP module for reliability and serviceability.

Schematics

KiCAD Schematics

KiCAD Schematics

PCB Layout

KiCAD PCB Layout

KiCAD PCB Layout

ESPhome program

Core Principal

  • One PWM output per fan at ~1.92 kHz (close to measured 2 kHz).
  • Home Assistant input_numbers define “level” per room; a script maps those to PWM setpoints around a 50% neutral.
  • A continuous loop enforces the cycle: 10 s pause at neutral, 70 s direction A, 10 s pause, 70 s direction B—aligning with typical 70–90 s reversal for regenerative HRV

Code

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
substitutions:
  ip: redacted
  node_name: ventomaxx
  friendly_name: Ventomaxx
  api_encryption_key: redacted
  ota_password: redacted

packages:
  device_base: !include /config/common/device_base.yaml

esphome:
  name: ${node_name}
  on_boot:
    priority: -100
    then:
      - script.execute: my_loop

esp8266:
  board: nodemcuv2
  framework:
    version: recommended

sensor:
  - platform: homeassistant
    name: "Bedroom Level"
    entity_id: input_number.ventilation_bedroom
    id: bedroom_level
    on_value:
      then:
        - script.execute: my_loop

  - platform: homeassistant
    name: "Kitchen Level"
    entity_id: input_number.ventilation_kitchen
    id: kitchen_level
    on_value:
      then:
        - script.execute: my_loop

  - platform: homeassistant
    name: "Livingroom Level"
    entity_id: input_number.ventilation_livingroom
    id: livingroom_level
    on_value:
      then:
        - script.execute: my_loop

  - platform: homeassistant
    name: "Nursery Level"
    entity_id: input_number.ventilation_nursery
    id: nursery_level
    on_value:
      then:
        - script.execute: my_loop

  - platform: homeassistant
    name: "Office Level"
    entity_id: input_number.ventilation_office
    id: office_level
    on_value:
      then:
        - script.execute: my_loop

output:
  - platform: esp8266_pwm
    pin: GPIO5 #D1
    frequency: 1920 Hz
    id: pwm_output_bedroom

  - platform: esp8266_pwm
    pin: GPIO4 #D2
    frequency: 1920 Hz
    id: pwm_output_kitchen

  - platform: esp8266_pwm
    pin: GPIO14 #D5
    frequency: 1920 Hz
    id: pwm_output_livingroom

  - platform: esp8266_pwm
    pin: GPIO13 #D7
    frequency: 1920 Hz
    id: pwm_output_nursery

  - platform: esp8266_pwm
    pin: GPIO12 #D6
    frequency: 1920 Hz
    id: pwm_output_office

fan:
  - platform: speed
    output: pwm_output_bedroom
    name: "Fan Bedroom"
    speed_count: 100
    id: fan_bedroom
    internal: true
    restore_mode: RESTORE_DEFAULT_OFF

  - platform: speed
    output: pwm_output_kitchen
    name: "Fan Kitchen"
    speed_count: 100
    id: fan_kitchen
    internal: true
    restore_mode: RESTORE_DEFAULT_OFF

  - platform: speed
    output: pwm_output_livingroom
    name: "Fan Livingroom"
    speed_count: 100
    id: fan_livingroom
    internal: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: speed
    output: pwm_output_nursery
    name: "Fan Nursery"
    speed_count: 100
    id: fan_nursery
    internal: true
    restore_mode: RESTORE_DEFAULT_OFF

  - platform: speed
    output: pwm_output_office
    name: "Fan Office"
    speed_count: 100
    id: fan_office
    internal: true
    restore_mode: RESTORE_DEFAULT_ON

script:
  - id: my_loop
    mode: restart
    then:
    - while:
        condition:
          lambda: |-
            return true;
        then:
          ### Start ###
          - logger.log: " "
          - logger.log: "Interval Begin"

          ### Step 1: Pause ###
          - logger.log: "Speed Off for 10 Sec"
          - fan.turn_on:
              id: fan_bedroom
              speed: 50

          - fan.turn_on:
              id: fan_kitchen
              speed: 50

          - fan.turn_on:
              id: fan_livingroom
              speed: 50

          - fan.turn_on:
              id: fan_nursery
              speed: 50

          - fan.turn_on:
              id: fan_office
              speed: 50

          - delay: 10 sec

          ### Step 2: First Direction ###
          - logger.log: "Speed inbound for 70 Sec"
          - lambda: |-
                auto call_bedroom = id(fan_bedroom).turn_on();
                call_bedroom.set_speed(50 - id(bedroom_level).state);
                call_bedroom.perform();

                auto call_kitchen = id(fan_kitchen).turn_on();
                call_kitchen.set_speed(50 - id(kitchen_level).state);
                call_kitchen.perform();

                auto call_livingroom = id(fan_livingroom).turn_on();
                call_livingroom.set_speed(50 + id(livingroom_level).state);
                call_livingroom.perform();

                auto call_nursery = id(fan_nursery).turn_on();
                call_nursery.set_speed(50 - id(nursery_level).state);
                call_nursery.perform();

                auto call_office = id(fan_office).turn_on();
                call_office.set_speed(50 + id(office_level).state);
                call_office.perform();

          - delay: 70 sec

          ### Step 3: Pause ###
          - logger.log: "Speed Off for 10 Sec"
          - fan.turn_on:
              id: fan_bedroom
              speed: 50

          - fan.turn_on:
              id: fan_kitchen
              speed: 50

          - fan.turn_on:
              id: fan_livingroom
              speed: 50

          - fan.turn_on:
              id: fan_nursery
              speed: 50

          - fan.turn_on:
              id: fan_office
              speed: 50

          - delay: 10 sec

          ### Step 4: Second Direction ###
          - logger.log: "Speed outbound for 70 Sec"
          - lambda: |-
                auto call_bedroom = id(fan_bedroom).turn_on();
                call_bedroom.set_speed(50 + id(bedroom_level).state);
                call_bedroom.perform();

                auto call_kitchen = id(fan_kitchen).turn_on();
                call_kitchen.set_speed(50 + id(kitchen_level).state);
                call_kitchen.perform();

                auto call_livingroom = id(fan_livingroom).turn_on();
                call_livingroom.set_speed(50 - id(livingroom_level).state);
                call_livingroom.perform();

                auto call_nursery = id(fan_nursery).turn_on();
                call_nursery.set_speed(50 + id(nursery_level).state);
                call_nursery.perform();

                auto call_office = id(fan_office).turn_on();
                call_office.set_speed(50 - id(office_level).state);
                call_office.perform();

          - delay: 70 sec

          ### End ###
          - logger.log: "Interval End"
          - logger.log: " "

Home-Assistant

Frontend

ESPHome’s native API auto‑discovers entities. Then it’s just a matter of configuring the frontend:

Fan Controls in Home-Assistant

Fan Controls in Home-Assistant

Automatization

I’am using the status of my shutters to determine if I want to reduze the strength my fans in the bedroom. This lookis like this in Home-Assistant:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
alias: Ventilation - Ventomaxx
description: ""
triggers:
  - device_id: dd3b07e7b906e21b716db5b866fe6871
    domain: cover
    entity_id: e9766c2e1b7a0e636c296aa6689556fb
    type: closed
    id: cover_bedroom_closed
    trigger: device
  - device_id: dd3b07e7b906e21b716db5b866fe6871
    domain: cover
    entity_id: e9766c2e1b7a0e636c296aa6689556fb
    type: opened
    id: cover_bedroom_opened
    trigger: device
  - device_id: ebf922f99be1e5004e3c6387f8aa374b
    domain: cover
    entity_id: c93c90393ee01dec1a2894977e3b593d
    type: closed
    trigger: device
    id: cover_nursery_closed
  - device_id: ebf922f99be1e5004e3c6387f8aa374b
    domain: cover
    entity_id: c93c90393ee01dec1a2894977e3b593d
    type: opened
    trigger: device
    id: cover_nursery_opened
  - type: turned_off
    device_id: b48b11cc3f8a2c6f8f575a21b0e6e4bd
    entity_id: e4fec3c726da7892b2e587919654ebc0
    domain: switch
    id: computer_tom_off
    trigger: device
  - type: turned_on
    device_id: b48b11cc3f8a2c6f8f575a21b0e6e4bd
    entity_id: e4fec3c726da7892b2e587919654ebc0
    domain: switch
    id: computer_tom_on
    trigger: device
  - entity_id:
      - zone.home
    to: "0"
    id: zone_nobody_home
    enabled: false
    trigger: state
  - entity_id:
      - zone.home
    to: null
    id: zone_someone_home
    from: "0"
    enabled: false
    trigger: state
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - cover_bedroom_closed
        sequence:
          - data:
              value: 15
            target:
              entity_id: input_number.ventilation_bedroom
            action: input_number.set_value
      - conditions:
          - condition: trigger
            id:
              - cover_bedroom_opened
        sequence:
          - data:
              value: 50
            target:
              entity_id: input_number.ventilation_bedroom
            action: input_number.set_value
      - conditions:
          - condition: trigger
            id:
              - cover_nursery_closed
        sequence:
          - data:
              value: 15
            target:
              entity_id: input_number.ventilation_nursery
            action: input_number.set_value
      - conditions:
          - condition: trigger
            id:
              - cover_nursery_opened
        sequence:
          - data:
              value: 50
            target:
              entity_id: input_number.ventilation_nursery
            action: input_number.set_value
      - conditions:
          - condition: trigger
            id:
              - computer_tom_off
        sequence:
          - data:
              value: 35
            target:
              entity_id: input_number.ventilation_office
            action: input_number.set_value
      - conditions:
          - condition: trigger
            id:
              - computer_tom_on
        sequence:
          - data:
              value: 15
            action: input_number.set_value
            target:
              entity_id: input_number.ventilation_office
      - conditions:
          - condition: trigger
            id:
              - zone_nobody_home
        sequence:
          - target:
              entity_id: scene.ventilation_max
            metadata: {}
            action: scene.turn_on
      - conditions:
          - condition: trigger
            id:
              - zone_someone_home
        sequence:
          - target:
              entity_id: scene.ventilation_typical
            metadata: {}
            action: scene.turn_on
mode: single

Potential future automatizations

  • Demand mode: raise kitchen/living when humidity spikes; reduce office when workstation off.
  • Kitchen mode: raise kitchen when stove is on/draws power.
  • Away mode: scene sets all levels low while preserving air change to protect the building fabric.