ESP32 Part 3 - Advanced

E17. Capstone: Networked Room Node

Put it together: a web page shows a live room level from a pot, an alert LED lights above a threshold, and /json serves the same data to programs.

Estimated time 4-5 hours

Learning outcomes

  • Combine Wi-Fi, analogRead and a web server in one sketch
  • Explain the three jobs loop() does: read, decide, answer
  • Show a live reading on a web page that refreshes itself
  • Light an alert LED when the reading passes a threshold
  • Serve the same data as JSON at /json
  • Build and test one block at a time, and say whether a fault is wiring, Wi-Fi or code

Parts and preparation

ESP32 DevKit, USB data cable, 10 k ohm potentiometer on GPIO 34 (stand-in for a room sensor), LED + series resistor on GPIO 2 (alert lamp), breadboard and jumpers, a phone or PC on the same Wi-Fi. Library: ArduinoJson by Benoit Blanchon (from ESP32 Lesson 10).

Before power: inspect wiring, confirm supply voltage and ensure all connected circuits share GND.

Libraries for this lesson

In Arduino IDE 2 open Tools → Manage Libraries…. Search by the Library Manager name and install the package by the exact author below. Similar names from other authors can use different APIs and will break the example.

IncludeLibrary Manager nameAuthorInstall note
WiFi.hWiFiEspressifBuilt into the esp32 Arduino core.
WebServer.hWebServerEspressifBuilt into the esp32 Arduino core.
ArduinoJson.hArduinoJsonBenoit BlanchonAlready installed in ESP32 Lesson 10.

What you will build

You are building a room monitor for your bench. A device like this is called a node: a small board that measures something and shares it over the network.

A knob (a potentiometer, or pot) stands in for a real room sensor, such as a light sensor. Turn it and the room level changes. When the level goes above a limit you choose - the threshold - a red alert LED lights. Anyone on the same Wi-Fi can open the node's web page on a phone and watch the level, and other programs can read the same numbers from /json.

Every piece is something you have done before: analogRead (ESP32 Lesson 04), a web page (ESP32 Lesson 09) and JSON (ESP32 Lesson 10). The new skill is making them work together in one sketch, and testing them one block at a time so you always know where a problem is.

The room node at a glance: a pot on GPIO 34 feeds the ESP32, which reads the level, decides the alert and answers browsers; the alert LED on GPIO 2 lights above 2000; through the Wi-Fi router a phone shows the Lab room page and a program reads the JSON from /json
Read a level, decide an alert, answer the browser.

The three jobs in loop()

The whole sketch comes down to three jobs that loop() does again and again:

1. Read: level = analogRead(potPin) measures the knob as a number from 0 to 4095.

2. Decide: if the level is above the threshold, switch the alert LED on and remember alertOn = true; otherwise switch it off and remember false.

3. Answer: server.handleClient() checks whether a browser is asking for something. If one is, it runs sendPage() or sendJson(). If nobody is asking, it comes straight back.

One pass takes about a millisecond, so loop() runs hundreds of times every second. That is why there is no delay() in loop(): a delay(2000) would freeze all three jobs, so the LED would lag behind the knob and a browser could wait 2 seconds for an answer.

setup() does the one-time work first: it makes the LED pin an output, joins the Wi-Fi, prints the address and tells the web server which function answers which path.

loop() cycles through three jobs: read the pot with analogRead, decide whether the level is above the threshold and switch the LED, then answer any browser with server.handleClient; a side card shows that without delay the browser is answered at once, while delay(2000) makes it wait up to 2 seconds
Read, decide, answer - then round again. No delay() anywhere.

Pins: why GPIO 34 and 3V3

The ESP32 has two sets of analog input pins, called ADC1 and ADC2. The Wi-Fi radio borrows ADC2 while it is on, so analogRead on an ADC2 pin stops working as soon as the node joins your network. GPIO 34 is an ADC1 pin, so it keeps reading with Wi-Fi on. It is also input only - perfect for a sensor, but it cannot drive an LED and has no built-in pull-up.

Power the pot from 3V3, never 5V. The middle leg can reach whatever voltage the outer legs have, and an ESP32 pin must never see more than 3.3 V.

The alert LED is on GPIO 2 with a 220-330 ohm resistor in series, which keeps the current to a few milliamps. On many DevKits GPIO 2 also drives a small on-board LED, so you may see two lights together - that is fine.

Wiring: pot ends to 3V3 and GND with the middle leg to GPIO 34; GPIO 2 through a 220-330 ohm resistor to the LED long leg, short leg to GND; notes say GPIO 34 is an ADC1 input that keeps reading with Wi-Fi on, ADC2 pins stop reading when Wi-Fi is on, and the pot must use 3V3, never 5 V
GPIO 34 keeps reading with Wi-Fi on. Everything runs from 3V3.
PartWiringWhy
Pot outer legs3V3 and GNDPins must stay at or below 3.3 V
Pot middle legGPIO 34ADC1: still reads while Wi-Fi is on
Alert LEDGPIO 2 -> 220-330 ohm -> LED -> GNDThe resistor limits the current

From knob to number

The pot is a voltage divider. Its middle leg gives 0 V at one end of the turn, 3.3 V at the other, and about 1.65 V in the middle.

The ESP32's ADC (analog-to-digital converter) measures that voltage and turns it into a whole number. It is 12-bit, which means 4096 steps: 0 for 0 V up to 4095 for the top of its range. As a rough guide, level = volts / 3.3 x 4095, so the middle of the knob reads about 2048.

The ESP32's ADC is not perfect. It reads 0 for the first tenth of a volt or so, and stops at 4095 a little before 3.3 V. The number also wobbles by a few counts even when you do not touch the knob. None of that matters here: you only need to know whether the level is above or below the threshold.

The pot divides 3.3 V: 0 V at one end, about 1.65 V in the middle, 3.3 V at the other; the 12-bit ADC turns 0 to 3.3 V into 0 to 4095, roughly level = volts / 3.3 x 4095; a graph shows the real ESP32 reading flat at 0 below about 0.1 V and flat at 4095 above about 3.1 V, with the threshold 2000 marked
Volts in, a number from 0 to 4095 out.

The threshold decides the alert

The threshold is the line between "all fine" and "alert". The sketch starts at 2000, roughly half way round the knob:

Level above 2000: alertOn becomes true and the LED comes on.

Level 2000 or below: alertOn becomes false and the LED goes off.

alertOn is a bool, a variable that is only ever true or false. The page and the JSON both read it, so they always agree with the LED.

With a real sensor you would choose the threshold from readings: watch the level on Serial in normal conditions and in alert conditions, then pick a number between them. If you hold the knob right on the line, the LED may flicker because the reading wobbles across it. That is normal for this simple version; a fix is one of the extras at the end.

A graph of the level over time as the knob is turned up and back down crosses the dashed threshold line at 2000; the alert LED strip underneath is ON only while the level is above the line; an inset shows a wobbly reading near the line making the LED flicker, and an optional fix that turns on above 2100 and off below 1900
Above the line: alert on. On or below it: alert off.
if (level > threshold) {                  // above the threshold?
  alertOn = true;                         // remember the alert is on
  digitalWrite(alertPin, HIGH);           // alert LED on
} else {                                  // at or below the threshold
  alertOn = false;                        // remember the alert is off
  digitalWrite(alertPin, LOW);            // alert LED off
}

How your phone finds the ESP32

The node uses station mode: like your phone, it joins your existing Wi-Fi with the network name (ssid) and password. When it joins, the router gives it an address on that network, such as 192.168.1.42, and the sketch prints it on Serial.

To open the page, type that address into a browser on a phone or PC on the same Wi-Fi. A web address has three parts: http:// says "speak web", 192.168.1.42 says which device, and the path, such as / or /json, says which page. The web server listens on port 80, the normal web door, so you never need to type the port.

The address is local: it only works for devices on your Wi-Fi, not from mobile data or across the internet. That keeps a bench project private. It also means anyone on the same Wi-Fi can open the page, so never put passwords or private data on it.

Inside your Wi-Fi network the router hands out addresses; the ESP32 joins in station mode and gets 192.168.1.42; a phone and laptop on the same Wi-Fi can reach it, but the internet cannot; the address http://192.168.1.42/json is split into http:// (speak web), 192.168.1.42 (which device, copied from Serial) and /json (which page)
Same Wi-Fi, exact address from Serial, then the path.

One request, start to finish

When you open http://192.168.1.42/json, this is what happens:

1. The browser sends a request: GET /json, which means "please send me the page at /json".

2. On its next pass of loop(), server.handleClient() notices the request.

3. The server looks up /json in the list you made with server.on() in setup(), and finds sendJson().

4. sendJson() builds the JSON text from the latest level and alertOn.

5. server.send(200, "application/json", body) sends it back. 200 means "OK", the middle part says what type of text it is, and body is the text itself.

6. The browser shows the JSON.

A path that is not in the list, such as /hello, gets the reply 404 Not found. That is a quick way to spot a typing mistake in an address.

Six steps between the browser and the ESP32: the browser sends GET /json; handleClient in loop notices it; the server.on list matches /json to sendJson (/ goes to sendPage, anything else gets 404 Not found); sendJson builds the reply; server.send(200, application/json, body) sends it; the browser shows the JSON
The browser asks, handleClient notices, server.on picks the function, server.send replies.
PathFunctionReply
/sendPage()The web page for people (text/html)
/jsonsendJson()The data for programs (application/json)
anything else(none)404 Not found

A page that refreshes itself

The page starts with <meta http-equiv='refresh' content='2'>. This line tells the browser to ask for the page again every 2 seconds, so the level stays live without you pressing refresh.

Each reply is a snapshot: sendPage() builds a brand-new page from the level at that moment. Between snapshots the page does not change, so it can be up to 2 seconds behind the knob - but the LED reacts at once, because loop() is checking all the time.

Why not refresh faster? Every reload is a new request for the ESP32 to answer. Every 2 seconds is plenty for a room level and keeps the node relaxed.

A timeline: every 2 seconds the browser sends GET / and the ESP32 replies with a snapshot page - level 1200 alert OFF, 1850 OFF, 2400 ON, 2650 ON - while the real level rises smoothly past the threshold 2000; the meta refresh content='2' sets the seconds between reloads
Every 2 seconds the browser asks again and gets a fresh snapshot.

Building the page from text

A web page is just text written in HTML. sendPage() builds it in a String called page, one piece at a time: page = starts it, and each page += adds more on the end.

Three HTML tags do all the work: <h1> makes a big heading, <p> makes a paragraph, and <a href='/json'> makes a link to /json. Each one is closed with a matching </h1>, </p> or </a>.

page += level adds the number as text, so 2650 becomes the characters 2650. Inside the HTML the sketch uses single quotes, so the double quotes stay free to mark where the Arduino text starts and ends.

Three columns: the sketch lines that add pieces to page, the joined HTML text that page then holds, and what the browser shows - a Lab room heading, Level: 2650, Alert: ON and a JSON link; matching colours link each piece across the columns
Each page += adds a piece; the browser turns the finished text into a page.

Same numbers, two formats

The node shares its data twice. The page at / is for people: headings and words a browser shows nicely. /json is for programs: plain data that is easy for code to pick apart.

{"room":"Lab room","level":2650,"alert":true}

JSON is made of key-value pairs. The key is the name, always in double quotes. The value can be text in quotes ("Lab room"), a number without quotes (2650), or true or false without quotes.

You do not type that text yourself. The sketch fills a JsonDocument - doc["level"] = level - and serializeJson() writes the quotes, colons and commas for you, so they are always right. Another ESP32 (ESP32 Lesson 10), a Python script or a dashboard such as Node-RED can then read your node.

Left: the page at / for people, showing Lab room, Level 2650 and Alert ON; right: the JSON at /json for programs with labels for keys in double quotes, a text value in quotes, a number without quotes and true or false without quotes; below, the JsonDocument and serializeJson lines that make it
The page is for people; /json is for programs. Both show the same numbers.

Build it in five tested steps

Do not type the whole sketch and hope. Add one block, upload, check it works, then add the next. If something breaks, the problem is in the block you just added.

1. Read: run the Step test sketch below. Pass: Serial shows about 0 at one end of the knob and about 4095 at the other.

2. Alert LED: same sketch. Pass: the LED comes on above 2000 and goes off below.

3. Wi-Fi: run the sketch from ESP32 Lesson 08 with your network name and password. Pass: Serial prints connected and an address.

4. Web page: run the room node worked sketch. Pass: your phone shows Lab room and the level follows the knob within 2 seconds.

5. /json: open http://your-address/json. Pass: it shows the same room, level and alert as the page.

Five build steps, each with what to run and how you know it passed: read the pot with the step test sketch (about 0 to 4095 on Serial), the alert LED (on above 2000), join Wi-Fi (connected and an address), the web page (level follows the knob within 2 seconds), and /json (same numbers as the page)
Only move on when the step passes.

Wiring, Wi-Fi or code?

When something does not work, find which layer the fault is in before changing anything. Open Serial Monitor at 115200 and work down the questions in order - each yes rules out a whole layer.

The level does not change: wiring. The LED does not switch: wiring, or a threshold the knob cannot reach. Only dots on Serial: Wi-Fi. The page will not open: network - the phone or the address. The page opens but does not update: code.

A yes/no fault finder: does the level change (no: check pot wiring), does the LED switch near 2000 (no: LED direction or threshold), does Serial print connected and an address (no: Wi-Fi name, password, 2.4 GHz), does the page open on the phone (no: same Wi-Fi and exact address), does the page update (no: handleClient, no delay, meta refresh); all yes means check /json and demo
Each yes rules out a layer. The first no tells you where to look.

Assessment checklist (pass level)

A reliable demo comes first. Extra features do not make up for a node that does not work.

Demo in this order: show the IP address on Serial, open the page on a phone, turn the knob past the threshold so the LED and the page both change, then open /json and point out the same numbers.

CriterionWhat good looks like
Starts upIP address on Serial; correct board and port
Web pageLevel changes as you turn the pot
AlertLED clearly on above the threshold, off below
JSON/json shows the same numbers as the page
SafetyPot on 3V3; resistor in series with the LED
ExplainStation mode, handleClient and why GPIO 34, in your own words
Fault-findSay whether a fault is wiring, Wi-Fi or code

Extras, only after a pass

Not required. Pick at most one, and keep a copy of your working sketch first:

Real sensor: swap the pot for a light sensor (an LDR from GPIO 34 to 3V3, with a 10 k ohm resistor from GPIO 34 to GND). The sketch stays the same; set a new threshold from the readings.

No flicker: use two lines - turn the alert on above 2100 and off only below 1900.

Publish the level with MQTT (ESP32 Lesson 11), show it as NeoPixel colours (ESP32 Lesson 06 and ESP32 Lesson 07), or add OTA updates (ESP32 Lesson 16).

Wiring and safe build sequence

  1. Potentiometer: 3V3 -> one end, GND -> other end, middle leg -> GPIO 34
  2. Alert LED: GPIO 2 -> 220-330 ohm -> LED long leg; LED short leg -> GND
  3. USB for power; phone or PC on the same Wi-Fi as the ESP32
Power rule: switch off before moving wires. Arduino I/O pins are control signals; high-current loads require a driver and suitable external supply.

Step test sketch: read the pot and switch the LED

Download .ino

What this sketch is for: Steps 1 and 2, with no Wi-Fi yet. Open Serial Monitor at 115200 and turn the knob: the level should run from about 0 to about 4095, and the LED should come on above 2000. Use the same breadboard as the worked sketch.

const int potPin = 34;                    // the pot (room sensor stand-in) is on GPIO 34
const int alertPin = 2;                   // the alert LED is on GPIO 2
const int threshold = 2000;               // above this level, the alert comes on

void setup() {                            // runs once
  Serial.begin(115200);                   // start Serial at the ESP32 speed
  pinMode(alertPin, OUTPUT);              // the alert LED pin is an output
  analogReadResolution(12);               // readings will be 0 to 4095
}

void loop() {                             // runs over and over
  int level = analogRead(potPin);         // read the room level, 0 to 4095

  Serial.print("Level: ");                // a label
  Serial.print(level);                    // the reading

  if (level > threshold) {                // above the threshold?
    digitalWrite(alertPin, HIGH);         // alert LED on
    Serial.println("  Alert: ON");        // finish the line with ON
  } else {                                // at or below the threshold
    digitalWrite(alertPin, LOW);          // alert LED off
    Serial.println("  Alert: OFF");       // finish the line with OFF
  }

  delay(200);                             // 5 lines a second is easy to read
}

How the code works

  1. This is the Read and Decide part of the room node, on its own.
  2. Write down the level at each end of the knob and in the middle. You will need them to explain your threshold.
  3. delay(200) is fine here because nothing else is running. The worked sketch has no delay, so it can answer browsers.
  4. Level stuck at 0 or 4095? Check the pot's middle leg is on GPIO 34 and its ends are on 3V3 and GND.

Worked sketch: Room node

Download .ino

What this sketch is for: The finished node: steps 4 and 5. Pass steps 1 to 3 first, then put in your Wi-Fi name and password and upload this. Match the breadboard layout below before upload.

Breadboard layout for ESP32 networked room node: potentiometer on GPIO 34 and alert LED on GPIO 2
Breadboard for the capstone node: pot outer legs to 3V3 and GND, middle leg to GPIO 34; alert LED with series resistor on GPIO 2 to GND. Click to enlarge.
#include <WiFi.h>                         // the Wi-Fi commands
#include <WebServer.h>                    // the web server commands
#include <ArduinoJson.h>                  // the JSON commands

String ssid = "YOUR_WIFI_SSID";           // your Wi-Fi network name
String password = "YOUR_WIFI_PASSWORD";   // your Wi-Fi password
String roomName = "Lab room";             // shown on the page and in the JSON

const int potPin = 34;                    // the pot (room sensor stand-in) is on GPIO 34
const int alertPin = 2;                   // the alert LED is on GPIO 2
const int threshold = 2000;               // above this level, the alert comes on

WebServer server(80);                     // a web server on port 80
int level = 0;                            // the latest pot reading, 0 to 4095
bool alertOn = false;                     // true while the level is above the threshold

void sendPage() {                         // runs when a browser asks for /
  String page = "<meta http-equiv='refresh' content='2'>"; // reload every 2 seconds
  page += "<h1>" + roomName + "</h1>";    // the room name as a heading
  page += "<p>Level: ";                   // a label
  page += level;                          // the reading
  page += "</p><p>Alert: ";               // end that line, start the next
  if (alertOn) {                          // is the alert on?
    page += "ON";                         // yes
  } else {                                // no
    page += "OFF";                        // say so
  }
  page += "</p><p><a href='/json'>JSON</a></p>"; // a link to the JSON
  server.send(200, "text/html", page);    // send the page
}

void sendJson() {                         // runs when a program asks for /json
  JsonDocument doc;                       // an empty JSON document
  doc["room"] = roomName;                 // add "room": "Lab room"
  doc["level"] = level;                   // add "level": the reading
  doc["alert"] = alertOn;                 // add "alert": true or false
  String body;                            // somewhere to put the text
  serializeJson(doc, body);               // turn the document into JSON text
  server.send(200, "application/json", body); // send it
}

void setup() {                            // runs once
  Serial.begin(115200);                   // start Serial at the ESP32 speed
  pinMode(alertPin, OUTPUT);              // the alert LED pin is an output
  analogReadResolution(12);               // readings will be 0 to 4095

  WiFi.begin(ssid, password);             // start joining the network
  Serial.print("Connecting");             // tell the user we are trying
  while (WiFi.status() != WL_CONNECTED) { // repeat until we have joined
    delay(500);                           // wait half a second
    Serial.print(".");                    // print a dot each time
  }
  Serial.println(" connected");           // we are on the network

  Serial.print("Open http://");           // tell the user what to type
  Serial.println(WiFi.localIP());         // the ESP32's address

  server.on("/", sendPage);               // path / shows the page
  server.on("/json", sendJson);           // path /json sends the JSON
  server.begin();                         // start the web server
}

void loop() {                             // runs over and over
  level = analogRead(potPin);             // read the room level

  if (level > threshold) {                // above the threshold?
    alertOn = true;                       // remember the alert is on
    digitalWrite(alertPin, HIGH);         // alert LED on
  } else {                                // at or below the threshold
    alertOn = false;                      // remember the alert is off
    digitalWrite(alertPin, LOW);          // alert LED off
  }

  server.handleClient();                  // answer any browser that is asking
}

How the code works

  1. Put your own network name and password in ssid and password, and keep them out of anything you share.
  2. Change roomName for your own label - it appears on the page and in the JSON.
  3. level and alertOn are declared at the top, outside any function, so loop(), sendPage() and sendJson() can all use them.
  4. threshold 2000 is a starting point; set it from the levels you saw with the step test sketch.
  5. server.on() in setup() links each path to its function; server.handleClient() in loop() runs them when a browser asks.
  6. page += level adds the number to the page as text.
  7. No delay() in loop(), so the page and the LED both respond straight away.

Test and record evidence

Expected result: Serial shows the IP address. The page shows Lab room, a level that changes as you turn the pot, and Alert ON or OFF. The alert LED is on above the threshold and off below it. /json shows the same room, level and alert. You can explain station mode, handleClient and why the pot is on GPIO 34.

Practical evidence checklist

Common faults and checks
  • Level stuck at 0 or 4095: pot ends on 3V3 and GND, middle leg on GPIO 34.
  • Level fine in the step test but stuck once Wi-Fi is on: the pot is on an ADC2 pin - move it to GPIO 34.
  • Alert always on or always off: change the threshold to a level you can reach with the pot.
  • LED never lights: turn it round (long leg towards the resistor and GPIO 2) and check the resistor value.
  • Only dots on Serial: check the 2.4 GHz network name and password as in ESP32 Lesson 08.
  • Page will not open: phone on the same Wi-Fi (not mobile data or a guest network), and the exact address from Serial starting http://.
  • Page opens but never changes: check the meta refresh line, and that loop() calls server.handleClient() with no delay().
  • 404 Not found: the path is misspelled - it must be exactly / or /json.
  • Address changed after a restart: the router can give a new one; read it again from Serial.
  • ArduinoJson.h not found: install ArduinoJson by Benoit Blanchon.
Challenge (extension - optional): After a pass: add "threshold" to the JSON, or show the alert as a red or green NeoPixel. Do not risk your pass demo for an extra.

Check your understanding

Q1. Which Wi-Fi mode does the room node use?

Show answer

Station - it joins the network.

Q2. Why is the pot on GPIO 34?

Show answer

It still reads while Wi-Fi is on, and it is an input pin.

Q3. Why power the pot from 3V3, not 5 V?

Show answer

ESP32 pins must not get more than 3.3 V.

Q4. What makes the page update by itself?

Show answer

The meta refresh tag reloads it every 2 seconds.

Q5. What does /json give?

Show answer

The room name, level and alert as JSON, for programs to read.

Q6. Why must loop() call handleClient() and have no long delay()?

Show answer

So the ESP32 answers browsers quickly while still reading the pot.

Q7. What are the three jobs loop() does?

Show answer

Read the pot, decide the alert, answer any browser.

Q8. What range does analogRead give on the ESP32?

Show answer

0 to 4095 (12-bit).

Q9. What does alertOn remember?

Show answer

Whether the level is above the threshold - true or false.

Q10. What reply does a path that is not in the server.on() list get?

Show answer

404 Not found.

Q11. Why can the page be up to 2 seconds behind the LED?

Show answer

The page is a snapshot that only reloads every 2 seconds; the LED is updated on every pass of loop().

Q12. In {"level":2650}, which part is the key and which is the value?

Show answer

level is the key, 2650 is the value.

Q13. Why can't a friend open your page from their mobile data?

Show answer

The address only works on your local Wi-Fi network.

Q14. Is MQTT needed to pass?

Show answer

No - it is an optional extra.

Q15. What is the first block to build and test?

Show answer

Reading the pot and printing it on Serial.