Build a resilient ESP32 room monitor with a DHT22, scheduled sampling, a local Wi-Fi API and clear
fault states. Follow the student and TEP AI from the first connection to a testable system.
Intermediate24 messages12 project steps16 min read3,115 wordsReference design
I want to build an ESP32 room temperature monitor for my final-year project. A basic temperature
display feels too simple. Can we make it technically stronger, show readings on my laptop, and
demonstrate what happens when a sensor or the Wi-Fi connection fails?
TEP AI
Project guide
Yes. Let’s treat this as a small measurement system with explicit behavior, rather than starting
with a collection of features. Our inputs are temperature and relative humidity from a DHT22. Our
outputs are a serial diagnostic stream and a browser view on your local network. The interesting
engineering work is deciding when a number is trustworthy, keeping the device responsive, and making
failures visible.
We will build it in layers. First, prove that the board can read the sensor without networking. Then
give acquisition its own schedule, keep a validated snapshot in memory, and let the HTTP endpoint
return that snapshot. Finally, let the browser distinguish a fresh measurement from an old one or a
lost connection. A web request must never directly trigger another sensor read.
For this example, choose a 2.5-second acquisition interval, a six-second freshness window, and a
thirty-second limit for displaying a previous measurement as stale. Those are our project policies,
not universal sensor specifications. We will also track consecutive failures and preserve raw
readings alongside a filtered temperature. The result is a reference design you can explain, test
and extend; the numerical outputs in this conversation remain illustrative until you collect your
own measurements.
First milestone: validated temperature and humidity over USB serial.
Final milestone: a local dashboard that exposes freshness, failures and recovery.
First milestoneTemperature + humidity in Serial Monitor
02
Choose and place the components
S
Student
Question 2
I have an ESP32 development board, a bare four-pin DHT22, a breadboard and jumper wires. Do I need a
display or an extra Wi-Fi module? Also, does placing the sensor next to the ESP32 affect the
readings?
TEP AI
Project guide
You can use the ESP32’s built-in networking and your laptop as the display, so there is no need to
add those two modules. Add a suitable USB data cable and a 10 kΩ pull-up resistor for the bare
sensor wiring shown below. We will use an ESP32 board with an accessible GPIO4, but you must confirm
the exact board’s pin labels and schematic before adopting that assignment.
Place the DHT22 where it measures the air you actually care about. A sensor directly beside a warm
regulator, a processor or an enclosure wall may describe that local microclimate rather than the
room. Start with short connections and keep the sensor separated from the board’s heat sources. This
makes the first measurements easier to interpret before you experiment with longer leads or an
enclosure.
For the project report, distinguish what you selected from what you verified. Record the board
model, sensor variant, supply voltage, connection lengths and physical arrangement. A three-pin
breakout may already include a pull-up and may use a different pin order; the four-pin diagram is
not a substitute for inspecting it. Keep the first hardware setup simple so that a later change in
behavior can be connected to a specific change in wiring, placement or firmware.
Please show the connections. I understand power and ground, but why does the data wire need a
resistor to the supply? Could I connect the sensor to 5 V and leave its data wire directly connected
to the ESP32?
TEP AI
Project guide
Use the 3.3 V arrangement in this example: sensor pin 1 to 3V3, pin 2 to GPIO4, pin 4 to GND, and
pin 3 unconnected. Place the 10 kΩ resistor between the data line and 3V3. Check the sensor’s
documented orientation before counting pins, and disconnect power while moving wires. The pin
labeled GPIO4 is the relevant signal; the fourth physical position on a board header is not
necessarily GPIO4.
The pull-up gives the released data line a defined high level. The sensor and controller exchange
timed digital pulses, so an undefined or slowly rising signal can prevent a valid transaction. As a
simple circuit estimate, 3.3 V across 10 kΩ corresponds to about 0.33 mA when the line is pulled
low. Cable capacitance and pull-up resistance also affect the rising edge. That is a reason to start
with short wires, not a reason to choose the smallest resistor available.
Do not casually translate a 5 V Arduino diagram into a 3.3 V ESP32 connection. In this project, both
the sensor supply and the data pull-up are 3.3 V. If a particular module requires another supply
arrangement, inspect its internal pull-up and use an interface compatible with the board’s input
limits. A common ground is essential: the receiver needs the same voltage reference as the device
producing the signal.
The wiring is ready. Should I begin with the web server code, or test the sensor by itself? I would
like a small sketch where I can understand every line before adding the more complex parts.
TEP AI
Project guide
Start with the small serial sketch below. Install the Adafruit DHT sensor library and its Adafruit
Unified Sensor dependency, select your actual ESP32 board and port, and set Serial Monitor to 115200
baud. The GPIO constant and the DHT22 constructor setting must agree with the physical connection
and sensor type. Compile this version first, so a dependency or upload problem is separated from a
networking problem.
The two measurements are checked before they are printed. A failed library read can produce NaN,
which means “not a number”; it must not become an ordinary plotted value. Do not replace it with
zero, because zero degrees or zero percent humidity would look like a valid observation. In this
first milestone, a plain error message is enough to tell us that acquisition failed.
The two-second delay is deliberately simple here. It helps isolate the sensor test, but we will
remove the long waiting period when the device also services browser requests. Keep a copy of this
basic sketch even after the advanced version works. Returning to a small, known configuration is
useful when you need to decide whether a later failure belongs to hardware, the acquisition layer or
the web interface. The downloadable example is educational source, not a claim that this particular
board has been physically tested.
esp32-room-monitor.ino
// AI Projects Lab — educational ESP32 + DHT22 example.
// Install Adafruit DHT sensor library and Adafruit Unified Sensor.
// Verify the GPIO label and sensor pinout for your actual board.
#include <DHT.h>
constexpr uint8_t SENSOR_GPIO = 4;
DHT roomSensor(SENSOR_GPIO, DHT22);
void setup() {
Serial.begin(115200);
roomSensor.begin();
}
void loop() {
delay(2000);
const float temperatureC = roomSensor.readTemperature();
const float humidityPct = roomSensor.readHumidity();
if (isnan(temperatureC) || isnan(humidityPct)) {
Serial.println("No reading: check power, data pin and pull-up.");
return;
}
Serial.print("Room: ");
Serial.print(temperatureC, 1);
Serial.print(" C | Humidity: ");
Serial.print(humidityPct, 1);
Serial.println(" %");
}
Basic serial milestone. Target-board compilation and hardware validation are still required.
05
Diagnose a failed reading systematically
S
Student
Question 5
The sketch uploads, but I keep getting the failed-reading message. I checked again: the data wire is
connected to the pin marked 5, while the code says GPIO4. If I fix that, what should I check next if
the error remains?
TEP AI
Project guide
That mismatch is a concrete first fault. With power disconnected, move the data wire to the board’s
GPIO4, or deliberately change the constant to the GPIO you verified from the board documentation.
Change one thing and test again. Do not simultaneously alter the pin, sensor type, read interval and
library, because then a successful result will not tell you which assumption was wrong.
If the failure continues, inspect the complete signal path: sensor orientation, ground continuity,
the 3.3 V supply at the sensor, and the pull-up connection. A breadboard power rail may be split in
the middle. A jumper can look inserted while making poor contact. A multimeter is useful for static
supply checks, while an oscilloscope or logic analyzer can help inspect digital timing if available.
An I²C address scan will not diagnose this sensor’s single-data-wire protocol.
Then check software assumptions. Confirm DHT22 rather than DHT11, allow the required interval
between transactions, and keep the network layer absent during this test. Adafruit’s implementation
caches recent reads, so repeatedly calling its getters is not proof of repeated physical
acquisitions. Write down the symptom, the exact intervention and the observed result. For this
conversation, we can describe the expected recovery after correcting the pin, but your report should
contain the actual serial output from your own hardware.
Now suppose the serial test is working. I want the browser to remain responsive while the ESP32
takes a reading every 2.5 seconds. Would a timer interrupt be better than delay(), and what happens
when millis() rolls over?
TEP AI
Project guide
A cooperative loop is enough for this project. Let the loop repeatedly service network work, then
check whether the next acquisition is due. Compare elapsed time using unsigned subtraction:
uint32_t(now - lastSampleMs) >= SAMPLE_MS. This handles the ordinary millisecond-counter rollover
when the loop is serviced regularly and the interval is short relative to the counter range. Avoid
comparing now with a future timestamp by simply adding the interval.
When a sample is due, perform one acquisition and update the reference time. Do not run a catch-up
loop that requests several immediate DHT transactions after the processor has been delayed. We want
a sensible minimum spacing, not a burst of stale cached results. The reference firmware waits for
the first interval after startup, which also gives the hardware time to settle.
A timer interrupt is not necessary here, and a timing-sensitive sensor transaction plus formatted
output does not belong in an interrupt handler. The DHT library itself still performs a short
synchronous exchange, and the simple web server can also wait on I/O. Therefore, call this a
cooperative design, not a guarantee of hard real-time behavior. Measure service time on the board if
responsiveness becomes a formal requirement. Only introduce tasks, queues or asynchronous networking
after you can identify the blocking operation and the timing budget it violates.
Cooperative scheduling · excerpt
const uint32_t now = millis();
if (uint32_t(now - lastSampleMs) >= SAMPLE_MS) {
lastSampleMs = now;
acquire();
}
if (httpStarted) server.handleClient();
I do not want the dashboard to keep showing an old temperature as if everything is fine. Could we
define the data structure and the failure states before implementing the API? What should happen
before the very first successful reading?
TEP AI
Project guide
Keep measurement validity separate from communication success. An HTTP response can arrive correctly
while the sensor is disconnected. Our snapshot therefore contains a state, a sequence number, the
age of the last accepted sample, raw values, a filtered temperature and a consecutive-failure count.
The sequence advances only when a new valid pair is accepted; repeated browser requests do not
create new samples.
Before the first attempt, the state is starting. If an attempt fails and there has never been a
valid pair, use unavailable. After a successful pair, use fresh while the most recent attempt
succeeded and the sample is within our six-second window. A failed attempt immediately changes a
previous valid measurement to stale. After thirty seconds without an accepted pair, the state is
unavailable even though a historical value still exists in memory.
Only fresh values belong in the top-level current-reading fields. A previous pair can remain under
lastGood, with its age clearly attached, so the interface can explain what was last observed. Use
JSON null for values that are not currently available; NaN is not valid JSON. The age accumulator in
the reference firmware saturates instead of wrapping back to a small value during a very long fault.
This prevents an expired measurement from accidentally looking recent. The policy is intentionally
explicit so we can test every transition without relying on the color of a status badge.
Fresh Current + validatedStale Historical, clearly labeledUnavailable No current measurement
That state model makes sense. Can you combine it with Wi-Fi and a small web interface? I also want
to know whether losing the router should stop the sensor, and where the Wi-Fi password belongs in a
project I might publish.
TEP AI
Project guide
The extended firmware below keeps acquisition independent of the network connection. Setup starts
station mode and begins joining your network, but it does not sit in an endless connection loop. The
main loop continues sampling. When Wi-Fi is available, it starts a small HTTP server; when the
connection is lost, it stops that server while acquisition continues. Automatic reconnection is
enabled, and the address is printed when service becomes available again.
The root route serves a minimal browser page, and /api/readings returns the current snapshot. That
handler serializes memory; it never talks to the sensor. This is an important separation because
opening several browser tabs must not change the physical read interval. Both routes send a no-store
cache policy. The prototype uses a single simple server and bounded response content; it is not
designed as a high-concurrency public service.
Replace the placeholder network credentials only in your local working copy. Before sharing the
firmware, remove real credentials, network identifiers and private logs. For this classroom
prototype, use a trusted local network and leave router port forwarding disabled. There is no
authentication or TLS in this sample. If remote access becomes a requirement, redesign the boundary
with an authenticated service and protected transport rather than placing this demonstration
endpoint directly on the public internet.
esp32-room-monitor-network.ino
/* AI Projects Lab: ESP32 + DHT22 reference firmware.
* Install Adafruit DHT sensor library and Adafruit Unified Sensor.
* Open this file as its own Arduino sketch; do not combine it with the basic sketch.
* Use a trusted local network. This HTTP demonstration has no authentication/TLS.
* Configure and compile for your board; physical hardware testing is still required.
*/
#include <Arduino.h>
#include <DHT.h>
#include <WiFi.h>
#include <WebServer.h>
#include <math.h>
#include <stdint.h>
#include <string.h>
const char* WIFI_SSID = "YOUR_LOCAL_SSID";
const char* WIFI_PASSWORD = "YOUR_LOCAL_PASSWORD";
constexpr uint8_t SENSOR_GPIO = 4;
constexpr uint32_t SAMPLE_MS = 2500;
constexpr uint32_t FRESH_MS = 6000;
constexpr uint32_t EXPIRE_MS = 30000;
constexpr float ALPHA = 0.25f;
DHT roomSensor(SENSOR_GPIO, DHT22);
WebServer server(80);
bool httpStarted = false;
bool attempted = false, hasReading = false, latestOk = false, hot = false;
uint32_t lastSampleMs = 0, clockMs = 0, ageMs = 0;
uint32_t sequence = 0, consecutiveFailures = 0;
float temperatureC = NAN, humidityPct = NAN, filteredC = NAN;
// Advancing a bounded age avoids an old fault becoming recent at counter rollover.
// The loop must run regularly; no millis()-based method recovers arbitrary full wraps.
void advanceAge(uint32_t now) {
const uint32_t elapsed = now - clockMs;
clockMs = now;
if (hasReading) {
ageMs = elapsed > UINT32_MAX - ageMs ? UINT32_MAX : ageMs + elapsed;
}
}
const char* measurementState() {
if (!attempted) return "starting";
if (!hasReading || ageMs > EXPIRE_MS) return "unavailable";
if (latestOk && ageMs <= FRESH_MS) return "fresh";
return "stale";
}
void acquire() {
const float t = roomSensor.readTemperature();
const float h = roomSensor.readHumidity();
advanceAge(millis());
attempted = true;
latestOk = isfinite(t) && isfinite(h) && t >= -40.0f && t <= 80.0f &&
h >= 0.0f && h <= 100.0f;
if (!latestOk) {
if (consecutiveFailures < UINT32_MAX) ++consecutiveFailures;
return; // Never replace a real reading with a fabricated zero.
}
if (!hasReading || ageMs > EXPIRE_MS) {
filteredC = t;
hot = false;
} else {
filteredC = ALPHA * t + (1.0f - ALPHA) * filteredC;
}
temperatureC = t;
humidityPct = h;
hasReading = true;
ageMs = 0;
consecutiveFailures = 0;
++sequence;
if (filteredC >= 30.0f) hot = true;
else if (filteredC <= 29.0f) hot = false;
}
String snapshotJson() {
advanceAge(millis());
const char* state = measurementState();
const bool fresh = strcmp(state, "fresh") == 0;
String result;
result.reserve(420);
result = "{\"state\":\"";
result += state;
result += "\",\"sequence\":" + String(sequence);
result += ",\"temperatureC\":" + (fresh ? String(temperatureC, 1) : String("null"));
result += ",\"humidityPct\":" + (fresh ? String(humidityPct, 1) : String("null"));
result += ",\"filteredC\":" + (fresh ? String(filteredC, 2) : String("null"));
result += ",\"alarm\":";
result += fresh ? (hot ? "\"high\"" : "\"normal\"") : "null";
result += ",\"consecutiveFailures\":" + String(consecutiveFailures);
result += ",\"lastGood\":";
if (hasReading) {
result += "{\"temperatureC\":" + String(temperatureC, 1);
result += ",\"humidityPct\":" + String(humidityPct, 1);
result += ",\"ageMs\":" + String(ageMs) + "}";
} else {
result += "null";
}
return result + "}";
}
const char PAGE[] PROGMEM = R"HTML(<!doctype html>
<html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 room monitor</title>
<style>body{font:16px/1.6 system-ui;max-width:720px;margin:36px auto;padding:16px;background:#f1f8fc;color:#23445b}pre{padding:18px;border:1px solid #c8dfea;border-radius:12px;background:white;white-space:pre-wrap;overflow-wrap:anywhere}h1{color:#126f9c}</style>
<h1>ESP32 room monitor</h1>
<p id="state" role="status">Connecting to this device...</p>
<pre id="reading">Waiting for a snapshot.</pre>
<p>Educational local-network prototype. Values marked stale are historical.</p>
<script>
const state = document.querySelector('#state');
const reading = document.querySelector('#reading');
async function poll() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('/api/readings', {cache:'no-store', signal:controller.signal});
if (!response.ok) throw new Error('HTTP ' + response.status);
const snapshot = await response.json();
state.textContent = 'Sensor: ' + snapshot.state + ' | Connection: available';
reading.textContent = JSON.stringify(snapshot, null, 2);
} catch {
state.textContent = 'Connection unavailable — sensor state is unknown.';
reading.textContent = 'No current snapshot. Check the device and local network.';
} finally {
clearTimeout(timer);
setTimeout(poll, 2500); // One outstanding request at a time.
}
}
poll();
</script></html>)HTML";
void setup() {
Serial.begin(115200);
roomSensor.begin();
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD); // Acquisition does not wait for association.
server.on("/", HTTP_GET, []() {
server.sendHeader("Cache-Control", "no-store");
server.send_P(200, "text/html; charset=utf-8", PAGE);
});
server.on("/api/readings", HTTP_GET, []() {
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json", snapshotJson());
});
server.onNotFound([]() { server.send(404, "text/plain", "Not found"); });
clockMs = lastSampleMs = millis();
}
void loop() {
const uint32_t now = millis();
advanceAge(now);
if (uint32_t(now - lastSampleMs) >= SAMPLE_MS) {
lastSampleMs = now;
acquire();
Serial.println(snapshotJson());
}
const bool connected = WiFi.status() == WL_CONNECTED;
if (connected && !httpStarted) {
server.begin();
httpStarted = true;
Serial.print("Local dashboard: http://");
Serial.println(WiFi.localIP());
} else if (!connected && httpStarted) {
server.stop();
httpStarted = false;
}
if (httpStarted) server.handleClient();
delay(1); // Yield briefly; the sensor interval is controlled by elapsed time.
}
Open this as a separate Arduino sketch. Replace the Wi-Fi placeholders locally and verify on your
board.
If the browser polls regularly, how do I stop overlapping requests or a cached response? And if
Wi-Fi disappears after one good response, should the page keep the last green “fresh” status
forever?
TEP AI
Project guide
Use one polling cycle at a time. The embedded page waits for a request to finish, then schedules the
next one. It also gives each request a finite timeout with AbortController. This prevents a slow or
lost request from accumulating an unbounded queue of new requests. The browser asks for uncached
data, and the server independently marks its responses as non-cacheable.
The browser displays sensor state and connection state as separate concepts. When a valid response
arrives, it shows the firmware’s snapshot. If the request fails, the interface replaces the
current-reading display with an explicit connection-unavailable message. It does not keep claiming
that the last received value is fresh. A stale sensor response, on the other hand, means that
communication worked but the measurement itself is not current. Those two failure modes lead you to
different diagnostic steps.
Our included page is deliberately small: a status label and a readable JSON snapshot. A richer
dashboard can turn those fields into cards and a chart, but it should retain the same semantics. Add
a chart point only when the sample sequence changes. Do not fill a communication gap with invented
measurements or interpret a reset sequence as a smooth continuation of the same session. Device time
since boot is also not a calendar timestamp; a persistent history needs an explicit clock strategy
and a boot or session identifier.
The readings may move slightly even when the room seems unchanged. Could we smooth the temperature
and add a high-temperature indicator? I do not want the indicator rapidly switching on and off
around one threshold.
TEP AI
Project guide
Keep the raw values and add a separate exponential moving average for presentation. In this example,
filtered = 0.25 × raw + 0.75 × previousFiltered. Initialize it from the first valid sample instead
of zero. At a 2.5-second sample interval, this setting intentionally adds visible lag. It reduces
short-term variation in the displayed trend; it does not calibrate the sensor or establish that the
absolute temperature is correct.
Do not update the filter with a failed read. After a gap long enough to make the previous
measurement unavailable, reinitialize from the next valid value so a long-expired estimate is not
blended into the recovery. If the signal jumps unexpectedly, first consider a real environmental
change, sensor placement and connection quality. A filter should not conceal evidence needed to
diagnose the system.
For a demonstration indicator, use hysteresis: enter the high-temperature state at 30 °C and leave
it at 29 °C. Those thresholds are example configuration choices, not safety limits. The reference
firmware applies them to the filtered temperature and publishes an alarm field only while the
snapshot is fresh. A stale or unavailable measurement produces a null alarm, which means unknown,
not normal. There is no heater, fan, relay or other actuator in this design. A real controller would
need independent fault behavior and response-time requirements before it could make decisions from
this signal.
Temperature smoothingfiltered = 0.25 × raw + 0.75 × previous
Example hysteresis: high at 30 °C · normal at 29 °C · unknown when not fresh.
11
Test faults, recovery and timing
S
Student
Question 11
How can I demonstrate that this is more than a sensor displaying numbers? I need a convincing
validation section, including fault cases and evidence that the dashboard and firmware agree.
TEP AI
Project guide
Write the expected behavior before running each test. Begin with normal operation: record serial
snapshots, fetch the endpoint and compare the sample sequence and values. Then request the endpoint
faster than acquisition and verify that the sequence does not advance merely because requests
arrive. Measure sample spacing and request latency separately; the cooperative design does not imply
that either timing is perfectly constant.
Test sensor faults and network faults independently. For a disconnected-sensor test, power down
before changing the wiring, then restart and observe the unavailable state. For a failure after
valid operation, use a controlled test hook that substitutes an invalid sample, or a suitable
isolated test arrangement, rather than casually disturbing a powered breadboard. Check the first
failure, the thirty-second expiry and the first valid recovery. Restart the access point separately
and confirm that serial acquisition continues while browser requests fail.
Finally, exercise edge cases in software: non-finite values, humidity outside the accepted range,
repeated failed reads, the threshold deadband, and a millisecond-counter rollover near its maximum.
The table below is a set of expected outcomes, not a record of physical tests already performed. In
your report, add the firmware revision, configuration, observed output and pass/fail result for
every case. If the observed behavior differs, keep the evidence and explain the correction; a
documented fault investigation is stronger than a screenshot with an unexplained green label.
Validation plan · expected outcomes to verify on hardware
Test case
Expected behavior
Evidence to collect
First valid pair
Fresh; sequence advances
Serial + API agree
One failed acquisition
Stale; current values and alarm null
Failure count + lastGood age
No valid pair for > 30 s
Unavailable; history stays labeled
Expiry transition
Access point offline
Acquisition continues; browser shows connection loss
Serial continuity + browser state
Valid sample after expiry
Fresh; filter resets; failures clear
Recovery snapshot
Rapid repeated requests
No extra sensor acquisitions
Unchanged sequence between samples
12
Review the design and plan the next iteration
S
Student
Question 12
Can we finish with a clear explanation of what I have built, which parts I should demonstrate, and
which improvements are worth leaving for a later version? I want the report to tell the engineering
story, not just show screenshots.
TEP AI
Project guide
Your story starts with a requirement: deliver useful room measurements without disguising failures.
The hardware layer provides a defined sensor connection. The acquisition layer reads at a controlled
interval and validates the pair. The state layer distinguishes current information from history. The
network layer publishes that state without controlling acquisition, and the browser communicates
both sensor faults and connection loss. Each responsibility has a boundary that you can describe and
test.
Demonstrate the basic serial sketch first, then the extended reference firmware. Explain the GPIO
mismatch as a small debugging example, show why a failed measurement is not converted to zero, and
compare fresh, stale and unavailable snapshots. Describe smoothing as a presentation choice with
lag, and explain why an unknown alarm is different from a normal reading. Include your wiring
diagram, source files, configuration and a validation table filled with your own observations.
For the next iteration, choose one extension with a clear reason: persistent logging, a better
enclosure, a different sensor, authenticated remote access or a multi-device dashboard. Do not add
all of them at once. The design and source here form an educational reference, with illustrative
conversation and sample data; physical accuracy, network recovery and target-board compilation still
need verification in your environment. Once you record those results, you will have a project report
that explains both the successful path and the limits of the system, rather than simply asserting
that the monitor works.
A complete reference design, ready for your validation
Two firmware milestones, a defined data contract, a local dashboard and a fault-test plan. Add
your own hardware measurements to complete the engineering evidence.
Your idea can start here, too.
Use this example as a starting point for your own project.