A room monitor that serves its own dashboard: NodeMCU, DHT11 and no cloud account
- Build Logs
- 29 Jul, 2026
Most "IoT" tutorials start by asking you to make an account somewhere. Then the free tier changes, or the service shuts down, and your project is landfill. This one has no account, no broker and no cloud: the NodeMCU serves its own dashboard over your WiFi, and it keeps working in ten years as long as the router does.
The build reads temperature and humidity, shows them on a small OLED, and serves a live web page to anything on the same network. Total cost is under ₹700.
What you will need
| Part | Qty | Approx. |
|---|---|---|
| NodeMCU ESP8266 (CP2102) | 1 | ₹300 |
| DHT11 Temperature & Humidity Module | 1 | ₹94 |
| 0.91" 128×32 I2C OLED | 1 | ₹284 |
Roughly ₹678. Plus a breadboard and jumper wires if you are not soldering.
We have specified the CP2102 NodeMCU deliberately. The cheaper CH340 variant works, but the CP2102 driver is already present on current macOS and Windows, which removes the single most common "my board does not appear as a COM port" problem before it happens.
Wiring
| From | To | Note |
|---|---|---|
| DHT11 VCC | NodeMCU 3V3 | |
| DHT11 GND | NodeMCU GND | |
| DHT11 DATA | NodeMCU D4 (GPIO2) | |
| OLED VCC | NodeMCU 3V3 | |
| OLED GND | NodeMCU GND | |
| OLED SDA | NodeMCU D2 (GPIO4) | I2C |
| OLED SCL | NodeMCU D1 (GPIO5) | I2C |
The pin labels lie. The silkscreen says D1, D2, D4; the chip wants GPIO numbers, and they do not match. D1 is GPIO5, D2 is GPIO4, D4 is GPIO2. The Arduino core defines D1, D2 and friends as constants so you can use the printed names — but the moment you copy a snippet written for a bare ESP-12 that says pin 4, you will be wiring to the wrong header. Pick one convention and stay with it.
Avoid D3, D4 and D8 for anything that pulls hard at boot: they are strapping pins that decide whether the chip enters flash mode. D4 is fine for the DHT11 because the sensor idles high, but hanging something that pulls it low at power-on will stop the board booting.
The DHT11 is slower than you think
The DHT11 is a cheap sensor with real limits worth knowing before you design around it: roughly ±2 °C and ±5% RH accuracy, 1 °C resolution, and — the one that catches people — it cannot be read faster than about once per second. Poll it in a tight loop and you get nan back, then conclude the sensor is faulty.
It is genuinely fine for "is the server room getting hot". It is not fine for anything needing precision; step up to a DHT22 or a BME280 for that.
The sketch
Install Adafruit Unified Sensor, DHT sensor library and Adafruit SSD1306 from the Library Manager first.
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>
const char* SSID = "your-network";
const char* PASS = "your-password";
#define DHTPIN D4
#define DHTTYPE DHT11
DHT dht(DHTPIN, DHTTYPE);
Adafruit_SSD1306 oled(128, 32, &Wire, -1);
ESP8266WebServer server(80);
float tempC = NAN, humidity = NAN;
unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 2000; // DHT11 needs >1s
void handleRoot() {
String html = F(
"<!doctype html><html><head>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Room Monitor</title>"
"<style>body{font-family:system-ui,sans-serif;background:#14171c;color:#f4f1ea;"
"margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}"
".w{text-align:center}.v{font-size:64px;font-weight:800;color:#21a0ff}"
".l{font-size:13px;letter-spacing:2px;text-transform:uppercase;opacity:.6}"
"</style></head><body><div class='w'>");
html += "<p class='l'>Temperature</p><p class='v'>";
html += isnan(tempC) ? "--" : String(tempC, 1);
html += "°C</p><p class='l'>Humidity</p><p class='v'>";
html += isnan(humidity) ? "--" : String(humidity, 0);
html += "%</p></div>"
"<script>setTimeout(function(){location.reload()},5000)</script>"
"</body></html>";
server.send(200, "text/html", html);
}
// A machine-readable endpoint, so this can feed something else later.
void handleJson() {
String j = "{\"temp_c\":";
j += isnan(tempC) ? "null" : String(tempC, 1);
j += ",\"humidity\":";
j += isnan(humidity) ? "null" : String(humidity, 0);
j += "}";
server.send(200, "application/json", j);
}
void setup() {
Serial.begin(115200);
dht.begin();
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("No OLED at 0x3C - try 0x3D"));
}
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print('.'); }
Serial.println();
Serial.print(F("Open http://"));
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/api", handleJson);
server.begin();
}
void loop() {
server.handleClient();
// Non-blocking read. delay() here would stall the web server.
if (millis() - lastRead >= READ_INTERVAL) {
lastRead = millis();
float t = dht.readTemperature();
float h = dht.readHumidity();
if (!isnan(t)) tempC = t; // keep the last good value
if (!isnan(h)) humidity = h;
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print(WiFi.localIP());
oled.setTextSize(2);
oled.setCursor(0, 14);
oled.print(isnan(tempC) ? "--" : String(tempC, 1));
oled.print("C ");
oled.print(isnan(humidity) ? "--" : String(humidity, 0));
oled.print("%");
oled.display();
}
}
Why the loop looks like that
Two decisions in loop() are the whole difference between this working and this being unreliable.
No delay(). The obvious way to read a sensor every two seconds is delay(2000). Do that and the web server is deaf for two seconds out of every two — page loads hang, and on the ESP8266 long blocking delays can starve the WiFi stack badly enough to reset the chip. The millis() comparison does the same job while leaving server.handleClient() free to run thousands of times a second.
Last-good-value caching. The DHT11 returns nan reasonably often — a missed timing window, a bit of electrical noise. Rendering that straight to the page makes the dashboard flicker between a reading and a dash. Keeping the previous good value means one dropped read is invisible.
Finding it on the network
The IP is printed to serial and shown on the OLED — which is the main reason the OLED earns its place. Your router will eventually change the address over DHCP, so for anything permanent either reserve a static lease in the router, or add mDNS:
#include <ESP8266mDNS.h>
// after WiFi connects:
MDNS.begin("roommon"); // then browse to http://roommon.local
// and in loop(): MDNS.update();
Things that go wrong
OLED stays blank. Wrong I2C address. These modules ship as 0x3C or 0x3D depending on batch. Run an I2C scanner sketch and use what it finds.
Readings are always nan. Polling faster than once a second, or the DHT11 data pin has no pull-up. Most modules include one; a bare three-pin sensor does not, and needs a 10k resistor from DATA to 3V3.
Board will not appear as a serial port. Missing USB-serial driver, or — surprisingly often — a charge-only USB cable with no data lines. Try a different cable before reinstalling anything.
Connects to WiFi, then reboots every few seconds. Usually power. The ESP8266 pulls short 300 mA bursts when transmitting; a weak USB port or thin cable browns it out. It is also worth checking you are on a 2.4 GHz network — the ESP8266 cannot see 5 GHz at all, and a modern router presenting one merged SSID hides this from you completely.
Page loads slowly or times out. Something blocking in loop(). Look for a stray delay().
Where to take it next
The /api endpoint is the interesting one. It makes this a data source rather than a gadget — poll it from a Raspberry Pi and log a month of readings, or graph it in Home Assistant. Add a relay and a threshold and you have thermostat control of a fan; the relay wiring and the flyback-diode warning from our RFID lock build apply exactly.
Tell us what you are building if you want a second opinion on the design before you order parts.