Learning Goals 5 min
Yesterday your web server returned static text. Today it serves a live sensor — every page refresh shows the current reading, and a small embedded chart updates in real time without you reloading. By the end of this lesson you will:
- Wire any analog sensor (LDR / pot / TMP36) to your ESP's A0 and read it from a handler.
- Return the reading as JSON via a
/data.jsonendpoint and embed a JavaScript chart that polls it every second. - Compare two delivery patterns: pull (browser polls JSON) vs push (Server-Sent Events / WebSocket) and explain when each fits.
Warm-Up 10 min
Build on yesterday's sketch. Add the sensor wiring:
| Component | ESP pin |
|---|---|
| Pot wiper (or LDR voltage divider out) | A0 |
| 3.3 V rail | top of divider / pot |
| GND | bottom of divider / pot |
Important on ESP8266: the A0 input is rated for 0–1.0 V, NOT 0–3.3 V. NodeMCU boards add an on-board voltage divider so A0 accepts 0–3.3 V. If you have a bare ESP-12 module, you need an external divider. Check your board.
Recall yesterday's pattern
Your server returns text/html for browsers and application/json for APIs. Today's page does both — an HTML shell that loads via the browser, then a JavaScript poller inside the page calls the JSON endpoint.
New Concept · Live data in the browser 25 min
Pull vs push — two patterns
| Pattern | How | Latency | Server load |
|---|---|---|---|
| Polling (pull) | JS calls fetch('/data.json') every N seconds | ~N seconds | One request per client per N seconds |
| Server-Sent Events (push) | Server holds open an HTTP connection and writes new data when it changes | ~milliseconds | One open connection per client |
| WebSocket (push, full-duplex) | Long-lived bidirectional connection | ~milliseconds | One persistent socket per client |
For a school project, polling every 1–2 seconds is by far the simplest and works everywhere. WebSocket is more efficient and lower latency but the library is a third-party add-on. We'll use polling today.
The JSON endpoint
server.on("/data.json", []() {
int raw = analogRead(A0);
String json = "{\"raw\":";
json += raw;
json += ",\"uptime\":";
json += (millis() / 1000);
json += "}";
server.send(200, "application/json", json);
});Two fields: the raw sensor reading and the device uptime in seconds. The JS will use both.
The HTML page with polling
const char index_html[] PROGMEM = R"HTML(
<!doctype html>
<html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP Live Sensor</title>
<style>
body { font-family: sans-serif;
padding: 1rem;
}
.big { font-size: 3rem;
font-weight: 700;
}
.bar { width: 100%;
height: 30px;
background: #eee;
border-radius: 4px;
}
.fill{ height: 100%;
background: #4ade80;
border-radius: 4px;
transition: width .3s ease;
}
</style>
</head><body>
<h1>ESP Live Sensor</h1>
<p class="big" id="val">--</p>
<div class="bar"><div class="fill" id="bar" style="width: 0"></div></div>
<p>Uptime: <span id="up">--</span> s</p>
<script>
async function tick() {
try {
const r = await fetch('/data.json');
const j = await r.json();
document.getElementById('val').textContent = j.raw;
document.getElementById('bar').style.width = (j.raw / 1023 * 100) + '%';
document.getElementById('up').textContent = j.uptime;
} catch (e) {
document.getElementById('val').textContent = '(offline)';
}
}
setInterval(tick, 1000);
tick();
</script>
</body></html>
)HTML";Three pieces: the static HTML shell, some CSS for a simple bar gauge, and the JS that polls /data.json every second and updates the page.
Serve it
server.on("/", []() {
server.send_P(200, "text/html", index_html);
});send_P reads the HTML from PROGMEM — important on ESP8266 because the page would otherwise eat RAM.
What the browser does
- You open
http://<esp-IP>/. ESP returns the HTML. - Browser parses it, runs the JS.
- JS calls
fetch('/data.json')immediately (thetick()at the bottom), then every 1000 ms. - Each call hits the ESP's
/data.jsonhandler, which reads A0 and returns JSON. - JS updates the big number, the bar width, and the uptime.
Watching: turn the pot or shine a torch on the LDR — the number and bar respond live.
Worked Example · Live LDR dashboard 25 min
Step 1 — wire an LDR voltage divider
A0 tops out at about 1 V, not 5 V, and most NodeMCU boards have a divider fitted so the pin header reads 0–3.3 V. Check yours before you feed it the full rail.LDR + 10 kΩ resistor in a divider; midpoint → A0. (L01-36 / L02-15 wiring.)
Step 2 — the full sketch
// L03-33 · ESP live sensor over WiFi
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#elif defined(ESP32)
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#endif
const char* SSID = "YourNetwork";
const char* PASSWORD = "YourPassword";
const char index_html[] PROGMEM = R"HTML(
<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP Live LDR</title>
<style>
body { font-family: sans-serif;
max-width: 480px;
margin: 0 auto;
padding: 1rem;
}
.big { font-size: 3.5rem;
font-weight: 700;
color: #4f46e5;
margin: .5rem 0;
}
.bar { width: 100%;
height: 28px;
background: #f1f5f9;
border-radius: 4px;
overflow: hidden;
}
.fill{ height: 100%;
background: linear-gradient(90deg,#fbbf24,#ef4444);
transition: width .3s ease;
}
.meta{ color: #64748b;
font-size: .9rem;
margin-top: .5rem;
}
</style></head><body>
<h1>ESP Live LDR</h1>
<p>Brightness reading:</p>
<p class="big" id="val">--</p>
<div class="bar"><div class="fill" id="bar" style="width:0"></div></div>
<p class="meta">Uptime: <span id="up">--</span> s ·
polled every 1 s</p>
<script>
async function tick() {
try {
const r = await fetch('/data.json?t=' + Date.now());
const j = await r.json();
document.getElementById('val').textContent = j.raw;
document.getElementById('bar').style.width = (j.raw / 1023 * 100) + '%';
document.getElementById('up').textContent = j.uptime;
} catch (e) {
document.getElementById('val').textContent = '(offline)';
}
}
setInterval(tick, 1000);
tick();
</script>
</body></html>
)HTML";
void handleRoot() {
server.send_P(200, "text/html", index_html);
}
void handleData() {
int raw = analogRead(A0);
String json = "{\"raw\":";
json += raw;
json += ",\"uptime\":";
json += (millis() / 1000);
json += "}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) { delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("# Open http://");
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/data.json", handleData);
server.begin();
}
void loop() {
server.handleClient();
}Step 3 — upload and open in a browser
Visit http://<esp-IP>/. Big number with a coloured bar updates every second. Cover the LDR with your hand → number drops, bar shrinks. Shine a torch → number rises, bar fills.
Step 4 — open in two browsers simultaneously
Open the page on your laptop AND your phone (both on the same WiFi). Both poll independently; both show the same live data. This is a real multi-client web app — running on a tiny, cheap chip.
Step 5 — add a tiny history chart
Modify the JS to keep the last 60 readings in a JS array and draw them as a sparkline. Snippet to add inside the <script> block (before setInterval):
// inside <script>
const history = [];
async function tick() {
const r = await fetch('/data.json?t=' + Date.now());
const j = await r.json();
history.push(j.raw);
if (history.length > 60) history.shift();
// ... existing val/bar/up updates ...
const c = document.getElementById('chart').getContext('2d');
c.clearRect(0, 0, 480, 80);
c.beginPath();
for (let i = 0; i < history.length; i++) {
const x = i * (480 / 60);
const y = 80 - (history[i] / 1023 * 80);
if (i === 0) c.moveTo(x, y);
else c.lineTo(x, y);
}
c.strokeStyle = '#4f46e5';
c.lineWidth = 2;
c.stroke();
}Add <canvas id="chart" width="480" height="80" style="border:1px solid #e5e7eb"></canvas> to the HTML. Now you have a rolling 60-second history chart — your first real-time IoT dashboard.
Step 6 — measure end-to-end latency
In the browser DevTools' Network tab, click one of the /data.json requests. The "Timing" tab shows DNS, connect, request, response. Total round-trip from a same-LAN ESP is typically 5–50 ms. Compared to a typical cloud API (~150 ms), your local ESP feels instant.
Basic 5 min
Goal: Map the raw 0..1023 to a friendly percentage. Show "Brightness: 47%" instead of "raw: 482".
Challenge 1 5 min
Goal: Add a TMP36 (L02-13) as a second sensor on A1 (ESP32) — or replace the LDR if you only have one ADC pin (ESP8266). Display both readings on the page.
Challenge 2 5 min
Goal: Add Server-Sent Events for true real-time push. Replace the polling with an EventSource. ESP8266 supports this via the ESPAsyncWebServer library.
Challenge 3 · The dashboard that says offline 10 min
A classmate made a cut-down dashboard. The ESP connects and the page loads. But the number only ever shows (offline).
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
const char* SSID = "YourNetwork";
const char* PASSWORD = "YourPassword";
const char index_html[] PROGMEM = R"HTML(
<!doctype html><html><body>
<h1>LDR</h1>
<p id="val">--</p>
<script>
async function tick() {
try {
const r = await fetch('/data.json');
const j = await r.json();
document.getElementById('val').textContent = j.raw;
} catch (e) {
document.getElementById('val').textContent = '(offline)';
}
}
setInterval(tick, 1000);
</script>
</body></html>
)HTML";
void handleRoot() {
server.send_P(200, "text/html", index_html);
}
void handleData() {
int raw = analogRead(A0);
String json = "{\"raw\":";
json += raw;
json += "\"uptime\":";
json += (millis() / 1000);
json += "}";
server.send(200, "application/json", json);
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
}
void loop() {
server.handleClient();
}- Visit
/data.jsonby hand in the browser. What comes back, and why? - Fix that, then visit it again. Write down the exact text the ESP sends.
- Find the second bug in that text. Explain why
r.json()throws. - Fix both bugs, upload, and check the live number.
It works if /data.json shows valid JSON such as {"raw":482,"uptime":37}, and the page number changes when you cover the LDR.
Recap 5 min
Live data over WiFi = JSON endpoint on the ESP + JavaScript poller in the browser. Polling every 1 s is the simplest pattern and works everywhere. Server-Sent Events / WebSocket give millisecond latency at the cost of an extra library. The architecture you built is the same as commercial IoT dashboards — just with a smaller chip and a single-user LAN audience. Tomorrow you flip the direction once more for the WiFi-controlled lamp project — POST commands from a browser into the ESP.
- Polling
- Pattern where the client repeatedly asks the server "anything new?". Simple, robust, higher latency than push.
- Server-Sent Events (SSE)
- An HTTP-based push protocol — server holds an open response and writes new events when they happen. JS side:
EventSource. - WebSocket
- A persistent full-duplex protocol upgraded from an HTTP connection. Bidirectional, lowest latency. Heavier than SSE.
fetch()- The modern JS API for making HTTP requests. Returns a Promise. Use
.then(r => r.json())to parse JSON responses. setInterval- JavaScript's "run this function every N ms" primitive. Used here to poll the JSON endpoint.
- Cache-buster
- Adding a unique query parameter (
?t=12345) to force the browser to re-fetch instead of using cached data. - Sparkline
- A tiny line chart embedded in a page. Used here to show recent sensor history.
- End-to-end latency
- Time from sensor change to UI update. Polling latency = poll interval. SSE/WebSocket latency = milliseconds.
Extra Mission 5 min
Part 1 — Design a home sensor dashboard
Pick something at home worth watching from a phone: how bright a reading corner is, how warm a bedroom gets, whether a fridge door was left open. On paper, design the dashboard page for it.
Your design must include:
- The sensor you will use, its pin, and its wiring.
- The value shown on the page, in friendly units (%, °C or "open/closed").
- Colours that match what you measure, for example blue to red for cold to hot.
- At least one warning level, with a number, that changes the page colour.
- A sketch of the page layout on a phone screen.
Part 2 — Make it
Build your dashboard on the ESP. Serve the page from PROGMEM, poll a JSON endpoint every second, and make the warning colour appear when your level is crossed.
Bring back next class: your design, your uploaded sketch, and a phone screenshot of the page showing the warning colour.