skip to content
BitsAndBytes
Table of Contents

I have a Zehnder ComfoAir E400 heat-recovery ventilation unit (MVHR) wired into Home Assistant via the excellent yoziru ESPHome project. Getting it into Home Assistant in the first place — reading the unit over RS485 and controlling the fan with a Shelly — was its own afternoon of work; this post is the layer on top. It throws off ~76 entities — temperatures, humidities, flow rates, fan duty cycles, bypass states — but they were scattered across the entity list with no story. I wanted one card I could glance at, tap, and actually understand.

This is the write-up of building that card. The interesting parts aren’t the YAML — they’re the three places where the obvious approach doesn’t work: a stock example card written for a different model, a “days until filter change” sensor that never reports a number, and a bypass you can watch but can’t touch.

The whole thing was built by talking to Claude Code, which edits my dashboards directly through the HA MCP server I set up last time. No hand-editing ui-lovelace.yaml, no copy-paste — just “make the bypass status more prominent with the reason” and watching it land.

The finished ventilation popup in Home Assistant: a self-explaining bypass status card at the top, the picture-elements airflow diagram with live temperatures and humidities on each of the four air streams, the fan-speed slider with Laag/Normaal/Hoog/Boost/Filter-reset buttons, and a markdown explainer at the bottom.

The airflow diagram: a picture-elements card

The yoziru repo ships an example picture-elements card: a schematic of the unit with sensor values floated on top at percentage coordinates. It’s exactly the mental model I wanted — you see the four air streams instead of reading a list.

The catch: the example is written for the ComfoAir Q350, whose entities are named sensor.zehnder_comfoair_q350_*. My E400 (via ESPHome) names them sensor.comfoair_*. So every entity had to be remapped, and a handful of Q350-only sensors — bypass-change timers, temperature profiles, heating/cooling-season binary sensors — simply don’t exist on my unit and got dropped.

A picture-elements card is just a background image plus elements positioned with top/left percentages:

type: picture-elements
image: /local/images/ventilation.png
style: |
ha-card { height: 220px !important; }
elements:
- type: state-label
entity: sensor.comfoair_extract_temperature # air leaving the house
style: {top: 20%, left: 91%, color: darkred}
- type: state-label
entity: sensor.comfoair_supply_temperature # fresh air into the house
style: {top: 76.2%, left: 91%, color: darkred}
- type: state-label
entity: sensor.comfoair_outdoor_temperature # incoming from outside
style: {top: 20%, left: 13%, color: "#4171b1"}
- type: state-label
entity: sensor.comfoair_exhaust_temperature # blown back outside
style: {top: 76.2%, left: 13%, color: "#4171b1"}
# ...humidities, flow rates, status, RMOT, bypass

The four corners map to the four air streams an MVHR juggles: outdoor (fresh, pre-exchanger) → supply (fresh, post-exchanger, into the house), and extract (stale, from the house) → exhaust (cooled, back outside). The heat exchanger sits in the middle trading warmth between the two.

Gotcha: those top/left values were tuned for the Q350’s schematic image. If your ventilation.png differs even slightly, labels land in the wrong spot and you nudge percentages 2–3% at a time. Coordinates are relative to the card box, so changing the card height just scales everything uniformly.

One card, two behaviours: the Bubble Card popup

I already drive my floor/room cards with Bubble Card: a compact Mushroom template card shows a one-line summary, and tapping it navigates to a #hash that a Bubble Card pop-up listens on. I wanted ventilation to behave identically — a small tile on the home view, a rich popup on tap.

The trigger is a mushroom-template-card with an emoji status line and a navigation action:

type: custom:mushroom-template-card
icon: mdi:hvac
icon_color: "{{ 'blue' if is_state('binary_sensor.comfoair_bypass','on') else 'teal' }}"
primary: Ventilatie
secondary: >-
{% set level = states('sensor.comfoair_ventilation_level')|float(0) %}
{% set temp = states('sensor.comfoair_supply_temperature')|float(0) %}
{% set hum = states('sensor.comfoair_extract_humidity')|float(0) %}
{% set f = ' 🔴' if is_state('binary_sensor.comfoair_filter_warning','on') else ' 🟢' %}
{{ '%.2f'|format(level) }}% | {{ '%.1f'|format(temp) }}°C → | {{ '%.2f'|format(hum) }}% 🏠 | Filter{{ f }}
tap_action:
action: navigate
navigation_path: "#ventilatie"

That renders as a compact tile on the home view, sitting alongside my room cards:

The compact ventilation tile on the home view, reading "Ventilatie — 48.00% | 18.7°C → | 54.80% 🏠 | Filter 🟢": ventilation level, supply-air temperature, indoor humidity, and the filter-health dot.

The popup is a single bubble-card whose content lives in its own nested cards: array — the diagram, the fan control, a bypass status card, and an explainer all stack inside it:

type: custom:bubble-card
card_type: pop-up
hash: "#ventilatie"
name: Ventilatie
icon: mdi:hvac
cards:
- type: vertical-stack
cards:
- <bypass status card>
- <picture-elements diagram>
- <mushroom-fan-card>
- <markdown explainer>

One detail worth stealing: format your numbers in the template. The raw humidity sensor reports 54.2999992370605, which is unreadable. {{ '%.2f'|format(hum) }} caps it; temperatures read fine at one decimal. Small thing, big difference on a phone.

The fan has three speeds — and a boost that reverts itself

fan.wtw_ventilatie has no preset modes; it’s a percentage fan with a step of ~33.33%. That maps cleanly to the ComfoAir’s discrete levels:

PercentageLevelLabel
0%0Away / Afwezig
33%1Low / Laag
67%2Medium / Normaal
100%3High / Hoog

So the popup gets four sub-buttons — Laag/Normaal/Hoog set a level directly, and a Boost that runs to 100% for 30 minutes and then puts it back where it was. That last bit needs a script, because a dashboard button can’t time itself:

alias: ComfoAir Boost 30 min
mode: single
sequence:
- variables:
prev_pct: "{{ state_attr('fan.wtw_ventilatie','percentage') | int(67) }}"
- action: fan.set_percentage
target: {entity_id: fan.wtw_ventilatie}
data: {percentage: 100}
- delay: "00:30:00"
- action: fan.set_percentage
target: {entity_id: fan.wtw_ventilatie}
data: {percentage: "{{ prev_pct }}"}

mode: single is the important choice. It captures the previous speed once at the start; pressing Boost again while it’s running is ignored, so a second press can’t “remember” 100% as the level to revert to. The trade-off is that a re-press doesn’t extend the timer, and an HA restart mid-boost loses the pending revert — for survive-restart behaviour you’d swap the in-script delay for a timer helper plus an automation.

The filter countdown the unit refuses to give you

I wanted “X days until filter change” on the card. There’s a sensor.comfoair_filter_remaining with a d (days) unit, so this looked trivial. It wasn’t — it read unknown. A week of history told the real story:

sensor.comfoair_filter_remaining
unknown → unavailable → unknown → unavailable → unknown …

It has never reported a number. The flips between unknown and unavailable just track the ESPHome device reconnecting. This is a firmware/protocol limitation of the E400 — the value isn’t in the data the unit sends. The filter entities that do work are the binary warning, the binary status, and a number for the replacement interval (180 days).

So a real countdown has to be tracked in Home Assistant, not read from the unit. Three pieces:

  1. An input_datetime.comfoair_filter_last_changed (date only) — when I last changed the filters.

  2. A template sensor that counts down from the configured interval:

    {% set i = states('number.comfoair_filter_replacement_interval')|float(180) %}
    {% set ts = state_attr('input_datetime.comfoair_filter_last_changed','timestamp') %}
    {% if ts is not none %}
    {{ (i - (as_timestamp(now()) - ts) / 86400) | round(0) }}
    {% else %}unknown{% endif %}
  3. A reset script that stamps the date to today and presses the unit’s own button.comfoair_reset_filter_timer, surfaced as a confirm-protected button in the popup.

The interval is read live from the number entity, so if I change it on the unit the countdown follows. It’s a small amount of plumbing to replace one sensor the hardware should have provided — but now the card shows Filter 179d 🟢 and means it.

The bypass: a switch you can see but not flip

“Should there be a button to open/close the bypass?” My first instinct was yes. Then I checked the entities:

binary_sensor.comfoair_bypass (on/off — read-only)
binary_sensor.comfoair_bypass_motor_extract_air
binary_sensor.comfoair_bypass_motor_outdoor_air

All three are binary_sensor. There is no switch, select, or number for the bypass anywhere in my ComfoAir entities. On the E400 the bypass is fully automatic — the unit decides, and nothing in HA can command it. A button would have had nothing to call.

That’s actually fine, because the logic is simple and the unit is good at it:

  • Closed (normal): air goes through the heat exchanger and recovers warmth. What you want in heating season.
  • Open: fresh outdoor air skips the exchanger and comes in at its real temperature. The unit does this for free summer cooling — when it’s warmer inside than your comfort setpoint and cooler outside.

Mine was open the whole time I built this: it’s June, indoor (extract) air ~22.6 °C, outdoor ~17 °C. Cooler outside than in, so the unit opened the bypass to free-cool the house. Working exactly as designed.

Since I can’t control it, I made it explain itself — a Mushroom template card at the top of the popup that switches icon, colour, and text on the bypass state, and reads out the live temperatures driving the decision:

type: custom:mushroom-template-card
entity: binary_sensor.comfoair_bypass
icon: "{% if is_state('binary_sensor.comfoair_bypass','on') %}mdi:valve-open{% else %}mdi:valve-closed{% endif %}"
icon_color: "{% if is_state('binary_sensor.comfoair_bypass','on') %}blue{% else %}green{% endif %}"
primary: >-
{% if is_state('binary_sensor.comfoair_bypass','on') %}Bypass OPEN — vrije koeling
{% else %}Bypass DICHT — warmteterugwinning{% endif %}
secondary: >-
{% set out = states('sensor.comfoair_outdoor_temperature')|float(0) %}
{% set ind = states('sensor.comfoair_extract_temperature')|float(0) %}
{% if is_state('binary_sensor.comfoair_bypass','on') %}
Verse lucht omzeilt de wisselaar: buiten {{ '%.1f'|format(out) }}°C is koeler
dan binnen {{ '%.1f'|format(ind) }}°C, dus koele buitenlucht komt direct binnen.
{% else %}
Lucht gaat dóór de wisselaar; warmte uit de afvoerlucht wordt teruggewonnen.
{% endif %}
multiline_secondary: true
tap_action: {action: more-info}

If you genuinely want manual control, it lives where the unit’s logic lives: the Zehnder ComfoConnect app (bypass mode Auto/Open/Closed and the comfort temperature), or — the deep end — adding a bypass override to the ESPHome firmware if the protocol accepts one. For me, Auto plus a self-explaining indicator is the right answer.

Don’t forget the legend

The last addition was the most boringly useful: a markdown card at the bottom of the popup explaining every value — the four air streams, what “ventilation level %” actually means (motor modulation to hit a target airflow, not the speed setting — and a slow climb at a fixed speed means the filters are clogging), RMOT, the bypass states, and what the four numbers on the trigger button map to. Six months from now I won’t remember why one humidity reading is tagged 🏠 (it’s the extract/indoor stream). The card will.

What I’d take away from this

  • Borrowed example cards are a starting point, not a paste. Entity names differ across models; verify every one and drop what your hardware doesn’t expose.
  • Trust history over the live state. “Why is this unknown?” is answered by a week of history, not a single read. It turned a five-minute card tweak into “the firmware doesn’t provide this, build it yourself.”
  • Read-only is information, not a dead end. You can’t flip the bypass, but you can make it the clearest thing on the card.
  • Build it conversationally. Every card, helper, and script here was created by describing what I wanted to Claude over the HA MCP server, iterating in plain language — “trim the line, it’s overflowing,” “make ‘binnen’ a house emoji” — and letting it apply the changes with optimistic-locked config edits. The dashboard YAML in this post is the output, not something I typed.

The unit was always ventilating my house just fine. Now I can finally see it think.