izzyfast.yaml
· 15 KiB · YAML
Eredeti
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");
}
| 1 | substitutions: |
| 2 | device_name: "izzifast" |
| 3 | friendly_name: "IZZI Fast Heat Recovery Unit" |
| 4 | default_max_volume: "23" # Default 23 (230 m³/h), e.g. 30 for 300 m³/h unit |
| 5 | web_username: "admin" |
| 6 | web_password: "XXX" |
| 7 | api_encryption_key: "XXX" |
| 8 | ota_password: "XXX" |
| 9 | |
| 10 | esphome: |
| 11 | name: "${device_name}" |
| 12 | friendly_name: "${friendly_name}" |
| 13 | |
| 14 | esp32: |
| 15 | board: esp32dev |
| 16 | framework: |
| 17 | type: esp-idf |
| 18 | |
| 19 | # WiFi configuration |
| 20 | wifi: |
| 21 | ssid: "XXX" |
| 22 | password: "XXX" |
| 23 | |
| 24 | # Optional fallback AP if WiFi is unreachable |
| 25 | ap: |
| 26 | ssid: "${device_name}_fallback" |
| 27 | password: "fallback_password" |
| 28 | |
| 29 | captive_portal: |
| 30 | |
| 31 | # Web Server interface with authentication and live log streaming |
| 32 | web_server: |
| 33 | port: 80 |
| 34 | auth: |
| 35 | username: "${web_username}" |
| 36 | password: "${web_password}" |
| 37 | |
| 38 | # Native Home Assistant API with noise encryption and OTA updates |
| 39 | api: |
| 40 | encryption: |
| 41 | key: "${api_encryption_key}" |
| 42 | |
| 43 | ota: |
| 44 | - platform: esphome |
| 45 | password: "${ota_password}" |
| 46 | |
| 47 | logger: |
| 48 | level: INFO |
| 49 | |
| 50 | # Hardware UART (Serial2) in hardware RS-485 Half-Duplex mode |
| 51 | uart: |
| 52 | id: uart_bus |
| 53 | tx_pin: GPIO17 |
| 54 | rx_pin: GPIO16 |
| 55 | baud_rate: 9600 |
| 56 | data_bits: 8 |
| 57 | parity: NONE |
| 58 | stop_bits: 1 |
| 59 | flow_control_pin: GPIO27 # Automatic hardware DE/RE transceiver control (ESP-IDF) |
| 60 | |
| 61 | # ============================================================================== |
| 62 | # TELEMETRY SENSORS |
| 63 | # ============================================================================== |
| 64 | sensor: |
| 65 | # 1. Outdoor intake temperature |
| 66 | - platform: template |
| 67 | name: "Outdoor Temperature" |
| 68 | id: s_outdoor_temp |
| 69 | unit_of_measurement: "°C" |
| 70 | device_class: temperature |
| 71 | state_class: measurement |
| 72 | accuracy_decimals: 0 |
| 73 | icon: "mdi:weather-snowy-heavy" |
| 74 | |
| 75 | # 2. Exhaust outlet temperature |
| 76 | - platform: template |
| 77 | name: "Exhaust Temperature" |
| 78 | id: s_exhaust_temp |
| 79 | unit_of_measurement: "°C" |
| 80 | device_class: temperature |
| 81 | state_class: measurement |
| 82 | accuracy_decimals: 0 |
| 83 | icon: "mdi:weather-windy" |
| 84 | |
| 85 | # 3. Supply air temperature (into rooms) |
| 86 | - platform: template |
| 87 | name: "Supply Temperature" |
| 88 | id: s_supply_temp |
| 89 | unit_of_measurement: "°C" |
| 90 | device_class: temperature |
| 91 | state_class: measurement |
| 92 | accuracy_decimals: 0 |
| 93 | icon: "mdi:air-filter" |
| 94 | |
| 95 | # 4. Extract air temperature (from rooms) |
| 96 | - platform: template |
| 97 | name: "Extract Temperature" |
| 98 | id: s_extract_temp |
| 99 | unit_of_measurement: "°C" |
| 100 | device_class: temperature |
| 101 | state_class: measurement |
| 102 | accuracy_decimals: 0 |
| 103 | icon: "mdi:home-thermometer" |
| 104 | |
| 105 | # 5. Calculated heat recovery efficiency |
| 106 | - platform: template |
| 107 | name: "Heat Recovery Efficiency" |
| 108 | id: s_efficiency |
| 109 | unit_of_measurement: "%" |
| 110 | state_class: measurement |
| 111 | accuracy_decimals: 1 |
| 112 | icon: "mdi:percent" |
| 113 | |
| 114 | # 6. Current supply air volume (m³/h) |
| 115 | - platform: template |
| 116 | name: "Supply Air Volume" |
| 117 | id: s_supply_volume |
| 118 | unit_of_measurement: "m³/h" |
| 119 | state_class: measurement |
| 120 | accuracy_decimals: 0 |
| 121 | icon: "mdi:fan-plus" |
| 122 | |
| 123 | # 7. Current extract air volume (m³/h) |
| 124 | - platform: template |
| 125 | name: "Extract Air Volume" |
| 126 | id: s_extract_volume |
| 127 | unit_of_measurement: "m³/h" |
| 128 | state_class: measurement |
| 129 | accuracy_decimals: 0 |
| 130 | icon: "mdi:fan-minus" |
| 131 | |
| 132 | # ============================================================================== |
| 133 | # BINARY SENSORS (LOGICAL STATES) |
| 134 | # ============================================================================== |
| 135 | binary_sensor: |
| 136 | # Physical bypass damper state |
| 137 | - platform: template |
| 138 | name: "Bypass Open" |
| 139 | id: bs_bypass_state |
| 140 | device_class: opening |
| 141 | icon: "mdi:swap-horizontal" |
| 142 | |
| 143 | # Housing cover / filter flap open alert (Byte 5 == 5) |
| 144 | - platform: template |
| 145 | name: "Cover Open" |
| 146 | id: bs_cover_open |
| 147 | device_class: problem |
| 148 | icon: "mdi:door-open" |
| 149 | |
| 150 | # Active heat exchanger defrosting (Byte 5 == 10) |
| 151 | - platform: template |
| 152 | name: "Defrost Active" |
| 153 | id: bs_defrost_active |
| 154 | device_class: heat |
| 155 | icon: "mdi:snowflake-melt" |
| 156 | |
| 157 | # ============================================================================== |
| 158 | # GLOBAL STATE & CONTROL VARIABLES |
| 159 | # ============================================================================== |
| 160 | globals: |
| 161 | - id: g_target_supply_speed |
| 162 | type: int |
| 163 | initial_value: "30" |
| 164 | - id: g_target_extract_speed |
| 165 | type: int |
| 166 | initial_value: "30" |
| 167 | - id: g_target_bypass_temp |
| 168 | type: int |
| 169 | initial_value: "22" |
| 170 | - id: g_target_bypass_mode |
| 171 | type: int |
| 172 | initial_value: "0" # 0: Auto, 1: Open, 2: Closed |
| 173 | - id: g_unit_on |
| 174 | type: bool |
| 175 | initial_value: "true" |
| 176 | - id: g_constant_flow |
| 177 | type: bool |
| 178 | initial_value: "true" |
| 179 | - id: g_max_volume |
| 180 | type: int |
| 181 | initial_value: "${default_max_volume}" |
| 182 | - id: g_vent_mode |
| 183 | type: int |
| 184 | initial_value: "0" # 0: Normal, 1: Fireplace, 2: Window, 3: Cooker Hood |
| 185 | |
| 186 | # ============================================================================== |
| 187 | # CONTROLS — SWITCHES |
| 188 | # ============================================================================== |
| 189 | switch: |
| 190 | # Ventilation unit master power (Unit State ON/OFF) |
| 191 | - platform: template |
| 192 | name: "Unit Power" |
| 193 | id: sw_unit_power |
| 194 | icon: "mdi:power" |
| 195 | optimistic: true |
| 196 | restore_mode: RESTORE_DEFAULT_ON |
| 197 | turn_on_action: |
| 198 | - lambda: 'id(g_unit_on) = true;' |
| 199 | turn_off_action: |
| 200 | - lambda: 'id(g_unit_on) = false;' |
| 201 | |
| 202 | # Constant Flow automatic compensation switch |
| 203 | - platform: template |
| 204 | name: "Constant Flow" |
| 205 | id: sw_constant_flow |
| 206 | icon: "mdi:gauge" |
| 207 | optimistic: true |
| 208 | restore_mode: RESTORE_DEFAULT_ON |
| 209 | turn_on_action: |
| 210 | - lambda: 'id(g_constant_flow) = true;' |
| 211 | turn_off_action: |
| 212 | - lambda: 'id(g_constant_flow) = false;' |
| 213 | |
| 214 | # ============================================================================== |
| 215 | # CONTROLS — NUMERICAL INPUTS (NUMBER) |
| 216 | # ============================================================================== |
| 217 | number: |
| 218 | # Target supply fan speed (0 - 100%) |
| 219 | - platform: template |
| 220 | name: "Target Supply Fan Speed" |
| 221 | id: num_supply_speed |
| 222 | unit_of_measurement: "%" |
| 223 | icon: "mdi:fan" |
| 224 | min_value: 0 |
| 225 | max_value: 100 |
| 226 | step: 5 |
| 227 | mode: slider |
| 228 | optimistic: true |
| 229 | restore_value: true |
| 230 | initial_value: 30 |
| 231 | set_action: |
| 232 | - lambda: 'id(g_target_supply_speed) = (int)x;' |
| 233 | |
| 234 | # Target extract fan speed (0 - 100%) |
| 235 | - platform: template |
| 236 | name: "Target Extract Fan Speed" |
| 237 | id: num_extract_speed |
| 238 | unit_of_measurement: "%" |
| 239 | icon: "mdi:fan" |
| 240 | min_value: 0 |
| 241 | max_value: 100 |
| 242 | step: 5 |
| 243 | mode: slider |
| 244 | optimistic: true |
| 245 | restore_value: true |
| 246 | initial_value: 30 |
| 247 | set_action: |
| 248 | - lambda: 'id(g_target_extract_speed) = (int)x;' |
| 249 | |
| 250 | # Bypass activation threshold temperature (18 - 26 °C) |
| 251 | - platform: template |
| 252 | name: "Bypass Threshold Temperature" |
| 253 | id: num_bypass_temp |
| 254 | unit_of_measurement: "°C" |
| 255 | icon: "mdi:thermometer-chevron-up" |
| 256 | min_value: 18 |
| 257 | max_value: 26 |
| 258 | step: 1 |
| 259 | mode: box |
| 260 | optimistic: true |
| 261 | restore_value: true |
| 262 | initial_value: 22 |
| 263 | set_action: |
| 264 | - lambda: 'id(g_target_bypass_temp) = (int)x;' |
| 265 | |
| 266 | # Maximum rated volume (x10 m³/h, e.g. 23 = 230 m³/h, 30 = 300 m³/h) |
| 267 | - platform: template |
| 268 | name: "Maximum Rated Volume" |
| 269 | unit_of_measurement: "x10 m³/h" |
| 270 | id: num_max_volume |
| 271 | icon: "mdi:gauge-full" |
| 272 | min_value: 15 |
| 273 | max_value: 50 |
| 274 | step: 1 |
| 275 | mode: box |
| 276 | optimistic: true |
| 277 | restore_value: true |
| 278 | initial_value: ${default_max_volume} |
| 279 | set_action: |
| 280 | - lambda: 'id(g_max_volume) = (int)x;' |
| 281 | |
| 282 | # ============================================================================== |
| 283 | # CONTROLS — DROPDOWN MENUS (SELECT) |
| 284 | # ============================================================================== |
| 285 | select: |
| 286 | # Bypass damper control mode |
| 287 | - platform: template |
| 288 | name: "Bypass Mode" |
| 289 | id: sel_bypass_mode |
| 290 | icon: "mdi:valve" |
| 291 | optimistic: true |
| 292 | restore_value: true |
| 293 | initial_option: "Auto" |
| 294 | options: |
| 295 | - "Auto" |
| 296 | - "Always Open" |
| 297 | - "Always Closed" |
| 298 | set_action: |
| 299 | - lambda: |- |
| 300 | if (x == "Auto") id(g_target_bypass_mode) = 0; |
| 301 | else if (x == "Always Open") id(g_target_bypass_mode) = 1; |
| 302 | else if (x == "Always Closed") id(g_target_bypass_mode) = 2; |
| 303 | |
| 304 | # Special ventilation mode |
| 305 | - platform: template |
| 306 | name: "Special Ventilation Mode" |
| 307 | id: sel_vent_mode |
| 308 | icon: "mdi:tune" |
| 309 | optimistic: true |
| 310 | restore_value: true |
| 311 | initial_option: "Normal" |
| 312 | options: |
| 313 | - "Normal" |
| 314 | - "Fireplace (Overpressure)" |
| 315 | - "Open Window (Exhaust Only)" |
| 316 | - "Cooker Hood (Compensation)" |
| 317 | set_action: |
| 318 | - lambda: |- |
| 319 | if (x == "Normal") id(g_vent_mode) = 0; |
| 320 | else if (x == "Fireplace (Overpressure)") id(g_vent_mode) = 1; |
| 321 | else if (x == "Open Window (Exhaust Only)") id(g_vent_mode) = 2; |
| 322 | else if (x == "Cooker Hood (Compensation)") id(g_vent_mode) = 3; |
| 323 | |
| 324 | # ============================================================================== |
| 325 | # MAIN RS-485 COMMUNICATION LOOP (100% PURE YAML, ZERO EXTERNAL FILES) |
| 326 | # ============================================================================== |
| 327 | interval: |
| 328 | - interval: 15ms |
| 329 | then: |
| 330 | - lambda: |- |
| 331 | static uint8_t rx_buf[64]; |
| 332 | static size_t rx_len = 0; |
| 333 | static uint32_t last_rx_byte_ms = 0; |
| 334 | static uint32_t last_status_ms = 0; |
| 335 | static bool send_pending = false; |
| 336 | |
| 337 | // 1. Ingest incoming bytes from hardware UART FIFO into local buffer |
| 338 | while (id(uart_bus)->available()) { |
| 339 | uint8_t b; |
| 340 | id(uart_bus)->read_byte(&b); |
| 341 | if (rx_len < sizeof(rx_buf)) { |
| 342 | rx_buf[rx_len++] = b; |
| 343 | } |
| 344 | last_rx_byte_ms = esphome::millis(); |
| 345 | } |
| 346 | |
| 347 | // 2. Inter-frame silence timeout: if > 50 ms elapsed since last byte and incomplete frame, discard noise |
| 348 | if (rx_len > 0 && (esphome::millis() - last_rx_byte_ms > 50) && rx_len < 22) { |
| 349 | rx_len = 0; |
| 350 | } |
| 351 | |
| 352 | // 3. Sliding Window parser for 0x63 status frame |
| 353 | while (rx_len >= 22) { |
| 354 | // Seek start byte 0x63 |
| 355 | if (rx_buf[0] != 0x63) { |
| 356 | memmove(&rx_buf[0], &rx_buf[1], --rx_len); |
| 357 | continue; |
| 358 | } |
| 359 | |
| 360 | int8_t t_outdoor = (int8_t)rx_buf[1]; |
| 361 | int8_t t_exhaust = (int8_t)rx_buf[2]; |
| 362 | int8_t t_supply = (int8_t)rx_buf[3]; |
| 363 | int8_t t_extract = (int8_t)rx_buf[4]; |
| 364 | uint8_t state = rx_buf[5]; |
| 365 | uint8_t bypass = rx_buf[8]; |
| 366 | uint8_t sup_vol = rx_buf[17]; |
| 367 | uint8_t ext_vol = rx_buf[18]; |
| 368 | |
| 369 | // Robust multi-level sanity validation (compensating lack of CRC checksum) |
| 370 | bool valid = (rx_buf[6] == 0 && rx_buf[7] == 0) && |
| 371 | (rx_buf[20] == 0 && rx_buf[21] == 0) && |
| 372 | (state == 0 || state == 5 || state == 10) && |
| 373 | (bypass == 0 || bypass == 1) && |
| 374 | (t_outdoor >= -35 && t_outdoor <= 55) && |
| 375 | (t_exhaust >= -35 && t_exhaust <= 55) && |
| 376 | (t_supply >= -35 && t_supply <= 55) && |
| 377 | (t_extract >= -35 && t_extract <= 55) && |
| 378 | (sup_vol <= 60 && ext_vol <= 60); |
| 379 | |
| 380 | if (valid) { |
| 381 | // Publish temperatures (signed int8_t for sub-zero support) |
| 382 | id(s_outdoor_temp).publish_state(t_outdoor); |
| 383 | id(s_exhaust_temp).publish_state(t_exhaust); |
| 384 | id(s_supply_temp).publish_state(t_supply); |
| 385 | id(s_extract_temp).publish_state(t_extract); |
| 386 | |
| 387 | // Calculate recovery efficiency |
| 388 | if (t_extract != t_outdoor) { |
| 389 | float eff = ((float)(t_supply - t_outdoor) / (float)(t_extract - t_outdoor)) * 100.0f; |
| 390 | if (eff < 0.0f) eff = 0.0f; |
| 391 | if (eff > 100.0f) eff = 100.0f; |
| 392 | id(s_efficiency).publish_state(eff); |
| 393 | } |
| 394 | |
| 395 | // Publish air flow volume in m³/h (raw byte * 10) |
| 396 | id(s_supply_volume).publish_state(sup_vol * 10); |
| 397 | id(s_extract_volume).publish_state(ext_vol * 10); |
| 398 | |
| 399 | // Publish binary sensors (Bypass, Cover, Defrost) |
| 400 | id(bs_bypass_state).publish_state(bypass == 1); |
| 401 | id(bs_cover_open).publish_state(state == 5); |
| 402 | id(bs_defrost_active).publish_state(state == 10); |
| 403 | |
| 404 | 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", |
| 405 | t_outdoor, t_exhaust, t_supply, t_extract, sup_vol * 10, ext_vol * 10, bypass ? "OPEN" : "CLOSED", state); |
| 406 | |
| 407 | last_status_ms = esphome::millis(); |
| 408 | send_pending = true; |
| 409 | |
| 410 | // Consume valid 22-byte frame from buffer |
| 411 | memmove(&rx_buf[0], &rx_buf[22], rx_len - 22); |
| 412 | rx_len -= 22; |
| 413 | } else { |
| 414 | // Corrupted packet or false 0x63 byte: slide by only 1 byte to keep synchronization |
| 415 | memmove(&rx_buf[0], &rx_buf[1], --rx_len); |
| 416 | ESP_LOGW("IZZIFast", "Corrupted 0x63 header or sanity check failed, sliding window 1 byte"); |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | // 2. Transmit 0x64 command frame (after min. 15 ms turnaround from status frame) |
| 421 | if (send_pending && (esphome::millis() - last_status_ms >= 15)) { |
| 422 | send_pending = false; |
| 423 | |
| 424 | uint8_t actual_supply = (uint8_t)id(g_target_supply_speed); |
| 425 | uint8_t actual_extract = (uint8_t)id(g_target_extract_speed); |
| 426 | |
| 427 | // Apply special ventilation mode modifiers |
| 428 | switch (id(g_vent_mode)) { |
| 429 | case 1: // Fireplace (exhaust -20% to create slight overpressure) |
| 430 | actual_extract = (uint8_t)(actual_extract * 0.80f); |
| 431 | break; |
| 432 | case 2: // Open window (exhaust only, supply 0%) |
| 433 | actual_supply = 0; |
| 434 | break; |
| 435 | case 3: // Cooker hood (exhaust -70% to compensate kitchen extraction) |
| 436 | actual_extract = (uint8_t)(actual_extract * 0.30f); |
| 437 | break; |
| 438 | default: |
| 439 | break; |
| 440 | } |
| 441 | |
| 442 | // EC motor minimum speed protection when unit is powered on |
| 443 | if (id(g_unit_on)) { |
| 444 | if (actual_supply > 0 && actual_supply < 15) actual_supply = 15; |
| 445 | if (actual_extract > 0 && actual_extract < 15) actual_extract = 15; |
| 446 | } |
| 447 | |
| 448 | // Construct 21-byte command frame 0x64 |
| 449 | uint8_t cmd[21] = { |
| 450 | 0x64, |
| 451 | 0x00, 0x00, 0x00, 0x00, 0x16, 0x05, 0x00, |
| 452 | (uint8_t)id(g_target_bypass_temp), |
| 453 | (uint8_t)id(g_target_bypass_mode), |
| 454 | actual_supply, |
| 455 | actual_extract, |
| 456 | (uint8_t)(id(g_unit_on) ? 0 : 1), |
| 457 | (uint8_t)id(g_max_volume), |
| 458 | (uint8_t)(id(g_constant_flow) ? 1 : 0), |
| 459 | 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 |
| 460 | }; |
| 461 | |
| 462 | // Send 21-byte command frame 0x64. |
| 463 | // ESP32 hardware UART in flow_control_pin mode automatically asserts GPIO 27 (DE/RE) |
| 464 | // and de-asserts it with sub-microsecond precision immediately after the last stop bit! |
| 465 | id(uart_bus)->write_array(cmd, 21); |
| 466 | |
| 467 | ESP_LOGI("IZZIFast", "Command 0x64 sent: Sup=%d%%, Ext=%d%%, Unit=%s, BypassMode=%d, MaxVol=%d, CF=%s", |
| 468 | 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"); |
| 469 | } |
| 470 |