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.

pulseview

A decoded Modbus frame

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;