substitutions: device_name: rack-fan-ctrl friendly_name: "Rack Fan Controller" # Temperature thresholds (Celsius) temp_min: "28.0" # Below this: minimum fan speed temp_low: "35.0" # Ramp midpoint temp_high: "42.0" # Ramp to max temp_crit: "50.0" # Emergency: 100% + alert # Fan duty limits (0.0 - 1.0) fan_min_duty: "0.20" fan_max_duty: "1.0" # Outtake offset (extra duty for negative pressure) outtake_offset: "0.05" # GPIO assignments - Waveshare ESP32-C6 LCD 1.47" pin_pwm_intake: GPIO0 pin_pwm_central: GPIO1 pin_pwm_outtake: GPIO2 pin_tach_intake: GPIO3 pin_tach_central: GPIO10 pin_tach_outtake: GPIO11 pin_onewire: GPIO9 # LCD pins (Waveshare ESP32-C6 LCD 1.47" defaults) pin_lcd_mosi: GPIO6 pin_lcd_clk: GPIO7 pin_lcd_cs: GPIO14 pin_lcd_dc: GPIO15 pin_lcd_rst: GPIO21 pin_lcd_bl: GPIO22 esphome: name: ${device_name} friendly_name: ${friendly_name} on_boot: priority: -100 then: - output.set_level: id: lcd_backlight level: 80% - switch.turn_on: auto_mode esp32: board: esp32-c6-devkitc-1 framework: type: esp-idf logger: level: INFO api: encryption: key: !secret api_encryption_key ota: - platform: esphome password: !secret ota_password wifi: ssid: !secret wifi_ssid password: !secret wifi_password ap: ssid: "${device_name}-fallback" password: "rackfan123" captive_portal: # --- SPI bus (shared by LCD) --- spi: clk_pin: ${pin_lcd_clk} mosi_pin: ${pin_lcd_mosi} # --- LCD Backlight --- output: - platform: ledc pin: ${pin_lcd_bl} id: lcd_backlight frequency: 1000Hz # --- Fan PWM outputs (25kHz per Intel 4-pin spec) --- - platform: ledc pin: ${pin_pwm_intake} id: pwm_intake frequency: 25000Hz - platform: ledc pin: ${pin_pwm_central} id: pwm_central frequency: 25000Hz - platform: ledc pin: ${pin_pwm_outtake} id: pwm_outtake frequency: 25000Hz # --- 1-Wire bus for DS18B20 sensors --- one_wire: - platform: gpio pin: ${pin_onewire} # --- Temperature sensors --- sensor: # DS18B20 sensors - addresses auto-discovered on first boot # After first boot, check logs for addresses and pin them here - platform: dallas_temp name: "Rack Temp Intake" id: temp_intake update_interval: 5s filters: - sliding_window_moving_average: window_size: 3 send_every: 1 - platform: dallas_temp name: "Rack Temp Central" id: temp_central update_interval: 5s filters: - sliding_window_moving_average: window_size: 3 send_every: 1 - platform: dallas_temp name: "Rack Temp Outtake" id: temp_outtake update_interval: 5s filters: - sliding_window_moving_average: window_size: 3 send_every: 1 # Fan tachometer (RPM) - 2 pulses per revolution - platform: pulse_counter pin: number: ${pin_tach_intake} mode: input: true pullup: true name: "Rack Fan RPM Intake" id: rpm_intake update_interval: 5s count_mode: rising_edge: INCREMENT falling_edge: DISABLE filters: - multiply: 0.5 # 2 pulses per rev -> divide by 2 - multiply: 12 # per-5s count * 12 = per-minute - platform: pulse_counter pin: number: ${pin_tach_central} mode: input: true pullup: true name: "Rack Fan RPM Central" id: rpm_central update_interval: 5s count_mode: rising_edge: INCREMENT falling_edge: DISABLE filters: - multiply: 0.5 - multiply: 12 - platform: pulse_counter pin: number: ${pin_tach_outtake} mode: input: true pullup: true name: "Rack Fan RPM Outtake" id: rpm_outtake update_interval: 5s count_mode: rising_edge: INCREMENT falling_edge: DISABLE filters: - multiply: 0.5 - multiply: 12 # Computed fan duty percentages (for display and HA) - platform: template name: "Rack Fan Duty Intake" id: duty_intake unit_of_measurement: "%" accuracy_decimals: 0 update_interval: 5s - platform: template name: "Rack Fan Duty Central" id: duty_central unit_of_measurement: "%" accuracy_decimals: 0 update_interval: 5s - platform: template name: "Rack Fan Duty Outtake" id: duty_outtake unit_of_measurement: "%" accuracy_decimals: 0 update_interval: 5s # WiFi signal for diagnostics - platform: wifi_signal name: "Rack Controller WiFi" update_interval: 60s # --- Auto/Manual mode switch --- switch: - platform: template name: "Rack Fan Auto Mode" id: auto_mode optimistic: true restore_mode: RESTORE_DEFAULT_ON # --- Configurable setpoints from HA --- number: - platform: template name: "Rack Target Temp" id: target_temp min_value: 20 max_value: 50 step: 1 initial_value: 35 optimistic: true unit_of_measurement: "C" restore_value: true - platform: template name: "Rack Fan Min Duty" id: min_duty min_value: 0 max_value: 100 step: 5 initial_value: 20 optimistic: true unit_of_measurement: "%" restore_value: true - platform: template name: "Rack Fan Max Duty" id: max_duty min_value: 20 max_value: 100 step: 5 initial_value: 100 optimistic: true unit_of_measurement: "%" restore_value: true # --- Binary sensor for overheat alert --- binary_sensor: - platform: template name: "Rack Overheat Alert" id: overheat_alert device_class: heat lambda: |- if (!id(temp_central).has_state()) return false; return id(temp_central).state >= ${temp_crit}; # --- Fan control automation --- interval: - interval: 5s then: - lambda: |- if (!id(auto_mode).state) return; // Get max temperature from all sensors (use central as primary) float temp = 0; int count = 0; if (id(temp_intake).has_state()) { temp += id(temp_intake).state; count++; } if (id(temp_central).has_state()) { temp += id(temp_central).state; count++; } if (id(temp_outtake).has_state()) { temp += id(temp_outtake).state; count++; } // Use central temp if available, otherwise average if (id(temp_central).has_state()) { temp = id(temp_central).state; } else if (count > 0) { temp = temp / count; } else { // No sensors reporting - run at 50% as safety auto call_i = id(pwm_intake).make_call(); call_i.set_level(0.5); call_i.perform(); auto call_c = id(pwm_central).make_call(); call_c.set_level(0.5); call_c.perform(); auto call_o = id(pwm_outtake).make_call(); call_o.set_level(0.55); call_o.perform(); return; } // Get configurable limits float d_min = id(min_duty).state / 100.0; float d_max = id(max_duty).state / 100.0; float t_target = id(target_temp).state; // Calculate duty: linear ramp from min to max // Ramp starts 7C below target, reaches max at 8C above target float t_start = t_target - 7.0; float t_end = t_target + 8.0; float duty = 0; if (temp <= t_start) { duty = d_min; } else if (temp >= t_end) { duty = d_max; } else { duty = d_min + (d_max - d_min) * (temp - t_start) / (t_end - t_start); } // Clamp if (duty < d_min) duty = d_min; if (duty > 1.0) duty = 1.0; // Outtake runs slightly faster for negative pressure float outtake_duty = duty + ${outtake_offset}; if (outtake_duty > 1.0) outtake_duty = 1.0; // Apply PWM auto call_i = id(pwm_intake).make_call(); call_i.set_level(duty); call_i.perform(); auto call_c = id(pwm_central).make_call(); call_c.set_level(duty); call_c.perform(); auto call_o = id(pwm_outtake).make_call(); call_o.set_level(outtake_duty); call_o.perform(); // Update duty sensors for HA / display id(duty_intake).publish_state(duty * 100.0); id(duty_central).publish_state(duty * 100.0); id(duty_outtake).publish_state(outtake_duty * 100.0); # --- Display (Waveshare ESP32-C6 1.47" ST7789 172x320) --- font: - file: "gfonts://Roboto" id: font_title size: 18 glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .:%/-" - file: "gfonts://Roboto" id: font_value size: 28 glyphs: "0123456789.C%RPM " - file: "gfonts://Roboto" id: font_label size: 14 glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .:%/-" - file: "gfonts://Roboto" id: font_small size: 11 glyphs: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .:%/-" color: - id: color_white white: 100% - id: color_bg red: 10% green: 10% blue: 15% - id: color_blue red: 30% green: 60% blue: 100% - id: color_cyan red: 0% green: 90% blue: 90% - id: color_green red: 20% green: 90% blue: 20% - id: color_yellow red: 100% green: 90% blue: 0% - id: color_orange red: 100% green: 60% blue: 0% - id: color_red red: 100% green: 20% blue: 20% - id: color_gray red: 50% green: 50% blue: 50% display: - platform: st7789v id: lcd cs_pin: ${pin_lcd_cs} dc_pin: ${pin_lcd_dc} reset_pin: ${pin_lcd_rst} model: Custom width: 172 height: 320 offset_height: 34 offset_width: 0 eightbitcolor: false update_interval: 2s rotation: 0 lambda: |- // Background it.fill(id(color_bg)); // Title bar it.filled_rectangle(0, 0, 172, 24, id(color_blue)); it.printf(86, 3, id(font_title), id(color_white), TextAlign::TOP_CENTER, "RACK FANS"); // Helper: pick color based on temperature auto temp_color = [&](float t) -> Color { if (t < 28) return id(color_green); if (t < 35) return id(color_cyan); if (t < 42) return id(color_yellow); if (t < 50) return id(color_orange); return id(color_red); }; // Helper: pick color based on duty auto duty_color = [&](float d) -> Color { if (d < 30) return id(color_green); if (d < 60) return id(color_cyan); if (d < 80) return id(color_yellow); return id(color_orange); }; int y = 30; int section_h = 90; // --- INTAKE ZONE --- it.printf(6, y, id(font_label), id(color_blue), "INTAKE"); y += 16; if (id(temp_intake).has_state()) { float t = id(temp_intake).state; it.printf(6, y, id(font_value), temp_color(t), "%.1fC", t); } else { it.printf(6, y, id(font_value), id(color_gray), "--.-C"); } y += 30; if (id(rpm_intake).has_state()) { it.printf(6, y, id(font_label), id(color_white), "%.0f RPM", id(rpm_intake).state); } if (id(duty_intake).has_state()) { it.printf(166, y, id(font_label), duty_color(id(duty_intake).state), TextAlign::TOP_RIGHT, "%.0f%%", id(duty_intake).state); } y += 18; // Separator it.horizontal_line(10, y, 152, id(color_gray)); y += 6; // --- CENTRAL ZONE --- it.printf(6, y, id(font_label), id(color_blue), "CENTRAL"); y += 16; if (id(temp_central).has_state()) { float t = id(temp_central).state; it.printf(6, y, id(font_value), temp_color(t), "%.1fC", t); } else { it.printf(6, y, id(font_value), id(color_gray), "--.-C"); } y += 30; if (id(rpm_central).has_state()) { it.printf(6, y, id(font_label), id(color_white), "%.0f RPM", id(rpm_central).state); } if (id(duty_central).has_state()) { it.printf(166, y, id(font_label), duty_color(id(duty_central).state), TextAlign::TOP_RIGHT, "%.0f%%", id(duty_central).state); } y += 18; // Separator it.horizontal_line(10, y, 152, id(color_gray)); y += 6; // --- OUTTAKE ZONE --- it.printf(6, y, id(font_label), id(color_blue), "OUTTAKE"); y += 16; if (id(temp_outtake).has_state()) { float t = id(temp_outtake).state; it.printf(6, y, id(font_value), temp_color(t), "%.1fC", t); } else { it.printf(6, y, id(font_value), id(color_gray), "--.-C"); } y += 30; if (id(rpm_outtake).has_state()) { it.printf(6, y, id(font_label), id(color_white), "%.0f RPM", id(rpm_outtake).state); } if (id(duty_outtake).has_state()) { it.printf(166, y, id(font_label), duty_color(id(duty_outtake).state), TextAlign::TOP_RIGHT, "%.0f%%", id(duty_outtake).state); } y += 22; // --- STATUS BAR --- it.horizontal_line(0, y, 172, id(color_blue)); y += 4; // Auto/Manual mode indicator if (id(auto_mode).state) { it.printf(6, y, id(font_small), id(color_green), "AUTO"); } else { it.printf(6, y, id(font_small), id(color_yellow), "MANUAL"); } // WiFi indicator if (WiFi.isConnected()) { it.printf(166, y, id(font_small), id(color_green), TextAlign::TOP_RIGHT, "WiFi OK"); } else { it.printf(166, y, id(font_small), id(color_red), TextAlign::TOP_RIGHT, "NO WiFi"); } // Overheat warning if (id(overheat_alert).state) { it.filled_rectangle(30, 140, 112, 30, id(color_red)); it.printf(86, 143, id(font_title), id(color_white), TextAlign::TOP_CENTER, "OVERHEAT!"); }