Built-in project · ESP32-C3-DevKitM-1

Smart home hub on an ESP32-C3

An ESP32-C3 web dashboard that switches a lamp and a fan through relays, dims a porch light, reads temperature, humidity and motion, and runs schedules from a real-time clock. The whole build, an ESP32-C3 and 9 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced Runs in your browser. Free, and no account needed.

The parts retail for about $30. On Mokxi they're free.

Smart home hub · ESP32-C3-DevKitM-1live0.000 s 0.00x
Click to open it in the editor
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
/*
 * Smart home hub: a web dashboard for lights, a fan, sensors and schedules.
 *
 * Switch a lamp and a fan through relays and dim a porch light from a web
 * page. A DHT22 and a PIR run the automatic modes, a DS3231 clock runs the
 * porch light's schedule, and a 16x2 LCD shows the status.
 *
 * The dashboard is this sketch's own WebServer. Press Run, open the Web tab
 * and use the buttons. The NETWORK under it is SIMULATED: no radio and no
 * listening socket, and only the Web tab in this page can reach
 * http://192.168.4.2/. The handlers and the pins are real code on the core.
 *
 * Every switch works from the Serial Monitor too: type "lamp on",
 * "fan auto" or "porch 40" and press Enter.
 *
 * Wiring (ESP32-C3-DevKitM-1):
 *   relay module IN1 -> GPIO 19 (the lamp), IN2 -> GPIO 18 (the fan). The
 *   module is active LOW, as most are: a LOW input pulls the coil in.
 *   porch light -> GPIO 10 through 100 ohms, dimmed with PWM
 *   DHT22 data -> GPIO 4 (from 3V3), PIR out -> GPIO 5 (from 5 V)
 *   DS3231 and LCD on I2C: SDA GPIO 1, SCL GPIO 0, both from 3V3
 */
#include <WiFi.h>
#include <WebServer.h>
#include <DHT.h>
#include <Wire.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>

const char *SSID = "Mokxi-Lab";
const char *PASSWORD = "breadboard";

const uint8_t LAMP_RELAY = 19;
const uint8_t FAN_RELAY = 18;
const uint8_t PORCH = 10;
const uint8_t DHT_PIN = 4;
const uint8_t PIR_PIN = 5;

enum Mode : uint8_t { OFF, ON, AUTO };
const char *const MODE_NAMES[3] = {"off", "on", "auto"};

WebServer server(80);
Mode lampMode = OFF;
Mode fanMode = OFF;
bool lampOn = false;
bool fanOn = false;
int porchLevel = 0;  // percent

DHT dht(DHT_PIN, DHT22);
float temperature = NAN;
float humidity = NAN;
float fanAbove = 26.0;                    // AUTO turns the fan on at this, in C
const unsigned long LAMP_HOLD_MS = 60000; // AUTO keeps the lamp on after motion
bool motion = false;
bool seenMotion = false;
unsigned long motionAt = 0;
unsigned long readAt = 0;
RTC_DS3231 rtc;
LiquidCrystal_I2C lcd(0x27, 16, 2);
int porchOnAt = 18 * 60;   // minutes after midnight
int porchOffAt = 23 * 60;
int porchScheduled = 60;   // the brightness the schedule sets, percent
int lastMinute = -1;
unsigned long lcdAt = 0;

// ---- outputs ----------------------------------------------------------------

void setRelay(uint8_t pin, bool on) {
  digitalWrite(pin, on ? LOW : HIGH);  // active LOW
}

void applyOutputs() {
  bool lamp = lampMode == ON;
  bool fan = fanMode == ON;
  if (lampMode == AUTO) {
    lamp = seenMotion && millis() - motionAt < LAMP_HOLD_MS;
  }
  if (fanMode == AUTO && !isnan(temperature)) {
    // Half a degree of hysteresis, so the fan does not chatter at the line.
    fan = fanOn ? temperature > fanAbove - 0.5 : temperature >= fanAbove;
  }
  if (lamp != lampOn) {
    lampOn = lamp;
    setRelay(LAMP_RELAY, lampOn);
    Serial.print("lamp ");
    Serial.print(lampOn ? "on" : "off");
    Serial.print(" (");
    Serial.print(MODE_NAMES[lampMode]);
    Serial.println(")");
  }
  if (fan != fanOn) {
    fanOn = fan;
    setRelay(FAN_RELAY, fanOn);
    Serial.print("fan ");
    Serial.print(fanOn ? "on" : "off");
    Serial.print(" (");
    Serial.print(MODE_NAMES[fanMode]);
    Serial.println(")");
  }
}

void setPorch(int level, const char *why) {
  porchLevel = constrain(level, 0, 100);
  analogWrite(PORCH, porchLevel * 255 / 100);
  Serial.print("porch ");
  Serial.print(porchLevel);
  Serial.print("% (");
  Serial.print(why);
  Serial.println(")");
}

Mode parseMode(const String &s) {
  if (s == "on") return ON;
  if (s == "auto") return AUTO;
  return OFF;
}

String hhmm(int minutes) {
  char text[6];
  snprintf(text, sizeof text, "%02d:%02d", minutes / 60, minutes % 60);
  return String(text);
}

int parseHhmm(const String &s, int fallback) {
  int colon = s.indexOf(':');
  if (colon < 1) return fallback;
  int h = s.substring(0, colon).toInt();
  int m = s.substring(colon + 1).toInt();
  if (h < 0 || h > 23 || m < 0 || m > 59) return fallback;
  return h * 60 + m;
}

// ---- the dashboard ----------------------------------------------------------

void modeButtons(String &p, const char *path, Mode current) {
  p += "<form method=\"post\" action=\"";
  p += path;
  p += "\">";
  const uint8_t modes = 3;
  for (uint8_t m = 0; m < modes; m++) {
    p += "<button name=\"mode\" value=\"";
    p += MODE_NAMES[m];
    p += m == current ? "\" class=\"on\">" : "\">";
    p += MODE_NAMES[m];
    p += "</button>";
  }
  p += "</form>";
}

void sendPage() {
  String p;
  p.reserve(3000);
  p += "<!doctype html><html><body><style>"
       "body{font-family:system-ui,sans-serif;margin:16px;background:#f4f5f7;color:#1d2026}"
       ".card{background:#fff;border-radius:14px;padding:12px 16px;margin:0 0 12px}"
       "h1{font-size:22px;margin:0 0 12px}h2{font-size:14px;margin:0 0 6px;color:#5a6270}"
       ".big{font-size:26px;font-weight:600}button{font:inherit;padding:5px 12px;margin:2px;"
       "border-radius:8px;border:1px solid #c9ced8;background:#fff}"
       "button.on{background:#2f6fec;color:#fff;border-color:#2f6fec}small{color:#5a6270}"
       "</style><h1>Home</h1>";
  DateTime now = rtc.now();
  p += "<div class=\"card\"><h2>Time</h2><div class=\"big\">";
  p += hhmm(now.hour() * 60 + now.minute());
  p += "</div></div>";
  p += "<div class=\"card\"><h2>Climate</h2><div class=\"big\">";
  if (isnan(temperature)) {
    p += "reading...";
  } else {
    p += String(temperature, 1);
    p += " &deg;C &middot; ";
    p += String(humidity, 0);
    p += "%";
  }
  p += "</div><small>";
  if (seenMotion) {
    p += "Motion ";
    p += String((millis() - motionAt) / 1000);
    p += " s ago";
  } else {
    p += "No motion yet";
  }
  p += "</small></div>";
  p += "<div class=\"card\"><h2>Lamp: ";
  p += lampOn ? "on" : "off";
  p += "</h2>";
  modeButtons(p, "/lamp", lampMode);
  p += "<small>Auto: on for a minute after motion.</small>";
  p += "</div><div class=\"card\"><h2>Fan: ";
  p += fanOn ? "on" : "off";
  p += "</h2>";
  modeButtons(p, "/fan", fanMode);
  p += "<form method=\"post\" action=\"/fan\"><small>Auto at </small><input type=\"number\" name=\"above\" step=\"0.5\" value=\"";
  p += String(fanAbove, 1);
  p += "\"><small> &deg;C </small><button>Set</button></form>";
  p += "</div><div class=\"card\"><h2>Porch light: ";
  p += String(porchLevel);
  p += "%</h2><form method=\"post\" action=\"/porch\"><input type=\"range\" name=\"level\" min=\"0\" max=\"100\" value=\"";
  p += String(porchLevel);
  p += "\"><button>Set</button></form>";
  p += "<form method=\"post\" action=\"/schedule\"><small>On at </small><input type=\"time\" name=\"on\" value=\"";
  p += hhmm(porchOnAt);
  p += "\"><small> off at </small><input type=\"time\" name=\"off\" value=\"";
  p += hhmm(porchOffAt);
  p += "\"><button>Save</button></form>";
  p += "</div><small>Served by the sketch on a simulated network.</small></body></html>";
  server.send(200, "text/html", p);
}

void handleRoot() { sendPage(); }

void handleLamp() {
  if (server.hasArg("mode")) lampMode = parseMode(server.arg("mode"));
  applyOutputs();
  sendPage();
}

void handleFan() {
  if (server.hasArg("mode")) fanMode = parseMode(server.arg("mode"));
  if (server.hasArg("above")) {
    fanAbove = server.arg("above").toFloat();
    Serial.print("fan auto at ");
    Serial.print(fanAbove, 1);
    Serial.println(" C");
  }
  applyOutputs();
  sendPage();
}

void handlePorch() {
  if (server.hasArg("level")) setPorch(server.arg("level").toInt(), "dashboard");
  sendPage();
}

void handleSchedule() {
  porchOnAt = parseHhmm(server.arg("on"), porchOnAt);
  porchOffAt = parseHhmm(server.arg("off"), porchOffAt);
  Serial.print("schedule: porch on at ");
  Serial.print(hhmm(porchOnAt));
  Serial.print(", off at ");
  Serial.println(hhmm(porchOffAt));
  sendPage();
}

// ---- the Serial Monitor -------------------------------------------------------

void readCommand() {
  if (!Serial.available()) return;
  String line = Serial.readStringUntil('\n');
  line.trim();
  int space = line.indexOf(' ');
  String what = space < 0 ? line : line.substring(0, space);
  String value = space < 0 ? String("") : line.substring(space + 1);
  if (what == "lamp") {
    lampMode = parseMode(value);
    applyOutputs();
  } else if (what == "fan") {
    fanMode = parseMode(value);
    applyOutputs();
  } else if (what == "porch") {
    setPorch(value.toInt(), "serial");
  } else if (line.length() > 0) {
    Serial.println("try: lamp on, fan off, porch 40");
  }
}

// ---- sensors ------------------------------------------------------------------

void readSensors() {
  bool now = digitalRead(PIR_PIN) == HIGH;
  if (now && !motion) {
    Serial.println("motion");
    seenMotion = true;
  }
  if (now) motionAt = millis();
  motion = now;

  // The DHT22 gives a new reading at most every two seconds.
  if (millis() - readAt < 2000 && readAt != 0) return;
  readAt = millis();
  float t = dht.readTemperature();
  float h = dht.readHumidity();
  if (isnan(t) || isnan(h)) return;
  bool first = isnan(temperature);
  if (first || fabs(t - temperature) >= 0.5 || fabs(h - humidity) >= 2) {
    Serial.print("climate ");
    Serial.print(t, 1);
    Serial.print(" C, ");
    Serial.print(h, 0);
    Serial.println("% humidity");
  }
  temperature = t;
  humidity = h;
}

// ---- the clock, the schedule and the screen ---------------------------------------

void runSchedule() {
  DateTime now = rtc.now();
  int minute = now.hour() * 60 + now.minute();
  if (minute == lastMinute) return;
  lastMinute = minute;
  if (minute == porchOnAt) {
    Serial.print(hhmm(minute));
    Serial.print(" schedule: ");
    setPorch(porchScheduled, "schedule");
  }
  if (minute == porchOffAt) {
    Serial.print(hhmm(minute));
    Serial.print(" schedule: ");
    setPorch(0, "schedule");
  }
}

void drawLcd() {
  if (millis() - lcdAt < 1000) return;
  lcdAt = millis();
  DateTime now = rtc.now();
  char top[17];
  char bottom[17];
  int t = isnan(temperature) ? 0 : (int)(temperature * 10 + 0.5);
  int h = isnan(humidity) ? 0 : (int)(humidity + 0.5);
  snprintf(top, sizeof top, "%02d:%02d %2d.%dC %3d%%", now.hour(), now.minute(), t / 10, t % 10, h);
  snprintf(bottom, sizeof bottom, "L:%-3s F:%-3s P%3d", lampOn ? "on" : "off", fanOn ? "on" : "off", porchLevel);
  lcd.setCursor(0, 0);
  lcd.print(top);
  lcd.setCursor(0, 1);
  lcd.print(bottom);
}

// ---- setup and loop ---------------------------------------------------------------

void setup() {
  Serial.begin(115200);
  pinMode(LAMP_RELAY, OUTPUT);
  pinMode(FAN_RELAY, OUTPUT);
  setRelay(LAMP_RELAY, false);
  setRelay(FAN_RELAY, false);
  pinMode(PORCH, OUTPUT);
  pinMode(PIR_PIN, INPUT);
  dht.begin();
  Wire.begin(1, 0);  // SDA on GPIO 1, SCL on GPIO 0
  if (!rtc.begin()) Serial.println("no clock on the I2C bus");
  lcd.init();
  lcd.print("Home hub");

  Serial.println("Simulated WiFi: only the Web panel can reach this server.");
  WiFi.begin(SSID, PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(250);
  }
  Serial.print("Home hub ready: open http://");
  Serial.print(WiFi.localIP());
  Serial.println("/ in the Web tab");

  server.on("/", handleRoot);
  server.on("/lamp", handleLamp);
  server.on("/fan", handleFan);
  server.on("/porch", handlePorch);
  server.on("/schedule", handleSchedule);
  server.begin();
}

void loop() {
  server.handleClient();
  readCommand();
  readSensors();
  applyOutputs();
  runSchedule();
  drawLcd();
  delay(2);
}

Parts list

11 parts, plus the jumper wires. They retail for about $30; on Mokxi they're free.

How it is wired

14 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Ground: ESP32-C3-DevKitM-1 pin GND; ESP32-C3-DevKitM-1 pin GND; Relay board, two channels pin GND; Filament lamp pin 2; DC motor pin 2; Resistor, 100 Ω pin 2; Temperature and humidity, DHT22 pin GND; Motion sensor pin GND; Real-time clock, DS3231 pin GND; Character LCD with I2C backpack pin GND
  • ESP32-C3-DevKitM-1 pin 3V3; Temperature and humidity, DHT22 pin VCC; Real-time clock, DS3231 pin VCC; Character LCD with I2C backpack pin VCC
  • ESP32-C3-DevKitM-1 pin 0; Real-time clock, DS3231 pin SCL; Character LCD with I2C backpack pin SCL
  • ESP32-C3-DevKitM-1 pin 1; Real-time clock, DS3231 pin SDA; Character LCD with I2C backpack pin SDA
  • ESP32-C3-DevKitM-1 pin 10; LED, yellow pin A
  • ESP32-C3-DevKitM-1 pin 4; Temperature and humidity, DHT22 pin DATA
  • ESP32-C3-DevKitM-1 pin 5; Motion sensor pin OUT
  • ESP32-C3-DevKitM-1 pin 5V; Relay board, two channels pin VCC; Relay board, two channels pin COM1; Relay board, two channels pin COM2
  • ESP32-C3-DevKitM-1 pin 5V; Motion sensor pin VCC
  • ESP32-C3-DevKitM-1 pin 18; Relay board, two channels pin IN2
  • ESP32-C3-DevKitM-1 pin 19; Relay board, two channels pin IN1
  • Relay board, two channels pin NO1; Filament lamp pin 1
  • Relay board, two channels pin NO2; DC motor pin 1
  • LED, yellow pin C; Resistor, 100 Ω pin 1

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.