Alpac Helty Flow VMC - The modbus way

When I bought a new house in 2021, I struggled a lot on how to interface the existing VMC (or MEV – Mechanical Extract Ventilation) to Home Assistant to control and monitor them in a more granular and automated fashion. The house is equipped with five Helty Flow Compact VMC’s by Alpac (Flow40 HRV with wall recess: the Helty concealed ventilation system (heltyair.com)) with no wifi nor remote control.

The only way to control them is via their modbus RS485 RTU interface. The 7 pins onboard connector provides the modbus pins in position 5 (data+) and 4 (data-).

I then decided to build a custom circuit to read available sensors from the VMC and control it remotely via modbus. What is needed:

I used 2x 15 pin headers for the NodeMCU and 2x 4 pin header for the modbus converter. I added a two poles terminal block to power the NodeMCU from a 5V power adapter. Circuit diagram is the following:

The as built circuit is the following:
image
image

And here is the final one with NodeMCU and modbus converter:
image

The firmware on the nodemcu must be compiled using Arduino IDE (I used v2.1.0 but any version is OK). You need to adjust the settings with your wifi and MQTT info, change the modbus slave ID if different from the default 2, adjust the name of the ESP device (mine is VMC_Letto in the below example) and eventually change the used MQTT topics.
The first upload of the firmware must be from a wired connection. After the initial deploy, it will support OTA updates by browsing to http:///update.

////////////////////////////////////////////////////////////////////////
//                                                                    //
//    ModBus gateway to Helty VMC via NodeMCU and WIFI/MQTT support   //
//                                                                    //
//    MQTT telemetry:                                                 //
//      vmcs/vmc_sala/state (speed status)                            //
//      vmcs/vmc_sala/info  (json format)                             //
//                    IntTemperature (internal temperature)           //
//                    ExtTemperature (external temperature)           //
//                    Alarm (Alarm flag)                              //
//                                                                    //
//    MQTT Settings telemetry:                                        //
//      vmcs/vmc_sala/teleperiod (MQTT update period)                 //
//                                                                    //
//    MQTT Commands:                                                  //
//      vmcs/vmc_sala/cmnd/teleperiod (MQTT update period)            //
//      vmcs/vmc_sala/cmnd/speed (0 to 7, speed setpoint)             //
//      vmcs/vmc_sala/LWT (Last Will Testament)                       //
//                                                                    //
//    Version History:                                                //
//      1.0 - Initial Tests                                           //
//      2.0 - First working build                                     //
//      3.0 - Moved info to json                                      //
////////////////////////////////////////////////////////////////////////
  
// include libraries
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <stdio.h>
#include <stdlib.h>
#include <ArduinoJson.h>

// required for OTA updates
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncElegantOTA.h>

// required for modbus communication
#include <ModbusRTU.h>
#include <SoftwareSerial.h>

//
//        CONFIGURATION SECTION
// wi-fi and mqtt connection parameters
//
const char* ssid = "<YOUR WIFI SSID>";        // Enter your WiFi name
const char* password =  "<YOUR WIFI PWD>";    // Enter WiFi password
const char* mqttServer = "<MQTT IP>"; 		// MQTT host IP
const int mqttPort = <MQTT PORT>;             // MQTT TCP port
const char* mqttUser = "<MQTT USER>";         // MQTT username
const char* mqttPassword = "<MQTT PWD>";     	// MQTT password
//
//     END OF CONFIGURATION SECTION
//


// RS485 setup with NodeMCU
#define RE_DE D2  // Connect RE&DE terminal to pin GPIO4
#define RX D6     // pin GPIO12
#define TX D7     // pin GPIO13

#define SLAVE_ID 2        // slave ID
#define REG_COUNT 1       // registries count (does not support multiple readings)
#define SPEED_HREG 1000   // holding register 1000 - speed
#define INTTEMP_IREG 1000 // input register 1000 - internal temperature
#define EXTTEMP_IREG 1001 // input register 1001 - external temperature
#define ALARM_IREG 1006   // input register 1006 - alarm flags

// speeds settings
#define SPEED_ZERO 0x0000   // speed zero
#define SPEED_ONE 0x0001    // speed one
#define SPEED_TWO 0x0002    // speed two
#define SPEED_THREE 0x0003  // speed three
#define SPEED_FOUR 0x0004   // speed four
#define SPEED_HYPER 0x0005  // hyper speed
#define SPEED_NIGHT 0x0006  // night speed
#define SPEED_COOL 0x0007   // free cooling speed

// variables
uint16_t res;
uint16_t value;
String Msg = "";            // message for debug to MQTT
long period = 2000;         // period between two consecutive mqtt updates (msec)
unsigned long time_now = 0; // timestamp for mqtt refresh
int speed = 0;              // speed set
int speedO = 0;             // speed set old value
char buffer [10];
char output [256];

// MQTT topics
const char* TOPIC_LWT =  "vmcs/vmc_letto/LWT";             // last will testament
const char* TOPIC_CMD =  "vmcs/vmc_letto/cmnd/#";          // all commands
const char* TOPIC_CMD1 = "vmcs/vmc_letto/cmnd/teleperiod"; // setup TelePeriod command
const char* TOPIC_CMD2 = "vmcs/vmc_letto/cmnd/speed";      // set speed command

// answers to command
const char* TOPIC_TELE =  "vmcs/vmc_letto/state";          // speed status
const char* TOPIC_TELE1 = "vmcs/vmc_letto/teleperiod";     // MQTT update period
const char* TOPIC_TELE2 = "vmcs/vmc_letto/info";           // info


// initialize libraries for modbus
SoftwareSerial S(RX, TX);
ModbusRTU mb;

// initialize web server for OTA
AsyncWebServer server(80);
// initialize wi-fi client
WiFiClient espClient;
// initialize mqtt client
PubSubClient client(espClient);
// prepare for json encoding
StaticJsonDocument<200> doc;



//
// callback function to monitor modbus communication errors
//
bool cb(Modbus::ResultCode event, uint16_t transactionId, void* data) { 
  if (event != Modbus::EX_SUCCESS) {
      Serial.printf_P("Request result: 0x%02X, Mem: %d\n", event, ESP.getFreeHeap());
  }
  return true;
}

//
// read holding register
// returns the register value
//
uint16_t rdHreg(uint16_t ADDRESS) {
  if (!mb.slave()) {    // Check if no transaction in progress
    digitalWrite(RE_DE, HIGH);
    mb.readHreg(SLAVE_ID, ADDRESS, &res, 1, cb); // Send Read Hreg from Modbus Server
    delayMicroseconds(120);
    digitalWrite(RE_DE,LOW);
    while(mb.slave()) { // Check if transaction is active
      mb.task();
      yield();
    }
    Serial.println(res);
  }
  return res;
}

//
// write holding register
//
void wtHreg(uint16_t ADDRESS, uint16_t VALUE) {
  if (!mb.slave()) {    // Check if no transaction in progress
    digitalWrite(RE_DE, HIGH);
    mb.writeHreg(SLAVE_ID, ADDRESS, VALUE, cb); // Send write Hreg to Modbus Server
    delayMicroseconds(120);
    digitalWrite(RE_DE,LOW);
    while(mb.slave()) { // Check if transaction is active
      mb.task();
      yield();
    }
  }
}

//
// read input register
// returns the register value
//
uint16_t rdIreg(uint16_t ADDRESS) {
  if (!mb.slave()) {    // Check if no transaction in progress
    digitalWrite(RE_DE, HIGH);
    mb.readIreg(SLAVE_ID, ADDRESS, &res, 1, cb); // Send Read Ireg from Modbus Server
    delayMicroseconds(120);
    digitalWrite(RE_DE,LOW);
    while(mb.slave()) { // Check if transaction is active
      mb.task();
      yield();
    }
    Serial.println(res);
  }
  return res;
}


//
// MQTT connection function
//
void mqtt_connect() {
  // Connect to MQTT broker
  if (client.connect("VMC_Letto", mqttUser, mqttPassword,TOPIC_LWT,1,true,"Offline" )) {
    // Connection to MQTT successful
    Serial.println("Connected!");  
    // Subscribe to settings topics
    client.subscribe(TOPIC_CMD);    // subscribe to all command topics
    client.publish(TOPIC_TELE1, itoa((int)period,buffer,10), true); // teleperiod
    client.publish(TOPIC_LWT, "Online");  // last will testament

  }
}

//
// WIFI connection function
//
void wifi_connect() {
  // Connect to WIFI
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Connecting to WiFi..");
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);

    WiFi.setAutoReconnect(true);
    WiFi.persistent(true);
    
  }
}

//
// Callback function for MQTT subscriptions
//
void mqtt_callback(char* topic, byte* payload, unsigned int length) {
 
  String message = (char*)payload;
  String thetopic = (char*)topic;
  message = message.substring(0,length);
  char buffer [5];

  // prepare MQTT message for debug
  Msg = "Message arrived in topic: " + thetopic + " -> " + message;
  Serial.println(Msg);
  
  // decode subscribed messages and return confirmation
  // telegram period
  if (thetopic == TOPIC_CMD1) {
    Serial.print("TelePeriod=");
    period = message.toInt();
    Serial.println(message);
    client.publish(TOPIC_TELE1, itoa(period,buffer,10), true);
  }

  // speed
  if (thetopic == TOPIC_CMD2) {
    Serial.print("Speed=");
    speed = message.toInt();
    Serial.println(message);
  }

  Serial.println();
  Serial.println("-----------------------");
 
}


//
// setup routine
//
void setup() {
  pinMode(RE_DE,OUTPUT);  // direction pin
  Serial.begin(115200);   // start serial port
  S.begin(19200, SWSERIAL_8N1); // setup software serial
  mb.begin(&S); // start software serial
  mb.master();  // start Master modbus processing

  // Connnect to local wifi
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
 
  // Wait for wifi connection
  Serial.println("Connecting to WiFi..");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  WiFi.setAutoReconnect(true);
  WiFi.persistent(true);

  // activate http server for OTA updates
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(200, "text/plain", "Hi! I am VMC Letto.");
  });

  // Start ElegantOTA
  AsyncElegantOTA.begin(&server);
  server.begin();
  Serial.println("HTTP server started");

  // Set MQTT broker
  client.setServer(mqttServer, mqttPort);
  
  // Set the callback function for MQTT subscriptions
  client.setCallback(mqtt_callback);
 
  // Connect to MQTT broker
  Serial.println("Connecting to MQTT...");
  while (!client.connected()) {
    Serial.print(".");
 
    // Attempt connection to MQTT
    if (client.connect("VMC_Letto", mqttUser, mqttPassword,TOPIC_LWT,1,true,"Offline" )) {
      // Connection to MQTT successful
      Serial.println("Connected!");  
      // Subscribe to settings topics
      client.subscribe(TOPIC_CMD);    // subscribe to all command topics
      client.publish(TOPIC_TELE1, itoa((int)period,buffer,10), true); // teleperiod
      client.publish(TOPIC_LWT, "Online"); // last will testament

    }
  }

  value = rdHreg(SPEED_HREG);
  client.publish(TOPIC_CMD2, itoa(value,buffer,10)); // update speed command

}


//
// main loop
//
void loop() {

  // manage millis reset
  if ((millis() - time_now) < 0) {
    time_now = millis();
  }

  //Update MQTT every period (msec)
  if ((millis() - time_now) >= period){

    // check MQTT connection and reconnect
    if (!client.connected()) {
      mqtt_connect();
    }

    value = rdHreg(SPEED_HREG);
    client.publish(TOPIC_TELE, itoa(value,buffer,10)); // update speed topic

    // Add values in the document
    //
    value = rdIreg(INTTEMP_IREG);  // internal temp x 0.1C
    doc["IntTemperature"] = (float)value*0.1;

    value = rdIreg(EXTTEMP_IREG);  // external temp x 0.1C
    doc["ExtTemperature"] = (float)value*0.1;

    value = rdIreg(ALARM_IREG);    // alarms
    doc["Alarm"] = value;
    
    
    serializeJson(doc, output);
    client.publish(TOPIC_TELE2, output); // update alarm topic

    // set next update
    time_now += period;
    
  }

  // set speed of vmc
  if (speed != speedO) {
    switch (speed) {
      case 0:
        wtHreg(SPEED_HREG, SPEED_ZERO);
        break;
      case 1:
        wtHreg(SPEED_HREG, SPEED_ONE);
        break;
      case 2:
        wtHreg(SPEED_HREG, SPEED_TWO);
        break;
      case 3:
        wtHreg(SPEED_HREG, SPEED_THREE);
        break;
      case 4:
        wtHreg(SPEED_HREG, SPEED_FOUR);
        break;
      case 5:
        wtHreg(SPEED_HREG, SPEED_HYPER);
        break;
      case 6:
        wtHreg(SPEED_HREG, SPEED_NIGHT);
        break;
      case 7:
        wtHreg(SPEED_HREG, SPEED_COOL);
        break;
    }
    
    client.publish(TOPIC_TELE, itoa(speed,buffer,10), true);
    speedO = speed;
  }


  // MQTT client loop
  client.loop();

}

The as-built implementation for the five MEV is the following: it’s all enclosed into a wall cabinet with DIN rails. To mount the circuit into the rails I used a DIN Rail SCad project I found on the internet. You can adapt it to you liking and to your breadboard measures:

HA Configuration:
On HA I use two different dashboards: Mattias’ spectacular dashboard ( A different take on designing a Lovelace UI - Share your Projects! - Home Assistant Community (home-assistant.io)) for the tablets and Minimalist ( :sunflower: Lovelace UI • Minimalist - Share your Projects! - Home Assistant Community (home-assistant.io)) on the cell phones. As such I had to build two button card templates.

Tablet’s dashboard
image

The main Template:

base_vmc:
  template:
    - settings
    - tilt
    - extra_styles
  variables:
    state_on: >
      [[[ return ['on'].indexOf(!entity || entity.state) !== -1; ]]]
    state: >
      [[[ return !entity || entity.state; ]]]
    speed: >
      [[[ return !entity || entity.attributes.preset_mode; ]]]
    entity_id: >
      [[[ return !entity || entity.entity_id; ]]]
    entity_picture: >
      [[[ return !entity || entity.attributes.entity_picture; ]]]
    timeout: >
      [[[ return !entity || Date.now() - Date.parse(entity.last_changed); ]]]
  aspect_ratio: 1/1
  show_state: false
  show_label: true
  show_icon: false
  label: >
    [[[
      if (entity) {
          return entity.attributes.ExtTemperature + "°C => " + entity.attributes.IntTemperature + "°C";
      }
    ]]]
  state_display: >
    [[[ if (variables.state === true) return variables.translate_unknown; ]]]
  tap_action:
    ui_sound_tablet: |
      [[[
        let screensaver = states[variables.entity_tablet] === undefined ||
            states[variables.entity_tablet].state;

        if (variables.state === 'off' && screensaver === 'off') {
            hass.callService('media_player', 'play_media', {
                entity_id: variables.entity_browser_mod,
                media_content_id: '/local/sound/on.m4a',
                media_content_type: 'music'
            });
        }
        if (variables.state_on && screensaver === 'off') {
            hass.callService('media_player', 'play_media', {
                entity_id: variables.entity_browser_mod,
                media_content_id: '/local/sound/off.m4a',
                media_content_type: 'music'
            });
        }
      ]]]
    action: call-service
    service: fan.decrease_speed
    service_data:
      entity_id: entity
      percentage_step: 14
  double_tap_action:
    action: call-service
    service: fan.increase_speed
    service_data:
      entity_id: entity
  hold_action:
    action: more-info
  styles:
    grid:
      - grid-template-areas: |
          "icon  filter"
          "n     n"
          "l     l"
      - grid-template-columns: repeat(2, 1fr)
      - grid-template-rows: auto repeat(2, min-content)
      - gap: 1.3%
      - align-items: start
    name:
      - justify-self: start
      - line-height: 121%
    state:
      - justify-self: start
      - line-height: 115%
    label:
      - justify-self: start
      - line-height: 115%
      - font-size: 13px
    card:
      - border-radius: var(--button-card-border-radius)
      - border-width: 0
      - -webkit-tap-highlight-color: rgba(0,0,0,0)
      - transition: none
      - --mdc-ripple-color: >
          [[[
            return variables.state_on
                ? 'rgb(0, 0, 0)'
                : '#97989c';
          ]]]
      - color: >
          [[[
            return variables.state_on
                ? '#4b5254'
                : '#97989c';
          ]]]
      - background-color: >
          [[[
            return variables.state_on
                ? 'rgba(255, 255, 255, 0.85)'
                : 'rgba(115, 115, 115, 0.25)';
          ]]]
    custom_fields:
      filter:
        - align-self: start
        - justify-self: end
  custom_fields:
    filter: >
      [[[
        return ((parseInt(entity.attributes.Alarm) & 1024) === 1024) ? 
          `<ha-icon icon="mdi:filter-remove-outline" style="width: 22px; height: 22px; color: red; animation: blink 2s ease infinite"></ha-icon>`
          : `<ha-icon icon="mdi:filter-check-outline" style="width: 22px; height: 22px; color: gray;"></ha-icon>`
      ]]]

And the icon template:

icon_vmc:
  styles:
    custom_fields:
      icon:
        - width: 77%
        - margin-left: -14%
        - margin-top: 1%
  custom_fields:
    icon: >
      [[[ 
          if (variables.speed === 'stop') {
              return '<font color=gray><ha-icon icon="mdi:fan-off" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'low') {
              return '<font color=#116a8f><ha-icon icon="mdi:fan-speed-1" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'medium') {
              return '<font color=#116a8f><ha-icon icon="mdi:fan-speed-2" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'high') {
              return '<font color=#116a8f><ha-icon icon="mdi:fan-speed-3" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'super') {
              return '<font color=#116a8f><ha-icon icon="mdi:fan-plus" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'hyper') {
              return '<font color=#116a8f><ha-icon icon="mdi:weather-windy" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'night') {
              return '<font color=#116a8f><ha-icon icon="mdi:sleep" width="100%"></font></ha-icon>';
          }
          if (variables.speed === 'cool') {
              return '<font color=#116a8f><ha-icon icon="mdi:sun-snowflake" width="100%"></font></ha-icon>';
          }

      ]]]

Ui-lovelace.yaml:

            - type: custom:button-card
              entity: fan.vmc_sala
              name: Sala
              template:
                - base_vmc
                - icon_vmc

Tap action decreases the speed, double tap action increases it.
External and Internal temperatures are reported as the button card label.
Status of filter is shown on the top right corner, it blinks red when it’s time to replace/clean the filter.
Icon changes based on the current preset mode.

Minimalist dashboard
image

Custom_card_templates.yaml:

###################
#    vmc card     #
###################
vmc_card:
  show_name: false
  show_icon: false
  template:
    - "icon_info_bg"
    - "ulm_translation_engine"
  hold_action:
    action: "more-info"
  styles:
    grid:
      - grid-template-areas: "'item1' 'item2' 'item3'"
      - grid-template-columns: "1fr"
      - grid-template-rows: "min-content  min-content min-content"
      - row-gap: "12px"
    card:
      - border-radius: "var(--border-radius)"
      - box-shadow: "var(--box-shadow)"
      - padding: "12px"
  custom_fields:
    item1:
      card:
        type: "custom:button-card"
        template:
          - "icon_info"
          - "ulm_translation_engine"
        tap_action:
          action: "more-info"
        entity: "[[[ return entity.entity_id ]]]"
        name: "[[[ return entity.name ]]]"
        label: >-
          [[[
              return (entity.attributes.ExtTemperature) + '°' + ' -> ' + entity.attributes.IntTemperature + '°';
          ]]]
        icon: >
          [[[
            var icon = "mdi:fan-off";
            var state = entity.attributes.preset_mode;
            if (state =='low') {
              return "mdi:fan-speed-1";
            } else if (state =='medium') {
              return "mdi:fan-speed-2";
            } else if (state =='high') {
              return "mdi:fan-speed-3";
            } else if (state =='super') {
              return "mdi:fan-plus";
            } else if (state =='hyper') {
              return "mdi:weather-windy";
            } else if (state =='night') {
              return "mdi:sleep";
            } else if (state =='cool') {
              return "mdi:sun-snowflake";
            }
            return icon;
          ]]]
        state:
          - operator: "template"
            value: "[[[return entity.attributes.preset_mode == 'low' || entity.attributes.preset_mode == 'medium' || entity.attributes.preset_mode == 'high' || entity.attributes.preset_mode == 'super']]]"
            styles:
              icon:
                - color: "rgba(var(--color-yellow),1)"
              img_cell:
                - background-color: "rgba(var(--color-yellow),0.2)"
          - operator: "template"
            value: "[[[return entity.attributes.preset_mode == 'night']]]"
            styles:
              icon:
                - color: "rgba(var(--color, 255, 165, 0),1)"
              img_cell:
                - background-color: "rgba(var(--color, 255, 165, 0),0.2)"
          - operator: "template"
            value: "[[[return entity.attributes.preset_mode == 'hyper']]]"
            styles:
              icon:
                - color: "rgba(var(--color-blue),1)"
              img_cell:
                - background-color: "rgba(var(--color-blue),0.2)"
          - operator: "template"
            value: "[[[return entity.attributes.preset_mode == 'cool']]]"
            styles:
              icon:
                - color: "rgba(var(--color-red),1)"
              img_cell:
                - background-color: "rgba(var(--color-red),0.2)"
    item2:
      card:
        type: "custom:button-card"
        template: "list_4_items"
        custom_fields:
          item1:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:power"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: stop
          item2:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:sleep"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: night
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'night']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color, 255, 165, 0),1)"
                    img_cell:
                      - background-color: "rgba(var(--color, 255, 165, 0),0.2)"
          item3:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:weather-windy"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: hyper
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'hyper']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-blue),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-blue),0.2)"
          item4:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:sun-snowflake"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: cool
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'cool']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-red),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-red),0.2)"
    item3:
      card:
        type: "custom:button-card"
        template: "list_4_items"
        custom_fields:
          item1:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:numeric-1"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: low
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'low']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-yellow),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-yellow),0.2)"
          item2:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:numeric-2"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: medium
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'medium']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-yellow),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-yellow),0.2)"
          item3:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:numeric-3"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: high
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'high']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-yellow),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-yellow),0.2)"
          item4:
            card:
              type: "custom:button-card"
              template: "widget_icon"
              icon: "mdi:numeric-4"
              tap_action:
                action: "call-service"
                service: "fan.set_preset_mode"
                service_data:
                  entity_id: "[[[ return entity.entity_id ]]]"
                  preset_mode: super
              state:
                - operator: "template"
                  value: "[[[return entity.attributes.preset_mode == 'super']]]"
                  styles:
                    icon:
                      - color: "rgba(var(--color-yellow),1)"
                    img_cell:
                      - background-color: "rgba(var(--color-yellow),0.2)"

Lovelace-minimalist.yaml:

          - type: 'custom:button-card'
            template: vmc_card
            name: Sala
            entity: fan.vmc_sala

Automation will be implemented using Node-Red …. Stay tuned, coming soon!

1 Like

Hello, great job! Here’s the link to remotely control the VMC wirelessly. Give it a try and let me know if it works with your model!
Link: GitHub - DanRobo76/VMC-HELTY-FLOW: Gestione remota della VMC tramite Home Assistant
:slight_smile:

Hi,
Unfortunately mine is the basic model without any connectivity but the modbus port. I had to necessarily go this path…

Hi svalmorri, great job!!!

Just bumped into it as I was struggling how to integrate the same VMC modules you show in your post above. I am testing some (relatively inexpensive) RS485 to Wifi modules available on Amazon - which I was very successful with on Galletti hydronic airco modules. but I can’t get it working…

Based on your experience - is the modbus integration on Alpac/Helty stricly to standards? the Alpac/Helty support is a bit difficult to reach out - hence the question. Apologies if a bit off topic.

Thanks in advance for any feedback you might provide! :slight_smile:

link to the RS485 to Wifi module: https://www.amazon.it/dp/B09PD5ZD56?psc=1&ref=ppx_yo2ov_dt_b_product_details

Helty implementation is pretty much a modbus standard one. If you PM me your email I can provide you the official definition. Unfortunately I cannot attach it here…

Hi @svalmorri, congratulations for your wonderful work and for sharing it.
I’m going to implement something very similar but i was wondering if in your opinion a single master device (a esp8266/esp32/esp8285) would be enough to control all the modbus slaves (the 5 VMC devices), since this is definitely supported by Modbus specification and i think one of the above MCUs could easily support the “load”.
I’ve some documentation from ALPAC but i’m struggling to understand how to change the slave address/Id of my VMC devices.
Do you have any suggestion or more documentation?
Another question: did you use the 120 ohm termination resistor?

You can definitely daisy chain them and control with a single master device, but you need to assign different IDs to each VMC.

Assigning a different ID is not possible with modbus command, you need an Alpac technician to connect an external programmer and change it. I took that opportunity when one VMC broke under warranty and the tech came onsite. However I did not go that path because the pipes were unable to host double the wires… too narrow.

The termination is not required when you have 1 to 1 connection, only when daisy chained.

1 Like