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>
This commit is contained in:
+15
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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!");
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user