Last active 1 week ago

's Avatar revised this gist 1 week ago. Go to revision

1 file changed, 469 insertions

izzyfast.yaml(file created)

@@ -0,0 +1,469 @@
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 + }
Newer Older