2f783dbc41
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>
527 lines
14 KiB
YAML
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!");
|
|
}
|