Compare commits

..

5 Commits

Author SHA1 Message Date
3c874914ef fix: kick-start fan on every 0→on transition
Previous kick-start only fired when duty < 200, but all level duties are
≥230, so L1 starts from rest at PWM 230 and the rotor can't break static
friction. Now we briefly drive 100% on any off→on transition before
dropping to the target duty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:05:33 +02:00
5e1f284c1c updated models and code 2026-04-26 10:09:22 +02:00
0508e97d9e updaed model 2026-04-17 23:46:46 +02:00
73d9932a2c Updated cad 2026-04-17 20:37:54 +02:00
079f2bcc9a docs: update README for PWM/MOSFET + level table, add secrets.h.example
Rewrite README to describe the 5-level temperature-driven PWM fan
control (replacing the old relay on/off description), and add a
secrets.h.example template so new clones have a reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 01:18:37 +02:00
15 changed files with 49113 additions and 18059 deletions

View File

@@ -1,70 +1,87 @@
# ESP32-C3 Fan Controller # ESP32-C3 Fan Controller
An automatic server fan controller built on the ESP32-C3, featuring temperature-based relay control, OLED status display, and Home Assistant integration via MQTT. An automatic server fan controller built on the ESP32-C3. Drives a 5 V fan over PWM through an IRLZ44N MOSFET, auto-selects fan speed from a temperature table with hysteresis, shows live status on an OLED, and publishes sensor/fan state to Home Assistant over MQTT.
## Features ## Features
- **Auto fan control** — relay switches ON/OFF based on temperature thresholds with hysteresis - **Temperature-driven PWM fan control** — 5 discrete speed levels, selected automatically from the current temperature
- **DHT22 sensor** — reads temperature and humidity every second - **Hysteresis** — 1 °C drop-down margin prevents level flapping
- **SSD1306 OLED** — displays live temp, humidity, and fan state - **DHT22 sensor** — reads temperature and humidity every 3 s
- **MQTT publishing** — streams sensor data and fan state to Home Assistant - **SSD1306 OLED** — displays live temp, humidity, fan state, and level
- **WiFi** — connects on boot, auto-reconnects if MQTT drops - **MQTT publishing** — streams sensor and fan data for Home Assistant
- **WiFi** — connects on boot, auto-reconnects WiFi and MQTT if they drop
## Hardware ## Hardware
| Component | Details | | Component | Details |
|---|---| |---|---|
| Microcontroller | ESP32-C3 (with onboard OLED) | | Microcontroller | ESP32-C3 |
| Sensor | DHT22 (temperature + humidity) | | Sensor | DHT22 (temperature + humidity) |
| Relay | 5V relay module (active-LOW) | | Driver | IRLZ44N N-channel MOSFET (logic-level) |
| Fan | USB 5V fan | | Fan | 2-wire 5 V fan |
| Display | SSD1306 128×64 OLED (I2C) | | Display | SSD1306 128×64 OLED (I²C) |
## Wiring ## Wiring
| ESP32-C3 Pin | Connected To | | ESP32-C3 Pin | Connected To |
|---|---| |---|---|
| `GPIO2` | DHT22 DATA (+ 4.7kΩ pullup to 3V3) | | `GPIO2` | DHT22 DATA (+ 4.7 kΩ pullup to 3V3) |
| `GPIO3` | Relay IN | | `GPIO3` | MOSFET gate (via ~150 Ω) + 100 kΩ gate→GND pulldown |
| `GPIO5` (SDA) | OLED SDA | | `GPIO5` (SDA) | OLED SDA |
| `GPIO6` (SCL) | OLED SCL | | `GPIO6` (SCL) | OLED SCL |
| `3V3` | DHT22 VCC, OLED VCC | | `3V3` | DHT22 VCC, OLED VCC |
| `GND` | DHT22 GND, OLED GND, Relay GND | | `GND` | DHT22 GND, OLED GND, MOSFET source, fan |
Relay wiring: Fan wiring:
- **COM** → USB 5V - Fan **+** → USB 5 V
- **NO** → Fan + (red wire) - Fan **** → MOSFET **drain**
- Fan (black wire) → GND - MOSFET **source** → GND
- MOSFET **gate**`GPIO3`
## Configuration ## Configuration
Copy `secrets.h.example` to `secrets.h` and fill in your values: Copy `main/secrets.h.example` to `main/secrets.h` and fill in your values:
```cpp ```cpp
#define WIFI_SSID "your_wifi_ssid" #define WIFI_SSID "your_wifi_ssid"
#define WIFI_PASS "your_wifi_password" #define WIFI_PASS "your_wifi_password"
#define MQTT_HOST "192.168.1.X" // Home Assistant IP #define MQTT_HOST "192.168.1.X"
#define MQTT_PORT 1883
#define MQTT_USER "mqtt_user" #define MQTT_USER "mqtt_user"
#define MQTT_PASS "mqtt_pass" #define MQTT_PASS "mqtt_password"
#define MQTT_CLIENT "esp32c3_fan"
``` ```
### Temperature Thresholds `secrets.h` is gitignored.
Edit these defines in `fan_controller.ino`: ### Fan Level Table
```cpp Fan speed is selected from a table of (temperature threshold → PWM duty) in `main/main.ino`:
#define TEMP_ON 28.0 // Fan turns ON above this (°C)
#define TEMP_OFF 25.0 // Fan turns OFF below this (°C) | Level | Temp ≥ | PWM duty (0255) |
``` |-------|--------|------------------|
| 0 (off) | — | 0 |
| 1 | 28 °C | 230 |
| 2 | 30 °C | 236 |
| 3 | 32 °C | 242 |
| 4 | 34 °C | 249 |
| 5 | 36 °C | 255 |
`TEMP_HYST` (default `1.0 °C`) controls the drop-down margin: the fan only drops a level once temperature falls this much below that level's threshold. Below level 1's threshold minus hysteresis, the fan turns off.
Tune `LEVELS[]` and `TEMP_HYST` in `main/main.ino` to match your thermal setup.
## MQTT Topics ## MQTT Topics
| Topic | Direction | Payload | All topics are publish-only (the firmware does not accept remote commands).
| Topic | Payload | Notes |
|---|---|---| |---|---|---|
| `esp32c3_fan/temperature` | Publish | `23.5` (°C) | | `esp32c3_fan/temperature` | `23.5` | °C, every loop |
| `esp32c3_fan/humidity` | Publish | `41` (%) | | `esp32c3_fan/humidity` | `41` | %, every loop |
| `esp32c3_fan/fan/state` | Publish | `ON` / `OFF` | | `esp32c3_fan/fan/state` | `ON` / `OFF` | retained |
| `esp32c3_fan/fan/speed` | `0`..`5` | current level, retained |
## Home Assistant Setup ## Home Assistant Setup
@@ -85,6 +102,10 @@ mqtt:
unit_of_measurement: "%" unit_of_measurement: "%"
device_class: humidity device_class: humidity
- name: "Server Fan Level"
unique_id: "esp32c3_fan_level"
state_topic: "esp32c3_fan/fan/speed"
binary_sensor: binary_sensor:
- name: "Server Fan" - name: "Server Fan"
unique_id: "esp32c3_fan" unique_id: "esp32c3_fan"
@@ -108,10 +129,12 @@ Install via Arduino IDE Library Manager:
## Project Structure ## Project Structure
``` ```
esp32c3-fan-controller/ fan4life/
├── fan_controller.ino # Main sketch ├── main/
├── secrets.h # WiFi & MQTT credentials (gitignored) │ ├── main.ino # Main sketch
├── secrets.h.example # Credentials template ├── secrets.h # WiFi & MQTT credentials (gitignored)
│ └── secrets.h.example # Credentials template
├── cad/ # Mechanical / enclosure files
├── .gitignore ├── .gitignore
└── README.md └── README.md
``` ```

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

1649
cad/sensor_box-Body.obj Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
cad/sensor_box.FCStd Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
# Blender 5.1.0 MTL File: 'None'
# www.blender.org
newmtl SVGMat
Ns 360.000000
Ka 1.000000 1.000000 1.000000
Kd 0.000000 0.000000 0.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 1.000000
illum 2

24273
cad/server-lid-Body.obj Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
cad/server-lid.FCStd Normal file

Binary file not shown.

BIN
cad/server-with-logo.stl Normal file

Binary file not shown.

View File

@@ -55,16 +55,21 @@ int yOffset = 30;
uint8_t fanLevel = 0; // 0 = off, 1..NUM_LEVELS uint8_t fanLevel = 0; // 0 = off, 1..NUM_LEVELS
void applyFan() { void applyFan() {
static uint8_t lastDuty = 0;
if (fanLevel == 0) { if (fanLevel == 0) {
ledcWrite(FAN_PIN, 0); ledcWrite(FAN_PIN, 0);
lastDuty = 0;
return; return;
} }
uint8_t duty = LEVELS[fanLevel - 1].duty; uint8_t duty = LEVELS[fanLevel - 1].duty;
if (duty > 0 && duty < 200) { // Kick-start whenever spinning up from stopped — full duty briefly so the
ledcWrite(FAN_PIN, 255); // kick-start // rotor breaks static friction, then drop to the target.
delay(300); if (lastDuty == 0) {
ledcWrite(FAN_PIN, 255);
delay(400);
} }
ledcWrite(FAN_PIN, duty); ledcWrite(FAN_PIN, duty);
lastDuty = duty;
} }
void publishFan() { void publishFan() {
@@ -95,12 +100,24 @@ uint8_t levelForTemp(float tempC) {
} }
void connectWiFi() { void connectWiFi() {
Serial.printf("Connecting to %s", WIFI_SSID); Serial.printf("Connecting to %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.setTxPower(WIFI_POWER_8_5dBm);
WiFi.disconnect(true, true);
delay(200);
WiFi.setAutoReconnect(true);
WiFi.persistent(false);
WiFi.begin(WIFI_SSID, WIFI_PASS); WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print("."); uint8_t result = WiFi.waitForConnectResult(15000);
if (result == WL_CONNECTED) {
Serial.printf("WiFi connected: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.printf("WiFi failed, result=%u, status=%d\n", result, WiFi.status());
} }
Serial.printf("\nWiFi connected: %s\n", WiFi.localIP().toString().c_str());
} }
void connectMQTT() { void connectMQTT() {
@@ -117,18 +134,45 @@ void connectMQTT() {
} }
void handle_oled(float tempC, float humidity) { void handle_oled(float tempC, float humidity) {
char line1[20], line2[20], line3[20]; (void)humidity;
snprintf(line1, sizeof(line1), "T: %.1f C", tempC); char tempStr[12];
snprintf(line2, sizeof(line2), "H: %.0f %%", humidity); snprintf(tempStr, sizeof(tempStr), "%.1f", tempC);
const char* lvl = fanLevel > 0 ? LEVELS[fanLevel - 1].label : "0";
snprintf(line3, sizeof(line3), "Fan: %s L%s",
fanLevel > 0 ? "ON" : "OFF", lvl);
u8g2.clearBuffer(); u8g2.clearBuffer();
u8g2.setFont(u8g2_font_4x6_tr);
u8g2.drawStr(xOffset, yOffset, line1); // Large temperature reading + small °C suffix, centered as a unit.
u8g2.drawStr(xOffset, yOffset + 10, line2); u8g2.setFont(u8g2_font_logisoso22_tn);
u8g2.drawStr(xOffset, yOffset + 20, line3); int tempW = u8g2.getStrWidth(tempStr);
const int suffixW = 10; // approx width reserved for "°C"
const int gap = 2;
int totalW = tempW + gap + suffixW;
int tempX = (128 - totalW) / 2;
int tempY = 46;
u8g2.drawStr(tempX, tempY, tempStr);
u8g2.setFont(u8g2_font_6x10_tr);
int suffixX = tempX + tempW + gap;
u8g2.drawStr(suffixX, tempY - 14, "o");
u8g2.drawStr(suffixX, tempY - 2, "C");
// Fan level indicator: 5 stacked bars at the bottom.
// Filled bars = current level, empty bars = remaining.
const int barCount = NUM_LEVELS;
const int barW = 18;
const int barH = 6;
const int barGap = 2;
const int barsW = barCount * barW + (barCount - 1) * barGap;
const int barY = 56;
int barX0 = (128 - barsW) / 2;
for (uint8_t i = 0; i < barCount; i++) {
int x = barX0 + i * (barW + barGap);
if (i < fanLevel) {
u8g2.drawBox(x, barY, barW, barH);
} else {
u8g2.drawFrame(x, barY, barW, barH);
}
}
u8g2.sendBuffer(); u8g2.sendBuffer();
} }

13
main/secrets.h.example Normal file
View File

@@ -0,0 +1,13 @@
// Copy this file to `secrets.h` and fill in your values.
// `secrets.h` is gitignored so credentials stay out of version control.
// --- WiFi ---
#define WIFI_SSID "your_wifi_ssid"
#define WIFI_PASS "your_wifi_password"
// --- MQTT ---
#define MQTT_HOST "192.168.1.X" // Home Assistant / broker IP
#define MQTT_PORT 1883
#define MQTT_USER "mqtt_user"
#define MQTT_PASS "mqtt_password"
#define MQTT_CLIENT "esp32c3_fan"