S&P Sabik 350 ventilation system with ESP32 + MAX485 control

Hello comunity,
I was looking for any option how to connect my air ventilation unit with Home Assistant. I couldnt find nothing about connection so I decided create my own. Belive it can help somenone. I have SPCM modul for communication but it uses connectair app and own cloud.

What we need:

  1. S&P Sabik 350 ventilation unit (maybe it works on 210 or 500)
  2. ESP32-WROOM-32D with WiFi and BT
  3. Communication unit MAX485 RS485 to TTL
  4. some cables
  5. working ESP home
  6. working Home Assistant

Setup

  1. first connect ESP32 with MAX485 and Sabik
ESP32 pinout

pinout

MAX485 pinout

pinout

Sabik 350 mainboard

ESP32 MAX485 SABIK 350 modbus RTU
5V VCC
GND GND
GPIO16 DI
GPIO17 RO
GPIO4 DE + RE
A connector 32
B connector 32
  1. install yaml into ESP
Code
esphome:
  name: "your esp name here"
  friendly_name: "your friendly name here"

esp32:
  board: esp32dev
  framework:
    type: arduino

logger:
  level: DEBUG

wifi:
  ssid: "your SSID here"
  password: "your password here"
  manual_ip: 
    static_ip: esp ip here
    gateway: gw ip here
    subnet: subnet here

ota:
  platform: esphome
  password: "your password here"

api:
  encryption:
    key:"your key here"

uart:
  id: modbus_uart
  tx_pin: GPIO16
  rx_pin: GPIO17
  baud_rate: 19200
  parity: EVEN
  stop_bits: 1

modbus:
  - id: hub1
    uart_id: modbus_uart
    flow_control_pin: GPIO4

modbus_controller:
  - id: sabik
    address: 1
    modbus_id: hub1
    command_throttle: 200ms
    setup_priority: -10

sensor:
  - platform: modbus_controller
    name: "Communication error"
    id: communication_error_raw
    address: 4
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Defrost status"
    id: defrost_status_raw
    address: 5
    register_type: read
    value_type: U_WORD    

  - platform: modbus_controller
    modbus_controller_id: sabik
    name: "Extract Air Temperature"
    address: 25 
    unit_of_measurement: "°C"
    register_type: read
    value_type: S_WORD
    accuracy_decimals: 1
    filters:
      - multiply: 0.1

  - platform: modbus_controller
    name: "Exhaust Air Temperature"
    address: 26 
    unit_of_measurement: "°C"
    register_type: read
    value_type: S_WORD
    accuracy_decimals: 1
    filters:
      - multiply: 0.1

  - platform: modbus_controller
    name: "Outdoor Air Temperature"
    address: 27
    unit_of_measurement: "°C"
    register_type: read
    value_type: S_WORD
    accuracy_decimals: 1
    filters:
      - multiply: 0.1

  - platform: modbus_controller
    name: "Supply Air Temperature"
    address: 28
    unit_of_measurement: "°C"
    register_type: read
    value_type: S_WORD
    accuracy_decimals: 1
    filters:
      - multiply: 0.1

  - platform: modbus_controller
    name: "Relative Humidity Extract Air"
    address: 29
    unit_of_measurement: "%"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Relative Humidity Exhaust Air"
    address: 30
    unit_of_measurement: "%"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Relative Humidity Outdoor Air"
    address: 31
    unit_of_measurement: "%"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Relative Humidity Supply Air"
    address: 32
    unit_of_measurement: "%"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "RPM Extract Motor"
    address: 61
    unit_of_measurement: "rpm"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "RPM Supply Motor"
    address: 62
    unit_of_measurement: "rpm"
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Bypass Damper Position Raw"
    id: bypass_damper_raw
    address: 63
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Actual Working Mode Raw"
    id: actual_working_mode_raw
    address: 90
    register_type: read
    value_type: U_WORD

  - platform: modbus_controller
    name: "Selected Airflow Raw"
    id: selected_airflow_raw
    address: 132
    register_type: holding
    value_type: U_WORD

text_sensor:
  - platform: template
    name: "Communication Error"
    lambda: |-
      switch (int(id(communication_error_raw).state)) {
        case 0: return {"No error"};
        case 1: return {"Remote controller error"};
        case 2: return {"Modbus RTU error"};
        default: return {"Unknown"};
      }
    update_interval: 10s

  - platform: template
    name: "Defrost Status"
    lambda: |-
      switch (int(id(defrost_status_raw).state)) {
        case 0: return {"Not active"};
        case 1: return {"Active (fireplace defrost)"};
        case 2: return {"Active (with pre-heater)"};
        case 3: return {"Active (unbalanced airflows)"};
        default: return {"Unknown"};
      }
    update_interval: 10s

  - platform: template
    name: "Bypass Damper Position"
    lambda: |-
      switch (int(id(bypass_damper_raw).state)) {
        case 0: return {"Closed"};
        case 1: return {"Open"};
        case 2: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Actual Working Mode"
    lambda: |-
      switch (int(id(actual_working_mode_raw).state)) {
        case 0: return {"Snooze"};
        case 1: return {"Low speed"};
        case 2: return {"Medium speed"};
        case 3: return {"High speed"};
        case 4: return {"Boost"};
        case 5: return {"Auto (humidity)"};
        case 6: return {"Auto (VOC)"};
        case 7: return {"Auto (0-10V)"};
        case 8: return {"Boost in auto"};
        case 9: return {"Weekly 1"};
        case 10: return {"Weekly 2"};
        case 11: return {"Weekly 3"};
        case 12: return {"Weekly 4"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Selected Airflow"
    lambda: |-
      switch (int(id(selected_airflow_raw).state)) {
        case 0: return {"Manual (low)"};
        case 1: return {"Manual (medium)"};
        case 2: return {"Manual (nominal)"};
        case 3: return {"Auto"};
        case 4: return {"Snooze"};
        default: return {"Unknown"};
      }
    update_interval: 5s      

  - platform: template
    name: "Active Alarm Status"
    lambda: |-
      if (id(active_alarms).state) {
        return {"Alarm Active"};
      } else {
        return {"No Alarm"};
      }
    update_interval: 5s  

  - platform: template
    name: "Filter Alarm Status"
    lambda: |-
      if (id(filter_alarm).state) {
        return {"Alarm On"};
      } else {
        return {"Alarm Off"};
      }
    update_interval: 5s  

  - platform: template
    name: "Extract Temp Sensor Text"
    lambda: |-
      switch (int(id(extract_temp_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Exhaust Temp Sensor Text"
    lambda: |-
      switch (int(id(exhaust_temp_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Outdoor Temp Sensor Text"
    lambda: |-
      switch (int(id(outdoor_temp_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Supply Temp Sensor Text"
    lambda: |-
      switch (int(id(supply_temp_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Extract Fan Status Text"
    lambda: |-
      switch (int(id(extract_fan_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Supply Fan Status Text"
    lambda: |-
      switch (int(id(supply_fan_status).state)) {
        case 0: return {"Correct"};
        case 1: return {"Error"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Bypass Active Text"
    lambda: |-
      switch (int(id(bypass_active).state)) {
        case 0: return {"Not active"};
        case 1: return {"Active"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Boost Contact Status Text"
    lambda: |-
      switch (int(id(boost_contact_status).state)) {
        case 0: return {"Not active"};
        case 1: return {"Active"};
        default: return {"Unknown"};
      }
    update_interval: 5s

  - platform: template
    name: "Boost Status Text"
    lambda: |-
      switch (int(id(boost_status).state)) {
        case 1: return {"Boost active"};
        case 0: return {"Boost not active"};
        default: return {"Unknown"};
      }
    update_interval: 5s

switch:
  - platform: modbus_controller
    name: "Manual By-pass"
    register_type: coil
    address: 8

  - platform: modbus_controller
    name: "Allow Automatic By-pass"
    register_type: coil
    address: 9

  - platform: modbus_controller
    name: "Summer Mode"
    register_type: coil
    address: 10

  - platform: modbus_controller
    name: "Manual Boost"
    register_type: coil
    address: 16

  - platform: modbus_controller
    name: "Snooze Mode"
    register_type: coil
    address: 17

  - platform: modbus_controller
    name: "Working Mode"
    register_type: coil
    address: 26

  - platform: modbus_controller
    name: "Reset Filter Alarm"
    register_type: coil
    address: 1
    id: reset_filter_alarm_switch


binary_sensor:
  - platform: modbus_controller
    name: "Active Alarms"
    address: 0
    id: active_alarms
    register_type: discrete_input

  - platform: modbus_controller
    name: "Filter Alarm"
    address: 1
    id: filter_alarm
    register_type: discrete_input

  - platform: modbus_controller
    name: "Extract Temp Sensor Status"
    id: extract_temp_status
    address: 6
    register_type: discrete_input

  - platform: modbus_controller
    name: "Exhaust Temp Sensor Status"
    id: exhaust_temp_status
    address: 7
    register_type: discrete_input

  - platform: modbus_controller
    name: "Outdoor Temp Sensor Status"
    id: outdoor_temp_status
    address: 8
    register_type: discrete_input

  - platform: modbus_controller
    name: "Supply Temp Sensor Status"
    id: supply_temp_status
    address: 9
    register_type: discrete_input

  - platform: modbus_controller
    name: "Extract Fan Status"
    id: extract_fan_status
    address: 10
    register_type: discrete_input

  - platform: modbus_controller
    name: "Supply Fan Status"
    id: supply_fan_status
    address: 11
    register_type: discrete_input

  - platform: modbus_controller
    name: "Bypass Active"
    id: bypass_active
    address: 15
    register_type: discrete_input

  - platform: modbus_controller
    name: "Boost Contact Status"
    id: boost_contact_status
    address: 28
    register_type: discrete_input

  - platform: modbus_controller
    name: "Boost Status"
    id: boost_status
    address: 29
    register_type: discrete_input

interval:
  - interval: 1s
    then:
      - if:
          condition:
            switch.is_on: reset_filter_alarm_switch
          then:
            - switch.turn_off: reset_filter_alarm_switch

select:
  - platform: modbus_controller
    id: selected_airflow_select
    name: "Selected Airflow"
    address: 132
    value_type: U_WORD
    optionsmap:
      "Manual (low airflow)": 0
      "Manual (medium airflow)": 1
      "Manual (nominal airflow)": 2
      "Auto": 3
      "Snooze": 4

  1. if is connection correct you will see at logs
[21:52:47][D][text_sensor:069]: 'Boost Status Text': Sending state 'Boost active'
[21:52:47][D][text_sensor:069]: 'Supply Temp Sensor Text': Sending state 'Correct'
[21:52:47][D][text_sensor:069]: 'Bypass Active Text': Sending state 'Not active'
[21:52:47][D][text_sensor:069]: 'Bypass Damper Position': Sending state 'Closed'
[21:52:47][D][text_sensor:069]: 'Extract Fan Status Text': Sending state 'Correct'
[21:52:48][D][text_sensor:069]: 'Actual Working Mode': Sending state 'Boost in auto'
[21:52:48][D][text_sensor:069]: 'Supply Fan Status Text': Sending state 'Correct'
[21:52:48][D][text_sensor:069]: 'Extract Temp Sensor Text': Sending state 'Correct'
[21:52:48][D][text_sensor:069]: 'Boost Contact Status Text': Sending state 'Not active'
  1. now you can create custom card at homeassistant dashboard. this is only example
code
type: vertical-stack
cards:
  - type: entities
    title: Rekuperácia – režimy a prepínače
    show_header_toggle: false
    entities:
      - entity: sensor.esphome_web_0ba9a0_rpm_supply_motor
      - entity: sensor.esphome_web_0ba9a0_rpm_extract_motor
      - entity: select.esphome_web_0ba9a0_selected_airflow
        name: Režim prúdenia
      - entity: sensor.esphome_web_0ba9a0_actual_working_mode
        name: Pracovný režim
      - entity: switch.esphome_web_0ba9a0_manual_by_pass
        name: Manuálny bypass
      - entity: switch.esphome_web_0ba9a0_allow_automatic_by_pass
        name: Automatický bypass
      - entity: switch.esphome_web_0ba9a0_manual_boost
        name: Boost režim
      - entity: switch.esphome_web_0ba9a0_snooze_mode
        name: Snooze režim
      - entity: switch.esphome_web_0ba9a0_summer_mode
        name: Letný režim
      - entity: switch.esphome_web_0ba9a0_reset_filter_alarm
        name: Reset filtračného alarmu
      - entity: switch.esphome_web_0ba9a0_summer_mode
  - type: entities
    title: Teploty a vlhkosti
    show_header_toggle: false
    entities:
      - entity: sensor.esphome_web_0ba9a0_supply_air_temperature
      - entity: sensor.esphome_web_0ba9a0_extract_air_temperature
      - entity: sensor.esphome_web_0ba9a0_outdoor_air_temperature
      - entity: sensor.esphome_web_0ba9a0_exhaust_air_temperature
      - entity: sensor.esphome_web_0ba9a0_relative_humidity_supply_air
      - entity: sensor.esphome_web_0ba9a0_relative_humidity_extract_air
      - entity: sensor.esphome_web_0ba9a0_relative_humidity_outdoor_air
      - entity: sensor.esphome_web_0ba9a0_relative_humidity_exhaust_air
  - type: entities
    title: Stav a diagnostika
    show_header_toggle: false
    entities:
      - entity: sensor.esphome_web_0ba9a0_actual_working_mode
      - entity: sensor.esphome_web_0ba9a0_selected_airflow
      - entity: sensor.esphome_web_0ba9a0_active_alarm_status
      - entity: sensor.esphome_web_0ba9a0_filter_alarm_status
      - entity: sensor.esphome_web_0ba9a0_bypass_damper_position
      - entity: sensor.esphome_web_0ba9a0_defrost_status
      - entity: sensor.esphome_web_0ba9a0_boost_status_text

  1. enjoy
2 Likes

This is great! Thank you.

I’m thinking of doing this also for our S&P Sabik 500. But it would be the first time to build a ESP32 board. I’m a bit insecure about the cabling (and housing) of the solution.

Could you help me a bit with the cabling? Or do you have a good source to start?

Thank you very much! Works like a charm!