Smart Wi-Fi Fan Controller with Alexa Integration, Offline Mode, and AP Setup (ESP8266)

This project turns a standard 12V PWM PC fan into a smart Wi-Fi controllable device using an ESP8266. It offers remote control via Alexa (using fauxmoESP), offline operation with a physical button in case there's no internet connection available and a Access Point Mode to give the Internet Credentials to the device.
It also features a captive portal, which, as expected, only triggers when accessing an HTTP (not HTTPS) website. Some parts of the code were assisted by ChatGPT. I hope that's not an issue.

All you need are a few parts: power the ESP-8266 with 5 V, feed the PWM fan from a separate 12 V rail provided by the step-up converter, and be sure both supplies share a common ground—otherwise the microcontroller cannot drive the fan. Solder the two push-buttons and all three fan leads to the GPIO pins named in the sketch, and consult the fan’s datasheet if you are unsure which wire is for VCC, tachometer, or PWM.

A long press on the first button (about three seconds) switches the ESP into Access-Point mode. It then advertises a network called “KeinNetz.” Connect to it, open the configuration page—usually http://192.168.4.1—and store your home-Wi-Fi credentials locally. The second button steps through preset speed levels even without an internet connection; holding it down turns the fan off completely.

Once the device is on your WLAN, Alexa discovers it automatically and lets you set the fan speed in exact percentage increments via the app or by voice. If anything is unclear, just ask. The complete code is available on the Arduino Forum.

Main Features:

  • PWM Fan Control: Smooth speed regulation from 0–100% via PWM output.

  • Persistent State Storage: Saves fan on/off state and speed in EEPROM, ensuring settings survive restarts.

  • Alexa Voice Control: Fully integrated with Alexa through the fauxmoESP library — no cloud setup needed!

  • Physical Buttons:

    • Button 1: Long press (5 seconds) resets Wi-Fi settings and launches AP mode.
    • Button 2: Short press increases fan speed in steps; long press turns the fan off.
  • Offline First: If no Wi-Fi connection is found, the system starts in Access Point (AP) mode with a built-in web server for network setup.

  • RPM Monitoring: Calculates and outputs the fan speed (RPM) to the serial monitor.

  • Hardware Setup:

    • ESP8266 (e.g., NodeMCU)
    • Step-up converter to supply stable 12V to the fan
    • Common ground between ESP8266, fan, and converter
    • Two buttons for manual interaction

Whether you want to add smart home control to a cooling fan, build a DIY ventilation system, or just explore ESP8266 capabilities — this project gives you a fully functioning, self-contained, and highly flexible solution. I´ve tried to add some other things, so there might be some useless code, but all in all it works great for me and was perfect for learning some more about programming.

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <EEPROM.h>
#include <DNSServer.h>
#include "fauxmoESP.h"

#define SERIAL_BAUDRATE 115200
#define BUTTON_PIN D7
#define BUTTON2_PIN D6         // Neuer Knopf für Lüftersteuerung
#define EEPROM_SIZE 512
#define SSID_ADDR 0
#define PASS_ADDR 32
#define FAN_STATE_ADDR 64    // Speicheradresse für Lüfterzustand (Ein/Aus)
#define FAN_SPEED_ADDR 65    // Speicheradresse für Lüftergeschwindigkeit (PWM-Wert)

ESP8266WebServer server(80);
DNSServer dnsServer;
const byte DNS_PORT = 53;
unsigned long buttonPressedTime = 0;
const unsigned long longPressTime = 5000; // Dauer für langen Tastendruck
bool apModeTriggered = false;

fauxmoESP fauxmo;

const int pwmPin = D3;  // Pin für PWM-Ausgabe (Fan)
const int tachPin = D1; // Pin für Tachosignal (Fan)
volatile int rpmCounter = 0;  // FAN

bool fanState = false;  // Zustand des Lüfters (Ein/Aus)
int fanSpeed = 0;       // PWM-Wert des Lüfters (0-255)

unsigned long apModeStartTime = 0; // Startzeitpunkt für den AP-Modus

// ----- Captive Portal Zusatzfunktionen -----

// Prüft, ob ein String ausschließlich aus Ziffern und Punkten besteht
bool isIp(String str) {
  for (unsigned int i = 0; i < str.length(); i++) {
    char c = str.charAt(i);
    if (!(isDigit(c) || c == '.')) return false;
  }
  return true;
}

// Fängt alle unbekannten HTTP-Anfragen ab und leitet den Client um
void handleNotFound() {
  if (!isIp(server.hostHeader())) {
    String redirectURL = String("http://") + WiFi.softAPIP().toString();
    Serial.printf("[CAPTIVE PORTAL] Redirecting client to %s\n", redirectURL.c_str());
    server.sendHeader("Location", redirectURL, true);
    server.send(302, "text/plain", "");
    return;
  }
  server.send(404, "text/plain", "404: Not Found");
}

// ----------------------------------------------

void IRAM_ATTR rpmCount() {  
  rpmCounter++;            
}

void setup() {
  delay(1000);
  Serial.begin(SERIAL_BAUDRATE);
  Serial.println("[DEBUG] Setup gestartet");
  
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(BUTTON2_PIN, INPUT_PULLUP);
  pinMode(pwmPin, OUTPUT);
  pinMode(tachPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(tachPin), rpmCount, FALLING);
  
  EEPROM.begin(EEPROM_SIZE);
  Serial.println("[DEBUG] EEPROM initialisiert");

  // Lüfterzustand und -geschwindigkeit aus EEPROM laden
  fanState = EEPROM.read(FAN_STATE_ADDR);
  fanSpeed = EEPROM.read(FAN_SPEED_ADDR);
  Serial.printf("[DEBUG] Gelesener Lüfterzustand: %s\n", fanState ? "EIN" : "AUS");
  Serial.printf("[DEBUG] Gelesene Lüftergeschwindigkeit: %d\n", fanSpeed);
  analogWrite(pwmPin, fanState ? fanSpeed : 0);

  String ssid = readStringFromEEPROM(SSID_ADDR);
  String pass = readStringFromEEPROM(PASS_ADDR);

  WiFi.mode(WIFI_STA);
  if (ssid.length() > 0) {
    Serial.printf("[WIFI] Versuche Verbindung zu %s\n", ssid.c_str());
    WiFi.begin(ssid.c_str(), pass.c_str());
    unsigned long connectStartTime = millis();
    while (WiFi.status() != WL_CONNECTED && (millis() - connectStartTime) < 60000) {
      delay(100);
      Serial.println(millis() - connectStartTime);
    }
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("\n[WIFI] Connection timeout. Gebe auf.");
      // Hier könntest du optional auch direkt den AP-Modus starten
    } else {
      Serial.println("\n[WIFI] Verbunden.");
      Serial.printf("[WIFI] IP-Adresse: %s\n", WiFi.localIP().toString().c_str());
      startFauxmo();
    }
  } else {
    Serial.println("[WIFI] Keine gespeicherten Zugangsdaten – starte Access Point.");
    startAccessPointMode();
  }
}

void loop() {
  // Timeout: Nach 90 Sekunden AP-Modus wieder beenden
  if (apModeTriggered && (millis() - apModeStartTime >= 90000)) {
    Serial.println("[AP MODE] Timeout erreicht. Beende AP-Modus.");
    dnsServer.stop();
    server.stop();
    WiFi.softAPdisconnect(true);
    apModeTriggered = false;
  }
  
  static unsigned long lastAPPrintTime = 0;
  if (apModeTriggered && (millis() - lastAPPrintTime >= 5000)) {
    Serial.printf("[AP MODE] Gerät ist seit %lu ms im AP-Modus.\n", millis() - apModeStartTime);
    lastAPPrintTime = millis();
  }
  
  // Verarbeitung des ersten Knopfs (D7)
  static bool lastButtonState = HIGH;
  bool currentButtonState = digitalRead(BUTTON_PIN);
  if (currentButtonState == LOW && lastButtonState == HIGH) {
    buttonPressedTime = millis();
    Serial.println("[BUTTON] Tastendruck erkannt, starte Timer...");
  }
  if (currentButtonState == HIGH && lastButtonState == LOW) {
    if (millis() - buttonPressedTime > longPressTime && !apModeTriggered) {
      Serial.println("[BUTTON] Langer Tastendruck – starte AP-Modus.");
      clearEEPROM();
      startAccessPointMode();
    }
    buttonPressedTime = 0;
  }
  lastButtonState = currentButtonState;
  
  // Verarbeitung des zweiten Knopfs (D6) für Lüftersteuerung
  static bool lastButton2State = HIGH;
  static unsigned long button2PressedTime = 0;
  bool currentButton2State = digitalRead(BUTTON2_PIN);
  if (currentButton2State == LOW && lastButton2State == HIGH) {
    button2PressedTime = millis();
    Serial.println("[BUTTON D8] Tastendruck erkannt, starte Timer...");
  }
  if (currentButton2State == HIGH && lastButton2State == LOW) {
    unsigned long duration = millis() - button2PressedTime;
    if (duration >= 1000) {
      fanState = false;
      fanSpeed = 0;
      analogWrite(pwmPin, 0);
      Serial.println("[BUTTON D8] Langer Tastendruck: Lüfter aus.");
    } else {
      if (!fanState) {
        fanState = true;
        fanSpeed = 51; // ca. 20%
        Serial.println("[BUTTON D8] Kurzer Tastendruck: Lüfter auf 20%.");
      } else {
        if (fanSpeed >= 255) {
          fanState = false;
          fanSpeed = 0;
          Serial.println("[BUTTON D8] Kurzer Tastendruck: Lüfter bei 100%, schalte aus.");
        } else {
          fanSpeed += 51;
          if (fanSpeed > 255) fanSpeed = 255;
          Serial.printf("[BUTTON D8] Kurzer Tastendruck: Lüftergeschwindigkeit auf %d.\n", fanSpeed);
        }
      }
      analogWrite(pwmPin, fanState ? fanSpeed : 0);
    }
    saveFanState();
  }
  lastButton2State = currentButton2State;
  
  // Im AP-Modus: DNS- und Webserveranfragen bearbeiten
  if (apModeTriggered) {
    dnsServer.processNextRequest();
    server.handleClient();
  } else {
    fauxmo.handle();
  }
  
  // RPM-Messung alle 5 Sekunden
  static unsigned long lastRPMTime = millis();
  if (millis() - lastRPMTime > 5000) {
    lastRPMTime = millis();
    int rpm = (rpmCounter / 2) * (60 / 5);
    Serial.printf("[MAIN] Lüfter RPM: %d\n", rpm);
    rpmCounter = 0;
  }
}

void startAccessPointMode() {
  WiFi.disconnect();
  WiFi.mode(WIFI_AP);
  // Verwende als SSID einen aussagekräftigen Namen
  WiFi.softAP("KeinNetz");
  WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0));
  
  server.close();
  server.on("/", HTTP_GET, handleRoot);
  server.on("/connect", HTTP_POST, handleConnect);
  server.onNotFound(handleNotFound);
  server.begin();
  
  dnsServer.start(DNS_PORT, "*", IPAddress(192, 168, 4, 1));
  
  Serial.println("[AP MODE] Access Point Modus gestartet");
  Serial.printf("[AP MODE] SSID: KeinNetz, IP: %s\n", WiFi.softAPIP().toString().c_str());
  apModeStartTime = millis();
  apModeTriggered = true;  // WICHTIG: Flag setzen, damit der Webserver in der Loop bearbeitet wird!
}

void handleRoot() {
  String page = "<html><head><meta charset='UTF-8'><title>WiFi Setup</title></head><body>"
                "<h1>Setup WiFi Connection</h1>"
                "<form action='/connect' method='POST'>"
                "SSID:<input type='text' name='ssid'><br>"
                "Password:<input type='password' name='pass'><br>"
                "<input type='submit' value='Connect'></form>"
                "</body></html>";
  server.send(200, "text/html", page);
  Serial.println("[SERVER] Root-Seite ausgeliefert.");
}

void handleConnect() {
  String ssid = server.arg("ssid");
  String pass = server.arg("pass");
  
  if (ssid.length() == 0 || pass.length() == 0) {
    Serial.println("[ERROR] SSID oder Passwort nicht angegeben.");
    server.send(400, "text/html", "<h1>Verbindungsfehler!</h1><p>SSID oder Passwort nicht angegeben.</p>");
    return;
  }
  
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid.c_str(), pass.c_str());
  Serial.printf("[CONNECT] Versuche Verbindung zu %s\n", ssid.c_str());
  
  unsigned long startTime = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startTime < 30000) {
    delay(500);
    Serial.print(".");
  }
  
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("\n[ERROR] Verbindung fehlgeschlagen. Bitte Details prüfen.");
    server.send(500, "text/html", "<h1>Verbindung fehlgeschlagen!</h1><p>Bitte überprüfe deine Eingaben.</p>");
    // AP-Modus bleibt aktiv, um einen erneuten Versuch zu ermöglichen
  } else {
    Serial.println("\n[CONNECT] Verbindung erfolgreich!");
    server.send(200, "text/html", "<h1>Verbunden!</h1><h2>IP-Adresse: " + WiFi.localIP().toString() + "</h2>");
    writeStringToEEPROM(SSID_ADDR, ssid);
    writeStringToEEPROM(PASS_ADDR, pass);
    EEPROM.commit();
    WiFi.softAPdisconnect(true);
    apModeTriggered = false;
    startFauxmo();
  }
}

void writeStringToEEPROM(int startAddr, const String &data) {
  for (int i = 0; i < data.length(); ++i) {
    EEPROM.write(startAddr + i, data[i]);
  }
  EEPROM.write(startAddr + data.length(), '\0');
  EEPROM.commit();
  Serial.println("[EEPROM] Zugangsdaten gespeichert.");
}

String readStringFromEEPROM(int startAddr) {
  String result;
  int offset = 0;
  char readChar = EEPROM.read(startAddr + offset);
  while (readChar != '\0') {
    result += readChar;
    offset++;
    readChar = EEPROM.read(startAddr + offset);
  }
  return result;
}

void clearEEPROM() {
  for (int i = 0; i < EEPROM_SIZE; i++) {
    EEPROM.write(i, 0);
  }
  EEPROM.commit();
  Serial.println("[EEPROM] EEPROM gelöscht.");
}

void saveFanState() {
  EEPROM.write(FAN_STATE_ADDR, fanState);
  EEPROM.write(FAN_SPEED_ADDR, fanSpeed);
  EEPROM.commit();
  Serial.println("[EEPROM] Lüfterstatus und -geschwindigkeit gespeichert.");
}

void startFauxmo() {
  fauxmo.createServer(true);
  fauxmo.setPort(80);
  fauxmo.enable(true);
  
  fauxmo.addDevice("Fan");
  Serial.println("[DEBUG] Device 'Fan' hinzugefügt.");
  delay(10);
  
  fauxmo.onSetState([](unsigned char device_id, const char * device_name, bool state, unsigned char value) {
    Serial.printf("[FAUXMO] Device #%d (%s) state: %s, value: %d\n", device_id, device_name, state ? "ON" : "OFF", value);
    if (strcmp(device_name, "Fan") == 0) {
      fanState = state;
      fanSpeed = value;
      analogWrite(pwmPin, fanState ? fanSpeed : 0);
      saveFanState();
    }
  });
  
  Serial.println("[FAUXMO] Fauxmo device initialized and running.");
}

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.