Integration of MAICO PP45 Decentralized ventilation with heat recovery

I have installed a MAICO PP45 decentralized ventilation system with heat recovery in our newly renovated house. I also have four MAICO 2-speed extraction fans as part of the whole house ventilation scheme.

Of course it had to be integrated with HA. For the systems I installed (lights, security alarm, ventilation) the requirement is that they work autark, and that they can be monitored and controlled from HA for the ‘nice to have’ features.

The MAICO Push-Pull fans work in pairs and are controlled by a RLS 45 K controller. Each controller can have up to three ventilator pairs connected. I have one controller for the ground floor with two ventilator pairs and one controller for first floor with one pair.

The RLS controllers have a MODBUS interface, so I have a LAN gateway connected to both controllers.

The extraction fans are controlled by the same PLC that controls my lights. That PLC is connected through LAN (Modbus protocol) with HA.

Everything comes together in HA:

HA also controls the speed as a function of time (f.i. low speed in bedrooms at night) and it controls the heat recovery / cross ventialtion mode (based on outside temperature).

HA controls the bathroom fan, starting a ventilation cycle when a RH spike (from the `Homematic underfloor heating control thermostat) is detected (a shower). And HA controls the Toilet fan based on the light (delayed on to low, then delayed on to high, then off). The fans can be manually controlled from the wall switches connected to the PLC (but never are …).

(the remaining 2 extraction fans are in a part of the house that is not ready yet)

YAML:

#
# *** MAICO RLS45 PACKAGE ***
#
# Connects two Maico RLS45 room air controllers to Home Assistant via
# Modbus RTU over a RS-485 -> Ethernet gateway.
#
# Native RLS45 Modbus interface: RTU only (9600, 8, Even, 1 stop bit).
# There is no native Modbus TCP on the RLS45 itself - TCP access requires
# a serial gateway, hence "rtuovertcp" below.
#
# Gateway: Waveshare RS485 TO ETH (B), MAC 04-EE-E8-1D-9A-3C, fixed via
# FRITZ!Box DHCP reservation at 192.168.178.95, TCP Server mode on port 502.
#

# INTEGRATIONS

modbus:
  - name: rls45_gateway
    type: rtuovertcp
    host: 192.168.178.95
    port: 502
    timeout: 5
    delay: 0
    message_wait_milliseconds: 30

    sensors:

    # --- Unit 1 (address 10, EG) ---

    # Ventilation level: 0=Off, 1..5=Ventilation step 1-5
      - name: "RLS45 Unit 1 Ventilation Level"
        slave: 10
        address: 201
        input_type: holding
        unique_id: 8A1F3C0051

    # Time remaining until filter change
      - name: "RLS45 Unit 1 Filter Time Remaining"
        slave: 10
        address: 300
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0021

    # Raw error code (0-7, see comment in template sensor below)
      - name: "RLS45 Unit 1 Error Code"
        slave: 10
        address: 302
        input_type: holding
        unique_id: 8A1F3C0023

    # Operating days - ventilation off
      - name: "RLS45 Unit 1 Operating Days Off"
        slave: 10
        address: 400
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0025

    # Operating hours (remainder, 0-23) - ventilation off
      - name: "RLS45 Unit 1 Operating Hours Off"
        slave: 10
        address: 401
        input_type: holding
        unit_of_measurement: "h"
        unique_id: 8A1F3C0024

    # Operating days - total
      - name: "RLS45 Unit 1 Operating Days Total"
        slave: 10
        address: 412
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0027

    # Operating hours (remainder, 0-23) - total
      - name: "RLS45 Unit 1 Operating Hours Total"
        slave: 10
        address: 413
        input_type: holding
        unit_of_measurement: "h"
        unique_id: 8A1F3C0026
        
        
    # --- Unit 2 (address 11, OG) ---

    # Ventilation level: 0=Off, 1..5=Ventilation step 1-5
      - name: "RLS45 Unit 2 Ventilation Level"
        slave: 11
        address: 201
        input_type: holding
        unique_id: 8A1F3C0052

    # Time remaining until filter change
      - name: "RLS45 Unit 2 Filter Time Remaining"
        slave: 11
        address: 300
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0028

    # Raw error code (0-7, see comment in template sensor below)
      - name: "RLS45 Unit 2 Error Code"
        slave: 11
        address: 302
        input_type: holding
        unique_id: 8A1F3C0030
        
    # Operating days - ventilation off
      - name: "RLS45 Unit 2 Operating Days Off"
        slave: 11
        address: 400
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0032

    # Operating hours (remainder, 0-23) - ventilation off
      - name: "RLS45 Unit 2 Operating Hours Off"
        slave: 11
        address: 401
        input_type: holding
        unit_of_measurement: "h"
        unique_id: 8A1F3C0031

    # Operating days - total
      - name: "RLS45 Unit 2 Operating Days Total"
        slave: 11
        address: 412
        input_type: holding
        unit_of_measurement: "d"
        unique_id: 8A1F3C0034
        
    # Operating hours (remainder, 0-23) - total
      - name: "RLS45 Unit 2 Operating Hours Total"
        slave: 11
        address: 413
        input_type: holding
        unit_of_measurement: "h"
        unique_id: 8A1F3C0033


    switches:

    # --- Unit 1 ---

    # Operating mode: off=Heat recovery (continuous ventilation), on=Cross ventilation
      - name: "RLS45 Unit 1 Cross Ventilation Mode"
        slave: 10
        address: 200
        write_type: holding
        command_on: 1
        command_off: 0
        verify:
          input_type: holding
          address: 200
          state_on: 1
          state_off: 0
        scan_interval: 10
        unique_id: 8A1F3C0041


    # --- Unit 2 ---

    # Operating mode: off=Heat recovery (continuous ventilation), on=Cross ventilation
      - name: "RLS45 Unit 2 Cross Ventilation Mode"
        slave: 11
        address: 200
        write_type: holding
        command_on: 1
        command_off: 0
        verify:
          input_type: holding
          address: 200
          state_on: 1
          state_off: 0
        scan_interval: 10
        unique_id: 8A1F3C0044


# SENSORS

template:

  - sensor:

    # --- Unit 1 ---

    # Human-readable error text for Unit 1, from raw error code (302)
      - name: "RLS45 Unit 1 Error Text"
        unique_id: 8A1F3C0061
        state: >
          {% set code = states('sensor.rls45_unit_1_error_code') | int(0) %}
          {% set codes = {
            0: 'No error',
            1: 'Internal error in RLS',
            2: 'Overtemperature/overload of mains adapter',
            3: 'No communication / failure of external power units, RLS or PPB 30 K',
            4: 'No communication / failure of sensors (RS485, I2C)',
            5: 'Internal system error slave power unit / RLS / PPB 30 K',
            6: 'No communication with EnOcean module (PP 45 EO)',
            7: 'No communication with taught-in sensor / error message from PP 45 RC'
          } %}
          {{ codes.get(code, 'Unknown code: ' ~ code) }}

    # Total operating time for Unit 1, combining days register + hour remainder register
      - name: "RLS45 Unit 1 Operating Hours Combined"
        unique_id: 8A1F3C0063
        state: >
          {% set days = states('sensor.rls45_unit_1_operating_days_total') | float(0) %}
          {% set hours = states('sensor.rls45_unit_1_operating_hours_total') | float(0) %}
          {{ (days * 24 + hours) | round(0) }}
        unit_of_measurement: "h"


#    # --- Unit 2 ---

    # Human-readable error text for Unit 2, from raw error code (302)
      - name: "RLS45 Unit 2 Error Text"
        unique_id: 8A1F3C0062
        state: >
          {% set code = states('sensor.rls45_unit_2_error_code') | int(0) %}
          {% set codes = {
            0: 'No error',
            1: 'Internal error in RLS',
            2: 'Overtemperature/overload of mains adapter',
            3: 'No communication / failure of external power units, RLS or PPB 30 K',
            4: 'No communication / failure of sensors (RS485, I2C)',
            5: 'Internal system error slave power unit / RLS / PPB 30 K',
            6: 'No communication with EnOcean module (PP 45 EO)',
            7: 'No communication with taught-in sensor / error message from PP 45 RC'
          } %}
          {{ codes.get(code, 'Unknown code: ' ~ code) }}

    # Total operating time for Unit 2, combining days register + hour remainder register
      - name: "RLS45 Unit 2 Operating Hours Combined"
        unique_id: 8A1F3C0064
        state: >
          {% set days = states('sensor.rls45_unit_2_operating_days_total') | float(0) %}
          {% set hours = states('sensor.rls45_unit_2_operating_hours_total') | float(0) %}
          {{ (days * 24 + hours) | round(0) }}
        unit_of_measurement: "h"


# FANS
# Create a fan entry in HA for each RLS45

  - fan:
    
    # --- Unit 1 ---

    # Ground floor fan pairs
      - name: "RLS45 Unit 1 Fan"
        unique_id: eg_fans
        optimistic: true

        # State: On if level is 1-5, Off if 0
        state: >
          {{ states('sensor.rls45_unit_1_ventilation_level') | int(0) > 0 }}

        # Percentage: Map 0-5 levels to 0%, 20%, 40%, 60%, 80%, 100%
        percentage: >
          {% set level = states('sensor.rls45_unit_1_ventilation_level') | int(0) %}
          {{ [0, 20, 40, 60, 80, 100][level] if level in [0,1,2,3,4,5] else 0 }}

        # Preset Mode: Read from the switch
        preset_mode: >
          {% if is_state('switch.rls45_unit_1_cross_ventilation_mode', 'on') %}
            Cross Ventilation
          {% else %}
            Heat Recovery
          {% endif %}

        speed_count: 5

        preset_modes:
          - "Heat Recovery"
          - "Cross Ventilation"

        turn_on:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 10
              address: 201
              value: 1

        turn_off:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 10
              address: 201
              value: 0

        set_percentage:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 10
              address: 201
              value: "{{ (percentage / 20) | round(0) | int }}"

        set_preset_mode:
          - action: >
              {% if preset_mode == 'Cross Ventilation' %} switch.turn_on {% else %} switch.turn_off {% endif %}
            target:
              entity_id: switch.rls45_unit_1_cross_ventilation_mode
    
    
    # --- Unit 2 ---
        
    # First floor fan pair    
      - name: "RLS45 Unit 2 Fan"
        unique_id: og_fans
        optimistic: true
                
        # State: On if level is 1-5, Off if 0
        state: >
          {{ states('sensor.rls45_unit_2_ventilation_level') | int(0) > 0 }}
    
        # Percentage: Map 0-5 levels to 0%, 20%, 40%, 60%, 80%, 100%
        percentage: >
          {% set level = states('sensor.rls45_unit_2_ventilation_level') | int(0) %}
          {{ [0, 20, 40, 60, 80, 100][level] if level in [0,1,2,3,4,5] else 0 }}
    
        # Preset Mode: Read from your existing verified switch
        preset_mode: >
          {% if is_state('switch.rls45_unit_2_cross_ventilation_mode', 'on') %}
            Cross Ventilation
          {% else %}
            Heat Recovery
          {% endif %}
    
        speed_count: 5
        
        preset_modes:
          - "Heat Recovery"
          - "Cross Ventilation"
    
        turn_on:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 11
              address: 201
              value: 1
    
        turn_off:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 11
              address: 201
              value: 0
    
        set_percentage:
          - action: modbus.write_register
            data:
              hub: rls45_gateway
              slave: 11
              address: 201
              value: "{{ (percentage / 20) | round(0) | int }}"
    
        set_preset_mode:
          - action: >
              {% if preset_mode == 'Cross Ventilation' %} switch.turn_on {% else %} switch.turn_off {% endif %}
            target:
              entity_id: switch.rls45_unit_2_cross_ventilation_mode   


# SCRIPTS

# These write momentary "command" registers (204/205) which have no
# meaningful read-back state, so they are exposed as scripts rather than
# persistent entities.

script:

    # --- Unit 1 ---
    
  rls45_unit1_error_reset:
    alias: "RLS45 Unit 1 - Reset Error"
    sequence:
      - action: modbus.write_register
        data:
          hub: rls45_gateway
          slave: 10
          address: 204
          value: 1

  rls45_unit1_filter_reset:
    alias: "RLS45 Unit 1 - Reset Filter Timer"
    sequence:
      - action: modbus.write_register
        data:
          hub: rls45_gateway
          slave: 10
          address: 205
          value: 1


    # --- Unit 2 ---
    
  rls45_unit2_error_reset:
    alias: "RLS45 Unit 2 - Reset Error"
    sequence:
      - action: modbus.write_register
        data:
          hub: rls45_gateway
          slave: 11
          address: 204
          value: 1

  rls45_unit2_filter_reset:
    alias: "RLS45 Unit 2 - Reset Filter Timer"
    sequence:
      - action: modbus.write_register
        data:
          hub: rls45_gateway
          slave: 11
          address: 205
          value: 1


# END