Skip to main content
NotesFirmwareNew

ESP32 Beginner Guide – P7: Build a Complete ESP32 Project

Finish the series with a Room Comfort Monitor: ESP32 reads a BME280, controls a warning LED and serves a local Wi-Fi dashboard.

Share

LinkedInFacebookX
A complete ESP32-S3 room comfort monitor prototype with BME280, pushbutton, status LED, and a local dashboard on a MacBook Pro

The first six parts covered choosing a board, uploading code, GPIO, sensors, Wi-Fi, and BLE. In this final part, we stop testing isolated pieces and build a device with explicit requirements, failure states, and completion criteria: the Room Comfort Monitor.

It reads a BME280 every two seconds, lights an LED when the room becomes too hot or humid, lets you mute the warning with a button, and displays the complete state on a local dashboard.

BME280 + button → ESP32 → LED + Wi-Fi dashboard

BLE from P6 is not forgotten. In a real product, it can handle first-time Wi-Fi provisioning or nearby access without a router. The first sketch uses only Wi-Fi because a complete project does not need every feature enabled at once.

Begin with requirements, not code

The project is complete when:

  • Temperature, humidity, and pressure update every two seconds.
  • The LED turns on at 30 °C or 70% humidity and above.
  • The button enables or disables the warning without multiple changes from contact bounce.
  • The dashboard shows readings, room state, and alarm state.
  • A JSON endpoint is available for future integration.
  • Losing Wi-Fi does not stop sensor sampling or the button.
  • Firmware retries Wi-Fi without becoming trapped in an endless wait loop.

The boundary matters too: this is a room-comfort observer, not a safety device. It does not replace a smoke, toxic-gas, or medical alarm.

Parts and pin mapping

An ESP32-S3-DevKitC-1, BME280, breadboard, LED, 220-ohm resistor, pushbutton, and jumper wires checked beside a MacBook Pro.

Confirming the parts and pin map before wiring avoids debugging hardware and firmware at the same time.

PartESP32-S3-DevKitC-1Note
BME280 VCC3V3Do not assume 5 V is safe before checking the breakout
BME280 GNDGNDAll modules share ground
BME280 SDAGPIO4I2C data
BME280 SCLGPIO5I2C clock
LED anodeGPIO2 through 220 ΩCathode to GND
PushbuttonGPIO6 and GNDUses INPUT_PULLUP; pressed is LOW

GPIO2 and GPIO6 are both exposed on the ESP32-S3-DevKitC-1 v1.1 headers. If you use another board, check that board's pinout and change the constants. Do not wire by copying physical positions from a photo.

You also need the same secrets.h used in P5:

#pragma once

constexpr char WIFI_SSID[] = "YOUR_2_4_GHZ_SSID";
constexpr char WIFI_PASSWORD[] = "YOUR_WIFI_PASSWORD";

Keep the real file out of Git and commit only a secrets.example.h containing placeholders.

Give each firmware block one responsibility

A readable loop() should only coordinate:

handle HTTP → read button → sample sensor → update LED → recover Wi-Fi

Each block gets its own millis() timer. Nothing holds the CPU in an endless while. HTTP handlers change state or return data; they do not perform I2C reads or wait for the network.

Complete sketch

The code below keeps HTML in flash, avoids assembling many temporary String objects per request, and uses JSON as the boundary between firmware and dashboard.

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include "secrets.h"

constexpr uint8_t SDA_PIN = 4;
constexpr uint8_t SCL_PIN = 5;
constexpr uint8_t LED_PIN = 2;
constexpr uint8_t BUTTON_PIN = 6;
constexpr uint8_t BME280_ADDRESS = 0x77;

constexpr float TEMP_WARNING_C = 30.0F;
constexpr float HUMIDITY_WARNING_PERCENT = 70.0F;
constexpr uint32_t SAMPLE_INTERVAL_MS = 2000;
constexpr uint32_t WIFI_RETRY_INTERVAL_MS = 10000;
constexpr uint32_t BUTTON_DEBOUNCE_MS = 40;

WebServer server(80);
Adafruit_BME280 bme;

bool sensorReady = false;
bool readingValid = false;
bool alarmEnabled = true;
bool warningActive = false;
bool wasWiFiConnected = false;

float temperature = NAN;
float humidity = NAN;
float pressure = NAN;

int lastButtonReading = HIGH;
int stableButtonState = HIGH;
uint32_t buttonChangedAt = 0;
uint32_t lastSampleAt = 0;
uint32_t lastWiFiAttemptAt = 0;

const char PAGE[] PROGMEM = R"HTML(
<!doctype html>
<html lang="en">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Room Comfort Monitor</title>
<style>
  body{font:16px system-ui;margin:0;background:#eef2f5;color:#17202a}
  main{max-width:760px;margin:40px auto;padding:0 18px}
  .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
  .card{background:#fff;padding:18px;border-radius:14px;box-shadow:0 4px 18px #0001}
  strong{display:block;font-size:1.6rem;margin-top:8px}
  #state{padding:14px;border-radius:12px;background:#dff5e6;margin:16px 0}
  button{border:0;border-radius:10px;padding:12px 16px;background:#17202a;color:#fff}
  @media(max-width:620px){.grid{grid-template-columns:1fr}}
</style>
<main>

  <h1>Room Comfort Monitor</h1>

  <div id="state">Loading...</div>
  <div class="grid">
    <div class="card">Temperature<strong id="t">--</strong></div>
    <div class="card">Humidity<strong id="h">--</strong></div>
    <div class="card">Pressure<strong id="p">--</strong></div>
  </div>
  <p>Alarm: <b id="alarm">--</b></p>
  <button onclick="toggleAlarm()">Toggle alarm</button>
</main>
<script>
async function refresh(){
  try{
    const r=await fetch('/api/status',{cache:'no-store'});
    const d=await r.json();
    document.getElementById('t').textContent=d.valid?d.temperature.toFixed(1)+' °C':'--';
    document.getElementById('h').textContent=d.valid?d.humidity.toFixed(1)+' %':'--';
    document.getElementById('p').textContent=d.valid?d.pressure.toFixed(1)+' hPa':'--';
    document.getElementById('alarm').textContent=d.alarmEnabled?'enabled':'muted';
    const state=document.getElementById('state');
    state.textContent=d.status;
    state.style.background=d.warning?'#ffd9d5':'#dff5e6';
  }catch(e){ document.getElementById('state').textContent='ESP32 offline'; }
}
async function toggleAlarm(){
  await fetch('/api/alarm/toggle',{method:'POST'});
  refresh();
}
refresh(); setInterval(refresh,2000);
</script>
</html>
)HTML";

void updateOutput() {
  warningActive = readingValid &&
      (temperature >= TEMP_WARNING_C ||
       humidity >= HUMIDITY_WARNING_PERCENT);
  digitalWrite(LED_PIN, alarmEnabled && warningActive ? HIGH : LOW);
}

void sampleSensor() {
  if (!sensorReady) {
    readingValid = false;
    updateOutput();
    return;
  }

  const float nextTemperature = bme.readTemperature();
  const float nextHumidity = bme.readHumidity();
  const float nextPressure = bme.readPressure() / 100.0F;

  readingValid = !isnan(nextTemperature) &&
                 !isnan(nextHumidity) &&
                 !isnan(nextPressure);
  if (readingValid) {
    temperature = nextTemperature;
    humidity = nextHumidity;
    pressure = nextPressure;
  }
  updateOutput();
}

void updateButton(uint32_t now) {
  const int reading = digitalRead(BUTTON_PIN);
  if (reading != lastButtonReading) {
    lastButtonReading = reading;
    buttonChangedAt = now;
  }

  if (now - buttonChangedAt >= BUTTON_DEBOUNCE_MS &&
      reading != stableButtonState) {
    stableButtonState = reading;
    if (stableButtonState == LOW) {
      alarmEnabled = !alarmEnabled;
      updateOutput();
      Serial.printf("Alarm %s\n", alarmEnabled ? "enabled" : "muted");
    }
  }
}

void sendStatus() {
  char readings[128];
  if (readingValid) {
    snprintf(readings, sizeof(readings),
             "\"temperature\":%.2f,\"humidity\":%.2f,\"pressure\":%.2f",
             temperature, humidity, pressure);
  } else {
    snprintf(readings, sizeof(readings),
             "\"temperature\":null,\"humidity\":null,\"pressure\":null");
  }

  const char *status = !readingValid ? "Sensor unavailable" :
                       warningActive ? "Check room conditions" :
                                       "Room looks comfortable";
  char json[320];
  snprintf(json, sizeof(json),
           "{%s,\"valid\":%s,\"warning\":%s,"
           "\"alarmEnabled\":%s,\"status\":\"%s\"}",
           readings,
           readingValid ? "true" : "false",
           warningActive ? "true" : "false",
           alarmEnabled ? "true" : "false",
           status);
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", json);
}

void configureServer() {
  server.on("/", HTTP_GET, []() {
    server.send_P(200, "text/html", PAGE);
  });
  server.on("/api/status", HTTP_GET, sendStatus);
  server.on("/api/alarm/toggle", HTTP_POST, []() {
    alarmEnabled = !alarmEnabled;
    updateOutput();
    sendStatus();
  });
  server.onNotFound([]() {
    server.send(404, "text/plain", "Not found");
  });
  server.begin();
}

void startWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  lastWiFiAttemptAt = millis();
}

void updateWiFi(uint32_t now) {
  const bool connected = WiFi.status() == WL_CONNECTED;
  if (!connected &&
      now - lastWiFiAttemptAt >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiAttemptAt = now;
    WiFi.reconnect();
  }

  if (connected && !wasWiFiConnected) {
    Serial.print("Dashboard: http://");
    Serial.println(WiFi.localIP());
  } else if (!connected && wasWiFiConnected) {
    Serial.println("Wi-Fi lost; local monitoring continues.");
  }
  wasWiFiConnected = connected;
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  digitalWrite(LED_PIN, LOW);

  lastButtonReading = digitalRead(BUTTON_PIN);
  stableButtonState = lastButtonReading;

  Wire.begin(SDA_PIN, SCL_PIN);
  sensorReady = bme.begin(BME280_ADDRESS, &Wire);
  sampleSensor();

  configureServer();
  startWiFi();
  Serial.println(sensorReady ? "BME280 ready." : "BME280 unavailable.");
}

void loop() {
  server.handleClient();

  const uint32_t now = millis();
  updateButton(now);

  if (now - lastSampleAt >= SAMPLE_INTERVAL_MS) {
    lastSampleAt = now;
    sampleSensor();
  }

  updateWiFi(now);
  delay(2);
}

If your scanner finds the BME280 at 0x76, change only BME280_ADDRESS. If an active-low LED module works backwards, swap HIGH and LOW in updateOutput().

Bring up one layer at a time

A user presses the button on an ESP32-S3 Room Comfort Monitor prototype with BME280 and warning LED beside a MacBook Pro.

The button enables or disables the local warning while sensor sampling and the dashboard continue running.

I test in this order:

  1. Power: the board boots reliably without random resets.
  2. I2C: a scanner finds 0x76 or 0x77.
  3. Sensor: Serial shows three plausible readings.
  4. Output: a separate Blink sketch turns the external LED on and off.
  5. Input: Serial logs only one change per button press.
  6. Network: ESP32 gets an IP address and serves the dashboard.
  7. Integration: button and web both change alarmEnabled; losing Wi-Fi does not freeze the LED in the wrong state.

This sounds slower, but it is usually faster than debugging a 200-line sketch before knowing whether SDA is plugged into the correct pin.

Test it as a device, not merely a web page

A MacBook Pro displays the Room Comfort Monitor dashboard beside an ESP32-S3 prototype correctly powered by USB-C.

The final test covers sensor data, warning state, local control, and Wi-Fi recovery together.

TestActionExpected result
Normal startupPower on while the router is availableReadings, IP address, and dashboard appear
Missing BME280Remove power, disconnect sensor, then power onDashboard reports sensor unavailable; LED stays off
Wi-Fi lossTemporarily turn off the routerSensor and button continue; reconnect follows network recovery
Temperature warningWarm the sensor gently with a hand, never a flameStatus changes above threshold; LED lights if alarm is enabled
Button bouncePress the button once normallyState changes exactly once
Web controlTap Toggle alarmLED and JSON reflect the new state
Unknown client routeOpen a nonexistent URLHTTP 404 is returned without resetting firmware

Do not place a hair dryer or other high-temperature source close to the BME280. The test only needs a trend; warmth from your hand is enough for a basic check.

When does the prototype become a small product?

A breadboard completes the lesson, not the product. Before long-term use, I would add:

  • Soldered connections and an enclosure with airflow around the BME280.
  • Physical distance between the sensor, regulator, ESP32, and hot exhaust air.
  • Less LED activity for a battery-powered version.
  • A setup page or BLE provisioning instead of hard-coded Wi-Fi credentials.
  • Protection for control endpoints; this web server belongs only on a trusted LAN.
  • Persistent settings with Preferences, a watchdog, reset-reason logs, and OTA with recovery.
  • A soak test lasting hours or days, including deliberate router and power interruptions.

When you want a messier real-world step, the ESP32 Smart Garden adds multiple sensors, imperfect analog data, and an actuator that needs fail-safe behavior.

ESP32 Beginner Guide final checklist

  • You can choose a specific development board instead of buying from a generic “ESP32” title.
  • You can upload code, read Serial, and handle basic port or boot failures.
  • You read the pinout before assigning GPIO.
  • You bring up a sensor separately before integration.
  • You understand when Wi-Fi, a web server, or BLE fits the problem.
  • You separate input, sampling, output, network, and API responsibilities.
  • You have tests for sensor failure, network loss, and rebooting.
  • You do not commit credentials or expose a demo web server to the Internet.

This is the final part of the ESP32 Beginner Guide. If the Room Comfort Monitor keeps working through repeated network and power interruptions, you have crossed the hardest beginner boundary: you are no longer making code merely run; you are designing how a device behaves when conditions are imperfect.

References

Share

LinkedInFacebookX

Keep exploring

Read next

Related articles

View more in Notes

Nastrotek uses cookies for analytics and ad personalization to help us understand how the site is used. You can accept or decline non-essential cookies. Privacy Policy