Reverse engineering a HRV protocol
Over the past few years, I’ve developed a strong interest in home automation. For example, Home Assistant automatically controls the interior climate by managing the thermostats, opening and closing blinds, and running expeller fans to manage humidity. After installing a Fantech Hero HRV to improve interior air quality, I was disappointed to find there was no existing Home Assistant integration. My goal was to integrate the HRV into the automation system to optimize comfort and energy use: vent the minimal amount required to maintain air quality, while taking into account temperature set points, humidity, and outdoor air quality.
Protocol Investigation
To complete the installation, an ECO-Touch controller is required to balance the HRV’s airflow (ensure the intake and exhaust rates are equal). The manual mentions the communication specification: RS-485 Modbus
.
Before starting this project, I wasn’t familiar with Modbus, although I had a little experience with RS-485. In brief: Modbus is a communication protocol widely used in industrial applications; a client issues commands to a server, which performs operations or returns data (for example: a register’s value). RS-485 is a serial communication standard that uses differential pairs and can operate over relatively long distances.
My high-level plan was to listen to the communication between the ECO-Touch controller (which acts as the client) and the HRV (the server). By manipulating the controller and observing the messages on the wire, I hoped to decode their meaning.
My first attempt was using a RS-485 to USB adapter and a Rust program to decode the traffic. While this worked to a certain degree, it became clear that not all the messages were being decoded. Rather than spend time determining whether the root cause was software or hardware (a missing termination resistor?), I opted to use a more powerful tool: an $8 logic analyzer from AliExpress.
Show me the bits
The logic analyzer is a better option as it is completely passive and invisible to the devices on the RS-485 communication bus. After wiring up the analyzer and configuring the Modbus protocol decoder in PulseView, the messages were properly decoded.
With the messages now being reliably decoded, I essentially brute-forced the protocol: changing various settings on the controller and observing the messages passed between it and the HRV.
The most difficult part of this process was attributing the correct message to the observable effect (eg: a change in register value / physical operation). In some cases there are numerous unrelated intermediate messages, making it difficult to determine which command was responsible. This became easier to manage once I understood the protocol well enough to start implementing it in ESPHome, as I could more easily track state changes in Home Assistant.
Advanced Features
The controller features a maximum speed ventilation for a set duration (eg: 30 minutes). Surprisingly, the controller writes to a register to set the duration, and the HRV is responsible for timekeeping. Once the timer expires, the controller is responsible for setting the current mode. I implemented this mode in Home Assistant as it is helpful to have the ability to vent for a certain amount of time (eg: after a bathroom fan is enabled).
The user can also select a combination mode where the unit will vent for a certain number of minutes per hour and recirculate for the remainder. I didn’t end up implementing this feature as Home Assistant is responsible for determining the mode based on air quality and a variety of inputs.
The controller’s balancing UX is counterintuitive - every time balancing mode is entered, it resets the previous airflow value. I’ve implemented balancing as two diagnostic sliders in Home Assistant, improving the speed and precision of this task.
ESPHome Code
The following is an excerpt of the relevant ESPHome configuration:
substitutions: tx_pin: GPIO3 rx_pin: GPIO1 mode_off: "Off" mode_vent_low: "Vent Low" mode_vent_medium: "Vent Medium" mode_vent_high: "Vent High" mode_timed_max: "Timed Max" mode_recirc_low: "Recirc Low" mode_recirc_medium: "Recirc Medium" mode_recirc_high: "Recirc High" esphome: name: hrv friendly_name: hrv # Disable logging logger: baud_rate: 0 captive_portal: api: services: - service: timed_max variables: time_minutes: int then: - globals.set: id: timed_max_expiry_state value: !lambda |- auto select = id(hrv_mode); auto index_opt = select->active_index(); if (index_opt.has_value() && select->state != "${mode_timed_max}") { return index_opt.value(); } else { return static_cast<unsigned int>(0); } - number.set: id: hrv_high_timer value: !lambda 'return time_minutes;' - select.set: id: hrv_mode option: ${mode_timed_max} globals: # The hrv_mode to change to once timed_max expires - id: timed_max_expiry_state type: unsigned int restore_value: no initial_value: '0' time: - platform: sntp on_time: # Every 5 minutes check to see if we got stuck in vent high mode - seconds: 0 minutes: /5 then: - lambda: |- if (id(hrv_high_timer) == 0 && id(hrv_mode)->state == "${mode_timed_max}") { // the timer has expired but for some reason we are still in the timed max auto call = id(hrv_mode).make_call(); call.set_index(id(timed_max_expiry_state)); call.perform(); } uart: id: fantech_uart baud_rate: 9600 rx_buffer_size: 1500 tx_pin: ${tx_pin} rx_pin: ${rx_pin} modbus: uart_id: fantech_uart id: fantech_modbus modbus_controller: - id: fantech_device address: 0x1 modbus_id: fantech_modbus setup_priority: -10 select: - platform: modbus_controller modbus_controller_id: fantech_device id: hrv_mode name: "Mode" address: 0x3E value_type: U_WORD optionsmap: ${mode_off}: 2 ${mode_vent_low}: 3 ${mode_vent_medium}: 4 ${mode_vent_high}: 5 ${mode_timed_max}: 6 ${mode_recirc_low}: 7 ${mode_recirc_medium}: 8 ${mode_recirc_high}: 9 on_value: then: - lambda: |- if (x != "${mode_timed_max}") { id(timed_max_expiry_state) = 0; id(hrv_high_timer) = 0; } number: - platform: modbus_controller modbus_controller_id: fantech_device id: hrv_high_timer name: "High Timer" address: 0x34 value_type: U_WORD unit_of_measurement: minutes min_value: 0 max_value: 720 # 12 hours multiply: 60 entity_category: DIAGNOSTIC on_value: then: - lambda: |- if (x == 0 && id(hrv_mode)->state == "${mode_timed_max}") { auto call = id(hrv_mode).make_call(); call.set_index(id(timed_max_expiry_state)); id(timed_max_expiry_state) = 0; call.perform(); } - platform: modbus_controller modbus_controller_id: fantech_device id: exhaust_fan_speed name: "Exhaust Fan Speed" address: 0x13 skip_updates: 20 value_type: U_WORD entity_category: CONFIG unit_of_measurement: percent min_value: 50 max_value: 100 lambda: |- if (x == 0) { return 100; } else { return 100 - (65536 - x); } write_lambda: |- if (x == 100) { return 0; } else { return 65536 - (100-x); } - platform: modbus_controller modbus_controller_id: fantech_device id: intake_fan_speed name: "Intake Fan Speed" address: 0x12 skip_updates: 20 value_type: U_WORD entity_category: CONFIG unit_of_measurement: percent min_value: 50 max_value: 100 lambda: |- if (x == 0) { return 100; } else { return 100 - (65536 - x); } write_lambda: |- if (x == 100) { return 0; } else { return 65536 - (100-x); } sensor: - platform: modbus_controller id: intake_temp modbus_controller_id: fantech_device name: "Intake Temperature" register_type: holding address: 0x2F value_type: S_WORD entity_category: DIAGNOSTIC skip_updates: 2 unit_of_measurement: °C device_class: temperature state_class: measurement - platform: modbus_controller modbus_controller_id: fantech_device name: "0x04" register_type: holding address: 0x04 value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x1F" register_type: holding address: 0x1F value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x20" register_type: holding address: 0x20 value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x39" register_type: holding address: 0x39 value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x5F" register_type: holding address: 0x5F value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x60" register_type: holding address: 0x60 value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 - platform: modbus_controller modbus_controller_id: fantech_device name: "0x62" register_type: holding address: 0x62 value_type: U_WORD entity_category: DIAGNOSTIC skip_updates: 5 text_sensor: # The current HRV mode. This may be different from the requested hrv_mode if, for example, a humidistat is calling for # max venting, or if the handler needs to defrost - platform: modbus_controller modbus_controller_id: fantech_device name: "Current Mode" register_type: holding address: 0x28 raw_encode: HEXBYTES entity_category: DIAGNOSTIC lambda: |- uint16_t value = modbus_controller::word_from_hex_str(x, 0); switch (value) { case 2: return std::string("${mode_off}"); case 3: return std::string("${mode_vent_low}"); case 4: return std::string("${mode_vent_medium}"); case 5: return std::string("${mode_vent_high}"); case 6: return std::string("${mode_timed_max}"); case 7: return std::string("${mode_recirc_low}"); case 8: return std::string("${mode_recirc_medium}"); case 9: return std::string("${mode_recirc_high}"); default: return std::string("Unknown (") + std::to_string(value) + std::string(")"); } return x;