skip to content
BitsAndBytes
Table of Contents

Our house came with a Zehnder ComfoAir E400 — the heat-recovery ventilation unit (MVHR / WTW) that quietly runs 24/7 and that you normally interact with through a tiny display and three preset levels. No app, no API, no data.

It turns out you can have all of it: every temperature, humidity and flow sensor live in Home Assistant, and smooth control over the fan level — with two cheap off-the-shelf devices and an afternoon of work.

This post documents the full build, including the mistakes, because those turned out to be the most instructive part.

The architecture

The trick is that reading and controlling use two independent paths to two different connectors on the unit:

  • Read — an M5Stack Atom Lite with an RS485 base talks Modbus RTU to the unit’s C3 connector. This exposes everything: temperatures, humidity, flow rates, fan duty, bypass position, even the firmware version.
  • Control — a Shelly Dimmer 0/1-10V feeds an analog 0-10V signal into the unit’s C1 connector, which sets the ventilation level.

The two never talk to each other. The Atom only listens; the Shelly only steers. Home Assistant is where they meet.

flowchart TB
HA["Home Assistant"]
ATOM["M5 Atom Lite + Atomic RS485 base<br/>(ESPHome)"]
SHELLY["Shelly Dimmer 0/1-10V PM Gen3"]

ATOM -->|"sensor data · native API (Wi-Fi)"| HA
HA -->|"brightness 0-100% (Wi-Fi)"| SHELLY

subgraph WTW["Zehnder ComfoAir E400"]
C3["C3 — RS485 service connector"]
C1["C1 — analog 0-10V input"]
end

C3 -->|"Modbus RTU · 19200 8N1"| ATOM
SHELLY -->|"0-10V analog signal"| C1

Parts list

PartRole
M5Stack Atom LiteESP32 running ESPHome
M5Stack Atomic RS485 baseRS485 transceiver, clicks onto the Atom
Shelly Dimmer 0/1-10V PM Gen3 (S3DM-0010WW)0-10V control signal
A short length of cableDonor for two thin signal wires — any cable; I used offcuts of ordinary power flex
5V USB-C chargerPowers the Atom
T10 Torx screwdriverDisplay cover of the unit

The software side is the excellent CodedCactus/zehnder-comfoair ESPHome package.

Shelly model-number minefield. If you want Zigbee/Matter instead of Wi-Fi, the Gen4 successor S4DM-0010WW does exactly the same job. But watch the lookalikes: S4SW-001X16EU is a relay switch (on/off only, no 0-10V) and S4DM-0A101WWL is a phase-cut mains dimmer (dims lamps, no 0-10V output). The string to look for is 0010 = 0-10V. Ask me how I know.

Part 1 — Reading the unit over RS485

Wiring the C3 connector

Remove the cover around the display (3× Torx T10). The C3 connector sits top-right, next to the display, with A+ and B- terminals. Two wires run from there to the screw block on the Atomic RS485 base:

Zehnder C3RS485 base
A+A (485A)
B-B (485B)

That’s the entire physical interface. Notes from the field:

  • Any two wires work at this distance. RS485 folklore demands twisted pair, but that buys noise rejection over long runs. At 20 cm it’s irrelevant — I just used two cores snipped from a length of ordinary power cable, and it’s been rock-solid.
  • The base’s two power terminals stay empty — the Atom gets USB-C power.
  • No 120 Ω terminator needed on a short run (the base doesn’t have one built in anyway).
  • The base clicks onto the Atom via its 9-pin connector; TX/RX/power flow through automatically. The transceiver does automatic direction switching, so the flow_control_pin you’ll see mentioned in MAX485 tutorials does not apply here — there is literally no direction pin to wire.

ESPHome from the laptop

I run ESPHome locally rather than as a Home Assistant add-on. It’s a Python package:

Terminal window
python3 -m venv ~/esphome-venv
source ~/esphome-venv/bin/activate
pip install esphome
mkdir wtw_esphome && cd wtw_esphome
esphome dashboard --open-ui .

Secrets live in secrets.yaml next to the configs:

wifi_ssid: "YourSSID"
wifi_password: "..."
wifi_ota_password: "..."
api_key: "44-char base64 string, generate with: openssl rand -base64 32"

The config

In the dashboard: New device → Empty Configuration, then paste. This is the full working file — based on the repo’s own examples/m5stack.yaml, which is the author’s tested config for exactly this hardware:

esphome:
name: comfoair
friendly_name: ComfoAir
esp32:
board: m5stack-atom
framework:
type: esp-idf
logger:
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
password: !secret wifi_ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: on
min_auth_mode: wpa2
substitutions:
tx_pin: GPIO19 # per the repo's tested m5stack example
rx_pin: GPIO22
update_interval: 15s
packages:
remote_package:
url: https://github.com/CodedCactus/zehnder-comfoair
ref: main
files: [components/zehnder_fw2.yaml,
components/filter.yaml,
components/extra-sensors.yaml]
refresh: 0s

Things worth knowing about this config:

  • Firmware variants. The repo ships zehnder_fw1.yaml / fw2 / fw3 for the different unit-firmware generations, plus a zehnder.yaml fallback that only logs a warning telling you to pick one. The core sensors live in a shared base that all variants include, so a “wrong” pick doesn’t break anything — and the unit tells you which one is right: one of the entities you get is Firmware version (register 0x06E, e.g. 20800 = 2.8.0). First digit → file. Mine reads 2.x → fw2.
  • The serial parameters (19200 baud, 8N1) and all Modbus plumbing live inside the package. The pins are the only hardware-specific thing you set.
  • tx_pin/rx_pin labeling on these bases is perspective-dependent (the ESP’s TX is the transceiver’s RX). Trust the repo’s example first; if it doesn’t talk, swapping them is a 2-minute OTA flash.

First flash goes over USB-C (the Atom auto-enters bootloader mode — no buttons to hold). Every flash after that is OTA over Wi-Fi.

Adopting it in Home Assistant

Once the node is on Wi-Fi, HA pops a “Discovered: ESPHome” card under Settings → Devices & Services. Because the node was built outside the HA add-on, HA asks for the API encryption key — that’s the api_key value from secrets.yaml.

After that: a device page with ~35 entities. Outdoor/supply/extract/exhaust temperatures, humidity, flow rates in m³/h, fan duty, bypass position, power. Several diagnostic entities (you’ll want them later — see Part 4) are present but disabled by default; enable them from the device page.

When it doesn’t talk: the 2×2 method

My first boot produced an endless stream of this:

[W][modbus:063]: Stop waiting for response from 1 256ms after last send
[D][modbus_controller:038]: Modbus command to device=1 register=0x12C no response received

This log is actually good news in disguise: compile, flash, Wi-Fi and the Modbus component are all proven working — the ESP is polling the right registers and hearing nothing back. That leaves exactly two binary unknowns: the tx/rx pins in YAML and the A/B wires at the terminals. 2 × 2 = four combinations, one of which is right. You eliminate them cheapest-first, watching esphome logs comfoair.yaml over Wi-Fi the whole time:

flowchart TD
T["Modbus timeouts:<br/>'Stop waiting for response'"] --> Q0{"Are the wires<br/>actually connected?"}
Q0 -->|"no (ahem)"| W0["Connect C3 A+/B-<br/>to the base"] --> OK["Sensor values appear"]
Q0 -->|yes| S1["Swap the two wires A↔B<br/>(30 s, no reflash)"]
S1 -->|"still timeouts"| S2["Swap tx/rx in YAML<br/>→ OTA flash"]
S2 -->|"still timeouts"| S3["Swap the wires back"]
S3 -->|"still timeouts"| PHY["Physical checks:<br/>terminals biting copper, not insulation<br/>wires in A/B, not the power pair<br/>C3 connector seated<br/>optionally add a GND wire"]
S1 -->|"data!"| OK
S2 -->|"data!"| OK
S3 -->|"data!"| OK

Full disclosure: my timeouts had the most complete explanation possible — the wires weren’t screwed in yet. The ESP spent ten minutes politely calling into thin air. The debug tree above still stands; just start at the first diamond.

Part 2 — Telling the unit to listen (the analog input)

The 0-10V input must be enabled and scaled on the unit itself. On the display: menu → login 4210 → analog 0-10V:

SettingValueWhy
min. instelling0 Vscaling starts at zero
max. instelling10.0 Vthe Shelly outputs a true 0-10V swing
activatieoninput is live

That 10 V maximum matters: the common alternative build drives this input straight from an ESP32 DAC pin, which tops out at 3.3 V — those guides have you lower the max to ~3.1 V so the unit rescales. With a real 0-10V source like the Shelly, you keep the full range and the clean mental model: 0-100% slider = 0-10 V = full ventilation range.

Part 3 — The control side: a Shelly as 0-10V source

⚠️ This is the one mains-voltage step. The Shelly sits on 230 V. Breaker off, verify dead, and if fixed wiring isn’t your comfort zone, this part is a 10-minute job for an electrician.

The Gen3’s terminal block, left to right: − + (the 0-10V output pair) — then S2 O S1 L N.

TerminalConnect to
L230 V live (brown)
N230 V neutral (blue)
+Zehnder C1 0-10V
Zehnder C1 GND
O, S1, S2nothing

Three lessons from this step:

  1. O is not L. O is the switched output (for the lamp this device normally dims), not a power input — the phase does nothing there. Live goes into L, the rightmost-but-one terminal.
  2. The shared GND is the whole signal. The wire to C1’s GND gives the 0-10V signal its reference. Without it, the voltage is meaningless. This is the classic mistake with analog control.
  3. The WTW keeps its own mains supply. Nothing from the Shelly’s load side goes to the unit — the Shelly only contributes the small control signal. (And resist the temptation to leave the unit on a cheap power strip after testing: a ventilation unit is a 24/7 appliance and deserves its own socket.)

Power back on, join the Shelly to Wi-Fi via the Shelly app, and HA auto-discovers it. Set it to 0-10V mode (not 1-10V — that mode can never go below 1 V). It appears as a dimmable light: brightness 0-100% = 0-10 V out.

Part 4 — Plot twist: your ventilation unit runs an auction

With everything wired, I hit the first real surprise: Shelly at low, unit stubbornly on level 2. The explanation is buried in the component source, and it reframes how you think about this unit:

- platform: combination
name: "Ventilation level"
type: max
sources: [analog_setpoint, rf_setpoint, three_way_setpoint, bathroom_setpoint]

There is no priority order and no fallback. There is an auction. Four control sources each hold a bid — the analog input (0x148), the RF channel (0x149), a 3-way switch input (0x14A), the bathroom switch — and the unit ventilates at the maximum of all of them.

flowchart LR
A["Analog setpoint<br/>Shelly · 0x148"] --> MAX{{"max()"}}
R["RF setpoint<br/>display & remotes · 0x149"] --> MAX
T["3-way switch<br/>0x14A"] --> MAX
B["Bathroom switch"] --> MAX
MAX --> V["Ventilation level"] --> F["Fans"]

My unit is the “E400 RF” variant (the model register 0x070 says so), and on it the display preset rides the RF channel. Display on level 2 = RF bid of 50% = max(anything_lower, 50) = level 2, forever. The fix is one menu action: set the display/RF level to 1, dropping that bid to the floor. From then on the Shelly’s bid carries.

Consequences of max-wins, which turn out to be exactly what you want:

  • The Shelly sets the floor, not an override. Anyone can still boost from the display or a bathroom switch — their bid temporarily exceeds yours — but nobody can push ventilation below what your automation demands.
  • 0 V means “no bid”, not “minimum”. The unit can’t distinguish 0 V from a broken wire, so at 0 V the analog source simply leaves the auction and the other sources rule. Fail-safe by design — and a free feature: Shelly entity off = manual mode at the unit.
  • The practical floor for “low” is therefore a small non-zero voltage, just above the unit’s detection threshold. Find it empirically: creep the slider up from 0% and watch for the moment the analog setpoint registers. Mine takes over around ~10%.

Part 5 — Verify and calibrate

The beauty of building the read-out first: the Modbus side becomes the measuring instrument for the control side. Enable these disabled-by-default entities on the device page and you can watch the full chain:

flowchart LR
S["Shelly slider<br/>50%"] --> W["wire<br/>5.0 V"] --> V1["0x13C<br/>Analog input voltage<br/>5.0 V"] --> V2["0x148<br/>Analog control setpoint<br/>50%"] --> AU["max() auction"] --> FL["Ventilation level<br/>→ flow m³/h"]

Each entity answers a different question:

Entity (register)Question it answers
Analog input voltage (0x13C)did the signal arrive? (electrical)
Analog control setpoint (0x148)was it understood? (scaling)
RF control setpoint (0x149)is the display outbidding me?
Ventilation levelwho won?
Flow rate (0x138/0x139)did the fans actually follow?

Diagnosis becomes mechanical: voltage right but setpoint wrong → unit-menu scaling. Setpoint right but level different → another source is outbidding you. Level right but flow wrong → that’s an actual unit problem.

Part 6 — The dashboard card

For the visual, I adapted the picture-elements card from yoziru/esphome-zehnder-comfoair. Fun fact: that repo targets a different unit (ComfoAir Q, which talks CAN bus) — and it doesn’t matter at all, because a picture-elements card is gloriously dumb. It’s three ingredients:

  1. A static PNG (the hexagon heat-exchanger graphic with the arrows) — grab ventilation.png from the repo into config/www/images/, referenced as /local/images/ventilation.png.
  2. state-label elements pinned at top/left percentages over the image, each rendering one entity’s current state.
  3. Entity IDs — the card’s only interface to the world. It doesn’t know CAN from Modbus from Wi-Fi; it just asks HA for states.

Adapting it = find-and-replace the entity IDs with your CodedCactus ones, delete the labels for Q-series-only data (RMOT, heating/cooling season, temperature profile), and nudge percentages where labels land oddly. One trap: the style: | block at the top of the example uses card-mod syntax — delete it if you don’t run that HACS plugin.

Part 7 — A printed box for the Shelly

The one part of this build that ends up sitting on 230 V is the Shelly, and a bare module dangling on its wires next to the unit always bugged me. So the last thing I did was print an enclosure for it: the MakerWorld model “SHELLY 1 PLUS GEN 3 / GEN 4 BOX”1. It’s drawn for the Shelly 1 Plus Gen 3 / Gen 4 modules rather than for the 0/1-10V dimmer specifically, but as a tidy housing for a mains-powered Shelly it does the job.

The practical details, from the model page1:

  • Outer dimensions 100 × 70 × 21 mm — small enough to tuck near the unit.
  • A cable passage sized for an 8 mm cable, so the flex enters cleanly.
  • A cover that snaps on and also takes 2× M3×8 screws, so it actually stays shut.
  • The published print profile is 0.2 mm layers, 3 walls, 15% infill, which comes out to roughly 1.8 hours of printing.

One honest caveat: a box keeps the module from flopping around loose and gives the wiring somewhere to live, but it does not make the mains side magically safe or up to code. The earlier breaker-off, verify-dead warnings from Part 3 still apply in full — an enclosure is a tidiness-and-strain-relief upgrade, not an electrical sign-off.

The gotcha recap

  • The README’s generic config and the repo’s tested example can drift apart — examples/m5stack.yaml had different pins, board, framework and component files than I’d assembled from the docs. The example wins.
  • zehnder.yaml is a fallback stub; pick your fw1/fw2/fw3 and let the Firmware version entity confirm it.
  • Modbus timeouts with a healthy boot = a wiring/pin permutation problem. Four combos, eliminate cheapest-first. (Or, you know, connect the wires.)
  • Shelly: 0010 in the model number = the 0-10V variant. OL. The /GND wire is the signal reference.
  • 0-10V mode, not 1-10V. Max. instelling stays at 10 V with a true 0-10V source.
  • The unit is an auction, max bid wins: put the display on level 1 or it caps your range from below, forever and silently.

Sources

Footnotes

  1. “SHELLY 1 PLUS GEN 3 / GEN 4 BOX”, MakerWorld 3D model — https://makerworld.com/nl/models/856825-shelly-1-plus-gen-3-gen-4-box?appSharePlatform=copy (outer dimensions 100 × 70 × 21 mm, 8 mm cable passage, snap-on cover plus 2× M3×8 screws, 0.2 mm / 3 walls / 15% infill print profile, ~1.8 h print time). 2