substitutions: device_name: "izzifast" friendly_name: "IZZI Fast Heat Recovery Unit" default_max_volume: "23" # Default 23 (230 m³/h), e.g. 30 for 300 m³/h unit web_username: "admin" web_password: "XXX" api_encryption_key: "XXX" ota_password: "XXX" esphome: name: "${device_name}" friendly_name: "${friendly_name}" esp32: board: esp32dev framework: type: esp-idf # WiFi configuration wifi: ssid: "XXX" password: "XXX" # Optional fallback AP if WiFi is unreachable ap: ssid: "${device_name}_fallback" password: "fallback_password" captive_portal: # Web Server interface with authentication and live log streaming web_server: port: 80 auth: username: "${web_username}" password: "${web_password}" # Native Home Assistant API with noise encryption and OTA updates api: encryption: key: "${api_encryption_key}" ota: - platform: esphome password: "${ota_password}" logger: level: INFO # Hardware UART (Serial2) in hardware RS-485 Half-Duplex mode uart: id: uart_bus tx_pin: GPIO17 rx_pin: GPIO16 baud_rate: 9600 data_bits: 8 parity: NONE stop_bits: 1 flow_control_pin: GPIO27 # Automatic hardware DE/RE transceiver control (ESP-IDF) # ============================================================================== # TELEMETRY SENSORS # ============================================================================== sensor: # 1. Outdoor intake temperature - platform: template name: "Outdoor Temperature" id: s_outdoor_temp unit_of_measurement: "°C" device_class: temperature state_class: measurement accuracy_decimals: 0 icon: "mdi:weather-snowy-heavy" # 2. Exhaust outlet temperature - platform: template name: "Exhaust Temperature" id: s_exhaust_temp unit_of_measurement: "°C" device_class: temperature state_class: measurement accuracy_decimals: 0 icon: "mdi:weather-windy" # 3. Supply air temperature (into rooms) - platform: template name: "Supply Temperature" id: s_supply_temp unit_of_measurement: "°C" device_class: temperature state_class: measurement accuracy_decimals: 0 icon: "mdi:air-filter" # 4. Extract air temperature (from rooms) - platform: template name: "Extract Temperature" id: s_extract_temp unit_of_measurement: "°C" device_class: temperature state_class: measurement accuracy_decimals: 0 icon: "mdi:home-thermometer" # 5. Calculated heat recovery efficiency - platform: template name: "Heat Recovery Efficiency" id: s_efficiency unit_of_measurement: "%" state_class: measurement accuracy_decimals: 1 icon: "mdi:percent" # 6. Current supply air volume (m³/h) - platform: template name: "Supply Air Volume" id: s_supply_volume unit_of_measurement: "m³/h" state_class: measurement accuracy_decimals: 0 icon: "mdi:fan-plus" # 7. Current extract air volume (m³/h) - platform: template name: "Extract Air Volume" id: s_extract_volume unit_of_measurement: "m³/h" state_class: measurement accuracy_decimals: 0 icon: "mdi:fan-minus" # ============================================================================== # BINARY SENSORS (LOGICAL STATES) # ============================================================================== binary_sensor: # Physical bypass damper state - platform: template name: "Bypass Open" id: bs_bypass_state device_class: opening icon: "mdi:swap-horizontal" # Housing cover / filter flap open alert (Byte 5 == 5) - platform: template name: "Cover Open" id: bs_cover_open device_class: problem icon: "mdi:door-open" # Active heat exchanger defrosting (Byte 5 == 10) - platform: template name: "Defrost Active" id: bs_defrost_active device_class: heat icon: "mdi:snowflake-melt" # ============================================================================== # GLOBAL STATE & CONTROL VARIABLES # ============================================================================== globals: - id: g_target_supply_speed type: int initial_value: "30" - id: g_target_extract_speed type: int initial_value: "30" - id: g_target_bypass_temp type: int initial_value: "22" - id: g_target_bypass_mode type: int initial_value: "0" # 0: Auto, 1: Open, 2: Closed - id: g_unit_on type: bool initial_value: "true" - id: g_constant_flow type: bool initial_value: "true" - id: g_max_volume type: int initial_value: "${default_max_volume}" - id: g_vent_mode type: int initial_value: "0" # 0: Normal, 1: Fireplace, 2: Window, 3: Cooker Hood # ============================================================================== # CONTROLS — SWITCHES # ============================================================================== switch: # Ventilation unit master power (Unit State ON/OFF) - platform: template name: "Unit Power" id: sw_unit_power icon: "mdi:power" optimistic: true restore_mode: RESTORE_DEFAULT_ON turn_on_action: - lambda: 'id(g_unit_on) = true;' turn_off_action: - lambda: 'id(g_unit_on) = false;' # Constant Flow automatic compensation switch - platform: template name: "Constant Flow" id: sw_constant_flow icon: "mdi:gauge" optimistic: true restore_mode: RESTORE_DEFAULT_ON turn_on_action: - lambda: 'id(g_constant_flow) = true;' turn_off_action: - lambda: 'id(g_constant_flow) = false;' # ============================================================================== # CONTROLS — NUMERICAL INPUTS (NUMBER) # ============================================================================== number: # Target supply fan speed (0 - 100%) - platform: template name: "Target Supply Fan Speed" id: num_supply_speed unit_of_measurement: "%" icon: "mdi:fan" min_value: 0 max_value: 100 step: 5 mode: slider optimistic: true restore_value: true initial_value: 30 set_action: - lambda: 'id(g_target_supply_speed) = (int)x;' # Target extract fan speed (0 - 100%) - platform: template name: "Target Extract Fan Speed" id: num_extract_speed unit_of_measurement: "%" icon: "mdi:fan" min_value: 0 max_value: 100 step: 5 mode: slider optimistic: true restore_value: true initial_value: 30 set_action: - lambda: 'id(g_target_extract_speed) = (int)x;' # Bypass activation threshold temperature (18 - 26 °C) - platform: template name: "Bypass Threshold Temperature" id: num_bypass_temp unit_of_measurement: "°C" icon: "mdi:thermometer-chevron-up" min_value: 18 max_value: 26 step: 1 mode: box optimistic: true restore_value: true initial_value: 22 set_action: - lambda: 'id(g_target_bypass_temp) = (int)x;' # Maximum rated volume (x10 m³/h, e.g. 23 = 230 m³/h, 30 = 300 m³/h) - platform: template name: "Maximum Rated Volume" unit_of_measurement: "x10 m³/h" id: num_max_volume icon: "mdi:gauge-full" min_value: 15 max_value: 50 step: 1 mode: box optimistic: true restore_value: true initial_value: ${default_max_volume} set_action: - lambda: 'id(g_max_volume) = (int)x;' # ============================================================================== # CONTROLS — DROPDOWN MENUS (SELECT) # ============================================================================== select: # Bypass damper control mode - platform: template name: "Bypass Mode" id: sel_bypass_mode icon: "mdi:valve" optimistic: true restore_value: true initial_option: "Auto" options: - "Auto" - "Always Open" - "Always Closed" set_action: - lambda: |- if (x == "Auto") id(g_target_bypass_mode) = 0; else if (x == "Always Open") id(g_target_bypass_mode) = 1; else if (x == "Always Closed") id(g_target_bypass_mode) = 2; # Special ventilation mode - platform: template name: "Special Ventilation Mode" id: sel_vent_mode icon: "mdi:tune" optimistic: true restore_value: true initial_option: "Normal" options: - "Normal" - "Fireplace (Overpressure)" - "Open Window (Exhaust Only)" - "Cooker Hood (Compensation)" set_action: - lambda: |- if (x == "Normal") id(g_vent_mode) = 0; else if (x == "Fireplace (Overpressure)") id(g_vent_mode) = 1; else if (x == "Open Window (Exhaust Only)") id(g_vent_mode) = 2; else if (x == "Cooker Hood (Compensation)") id(g_vent_mode) = 3; # ============================================================================== # MAIN RS-485 COMMUNICATION LOOP (100% PURE YAML, ZERO EXTERNAL FILES) # ============================================================================== interval: - interval: 15ms then: - lambda: |- static uint8_t rx_buf[64]; static size_t rx_len = 0; static uint32_t last_rx_byte_ms = 0; static uint32_t last_status_ms = 0; static bool send_pending = false; // 1. Ingest incoming bytes from hardware UART FIFO into local buffer while (id(uart_bus)->available()) { uint8_t b; id(uart_bus)->read_byte(&b); if (rx_len < sizeof(rx_buf)) { rx_buf[rx_len++] = b; } last_rx_byte_ms = esphome::millis(); } // 2. Inter-frame silence timeout: if > 50 ms elapsed since last byte and incomplete frame, discard noise if (rx_len > 0 && (esphome::millis() - last_rx_byte_ms > 50) && rx_len < 22) { rx_len = 0; } // 3. Sliding Window parser for 0x63 status frame while (rx_len >= 22) { // Seek start byte 0x63 if (rx_buf[0] != 0x63) { memmove(&rx_buf[0], &rx_buf[1], --rx_len); continue; } int8_t t_outdoor = (int8_t)rx_buf[1]; int8_t t_exhaust = (int8_t)rx_buf[2]; int8_t t_supply = (int8_t)rx_buf[3]; int8_t t_extract = (int8_t)rx_buf[4]; uint8_t state = rx_buf[5]; uint8_t bypass = rx_buf[8]; uint8_t sup_vol = rx_buf[17]; uint8_t ext_vol = rx_buf[18]; // Robust multi-level sanity validation (compensating lack of CRC checksum) bool valid = (rx_buf[6] == 0 && rx_buf[7] == 0) && (rx_buf[20] == 0 && rx_buf[21] == 0) && (state == 0 || state == 5 || state == 10) && (bypass == 0 || bypass == 1) && (t_outdoor >= -35 && t_outdoor <= 55) && (t_exhaust >= -35 && t_exhaust <= 55) && (t_supply >= -35 && t_supply <= 55) && (t_extract >= -35 && t_extract <= 55) && (sup_vol <= 60 && ext_vol <= 60); if (valid) { // Publish temperatures (signed int8_t for sub-zero support) id(s_outdoor_temp).publish_state(t_outdoor); id(s_exhaust_temp).publish_state(t_exhaust); id(s_supply_temp).publish_state(t_supply); id(s_extract_temp).publish_state(t_extract); // Calculate recovery efficiency if (t_extract != t_outdoor) { float eff = ((float)(t_supply - t_outdoor) / (float)(t_extract - t_outdoor)) * 100.0f; if (eff < 0.0f) eff = 0.0f; if (eff > 100.0f) eff = 100.0f; id(s_efficiency).publish_state(eff); } // Publish air flow volume in m³/h (raw byte * 10) id(s_supply_volume).publish_state(sup_vol * 10); id(s_extract_volume).publish_state(ext_vol * 10); // Publish binary sensors (Bypass, Cover, Defrost) id(bs_bypass_state).publish_state(bypass == 1); id(bs_cover_open).publish_state(state == 5); id(bs_defrost_active).publish_state(state == 10); ESP_LOGI("IZZIFast", "Status 0x63: Out=%d°C, Exh=%d°C, Sup=%d°C, Ext=%d°C | Flow=%d/%d m³/h | Bypass=%s | State=%d", t_outdoor, t_exhaust, t_supply, t_extract, sup_vol * 10, ext_vol * 10, bypass ? "OPEN" : "CLOSED", state); last_status_ms = esphome::millis(); send_pending = true; // Consume valid 22-byte frame from buffer memmove(&rx_buf[0], &rx_buf[22], rx_len - 22); rx_len -= 22; } else { // Corrupted packet or false 0x63 byte: slide by only 1 byte to keep synchronization memmove(&rx_buf[0], &rx_buf[1], --rx_len); ESP_LOGW("IZZIFast", "Corrupted 0x63 header or sanity check failed, sliding window 1 byte"); } } // 2. Transmit 0x64 command frame (after min. 15 ms turnaround from status frame) if (send_pending && (esphome::millis() - last_status_ms >= 15)) { send_pending = false; uint8_t actual_supply = (uint8_t)id(g_target_supply_speed); uint8_t actual_extract = (uint8_t)id(g_target_extract_speed); // Apply special ventilation mode modifiers switch (id(g_vent_mode)) { case 1: // Fireplace (exhaust -20% to create slight overpressure) actual_extract = (uint8_t)(actual_extract * 0.80f); break; case 2: // Open window (exhaust only, supply 0%) actual_supply = 0; break; case 3: // Cooker hood (exhaust -70% to compensate kitchen extraction) actual_extract = (uint8_t)(actual_extract * 0.30f); break; default: break; } // EC motor minimum speed protection when unit is powered on if (id(g_unit_on)) { if (actual_supply > 0 && actual_supply < 15) actual_supply = 15; if (actual_extract > 0 && actual_extract < 15) actual_extract = 15; } // Construct 21-byte command frame 0x64 uint8_t cmd[21] = { 0x64, 0x00, 0x00, 0x00, 0x00, 0x16, 0x05, 0x00, (uint8_t)id(g_target_bypass_temp), (uint8_t)id(g_target_bypass_mode), actual_supply, actual_extract, (uint8_t)(id(g_unit_on) ? 0 : 1), (uint8_t)id(g_max_volume), (uint8_t)(id(g_constant_flow) ? 1 : 0), 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 }; // Send 21-byte command frame 0x64. // ESP32 hardware UART in flow_control_pin mode automatically asserts GPIO 27 (DE/RE) // and de-asserts it with sub-microsecond precision immediately after the last stop bit! id(uart_bus)->write_array(cmd, 21); ESP_LOGI("IZZIFast", "Command 0x64 sent: Sup=%d%%, Ext=%d%%, Unit=%s, BypassMode=%d, MaxVol=%d, CF=%s", actual_supply, actual_extract, id(g_unit_on) ? "ON" : "OFF", id(g_target_bypass_mode), id(g_max_volume), id(g_constant_flow) ? "ON" : "OFF"); }