Automating my Fantech HRV with Modbus

Date: 2026-09-20

Tags: Home Automation, ESPHome


Ventilation in my home is handled by a Fantech Hero 120H heat-recovery ventilator (HRV), managed by a Fantech EDF-8 wall controller. An HRV exchanges stale indoor air for fresh outdoor air while transferring heat between the two streams, so you get ventilation without throwing away all the energy spent heating or cooling your home.

I wanted my HRV integrated into Home Assistant so that ventilation tracks humidity, occupancy, and air quality, rather than sitting at whatever fixed speed I last picked on the wall.

To automate this I used:

  • A Seeed Studio Xiao ESP32-C6 (other ESP32 boards work too)

  • An RS-485 to UART transceiver module

../../../_images/final_result.webp

The final result, the HRV controller is in the top right of the image.

I took the hard path and reverse engineered the protocol between the EDF-8 and the HRV; that’s documented below for anyone who wants to use the interface. The reverse engineering effort turned out to be unnecessary, because the Hero 120H also speaks Modbus, and that’s what I actually used.

Reverse engineering the two-wire controller

Before I attempted the easier path (next section), I reverse engineered my two-wire controller. I put a scope on the two controller wires, probe on the white wire and ground on the black, and captured the bus in every controller state: each fan speed, each air source, off, periodic mode, and auto mode with a humidity setpoint.

Physical layer

  • The controller uses only two wires, data to/from the HRV and power from the HRV share the same pair.

  • The manual says the two wires do not have polarity.

    • I assume there is a bridge rectifier in the controller.

  • The bus idles high at 14.3 V with 1.4 V peak-to-peak ripple.

    • I assume there is a reservoir capacitor to ride through the brief lows during data TX/RX.

  • The two transmitters produce two different swings:

    • HRV: high ~14.3 V, low ~0 V.

    • Controller: high ~11 V, low ~3 V.

    • Exact values likely depend on the resistance between the HRV and controller.

Bus traffic

The HRV is the bus master and polls the controller about once per second (1020 ± 5 ms). Each cycle is two transmissions:

  1. Poll: the HRV sends a single byte, 0x2A (*).

  2. Response: the controller answers with a 6-byte ASCII frame, $C<air><fan>H>.

../../../_images/hrv_two_wire_poll_response.png

Oscilloscope capture of a single poll/response cycle.

The response frame is the controller telling the HRV its current selection:

Byte

Value

Meaning

0

$ (0x24)

start delimiter

1

C (0x43)

constant in every capture, purpose unknown

2

E, R, or S

air source: External, Recirculate, Standby (off)

3

L, M, or H

fan speed: Low, Medium, High

4

H (0x48)

constant in every capture, purpose unknown

5

> (0x3E)

end delimiter

For example, medium fan with external air is $CEMH>, and off is $CS?H> with the fan byte retaining its previous value.

Pressing buttons on the controller produces no extra traffic. The 1 Hz response is the command channel.

Other controller features

The captures when the controller is in periodic mode (20 minutes per hour) and hygrostat mode (humidity control) are byte-for-byte identical to the corresponding manual-speed captures.

The controller resolves its mode and setpoint locally and transmits only the resolved air source and fan speed.

I didn’t end up using the two wire bus because it’s bespoke, and there was an easier path.

Bypassing the controller with Modbus

Although my EDF-8 is a two-wire controller, the Hero 120H manual documents a priority order for all of its control inputs:

  1. Remote Off (S+/S-)

  2. Timer (T+/T-)

  3. Defrost Cycle (internal)

  4. Dehumidistat (D+/D-)

  5. Four-wire controller (B/A)

  6. Two-wire controller (W/W)

  7. Speed selection switch

The list is ordered by precedence, and my EDF-8 sits near the bottom of it. Above my EDF-8 is the “four-wire controller” on terminals B/A, and B/A is the classic naming for the two lines of an RS-485 differential pair.

Dustin Thomson had a similar Fantech Hero HRV with a Modbus controller, reverse engineered its RS-485 interface, and documented the Modbus registers. I had no confirmation that this would work on my Hero 120H, so I blindly tried Modbus. I wired an ESP32-C6 and an RS-485 transceiver to the B/A terminals and queried register 0x3E; it worked, and the register map from Dustin’s post applied to my unit as-is.

Hardware

The ESP32-C6’s UART pins connect to the RS-485 to UART transceiver, and the board’s A/B/G outputs connect to the HRV’s B/A/G terminals.

ESP32-C6 Pin

RS-485 to UART transceiver

3.3V

VCC

GND

GND

GPIO16

TXD

GPIO17

RXD

RS-485 to UART transceiver

HRV

A+

A

B-

B

GND

G

Case

Inspired by Guo-Rong’s case design for the pico-cec project (see Automating TV power with HDMI-CEC), I built a 3-piece design:

  1. A lid.

  2. A middle tray for the Xiao ESP32-C6.

  3. A bottom tray for the RS-485 to UART transceiver module.

I took an off-the-shelf 3D model for the Xiao ESP32-C6, and had Sonnet create an OpenSCAD model of the RS-485 module from my measurements.

I used OpenSCAD because it creates 3D models from code, which makes it easy for LLMs to use.

I gave Sonnet instructions on how to render .scad files to images and set it to work adapting the pico-cec design for this project. This took many iterations; Sonnet’s vision capabilities still can’t tell when something is parallel or perpendicular.

../../../_images/3d_model.png

Transparent render of the case.

The assembly is straightforward: 4 hookup wires soldered between the two boards, with 4 × 20 mm M3 screws and nuts to hold everything together.

../../../_images/hrv_controller.webp

Assembled controller.

I used the first iteration of this case I printed, but there are some things I would change if I printed another:

  • The screw holes were too small, I had to drive the screws into the plastic itself.

  • The ESP32-C6 tray had too much extra space at the back.

All design files for the case are on GitHub: newAM/fantech-hrv-esp32c6

Mounting

I mounted the assembly next to the HRV with some metal strapping that was lying around.

../../../_images/final_result.webp

The final result, the HRV controller is in the top right of the image.

Firmware

I adapted Dustin Thomson’s ESPHome configuration with some changes:

  • Added API encryption.

  • Added an "Unset" option mapped to register value 0.

    • 0x3E reads 0 until it has been written over Modbus at least once.

  • Changed board specific configuration to match my Xiao ESP32C6.

  • Changed the select mode to optimistic. The UI updates immediately on write instead of waiting for the next poll.

  • Fixed compiler warnings.

The full ESPHome configuration is on GitHub: hrv.yaml

Home Assistant

Home Assistant auto discovered the new ESPHome device.

There are only two useful entities that I put on my dashboard: the intake temperature, and the HRV mode.

../../../_images/hrv_card_home_assistant.png

HRV card in Home Assistant.

The most useful automation I have created turns on HRV ventilation when carbon dioxide levels exceed 800 ppm. For why that threshold matters, see Mike Bowler’s excellent piece on carbon dioxide: The bottleneck might be the air in the room

What happened to the wall controller

The EDF-8 is still on the wall, and as long as I only read registers over RS-485 it works as before: it keeps answering the HRV’s polls, state changes made on the controller still reach the HRV, and its display stays accurate.

The first write over Modbus changes the relationship. After setting the state via Modbus the original controller does nothing:

  • State changes made on the controller no longer apply to the HRV.

  • The controller’s display becomes inaccurate: changes made over RS-485 do not update the display, it shows the controller’s last selection rather than what the HRV is actually doing.