Genvex ECO 375, Nibe ERS 10-500, Alpha-Innotec LG 300 ECO B(E) ventilation
Heat Recovery Ventilation system control via ModBUS protocol for Genvex ECO 375, Nibe ERS 10-500, Alpha-Innotec LG 300 ECO B(E). The ventilation unit must contain the Optima 260 (ES 960C) controller board, and have ModBUS enabled at 19600 baud.
Use an RS485 transceiver like MAX485 to the device via ModBUS. Some devices come pre-installed with a cloud controller already plugged in to the ModBUS port, this has to be be disconnected (ESPHome will go instead).
Basic configuration
The configuration below is based on an Olimex ESP32-EVB board for Ethernet connection to the network but any ESP32 module can be used (adjust GPIO pins accordingly).
substitutions:
  device_name: ventilation-system
  friendly_name: "Ventitaltion"
  device_ip: 192.168.1.10
  device_description: "Heat Recovery Ventilation System Control"
  modbus_ctrl_id: genvex_modbus
esphome:
  name: ${device_name}
  comment: "${device_description}"
esp32:
  board: esp32-evb
ethernet:
  type: LAN8720
  mdc_pin: GPIO23
  mdio_pin: GPIO18
  clk_mode: GPIO0_IN
  phy_addr: 0
  manual_ip:
    static_ip: ${device_ip}
    gateway: 192.168.1.1
    subnet: 255.255.255.0
  use_address: ${device_ip}
time:
- platform: homeassistant
  id: homeassistant_time
uart:
  tx_pin: GPIO4 # DI
  rx_pin: GPIO36 # RO
  baud_rate: 19600
  parity: EVEN
  id: mbus
modbus:
  flow_control_pin: GPIO14 # DE+RE
  id: modbus1
  uart_id: mbus
modbus_controller:
- id: ${modbus_ctrl_id}
  address: 1
  modbus_id: modbus1
  setup_priority: -10
  command_throttle: 20ms
  update_interval: 5s
sensor:
- platform: uptime
  name: ${friendly_name} controller uptime
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Inlet fan
  icon: "mdi:fan"
  id: genvex_inlet_fan
  register_type: read
  address: 9
  unit_of_measurement: "%"
  value_type: U_WORD
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Extract fan
  icon: "mdi:fan"
  id: genvex_extract_fan
  register_type: read
  address: 10
  unit_of_measurement: "%"
  value_type: U_WORD
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Supply air
  device_class: temperature
  register_type: read
  address: 0
  unit_of_measurement: "°C"
  value_type: U_WORD
  accuracy_decimals: 1
  filters:
    - filter_out: 0
    - heartbeat: 120s
    - offset: -300
    - multiply: 0.1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Fresh air
  device_class: temperature
  register_type: read
  address: 2
  unit_of_measurement: "°C"
  value_type: U_WORD
  accuracy_decimals: 1
  filters:
    - filter_out: 0
    - heartbeat: 120s
    - offset: -300
    - multiply: 0.1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Exhaust air
  device_class: temperature
  register_type: read
  address: 3
  unit_of_measurement: "°C"
  value_type: U_WORD
  accuracy_decimals: 1
  filters:
    - filter_out: 0
    - heartbeat: 120s
    - offset: -300
    - multiply: 0.1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Extract air
  device_class: temperature
  id: genvex_temp_t7
  register_type: read
  address: 6
  unit_of_measurement: "°C"
  value_type: U_WORD
  accuracy_decimals: 1
  filters:
    - filter_out: 0
    - heartbeat: 120s
    - offset: -300
    - multiply: 0.1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Bypass
  icon: "mdi:debug-step-over"
  unit_of_measurement: "%"
  register_type: read
  address: 11
  value_type: U_WORD
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Humidity
  device_class: humidity
  unit_of_measurement: "%"
  id: genvex_hum
  register_type: read
  address: 13
  value_type: U_WORD
  filters:
    - filter_out: 0
    - heartbeat: 120s
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Mode (modbus)
  icon: "mdi:speedometer-slow"
  id: genvex_mode
  register_type: holding
  entity_category: diagnostic
  address: 0
  value_type: U_WORD
  disabled_by_default: true
  bitmask: 0x7fffffff
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Alarm code (DEC)
  icon: "mdi:alert-circle-outline"
  register_type: read
  entity_category: diagnostic
  address: 15
  value_type: U_WORD
  disabled_by_default: true
  bitmask: 0x7fffffff
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Reheating
  icon: "mdi:water-boiler"
  register_type: read
  unit_of_measurement: "%"
  address: 5
  value_type: U_WORD
  bitmask: 0x7fffffff
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Reheating control
  icon: "mdi:water-boiler"
  register_type: read
  unit_of_measurement: "%"
  address: 12
  value_type: U_WORD
  bitmask: 0x7fffffff
- platform: template
  name: ${friendly_name} Humidity absolute
  lambda: |-
      const float mw = 18.01534;    // molar mass of water g/mol
      const float r = 8.31447215;   // Universal gas constant J/mol/K
      return (6.112 * powf(2.718281828, (17.67 * id(genvex_temp_t7).state) /
        (id(genvex_temp_t7).state + 243.5)) * id(genvex_hum).state * mw) /
        ((273.15 + id(genvex_temp_t7).state) * r); // in grams/m^3
  accuracy_decimals: 2
  update_interval: 120s
  icon: 'mdi:water'
  unit_of_measurement: 'g/m³'
- platform: template
  name: ${friendly_name} Dew point
  lambda: |-
      return (243.5*(log(id(genvex_hum).state/100)+((17.67*id(genvex_temp_t7).state)/
      (243.5+id(genvex_temp_t7).state)))/(17.67-log(id(genvex_hum).state/100)-
      ((17.67*id(genvex_temp_t7).state)/(243.5+id(genvex_temp_t7).state))));
  unit_of_measurement: °C
  update_interval: 120s
  icon: 'mdi:thermometer-alert'
binary_sensor:
- platform: status
  name: ${friendly_name} controller network
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Alarm General Error
  device_class: problem
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Bypass1
  device_class: opening
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x2
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Relay R9
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x4
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Alarm Filter
  device_class: problem
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x8
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Alarm Frost
  device_class: problem
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x10
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Alarm Hygrostat
  device_class: problem
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x40
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Alarm Device Stopped
  device_class: problem
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x80
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Bypass2
  device_class: opening
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x400
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Relay R8
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x1000
  disabled_by_default: true
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Relay R2 Electric Reheating
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x2000
  disabled_by_default: true
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: Relay R3 Preheating
  register_type: read
  entity_category: diagnostic
  address: 15
  bitmask: 0x4000
  disabled_by_default: true
- platform: template
  name: ${friendly_name} turning off
  device_class: running
  entity_category: diagnostic
  id: genvex_turningoff
  lambda: |-
    if ((id(genvex_inlet_fan).state != 0 or id(genvex_extract_fan).state != 0) and id(genvex_mode).state == 0) {
      ESP_LOGD("ModbusLambda", "Cooldown extrarun before turning off");
      return true;
    } else {
      return false;
    }
  on_state:
  # When turning off the unit, it will still run for about 3 minutes at its current speed. This trick helps with that,
  # it switches first to the lowest speed and then turns it off, so that those 3 minutes will be done in the most silent way.
    then:
      - if:
          condition:
            and:
              - binary_sensor.is_on: genvex_turningoff
              - lambda: return (id(genvex_inlet_fan).state != id(genvex_speed_in_1).state or id(genvex_extract_fan).state != id(genvex_speed_out_1).state);
          then:
            - lambda: |-
                modbus_controller::ModbusController *controller = id(${modbus_ctrl_id});
                modbus_controller::ModbusCommandItem set_mode_slowfirst_command = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 0, 1);
                modbus_controller::ModbusCommandItem set_mode_command = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 0, 0);
                set_mode_slowfirst_command.on_data_func = [=](modbus_controller::ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
                  ESP_LOGD("ModbusLambda", "Slow down mode 1 succes - Queuing set mode 0 command");
                  controller->queue_command(set_mode_command);
                };
                ESP_LOGD("ModbusLambda", "Queuing set mode 1 slow down to lowest speed first command");
                controller->queue_command(set_mode_slowfirst_command);
number:
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Max duration for level 3-4
  icon: "mdi:timer-sand"
  address: 14
  unit_of_measurement: "h"
  value_type: U_WORD
  entity_category: config
  min_value: 1
  max_value: 9
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 1 supply
  icon: "mdi:fan-speed-1"
  address: 7
  unit_of_measurement: "%"
  id: genvex_speed_in_1
  value_type: U_WORD
  entity_category: config
  min_value: 10
  max_value: 45
  step: 1
  mode: box
  
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 1 extract
  icon: "mdi:fan-speed-1"
  address: 10
  unit_of_measurement: "%"
  id: genvex_speed_out_1
  value_type: U_WORD
  entity_category: config
  min_value: 10
  max_value: 45
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 2 supply
  icon: "mdi:fan-speed-2"
  address: 8
  unit_of_measurement: "%"
  id: genvex_speed_in_2
  value_type: U_WORD
  entity_category: config
  min_value: 40
  max_value: 75
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 2 extract
  icon: "mdi:fan-speed-2"
  address: 11
  unit_of_measurement: "%"
  id: genvex_speed_out_2
  value_type: U_WORD
  entity_category: config
  min_value: 40
  max_value: 75
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 3 supply
  icon: "mdi:fan-speed-3"
  address: 9
  unit_of_measurement: "%"
  id: genvex_speed_in_3
  value_type: U_WORD
  entity_category: config
  min_value: 60
  max_value: 85
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Level 3 extract
  icon: "mdi:fan-speed-3"
  address: 12
  unit_of_measurement: "%"
  id: genvex_speed_out_3
  value_type: U_WORD
  entity_category: config
  min_value: 60
  max_value: 85
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Temperature target
  unit_of_measurement: "°C"
  icon: "mdi:cog-outline"
  address: 2
  lambda: "return (x + 100) / 10;"
  write_lambda: "return x * 10 - 100;"
  value_type: U_WORD
  entity_category: config
  min_value: 10
  max_value: 30
  step: 0.1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Bypass open internal delta
  unit_of_measurement: "°C"
  icon: "mdi:cog-outline"
  address: 18
  lambda: "return x / 10;"
  write_lambda: "return x * 10;"
  value_type: U_WORD
  entity_category: config
  min_value: 1
  max_value: 10
  step: 0.1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Bypass close external delta
  unit_of_measurement: "°C"
  icon: "mdi:cog-outline"
  address: 26
  value_type: U_WORD
  entity_category: config
  min_value: 0
  max_value: 20
  step: 1
  mode: box
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Filter change period
  unit_of_measurement: "mo"
  icon: "mdi:cog-outline"
  address: 5
  value_type: U_WORD
  entity_category: config
  min_value: 0
  max_value: 12
  step: 1
  mode: box
  disabled_by_default: true
text_sensor:
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} unit time
  icon: "mdi:calendar-clock"
  address: 200
  register_type: holding
  register_count: 6
  skip_updates: 12
  force_new_range: true
  lambda: return str_sprintf("20%02d-%02d-%02d %02d:%02d", data[11], data[9], data[7], data[1], data[3]);
select:
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Speed
  icon: "mdi:fan"
  id: genvex_sel
  address: 0
  value_type: U_WORD
  optimistic: true
  optionsmap:
    "Low": 1
    "Medium": 2
    "High": 3
    "Turbo": 4
    "Off": 0
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Display model
  icon: "mdi:cog-outline"
  entity_category: config
  address: 41
  value_type: U_WORD
  skip_updates: 24
  disabled_by_default: true
  optionsmap:
    "None installed": 0
    "OPT100": 1
    "Boost button": 2
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Regulation method
  icon: "mdi:cog-outline"
  entity_category: config
  address: 16
  value_type: U_WORD
  skip_updates: 24
  disabled_by_default: true
  optionsmap:
    "Room": 0
    "Supply": 1
    "Extract": 2
switch:
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Reheating
  entity_category: config
  icon: "mdi:water-boiler"
  register_type: holding
  address: 1
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Max duration for level 3-4 enable
  entity_category: config
  icon: "mdi:toggle-switch"
  register_type: holding
  address: 4
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Humidity control
  entity_category: config
  icon: "mdi:air-humidifier"
  register_type: holding
  address: 6
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Filter change autostop
  entity_category: config
  icon: "mdi:toggle-switch"
  disabled_by_default: true
  register_type: holding
  address: 15
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Device stop enable
  entity_category: config
  icon: "mdi:toggle-switch"
  disabled_by_default: true
  register_type: holding
  address: 25
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Calendar mode
  entity_category: config
  icon: "mdi:toggle-switch"
  disabled_by_default: true
  register_type: holding
  address: 45
  bitmask: 1
- platform: modbus_controller
  modbus_controller_id: ${modbus_ctrl_id}
  name: ${friendly_name} Filter reset
  entity_category: config
  icon: "mdi:air-filter"
  register_type: holding
  address: 47
  bitmask: 1
button:
- platform: restart
  name: ${friendly_name} controller restart
  entity_category: diagnostic
- platform: safe_mode
  name: ${friendly_name} controller safe mode boot
  entity_category: diagnostic
- platform: template
  name: ${friendly_name} modbus manual update
  entity_category: config
  icon: "mdi:eye-refresh-outline"
  disabled_by_default: true
  on_press:
    then:
      - lambda: 'return id(${modbus_ctrl_id})->update();'
- platform: template
  name: ${friendly_name} Sync date and time
  entity_category: diagnostic
  icon: "mdi:clock-edit-outline"
  disabled_by_default: true
  on_press:
    if:
      condition:
        time.has_time:
      then:
        - lambda: |-
              // get local time and sync to device
              time_t now = ::time(nullptr);
              struct tm *time_info = ::localtime(&now);
              uint16_t minute = time_info->tm_min;
              uint16_t hour = time_info->tm_hour;
              uint16_t day = time_info->tm_mday;
              uint16_t month = time_info->tm_mon + 1;
              uint16_t year = time_info->tm_year % 100;
              modbus_controller::ModbusController *controller = id(${modbus_ctrl_id});
              modbus_controller::ModbusCommandItem set_hour = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 200, hour);
              modbus_controller::ModbusCommandItem set_minute = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 201, minute);
              modbus_controller::ModbusCommandItem set_day = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 203, day);
              modbus_controller::ModbusCommandItem set_month = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 204, month);
              modbus_controller::ModbusCommandItem set_year = modbus_controller::ModbusCommandItem::create_write_single_command(controller, 205, year);
              // commands have to be run in reversed order nested in on_data_func's because the queue_command normally exits the procedure
              set_month.on_data_func = [=](modbus_controller::ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
                ESP_LOGI("ModbusLambda", "Setting year to %02d", year);
                controller->queue_command(set_year);
              };
              set_day.on_data_func = [=](modbus_controller::ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
                ESP_LOGI("ModbusLambda", "Setting month to %02d", month);
                controller->queue_command(set_month);
              };
              set_minute.on_data_func = [=](modbus_controller::ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
                ESP_LOGI("ModbusLambda", "Setting day to %02d", day);
                controller->queue_command(set_day);
              };
              set_hour.on_data_func = [=](modbus_controller::ModbusRegisterType register_type, uint16_t start_address, const std::vector<uint8_t> &data) {
                ESP_LOGI("ModbusLambda", "Setting minute to %02d", minute);
                controller->queue_command(set_minute);
              };
              ESP_LOGI("ModbusLambda", "Setting hour to %02d", hour);
              controller->queue_command(set_hour);
Notes
If you get CRC errors use shorter cables and double-check for the 120 Ohm termination resistors as per ModBUS standard.
The factory pre-installed cloud controller (usually a separate box or board) has to be disconnected because it acts a ModBUS Master, just like the ESPHome node configured as above. You can't have two ModBUS RTU Masters on the same bus.
These units were shipped with various controller boards inside, this configuration only matches Optima 260 (ES 960C).