Last active 1 week ago

izzyfast.yaml Raw
1substitutions:
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
10esphome:
11 name: "${device_name}"
12 friendly_name: "${friendly_name}"
13
14esp32:
15 board: esp32dev
16 framework:
17 type: esp-idf
18
19# WiFi configuration
20wifi:
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
29captive_portal:
30
31# Web Server interface with authentication and live log streaming
32web_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
39api:
40 encryption:
41 key: "${api_encryption_key}"
42
43ota:
44 - platform: esphome
45 password: "${ota_password}"
46
47logger:
48 level: INFO
49
50# Hardware UART (Serial2) in hardware RS-485 Half-Duplex mode
51uart:
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# ==============================================================================
64sensor:
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# ==============================================================================
135binary_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# ==============================================================================
160globals:
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# ==============================================================================
189switch:
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# ==============================================================================
217number:
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# ==============================================================================
285select:
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# ==============================================================================
327interval:
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