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
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 $5 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.
Try It Yourself 15 min
Goal: Map the raw 0..1023 to a friendly percentage. Show "Brightness: 47%" instead of "raw: 482".
Hint
int pct = map(raw, 0, 1023, 0, 100);
json += ",\"pct\":";
json += pct;Update the JS to use j.pct.
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.
Hint
For ESP8266, you only have one ADC. So either multiplex (read different sensors at different times via an analog multiplexer chip) or use a digital sensor like DHT11 (with the DHT library).
Goal: Add Server-Sent Events for true real-time push. Replace the polling with an EventSource. ESP8266 supports this via the ESPAsyncWebServer library.
Hint
Install ESPAsyncWebServer + ESPAsyncTCP (ESP8266) or AsyncTCP (ESP32) from the Library Manager. The server side becomes:
AsyncEventSource events("/events");
// in setup: server.addHandler(&events);
// periodically: events.send(String(raw).c_str(), "sensor", millis());JS side: const e = new EventSource('/events'); e.addEventListener('sensor', ev => ...);. Lower latency, less overhead than polling.
Mini-Challenge · Make the dashboard pretty 10 min
- Pick colours that match what you're measuring (red→blue for cold→hot temp, yellow→blue for low→high light).
- Add the device name and uptime as a footer.
- Make it work on mobile — viewport meta + min-font-size 16 px so iOS doesn't zoom in on inputs.
- Screenshot from your phone with the page open. Frame for sharing.
A polished dashboard for a 5 V LDR is the exact same architecture as a real IoT product's dashboard. You've built a real one.
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.
Homework 5 min
- Save your dashboard sketch as
esp-live-sensor.ino. - Take a screenshot from your phone showing the live page with a real sensor reading.
- Read ahead to ARD-L03-34 (WiFi-Controlled Lamp). Tomorrow we build the "real product": a lamp toggled from any phone on your home network.
- Bring tomorrow: an LED + relay (if you have one), or just an LED for safety. We'll talk about scaling up to mains in the lesson but not actually wire mains in class.
Bring back next class:
- Saved dashboard sketch.
- Phone screenshot.
- LED + relay module if available.