From 2f783dbc41bca0982f69616daa34c83f1a0ae0a9 Mon Sep 17 00:00:00 2001 From: jazzymc Date: Sun, 12 Apr 2026 16:09:00 +0000 Subject: [PATCH] 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) --- .gitignore | 15 + README.md | 136 ++++++++ esphome/rack-fan-controller.yaml | 526 +++++++++++++++++++++++++++++++ esphome/secrets.yaml.example | 5 + esphome/simulation.yaml | 51 +++ 5 files changed, 733 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 esphome/rack-fan-controller.yaml create mode 100644 esphome/secrets.yaml.example create mode 100644 esphome/simulation.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcf4563 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ESPHome build artifacts +esphome/.esphome/ +esphome/**/.pioenvs/ +esphome/**/.piolibdeps/ + +# Secrets (never commit) +esphome/secrets.yaml + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e2fed6 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Rack Fan Controller + +ESP32-C6 based smart rack/cabinet fan controller with temperature monitoring, automatic PWM fan speed control, and Home Assistant integration via ESPHome. + +## Features + +- **3-zone fan control**: Intake, Central, Outtake (2 fans per zone, 6 total) +- **Temperature monitoring**: 3x DS18B20 sensors (one per zone) +- **RPM monitoring**: Tachometer reading from one fan per zone +- **Auto speed control**: Temperature-based PWM ramping with configurable thresholds +- **Negative pressure**: Outtake fans run slightly faster for dust control +- **1.47" LCD display**: Real-time temps, RPMs, and fan duty on Waveshare ST7789 +- **Home Assistant**: Full integration via ESPHome native API +- **USB-C powered**: 65W PD charger -> ZY12PDN 12V trigger -> fans + ESP32 + +## Hardware + +| Component | Qty | Notes | +|-----------|-----|-------| +| Waveshare ESP32-C6 LCD 1.47" | 1 | No-touch version, ST7789 172x320 | +| Arctic P12 PWM 120mm | 6 | 4-pin, 2 per zone | +| ZY12PDN USB-C PD trigger | 1 | Set to 12V output | +| LM2596 buck converter | 1 | 12V -> 5V for ESP32 | +| DS18B20 waterproof probe | 3 | 1m cable, 1-Wire bus | +| PWM Y-splitter (4-pin) | 3 | 1-to-2 per zone | +| KF301 screw terminals | 10 | PCB connections | +| 4.7kOhm resistor | 1 | DS18B20 pull-up | +| 5x7cm prototype PCB | 1 | Main board | + +## GPIO Mapping (Waveshare ESP32-C6 LCD 1.47") + +### LCD (occupied - do not use) +| Function | GPIO | +|----------|------| +| LCD MOSI | 6 | +| LCD CLK | 7 | +| LCD CS | 14 | +| LCD DC | 15 | +| LCD RST | 21 | +| LCD BLK | 22 | +| RGB LED | 8 | +| SD MISO | 5 | +| SD CS | 4 | + +### Project Pin Assignment +| Function | GPIO | Notes | +|----------|------|-------| +| PWM Intake | 0 | Zone 1 - via Y-splitter to 2 fans | +| PWM Central | 1 | Zone 2 - via Y-splitter to 2 fans | +| PWM Outtake | 2 | Zone 3 - via Y-splitter to 2 fans | +| Tach Intake | 3 | RPM from 1 fan in zone 1 | +| Tach Central | 10 | RPM from 1 fan in zone 2 | +| Tach Outtake | 11 | RPM from 1 fan in zone 3 | +| DS18B20 bus | 9 | All 3 sensors on single 1-Wire bus | + +## Wiring Diagram + +``` +USB-C PD Charger (65W+) + | + USB-C cable + | + v +ZY12PDN (set to 12V) ----+------ 12V Rail + | | + v +--- Y-splitter --- Fan1 + Fan2 (INTAKE) +LM2596 (out=5V) | | + | +--- Y-splitter --- Fan3 + Fan4 (CENTRAL) + v | | +ESP32-C6 (5V in) +--- Y-splitter --- Fan5 + Fan6 (OUTTAKE) + | + +-- GPIO0 (PWM) ----> Intake splitter PWM pin + +-- GPIO1 (PWM) ----> Central splitter PWM pin + +-- GPIO2 (PWM) ----> Outtake splitter PWM pin + | + +-- GPIO3 (TACH) <--- Fan1 tach (yellow wire) + +-- GPIO10 (TACH) <-- Fan3 tach (yellow wire) + +-- GPIO11 (TACH) <-- Fan5 tach (yellow wire) + | + +-- GPIO9 (1-Wire) --+-- DS18B20 (intake) + +-- DS18B20 (central) + +-- DS18B20 (outtake) + | + 4.7k pull-up to 3.3V +``` + +### 4-Pin Fan Connector Pinout +``` +Pin 1 (Black) = GND +Pin 2 (Yellow) = +12V +Pin 3 (Green) = Tach (RPM signal) +Pin 4 (Blue) = PWM control +``` + +## Temperature Control Logic + +| Temp Range | Intake | Central | Outtake | Notes | +|------------|--------|---------|---------|-------| +| < 28C | 20% | 20% | 25% | Near-silent baseline | +| 28-35C | 20-50% | 20-50% | 25-55% | Gentle ramp | +| 35-42C | 50-80% | 50-80% | 55-85% | Medium cooling | +| 42-50C | 80-100% | 80-100% | 85-100% | Full cooling | +| > 50C | 100% | 100% | 100% | Emergency + HA alert | + +Outtake always runs +5% over intake for slight negative pressure (dust control). + +## Build & Flash + +```bash +# Install ESPHome +pip install esphome + +# Validate config +esphome config esphome/rack-fan-controller.yaml + +# Flash (first time via USB) +esphome run esphome/rack-fan-controller.yaml + +# Subsequent updates via OTA +esphome run esphome/rack-fan-controller.yaml --device rack-fan-ctrl.local +``` + +## Home Assistant + +After flashing, the device auto-discovers in HA via ESPHome integration. Exposed entities: + +- `sensor.rack_temp_intake` / `central` / `outtake` +- `sensor.rack_fan_rpm_intake` / `central` / `outtake` +- `sensor.rack_fan_duty_intake` / `central` / `outtake` +- `switch.rack_fan_auto_mode` (enable/disable auto control) +- `number.rack_fan_target_temp` (target temperature setpoint) +- `number.rack_fan_min_duty` / `max_duty` (manual override range) + +## License + +MIT diff --git a/esphome/rack-fan-controller.yaml b/esphome/rack-fan-controller.yaml new file mode 100644 index 0000000..ad7c863 --- /dev/null +++ b/esphome/rack-fan-controller.yaml @@ -0,0 +1,526 @@ +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!"); + } diff --git a/esphome/secrets.yaml.example b/esphome/secrets.yaml.example new file mode 100644 index 0000000..e4e93f3 --- /dev/null +++ b/esphome/secrets.yaml.example @@ -0,0 +1,5 @@ +# Copy to secrets.yaml and fill in your values +wifi_ssid: "YourWiFiSSID" +wifi_password: "YourWiFiPassword" +api_encryption_key: "generate-with-esphome" +ota_password: "your-ota-password" diff --git a/esphome/simulation.yaml b/esphome/simulation.yaml new file mode 100644 index 0000000..5418337 --- /dev/null +++ b/esphome/simulation.yaml @@ -0,0 +1,51 @@ +# Simulation overlay for testing without physical hardware +# Usage: esphome run rack-fan-controller.yaml +# Then use HA developer tools to set simulated sensor values +# +# This file documents how to test the controller logic +# without real DS18B20 sensors or fans connected. +# +# Method 1: ESPHome dashboard +# - Flash the normal config to any ESP32-C6 +# - The sensors will show "unknown" but the control loop +# handles missing sensors gracefully (defaults to 50% duty) +# - Use HA number entities to adjust target temp and duty limits +# +# Method 2: Home Assistant template sensors +# Add these to your HA configuration.yaml to simulate temps: +# +# input_number: +# sim_rack_temp_intake: +# name: "Sim Rack Temp Intake" +# min: 15 +# max: 70 +# step: 0.5 +# initial: 25 +# unit_of_measurement: "C" +# sim_rack_temp_central: +# name: "Sim Rack Temp Central" +# min: 15 +# max: 70 +# step: 0.5 +# initial: 30 +# unit_of_measurement: "C" +# sim_rack_temp_outtake: +# name: "Sim Rack Temp Outtake" +# min: 15 +# max: 70 +# step: 0.5 +# initial: 27 +# unit_of_measurement: "C" +# +# Method 3: Test the display layout +# Flash to the real Waveshare ESP32-C6 board (even without +# sensors/fans connected). The display will render with +# "--.-C" placeholders and you can verify the layout. +# +# Expected behavior matrix: +# temp < 28C -> duty = min_duty (default 20%) +# temp = 35C -> duty ~= 50% (midpoint of default ramp) +# temp = 42C -> duty ~= 80% +# temp > 43C -> duty = max_duty (default 100%) +# temp > 50C -> overheat alert triggers +# no sensors -> duty = 50% (safety fallback)