Compare commits
5 Commits
cdf9b081bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c874914ef | |||
| 5e1f284c1c | |||
| 0508e97d9e | |||
| 73d9932a2c | |||
| 079f2bcc9a |
89
README.md
89
README.md
@@ -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.7 kΩ 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 (0–255) |
|
||||||
```
|
|-------|--------|------------------|
|
||||||
|
| 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
|
||||||
```
|
```
|
||||||
|
|||||||
41050
cad/fan_box-Body.obj
41050
cad/fan_box-Body.obj
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
cad/fan_box.20260417-231442.FCBak
Normal file
BIN
cad/fan_box.20260417-231442.FCBak
Normal file
Binary file not shown.
Binary file not shown.
1649
cad/sensor_box-Body.obj
Normal file
1649
cad/sensor_box-Body.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cad/sensor_box.20260425-235248.FCBak
Normal file
BIN
cad/sensor_box.20260425-235248.FCBak
Normal file
Binary file not shown.
BIN
cad/sensor_box.FCStd
Normal file
BIN
cad/sensor_box.FCStd
Normal file
Binary file not shown.
12
cad/server-lid-Body-with-logo.mtl
Normal file
12
cad/server-lid-Body-with-logo.mtl
Normal 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
24273
cad/server-lid-Body.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cad/server-lid.20260424-230341.FCBak
Normal file
BIN
cad/server-lid.20260424-230341.FCBak
Normal file
Binary file not shown.
BIN
cad/server-lid.FCStd
Normal file
BIN
cad/server-lid.FCStd
Normal file
Binary file not shown.
BIN
cad/server-with-logo.stl
Normal file
BIN
cad/server-with-logo.stl
Normal file
Binary file not shown.
@@ -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
13
main/secrets.h.example
Normal 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"
|
||||||
Reference in New Issue
Block a user