By the end of this guide, you’ll have a working weather station sitting on your desk — or mounted anywhere in your home — that broadcasts live temperature and humidity readings over your Wi-Fi network. Open a browser on your phone, tablet, or laptop, type in an IP address, and you’ll see a clean dashboard updating in real time. No cloud account required. No subscription. No proprietary app. Just your hardware, your network, and a web page you built yourself.
This is a genuinely useful project, not just a tutorial exercise. A local weather station gives you indoor environmental data that commercial smart home sensors charge a premium for. It’s also the foundation for more ambitious builds: add more sensors, log to a database, send alerts when humidity spikes — the architecture scales with your ambitions.
What You’ll Build
The finished project consists of an ESP32 connected to a DHT22 temperature and humidity sensor. The ESP32 connects to your home Wi-Fi network and runs a lightweight web server. When you visit its IP address in a browser, it serves an HTML page showing current temperature, humidity, and a calculated heat index — refreshing automatically every 10 seconds. The dashboard works on any device with a browser: phone, laptop, tablet, or smart TV.
Optionally, you can extend the project to push readings to ThingSpeak every few minutes, giving you historical data and charts you can access from anywhere in the world.
Parts List
- ESP32 NodeMCU development board (CP2102, ESP-WROOM-32, Type-C)
- DHT22 sensor module (the module version already includes the pull-up resistor)
- Solderless breadboard
- Jumper wires
- USB-C cable (for power and programming)
- A computer with the Arduino IDE installed
If you’re using a bare DHT22 sensor (three or four pins, no module board) rather than the breakout module, you’ll also need a 10 kΩ pull-up resistor between the data pin and 3.3 V. The module version has this built in.
Hardware Wiring
The DHT22 module has three pins: VCC, DATA, and GND. The wiring is simple:
- DHT22 VCC → ESP32 3.3V
- DHT22 GND → ESP32 GND
- DHT22 DATA → ESP32 GPIO4
GPIO4 is a safe default — it’s not used by any internal ESP32 function and doesn’t have startup state issues. If you need to use a different pin, avoid GPIO0, GPIO2, GPIO12, and GPIO15 (these affect boot behaviour) and GPIO34–39 (input-only). GPIO4, 5, 13, 14, 16–19, 21–23, 25–27, and 32–33 are all clean choices for sensor data.
Place the ESP32 across the centre divide of your breadboard. Place the DHT22 module nearby and connect the three wires using jumper wires. That’s the entire hardware assembly — no soldering, no complex circuitry. The DHT22’s single-wire digital protocol handles everything else in software.
Library Setup
This project uses three libraries. Two are installed through the Arduino IDE Library Manager; one requires a manual installation from GitHub.
Step 1 — Add ESP32 Board Support
If you haven’t used an ESP32 with the Arduino IDE before, you need to add the board package first. Go to File → Preferences and add this URL to the “Additional Boards Manager URLs” field:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.jsonThen go to Tools → Board → Boards Manager, search for “esp32” by Espressif Systems, and install it. Once installed, select your board under Tools → Board → ESP32 Arduino → ESP32 Dev Module.
Step 2 — Install the DHT Library
Go to Sketch → Include Library → Manage Libraries. Search for DHT sensor library by Adafruit and install it. Also install Adafruit Unified Sensor when prompted — it’s a dependency.
Step 3 — Install ESPAsyncWebServer
ESPAsyncWebServer is not in the Library Manager. Download both required libraries from GitHub:
- ESPAsyncWebServer:
https://github.com/me-no-dev/ESPAsyncWebServer - AsyncTCP (dependency):
https://github.com/me-no-dev/AsyncTCP
For each: click the green “Code” button → “Download ZIP”. In the Arduino IDE, go to Sketch → Include Library → Add .ZIP Library and select the downloaded file. Repeat for both. Restart the IDE after installing.
ESPAsyncWebServer is worth the manual install. Unlike the basic ESP32 WebServer library, it handles multiple simultaneous connections without blocking your main loop — important when multiple devices on your network are hitting the dashboard at the same time.
Code Walkthrough — Reading the Sensor
Let’s build the sketch in logical sections. Start with your credentials and the sensor setup at the top of the file:
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <DHT.h>
// Wi-Fi credentials — replace with your network details
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// DHT22 sensor setup
#define DHT_PIN 4
#define DHT_TYPE DHT22
DHT dht(DHT_PIN, DHT_TYPE);
// Web server on port 80
AsyncWebServer server(80);
// Global variables to hold the latest readings
float temperature = 0.0;
float humidity = 0.0;
float heatIndex = 0.0;Next, a function to read the sensor and update the global variables:
void readSensor() {
float t = dht.readTemperature(); // Celsius by default
float h = dht.readHumidity();
// Check for failed readings
if (isnan(t) || isnan(h)) {
Serial.println("DHT22 read failed — check wiring");
return; // Keep the previous valid reading rather than overwriting with NaN
}
temperature = t;
humidity = h;
heatIndex = dht.computeHeatIndex(t, h, false); // false = Celsius
}
The DHT22 takes up to 2 seconds to complete a reading — it’s a slow sensor by design. Reading it more frequently than once every 2 seconds will fail; the library handles this gracefully by returning NaN. In this project, readings are updated every 10 seconds (driven by the auto-refresh in the HTML), so timing is never an issue.
The isnan() check is important. If the sensor is disconnected, unpowered, or the data line is floating, readTemperature() returns NaN (Not a Number). Without the check, NaN propagates into your dashboard and displays as garbage. With the check, the last valid reading is preserved.
Code Walkthrough — Serving the Web Page
The ESP32 serves an HTML page directly from its memory. Store the page as a string in PROGMEM to keep it out of precious SRAM:
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="10">
<title>ESP32 Weather Station</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 2rem; background: #f0f4f8; }
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px;
margin: 1rem auto; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.value { font-size: 3rem; font-weight: bold; color: #2563eb; }
.label { font-size: 1rem; color: #64748b; margin-top: 0.5rem; }
h1 { color: #1e293b; }
</style>
</head>
<body>
<h1>Weather Station</h1>
<div class="card">
<div class="value">%TEMPERATURE%°C</div>
<div class="label">Temperature</div>
</div>
<div class="card">
<div class="value">%HUMIDITY%%</div>
<div class="label">Humidity</div>
</div>
<div class="card">
<div class="value">%HEATINDEX%°C</div>
<div class="label">Heat Index</div>
</div>
<p style="color:#94a3b8;font-size:0.85rem">Auto-refreshes every 10 seconds</p>
</body>
</html>
)rawliteral";The placeholders %TEMPERATURE%, %HUMIDITY%, and %HEATINDEX% are replaced with live data when each request comes in. ESPAsyncWebServer handles this through a processor function:
String processor(const String& var) {
if (var == "TEMPERATURE") return String(temperature, 1); // 1 decimal place
if (var == "HUMIDITY") return String(humidity, 1);
if (var == "HEATINDEX") return String(heatIndex, 1);
return String(); // return empty string for unrecognised placeholders
}Now the setup() function — connecting to Wi-Fi and registering the route:
void setup() {
Serial.begin(115200);
dht.begin();
// Take an initial reading before the server starts
delay(2000); // DHT22 needs a moment after power-on
readSensor();
// Connect to Wi-Fi
Serial.print("Connecting to Wi-Fi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected. IP address: ");
Serial.println(WiFi.localIP());
// Register routes
server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", index_html, processor);
});
server.begin();
Serial.println("Web server started.");
}And the loop() — clean and minimal because ESPAsyncWebServer handles requests asynchronously:
void loop() {
// Read sensor every 10 seconds
static unsigned long lastRead = 0;
if (millis() - lastRead >= 10000) {
readRead();
lastRead = millis();
}
}Upload the sketch. Open the Serial Monitor at 115200 baud. After a few seconds you’ll see the IP address printed — something like 192.168.1.47. Type that address into any browser on the same network and your dashboard loads.
Auto-Refresh and the Complete Sketch
The auto-refresh is handled by a single HTML meta tag in the page:
<meta http-equiv="refresh" content="10">Every 10 seconds, the browser requests the page again. The ESP32 reads the current sensor values and sends a freshly generated page. It’s the simplest possible approach — no JavaScript, no WebSockets, no AJAX. The trade-off is a brief white flash as the page reloads. For a weather dashboard, this is perfectly acceptable.
If you want seamless updates without page reloads, you can add a second route that serves just the sensor data as JSON, and use JavaScript fetch() to update the values in place. That’s a worthwhile extension once the basic version is working.
Here is the complete sketch assembled for easy copy-paste:
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <DHT.h>
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
#define DHT_PIN 4
#define DHT_TYPE DHT22
DHT dht(DHT_PIN, DHT_TYPE);
AsyncWebServer server(80);
float temperature = 0.0;
float humidity = 0.0;
float heatIndex = 0.0;
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="10">
<title>ESP32 Weather Station</title>
<style>body{font-family:sans-serif;text-align:center;padding:2rem;background:#f0f4f8}
.card{background:white;border-radius:12px;padding:2rem;max-width:400px;margin:1rem auto;box-shadow:0 2px 8px rgba(0,0,0,.1)}
.value{font-size:3rem;font-weight:bold;color:#2563eb}.label{font-size:1rem;color:#64748b;margin-top:.5rem}
h1{color:#1e293b}</style></head><body>
<h1>Weather Station</h1>
<div class="card"><div class="value">%TEMPERATURE%°C</div><div class="label">Temperature</div></div>
<div class="card"><div class="value">%HUMIDITY%%</div><div class="label">Humidity</div></div>
<div class="card"><div class="value">%HEATINDEX%°C</div><div class="label">Heat Index</div></div>
<p style="color:#94a3b8;font-size:.85rem">Updates every 10 seconds</p>
</body></html>
)rawliteral";
String processor(const String& var) {
if (var == "TEMPERATURE") return String(temperature, 1);
if (var == "HUMIDITY") return String(humidity, 1);
if (var == "HEATINDEX") return String(heatIndex, 1);
return String();
}
void readSensor() {
float t = dht.readTemperature();
float h = dht.readHumidity();
if (isnan(t) || isnan(h)) { Serial.println("Sensor read failed"); return; }
temperature = t;
humidity = h;
heatIndex = dht.computeHeatIndex(t, h, false);
}
void setup() {
Serial.begin(115200);
dht.begin();
delay(2000);
readSensor();
WiFi.begin(ssid, password);
Serial.print("Connecting");
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.println("\nIP: " + WiFi.localIP().toString());
server.on("/", HTTP_GET, [](AsyncWebServerRequest* request){
request->send_P(200, "text/html", index_html, processor);
});
server.begin();
}
void loop() {
static unsigned long lastRead = 0;
if (millis() - lastRead >= 10000) { readSensor(); lastRead = millis(); }
}Optional: Pushing Data to ThingSpeak
ThingSpeak is a free IoT analytics platform that stores sensor data and generates charts you can access from anywhere. It takes about 10 minutes to add to this project.
Setup
- Create a free account at
thingspeak.com - Create a new Channel with two fields: Field 1 = Temperature, Field 2 = Humidity
- Copy your Channel’s Write API Key from the API Keys tab
Code Addition
ThingSpeak has a free interval limit of one update per 15 seconds. Add this to your sketch:
#include <HTTPClient.h>
const char* thingspeakKey = "YOUR_API_KEY_HERE";
const char* thingspeakURL = "http://api.thingspeak.com/update";
void postToThingSpeak() {
if (WiFi.status() != WL_CONNECTED) return;
HTTPClient http;
String url = String(thingspeakURL) +
"?api_key=" + thingspeakKey +
"&field1=" + String(temperature, 1) +
"&field2=" + String(humidity, 1);
http.begin(url);
int responseCode = http.GET();
if (responseCode > 0) {
Serial.println("ThingSpeak update OK: " + String(responseCode));
} else {
Serial.println("ThingSpeak error: " + String(responseCode));
}
http.end();
}In loop(), call postToThingSpeak() on a separate timer — every 60 seconds is sensible and stays well within rate limits:
void loop() {
static unsigned long lastRead = 0;
static unsigned long lastPost = 0;
if (millis() - lastRead >= 10000) {
readSensor();
lastRead = millis();
}
if (millis() - lastPost >= 60000) {
postToThingSpeak();
lastPost = millis();
}
}Once data is flowing, ThingSpeak’s channel page displays live charts. You can embed these charts in other web pages, set up alerts via ThingSpeak Actions, or pull the data into Home Assistant or Node-RED for further automation.
Troubleshooting
Can’t see the IP address in Serial Monitor
Make sure the Serial Monitor baud rate is set to 115200. If you see garbled characters, the baud rate is wrong. If you see dots printing indefinitely, the ESP32 can’t connect to Wi-Fi — double-check that ssid and password are correct, that your network is 2.4 GHz (the ESP32 does not support 5 GHz), and that your router isn’t blocking new devices.
Dashboard shows but temperature reads NaN or 0.0
The DHT22 read failed. Check that DATA is connected to GPIO4, that the module is powered from 3.3 V (not 5 V — though many modules tolerate 5 V, 3.3 V is safer on the ESP32), and that you’re using the module version (with built-in pull-up) not a bare sensor without one. Also confirm the Adafruit DHT library is installed correctly by running the library’s built-in example first.
Page not loading from other devices
Confirm the device you’re browsing from is on the same Wi-Fi network as the ESP32. Type http:// before the IP address — some browsers default to HTTPS and will fail to connect to a plain HTTP server. The address should look like http://192.168.1.47 not 192.168.1.47 alone.
ESP32 not recognised by Arduino IDE (no COM port appearing)
The CP2102 USB-to-serial chip requires a driver. Download and install the CP210x driver from Silicon Labs’ website. On Windows, Device Manager will show an unknown device under Ports until the driver is installed. On macOS Ventura and later, the driver may need explicit approval in System Settings → Privacy & Security.
ESPAsyncWebServer won’t compile
This usually means AsyncTCP is missing. Both libraries must be installed: ESPAsyncWebServer and AsyncTCP. If you installed only one, add the other via Sketch → Include Library → Add .ZIP Library and restart the IDE.
What to Build Next
This project is a foundation, not a ceiling. A few natural next steps:
- Add a BMP280 or BME280: These I2C sensors add barometric pressure and altitude to your dashboard. The BME280 also includes humidity, so you could cross-reference readings between two sensors for validation.
- Add a small OLED display: A 0.96″ I2C OLED gives you a local readout without needing a browser. Temperature on line one, humidity on line two — always visible without connecting to anything.
- Upgrade the dashboard to use AJAX: Replace the meta-refresh with a JavaScript
fetch()call hitting a/dataendpoint that returns JSON. The dashboard updates in place without the page flash, and you can add a live graph using Chart.js loaded from a CDN. - Log to an SD card: Add a micro-SD module and write timestamped readings to a CSV. At one reading per minute, a 1 GB card stores years of data.
- Integrate with Home Assistant: Expose your sensor data as MQTT topics or as a REST API endpoint. Home Assistant’s auto-discovery can pick up ESP32 devices running ESPHome firmware, which is worth exploring once you’re comfortable with the basics.
You now have a working, network-accessible weather station built from an ESP32 and a DHT22 — two components, a breadboard, and some jumper wires. The architecture — sensor reading, web server, live data delivery — applies to dozens of other ESP32 projects. Once you understand how the pieces fit together here, the next build will come together much faster.
