How warm does your bedroom get at night? Is the bathroom too damp after a shower? When is the hallway busiest? A room monitor answers questions like these, but only if it keeps a history. In this capstone you build one with the ESP32-S3, the newer and faster member of the ESP32 family. A DHT11 measures temperature and humidity, a PIR sensor counts movement, and once a minute the S3 writes a line into a file in its own flash memory. Any phone or computer on your home Wi-Fi can open the monitor's page and see live values and charts for the last 6 hours, 24 hours or 7 days. The board's colour LED shows at a glance whether the room is comfortable. And once the monitor is on the shelf, you can upload a new version of the sketch over Wi-Fi, without a cable.
What you'll learn
- How the ESP32-S3 differs from the classic ESP32, and how to set it up in the Arduino IDE
- How to store data in a file in the board's flash memory with LittleFS, and keep the file from filling up
- How to get the real time from the internet and log one line per minute
- How to draw charts in the browser from data the ESP32-S3 sends as JSON
- How to find the board by name with mDNS and update it over Wi-Fi (OTA)
What you need
- An ESP32-S3 board: the Espressif ESP32-S3-DevKitC-1 or a copy of it (for example N8R8 or N16R8), with a USB-C cable. It is not in the kit.
- DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
- HC-SR501 PIR motion sensor (ESP32 IoT Starter Kit)
- Breadboard and jumper wires (ESP32 IoT Starter Kit)
- A USB-C cable, and a 2.4 GHz Wi-Fi network
About the ESP32-S3-DevKitC-1: it is longer than the ESP32 board from the kit and has two USB-C ports. Use the one labelled UART (or COM) for uploading and the Serial Monitor. The small LED near the ports is an RGB LED that the sketch can set to any colour. The GPIO numbers are printed next to the pins. The ESP32-S3 works at 3.3 V like the classic ESP32, so the same sensors work with it.
Set up the ESP32-S3 in the Arduino IDE
- In the Arduino IDE, open Boards Manager and install or update esp32 by Espressif Systems to version 3 or newer. The ESP32-S3 support, Wi-Fi, Bluetooth, LittleFS and ESP-NOW all come with it.
- Choose Tools → Board → esp32 → ESP32S3 Dev Module.
- Plug the board into the UART port and choose its port under Tools → Port.
- Partition Scheme: leave the default, Default 4MB with spiffs. The "spiffs" part is the space for the log file.
- If an upload ever fails with "Failed to connect", hold the BOOT button, press and let go of RST, let go of BOOT, and upload again. Press RST once more afterwards to start the sketch.
Wiring

Build it step by step
Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.



| From | To |
|---|---|
| DHT11 module S (signal) | ESP32-S3 GPIO 4 |
| DHT11 module + and − | 3V3 and GND |
| PIR sensor OUT | ESP32-S3 GPIO 5 |
| PIR sensor VCC | 5V (the pin may be labelled 5V0 or 5Vin) |
| PIR sensor GND | GND |
Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.
The PIR sensor needs 5 V to work, but its output is only 3.3 V, so it is safe for the ESP32-S3. Turn the time-delay knob (Tx) fully anticlockwise for the shortest delay (about 3 s). Leave the sensitivity knob in the middle: turned clockwise it reaches farther (up to about 7 m). After switching on, the PIR needs about a minute to settle; it may report movement during that time.
The sketch
In the Arduino IDE, open Library Manager and install the DHT sensor library by Adafruit (say yes when it asks to install Adafruit Unified Sensor too).
Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).
Set TIME_ZONE for your place, so the charts show your local time. Some examples: UK GMT0BST,M3.5.0/1,M10.5.0, Estonia and Finland EET-2EEST,M3.5.0/3,M10.5.0/4, New York EST5EDT,M3.2.0,M11.1.0, India IST-5:30, UAE <+04>-4. Also change OTA_PASSWORD, so nobody else on your Wi-Fi can upload sketches to your monitor.
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <LittleFS.h>
#include <DHT.h>
#include <time.h>
// ---------- settings ----------
const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* DEVICE_NAME = "roommonitor"; // the page is at http://roommonitor.local
const char* OTA_PASSWORD = "change-me-123"; // asked for when you upload over Wi-Fi
const char* TIME_ZONE = "CET-1CEST,M3.5.0,M10.5.0/3"; // Central Europe; see the lesson for yours
const int DHT_PIN = 4;
const int PIR_PIN = 5;
const int RGB_PIN = RGB_BUILTIN; // DevKitC-1 v1.1: use 38 instead
const float TOO_COLD = 18, TOO_WARM = 26; // °C
const float TOO_DRY = 30, TOO_DAMP = 65; // % humidity
const size_t MAX_LOG = 400000; // bytes, about 13 days of one line a minute
DHT dht(DHT_PIN, DHT11);
WebServer server(80);
float temp = NAN, hum = NAN; // the latest reading
float sumT = 0, sumH = 0; // to work out the average of each minute
int samples = 0, motionNow = 0, motionLast = 0;
bool lastPir = false;
unsigned long lastRead = 0;
long lastMinute = -1;
const char PAGE[] = R"rawliteral(<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Room monitor</title>
<style>
body{margin:0;font-family:Arial,sans-serif;background:#F6F8FC;color:#1B3A7B}
header{background:#1B3A7B;color:#fff;padding:12px 16px;display:flex;justify-content:space-between;align-items:center}
header b{font-size:20px} #clock{font-size:14px;opacity:.85}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:12px}
.tile{background:#fff;border-radius:16px;padding:12px 14px;box-shadow:0 2px 8px rgba(27,58,123,.08)}
.tile span{font-size:14px;opacity:.75} .tile b{display:block;font-size:30px;margin-top:4px}
.card{background:#fff;border-radius:16px;margin:12px;padding:12px 14px;box-shadow:0 2px 8px rgba(27,58,123,.08)}
.card h3{margin:0 0 8px;font-size:16px} canvas{width:100%;height:180px}
.range{margin:0 12px} .range button{border:0;border-radius:12px;padding:10px 14px;margin:2px;font-weight:bold;background:#E3E8F2;color:#1B3A7B}
.range button.on{background:#1B3A7B;color:#fff} a{color:#DE544A;font-weight:bold}
</style></head><body>
<header><b>Room monitor</b><span id="clock">-</span></header>
<div class="tiles">
<div class="tile"><span>Temperature</span><b id="t">-</b></div>
<div class="tile"><span>Humidity</span><b id="h">-</b></div>
<div class="tile"><span>Movement (last minute)</span><b id="m">-</b></div>
<div class="tile"><span>Comfort</span><b id="c">-</b></div>
</div>
<div class="range"><button data-h="6">6 hours</button><button data-h="24" class="on">24 hours</button><button data-h="168">7 days</button></div>
<div class="card"><h3>Temperature (°C)</h3><canvas id="ct"></canvas></div>
<div class="card"><h3>Humidity (%)</h3><canvas id="ch"></canvas></div>
<div class="card"><h3>Movement (detections per minute)</h3><canvas id="cm"></canvas></div>
<div class="card">Download all data: <a href="/log.csv">log.csv</a> · <a href="/log-old.csv">log-old.csv</a></div>
<script>
var hours = 24;
function draw(id, xs, ys, color, bars) {
var c = document.getElementById(id), w = c.width = c.clientWidth * 2, h = c.height = c.clientHeight * 2, g = c.getContext('2d');
g.clearRect(0, 0, w, h);
var v = ys.filter(function (y) { return y !== null; });
if (!v.length) { g.fillStyle = '#8A97B5'; g.font = '28px Arial'; g.fillText('No data yet: the first point appears after one minute.', 20, h / 2); return; }
var lo = Math.min.apply(null, v), hi = Math.max.apply(null, v);
if (bars) lo = 0;
if (hi - lo < 1) { hi += 0.5; lo -= bars ? 0 : 0.5; }
var L = 70, B = h - 40, x0 = xs[0], x1 = xs[xs.length - 1] || x0 + 1;
var X = function (t) { return L + (w - L - 10) * (t - x0) / Math.max(1, x1 - x0); };
var Y = function (y) { return B - (B - 10) * (y - lo) / (hi - lo); };
g.strokeStyle = '#E3E8F2'; g.fillStyle = '#5B6B8C'; g.font = '22px Arial'; g.lineWidth = 2;
[lo, (lo + hi) / 2, hi].forEach(function (y) { g.beginPath(); g.moveTo(L, Y(y)); g.lineTo(w, Y(y)); g.stroke(); g.fillText(y.toFixed(1), 4, Y(y) + 8); });
[x0, (x0 + x1) / 2, x1].forEach(function (t, i) {
var d = new Date(t * 1000), s = hours > 24 ? d.toLocaleDateString([], { weekday: 'short' }) + ' ' : '';
g.fillText(s + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), Math.min(X(t), w - 150) - (i ? 40 : 0), h - 8);
});
g.strokeStyle = g.fillStyle = color; g.lineWidth = 4; g.beginPath();
var up = false;
xs.forEach(function (t, i) {
if (ys[i] === null) { up = false; return; }
if (bars) { g.fillRect(X(t) - 2, Y(ys[i]), 4, B - Y(ys[i])); return; }
if (up) g.lineTo(X(t), Y(ys[i])); else g.moveTo(X(t), Y(ys[i]));
up = true;
});
if (!bars) g.stroke();
}
function history() {
fetch('/history?h=' + hours).then(function (r) { return r.json(); }).then(function (d) {
draw('ct', d.t, d.temp, '#DE544A'); draw('ch', d.t, d.hum, '#2F6FDB'); draw('cm', d.t, d.motion, '#1E9E5A', true);
});
}
function now() {
fetch('/now').then(function (r) { return r.json(); }).then(function (d) {
document.getElementById('t').textContent = d.temp === null ? '-' : d.temp.toFixed(1) + ' °C';
document.getElementById('h').textContent = d.hum === null ? '-' : d.hum.toFixed(0) + ' %';
document.getElementById('m').textContent = d.motion;
document.getElementById('c').textContent = d.comfort;
document.getElementById('clock').textContent = d.time ? new Date(d.time * 1000).toLocaleTimeString() : 'Waiting for the time...';
});
}
document.querySelectorAll('.range button').forEach(function (b) {
b.onclick = function () {
document.querySelectorAll('.range button').forEach(function (x) { x.classList.remove('on'); });
b.classList.add('on'); hours = +b.dataset.h; history();
};
});
now(); history();
setInterval(now, 5000); setInterval(history, 60000);
</script></body></html>)rawliteral";
// ---------- comfort ----------
String comfort() {
if (isnan(temp) || isnan(hum)) return "No sensor";
if (temp < TOO_COLD) return "Too cold";
if (temp > TOO_WARM) return "Too warm";
if (hum < TOO_DRY) return "Too dry";
if (hum > TOO_DAMP) return "Too damp";
return "Good";
}
void showComfort() { // the board's own RGB LED, dimmed
String c = comfort();
if (c == "Good") rgbLedWrite(RGB_PIN, 0, 20, 0);
else if (c == "Too cold" || c == "Too damp") rgbLedWrite(RGB_PIN, 0, 0, 25);
else if (c == "No sensor") rgbLedWrite(RGB_PIN, 15, 15, 15);
else rgbLedWrite(RGB_PIN, 25, 6, 0);
}
// ---------- the log: one line a minute, "time,temperature,humidity,movement" ----------
void logMinute(time_t t) {
if (samples == 0) return;
File f = LittleFS.open("/log.csv", "a");
if (!f) return;
f.printf("%ld,%.1f,%.0f,%d\n", (long)t, sumT / samples, sumH / samples, motionLast);
bool full = f.size() > MAX_LOG;
f.close();
if (full) { // keep the newest two files only
LittleFS.remove("/log-old.csv");
LittleFS.rename("/log.csv", "/log-old.csv");
}
}
// Sends the history as JSON. Many minutes are put together into one point, so there are never more than 300 points.
void sendHistory() {
int hours = constrain(server.arg("h").toInt(), 1, 24 * 9);
time_t now = time(nullptr), since = now - hours * 3600L;
long bucket = max(60L, hours * 3600L / 300);
String t = "[", te = "[", hu = "[", mo = "[";
long current = -1;
float st = 0, sh = 0; int sm = 0, n = 0;
auto flush = [&]() {
if (n == 0) return;
if (t.length() > 1) { t += ','; te += ','; hu += ','; mo += ','; }
t += String(current * bucket + bucket / 2);
te += String(st / n, 1); hu += String(sh / n, 0); mo += String(sm);
st = sh = 0; sm = n = 0;
};
long firstNew = LONG_MAX; // the first time in log.csv: is the old file needed at all?
char buf[48];
File cur = LittleFS.open("/log.csv", "r");
if (cur) {
buf[cur.readBytesUntil('\n', buf, sizeof(buf) - 1)] = 0;
sscanf(buf, "%ld", &firstNew);
cur.close();
}
const char* files[2] = {"/log-old.csv", "/log.csv"};
for (int i = 0; i < 2; i++) {
if (i == 0 && firstNew <= since) continue; // everything we need is in log.csv
File f = LittleFS.open(files[i], "r");
if (!f) continue;
while (f.available()) {
buf[f.readBytesUntil('\n', buf, sizeof(buf) - 1)] = 0;
long ts; float lt, lh; int lm;
if (sscanf(buf, "%ld,%f,%f,%d", &ts, <, &lh, &lm) != 4 || ts < since) continue;
if (ts / bucket != current) { flush(); current = ts / bucket; }
st += lt; sh += lh; sm = max(sm, lm); n++;
}
f.close();
}
flush();
server.send(200, "application/json", "{\"t\":" + t + "],\"temp\":" + te + "],\"hum\":" + hu + "],\"motion\":" + mo + "]}");
}
void sendNow() {
time_t now = time(nullptr);
String json = "{\"temp\":" + (isnan(temp) ? String("null") : String(temp, 1)) +
",\"hum\":" + (isnan(hum) ? String("null") : String(hum, 0)) +
",\"motion\":" + String(motionLast) + ",\"comfort\":\"" + comfort() + "\"" +
",\"time\":" + (now > 1700000000 ? String((long)now) : String("null")) + "}";
server.send(200, "application/json", json);
}
void sendFile(const char* name) {
File f = LittleFS.open(name, "r");
if (!f) { server.send(404, "text/plain", "No data yet"); return; }
server.sendHeader("Content-Disposition", String("attachment; filename=") + (name + 1));
server.streamFile(f, "text/csv");
f.close();
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT);
dht.begin();
rgbLedWrite(RGB_PIN, 15, 15, 15);
if (!LittleFS.begin(true)) Serial.println("LittleFS failed: check the partition scheme");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println();
configTzTime(TIME_ZONE, "pool.ntp.org", "time.google.com"); // the clock comes from the internet
ArduinoOTA.setHostname(DEVICE_NAME); // also starts mDNS: http://roommonitor.local
ArduinoOTA.setPassword(OTA_PASSWORD);
ArduinoOTA.onStart([]() { Serial.println("Wireless update started"); });
ArduinoOTA.begin();
MDNS.addService("http", "tcp", 80);
server.on("/", []() { server.send(200, "text/html", PAGE); });
server.on("/now", sendNow);
server.on("/history", sendHistory);
server.on("/log.csv", []() { sendFile("/log.csv"); });
server.on("/log-old.csv", []() { sendFile("/log-old.csv"); });
server.begin();
Serial.printf("Open http://%s.local or http://", DEVICE_NAME);
Serial.println(WiFi.localIP());
}
void loop() {
ArduinoOTA.handle();
server.handleClient();
bool pir = digitalRead(PIR_PIN); // count each new movement
if (pir && !lastPir) motionNow++;
lastPir = pir;
if (millis() - lastRead >= 5000) { // the DHT11 needs at least 1 s between readings
lastRead = millis();
float t = dht.readTemperature(), h = dht.readHumidity();
if (!isnan(t) && !isnan(h)) {
temp = t; hum = h;
sumT += t; sumH += h; samples++;
} else Serial.println("DHT11 not answering: check its wires");
showComfort();
}
time_t now = time(nullptr);
if (now > 1700000000 && now / 60 != lastMinute) { // a new minute has started (and the clock is set)
if (lastMinute >= 0) {
motionLast = motionNow;
logMinute(lastMinute * 60);
}
lastMinute = now / 60;
sumT = sumH = 0; samples = 0; motionNow = 0;
}
}

Try it
- Upload the sketch and open the Serial Monitor at 115200 baud. After "Connecting to Wi-Fi..." it shows the address, for example
Open http://roommonitor.local or http://192.168.1.57. - On a phone or computer on the same Wi-Fi, open
http://roommonitor.local. If that name doesn't work (some Android phones don't support it), use the number address instead. - The four tiles fill in within a few seconds: temperature, humidity, movement in the last minute, and comfort. The board's LED turns green when the room is comfortable, blue when it is too cold or too damp, and orange when it is too warm or too dry.
- Wave in front of the PIR sensor: the movement tile counts up. Breathe on the DHT11: the humidity rises within a few readings.
- After a minute the first point appears in the charts, and from then on one every minute. Leave the monitor running for a day, then look at the 24-hour chart: you'll see the heating, the sun, people coming and going.
- Tap log.csv to download all the data. It opens in any spreadsheet program: the first column is the time in seconds since 1 January 1970 (Unix time).
- Now try a wireless update. Change something small in the sketch, for example
TOO_WARM = 25. In the Arduino IDE, open Tools → Port: under Network ports you'll find roommonitor. Choose it, upload, and type yourOTA_PASSWORDwhen the IDE asks. The monitor restarts with the new sketch, and the log is still there.
What you should see

How it works

The ESP32-S3 has two fast cores at 240 MHz, a USB port built into the chip, on many boards (N8R8, N16R8) 8 MB of extra PSRAM, and extra instructions that speed up artificial intelligence. Its flash memory is where the sketch lives, but part of it is set aside as a small disk: LittleFS turns that part into files and folders. Unlike RAM, flash keeps its data when the power is off, so the history survives a power cut and even a wireless update.
Every 5 seconds the sketch reads the DHT11 and adds the values up. When the clock reaches a new minute, it writes the average of that minute into /log.csv as one short line, for example 1793350860,21.4,47,3: the time, the temperature, the humidity, and how many times the PIR saw movement. About 21 bytes a minute is about 30 KB a day. When the file grows past 400 KB (about 13 days), the sketch renames it to log-old.csv and starts a new one, so the flash never fills up. Flash memory wears out after many thousands of writes to the same place, which is why the sketch writes once a minute and not every 5 seconds.
The time comes from the internet: configTzTime() asks an NTP time server, and the ESP32-S3 keeps the clock running by itself. The sketch only logs once the clock is set. mDNS lets the board announce its own name on your network, so you can type roommonitor.local instead of a number that your router may change.
The web page does the drawing. It asks for /now every 5 seconds and /history?h=24 every minute. The ESP32-S3 reads the log line by line and joins neighbouring minutes into at most 300 points (for 7 days, one point covers about 34 minutes), and sends them as JSON: lists of numbers that JavaScript reads directly. Your phone then draws the charts on a <canvas>. Letting the browser do the drawing keeps the sketch small and the board fast.
OTA (over the air) updates work through ArduinoOTA: the board listens on the network for a new sketch, checks the password, writes the sketch into a second, free part of the flash, and only switches over when it has arrived completely. If the update stops halfway, the old sketch keeps running.
Try this next
- Add a "Download last 24 hours" button that only sends the newest 1440 lines.
- Send yourself a message when the room is too warm for more than 10 minutes, as in the Telegram room monitor.
- Draw a line for the comfortable range (18 to 26 °C) on the temperature chart, so you can see at a glance when the room was outside it.
- Add a second monitor in another room, then combine both on one page. The next capstone, the ESP-NOW sensor network, shows a way to do this without connecting every sensor to Wi-Fi.
- Work out the "feels like" temperature from temperature and humidity (the heat index) and add it as a fifth tile.
Troubleshooting
- It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.
- Nothing appears in the Serial Monitor: plug the cable into the port labelled UART (or COM), or set Tools → USB CDC On Boot to Enabled if you use the port labelled USB.
- roommonitor.local doesn't open: use the number address from the Serial Monitor instead. Some phones and older Windows computers don't understand .local names.
- The clock says "Waiting for the time..." and the charts stay empty: the monitor can't reach the internet time server. Check that your Wi-Fi has internet, then press RST.
- "DHT11 not answering": check the S wire on GPIO 4 and the + and − pins. A DHT11 needs about a second after power-up before its first reading.
- The movement count never stops: the PIR is seeing itself warm up, or its delay knob is turned up. Turn the delay (Tx) knob fully anticlockwise and wait a minute.
- "LittleFS failed": choose a partition scheme with spiffs in it under Tools → Partition Scheme, then upload again.
- The board doesn't appear under Network ports: wait a minute after it has started, check that the computer is on the same Wi-Fi, and allow the Arduino IDE through your computer's firewall.
-
The LED stays off: on the ESP32-S3-DevKitC-1 v1.1 the RGB LED is on GPIO 38. Set
RGB_PIN = 38.