Demand-controlled ventilation with IKEA VINDSTYRKA
/ 11 min read
Table of Contents
In the last post I built a dashboard that let me see my Zehnder ComfoAir E400 think — airflow diagram, fan controls, a self-explaining bypass. But the unit was still dumb. It ran at a fixed speed regardless of what the air was actually doing: the same 67% — its medium setting — whether someone was searing steak on the ground floor or the house was empty and spotless.
I also happen to have an IKEA VINDSTYRKA air-quality sensor on every floor — four of them, reporting PM2.5, a VOC index, temperature, and humidity over Zigbee. They’d been sitting there as pretty gauges. The whole point of putting a sensor on every floor is to close a loop: let the demand signal (air quality) drive the supply (ventilation). This post is that loop.
Like everything in this series, it was built by talking to Claude Code over the HA MCP server — “boost the fan when any floor’s air goes bad, settle back when they’re all clean” — and iterating in plain language. The config below is the output, not something I typed by hand.
And as usual, the interesting parts aren’t the YAML. They’re the three places the obvious approach is quietly wrong: averaging your sensors, thresholding humidity, and the temperature automation that sounds great until you remember where heat goes.
One fan, four sensors: worst-floor-wins
The first instinct is to average the four sensors and drive the fan off that. It’s exactly wrong.
A central MVHR has one fan for the whole house — fan.wtw_ventilatie, a percentage fan whose ~33% step maps to the ComfoAir’s discrete levels: 33% = low, 67% = medium, 100% = high (and 0% = away). Those raw percentages are how Home Assistant exposes the fan, so they’re what shows up in the automations below — read “67%” as “medium” and “100%” as “high” throughout. It can’t ventilate the kitchen harder than the bedroom; loudest room wins. So if you average, a single smoky kitchen at VOC 450 gets blended with three clean floors at 90 and lands around 180 — never enough to trigger anything. The one room that needs air is the one the average erases.
So the rule is max, not mean: drive everything off the worst floor. For humidity I made that explicit with a min_max helper (no template needed — it’s a built-in):
# Helper: min_max, type "max" over the four VINDSTYRKA humidity sensorssensor.wtw_max_humidity: type: max entity_ids: - sensor.0_woonkamer_vindstyrka_humidity - sensor.1_woonkamer_vindstyrka_humidity - sensor.2_slaapkamer_duuk_vindstyrka_humidity - sensor.3_slaapkamer_vindstyrka_humidityFor VOC and PM2.5 I didn’t even need a helper, because a numeric_state trigger accepts a list of entities and fires when any of them crosses the line. More on that below.
The cooking signal: VOC and particulates
Two of the three triggers are easy, once you calibrate them against your own house instead of guessing. A week of statistics told me what “normal” looks like here:
- VOC index idles around 90–150 and spikes to 300–500 during real events — cooking, cleaning, a candle, paint off-gassing. (The VINDSTYRKA’s index is relative: ~100 is its self-calibrated baseline, not an absolute ppb.)
- PM2.5 is excellent at rest — 1–3 µg/m³ — with the occasional cooking spike to 11–35.
So the thresholds write themselves: VOC above 300, PM2.5 above 25. The for: delays keep a brief blip from triggering — cooking sustains, a one-minute flutter doesn’t:
trigger: - id: voc platform: numeric_state entity_id: # a list → fires if ANY floor crosses - sensor.0_woonkamer_vindstyrka_voc_index - sensor.1_woonkamer_vindstyrka_voc_index - sensor.2_slaapkamer_duuk_vindstyrka_voc_index - sensor.3_slaapkamer_vindstyrka_voc_index above: 300 for: {minutes: 3} - id: pm platform: numeric_state entity_id: [ ...the four _pm25 sensors... ] above: 25 for: {minutes: 2}That list-of-entities trick is the native expression of worst-floor-wins, and it’s worth internalising: a numeric_state trigger with multiple entities is an OR, while the same as a condition is an AND (all must satisfy). I use both, and that asymmetry turns out to be exactly what hysteresis needs.
The humidity trap
This is the one that bites. Showers dump moisture, mould loves moisture, so: “boost when humidity is high.” Easy — above: 80, done.
Except my humidity sensors are the VINDSTYRKAs, which live in bedrooms, not bathrooms. And a week of history showed the master bedroom hitting 81–84% almost every night — not from showers, but from two people sleeping with the door shut. A flat 80% threshold wouldn’t be a boost; it would pin the fan at 100% all night, every night, for the rest of the summer.
Gotcha: a static threshold can’t tell “someone showered” from “someone is asleep.” Both are high humidity. The difference isn’t the level — it’s the slope.
A shower spikes humidity fast (tens of percent per hour); sleeping bodies raise it slowly (a couple of percent per hour). So the real shower detector is a rate-of-change, which Home Assistant gives you as another built-in helper — a derivative over the max-humidity sensor:
# Helper: derivative of sensor.wtw_max_humidity, %/h, 10-min windowsensor.wtw_humidity_rate: source: sensor.wtw_max_humidity unit_time: h time_window: {minutes: 10}Now humidity gets handled by two triggers, and the primary one is the slope:
- Fast rise —
sensor.wtw_humidity_rateabove 20 %/h. Catches a shower instantly and clears itself within ~15 minutes as the rate falls back to zero, regardless of the absolute level. This is the one that does the work. - Absolute net —
sensor.wtw_max_humidityabove 85%. Sits above the nightly sleeping band (81–84%), so it only catches genuine excess — a 40-minute shower, laundry drying indoors — not routine breathing.
Same outcome the naïve above: 80 was reaching for, without the all-night failure mode. The lesson generalises: when a threshold can’t separate two situations, check whether the derivative can.
Two automations and a deadband
The control loop is deliberately split in two, with hysteresis between them so it can’t oscillate.
Boost (mode: restart) reacts to any of the four demand triggers — VOC, PM2.5, fast humidity rise, absolute humidity — and drives the fan to 100% (high):
action: - service: fan.set_percentage target: {entity_id: fan.wtw_ventilatie} data: {percentage: 100} - service: input_select.select_option # the "why", see below target: {entity_id: input_select.wtw_ventilation_reason} data: option: >- {% if trigger.id == 'voc' %}Luchtkwaliteit (VOC) {% elif trigger.id == 'pm' %}Fijnstof (PM2.5) {% else %}Vocht (RH){% endif %}Return to normal (mode: single) drops back to 67% (medium) — but only once all floors are comfortably back under, sustained for 15 minutes, and only if a boost is actually active. The clear thresholds sit well below the boost thresholds (VOC 220 vs 300, PM 10 vs 25), so the system has a deadband and won’t flap on a value hovering right at the line:
condition: - condition: not # don't act if already idle conditions: - {condition: state, entity_id: input_select.wtw_ventilation_reason, state: "Schoon"} - condition: numeric_state # list + below: = ALL must be under entity_id: [ ...the four _voc_index sensors... ] below: 220 - condition: numeric_state entity_id: [ ...the four _pm25 sensors... ] below: 10 - {condition: numeric_state, entity_id: sensor.wtw_max_humidity, below: 80} - {condition: numeric_state, entity_id: sensor.wtw_humidity_rate, below: 5}Everything here is a native trigger or condition — numeric_state, state, for: — not a wall of {{ states('...') | float > x }}. Native constructs validate when the config loads and fail loudly; templates fail silently at runtime. The only template in the whole build is in a data: field, where it belongs: picking the label for the status chip.
A chip that says why
An automation that silently changes your fan speed is a black box, and black boxes are how you end up distrusting your own house. So the boost writes its reason to an input_select, and the ventilation popup shows it as a Mushroom card that recolours itself (its idle green state is shown below):
type: custom:mushroom-template-cardentity: input_select.wtw_ventilation_reasonicon: >- {% set r = states('input_select.wtw_ventilation_reason') %} {% if r == 'Luchtkwaliteit (VOC)' %}mdi:scent {% elif r == 'Fijnstof (PM2.5)' %}mdi:smog {% elif r == 'Vocht (RH)' %}mdi:water-percent {% else %}mdi:leaf{% endif %}icon_color: >- {% set r = states('input_select.wtw_ventilation_reason') %} {% if r == 'Schoon' %}green{% elif r == 'Vocht (RH)' %}blue{% else %}orange{% endif %}primary: >- {% set r = states('input_select.wtw_ventilation_reason') %} {% if r == 'Schoon' %}Auto-ventilatie: lucht is schoon {% else %}Auto-boost actief: {{ r }}{% endif %}secondary: "VOC max … · PM2.5 max … µg/m³ · RV max …% (… %/u)"multiline_secondary: trueIdle it’s a green leaf reading “lucht is schoon” (clean); during a boost it turns orange for cooking/particulates or blue for moisture, and the secondary line shows the live worst-floor numbers that are driving the decision. When I glance at the popup I know not just that it’s running hard, but why.
Right below the chip, a markdown legend documents the whole loop in plain language — the three triggers, and the fact that it boosts to 100% (high) and settles back to 67% (medium) once every floor has been clean for 15 minutes:
The automation I deleted before building it
The obvious next idea was summer free-cooling: on a warm night, when it’s cooler outside than in, run the fan hard to flush the day’s heat out. The bypass is already open (the unit opens it automatically for exactly this), so you’d just need to crank the fan when indoor temperature is high and outdoor is lower.
I sketched it, and then killed it — because the same worst-floor-wins logic that’s correct for air quality is wrong for temperature.
Heat rises, and my attic is a permanent hotspot: it’s the warmest floor essentially always. Drive a night-purge off the max indoor temperature and the attic never reaches the comfort target, so the “stop” condition never fires — the fan runs at 100% most summer nights while the ground floor gets needlessly over-cooled, chasing an attic that won’t come down. Bad air anywhere is a reason to ventilate the whole house; one hot room is not a reason to chill all the others.
You could rescue it — drive off the bedrooms you actually sleep in, or gate it on the lower floors also being warm — but that’s real complexity for a marginal gain over what the automatic bypass already does passively. So it went in the bin before it ever ran. Knowing not to build something is a feature.
Testing without waiting for dinner
You don’t want to stand in the kitchen with a frying pan to test an automation. Triggering it through the MCP server with skip_condition: true runs the action path directly:
automation.trigger automation.wtw_vraaggestuurde_boost (skip_condition: true) → fan.wtw_ventilatie = 100% → input_select.wtw_ventilation_reason = "Vocht (RH)"One honest wrinkle: a manual trigger has no trigger.id, so the reason template falls to its else branch and always says “Vocht (RH)”. That’s a testing artifact, not a bug — a real VOC or PM2.5 event carries its trigger id and picks the right label. The action path is what you’re verifying here; the trigger thresholds prove themselves the next time someone actually cooks, and the automation trace tells you whether they fired at the right sensitivity.
What I’d take away from this
- Max, not mean. For whole-house ventilation off per-room sensors, worst-room-wins. An average is the one transform that hides the exact event you care about.
- When a threshold can’t separate two cases, try the derivative. “High humidity” couldn’t tell a shower from a sleeping body; the rate of humidity could. A
derivativehelper turned a broken automation into a working one. - Calibrate against your own history, not round numbers. A week of statistics set every threshold here — and set the deadband between boost and release so the loop can’t oscillate.
- Make automated decisions legible. An
input_selectreason + a recolouring chip is the difference between trusting your house and fighting it. - The best automation is sometimes the one you don’t build. Free-cooling died on a one-sentence argument about where heat goes. Cheaper to delete an idea than to debug it at 3am in July.
The dashboard let me see the unit think. Now it thinks for itself — and tells me why.