Files
jazzymc 2f783dbc41 Initial commit: ESP32-C6 rack fan controller with ESPHome
3-zone PWM fan controller (intake/central/outtake) with DS18B20 temp
sensors, RPM monitoring, ST7789 LCD display, and Home Assistant
integration via ESPHome native API. USB-C PD powered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:09:00 +00:00

527 lines
14 KiB
YAML

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!");
}