PROJECT 8 · WI‑FI / WEB

Output State Synchronization

Control one LED from both a web toggle and a physical button: with the page kept in sync.

Builds on: Project 7's async server and Project 4's millis() debounce, combined so two inputs can safely agree on one output.

⏱ ~35 min 📊 Intermediate 🔖 Prerequisite: Project 4 & 7
⬇ Download the sketch Save Project_8_Output_State_Synchronization_Web_Server.ino, then open it in the Arduino IDE (setup: Foundation 3). Needs the ESP Async WebServer + AsyncTCP libraries and your Wi-Fi credentials.
Web toggle and physical button both control the LED; the page updates
Two controls, one output, and a web page that always shows the true state.
🎲 Try it without hardware 📄 diagram.json Open the Wokwi simulator, then open or download diagram.json above and paste its wiring in to load the circuit.

🎯 Objective

By the end of this project, you will:

  • Control one output from two independent sources without them fighting each other.
  • Understand why a web page goes stale, and fix it by polling the server.
  • Use setInterval() and XMLHttpRequest to refresh state in the background, with no page reload.
  • Serve a tiny state endpoint (/state) that answers with one character.
  • Debounce a button with millis() rather than delay(), so the server keeps responding.
  • See why the empty loop() of Project 7 was worth having.

1The synchronization trick

The interesting part is keeping the web page correct when you press the physical button. The page can't know, so it asks. Every second the browser requests /state and updates the toggle to match the real pin:

setInterval(function () {           // once per second
  xhttp.open("GET", "/state", true);  // ask the ESP32 for the state
  ...
}, 1000);
  • Web toggle/update?state=… → LED changes.
  • Physical button → LED changes in loop().
  • Either way → the 1‑second /state poll refreshes the page.

2Install the required libraries

Same async stack as Project 7: install both: ESP Async WebServer and AsyncTCP.

3Parts & wiring

QtyPartNotes
1ESP32 · breadboard · jumper wires-
1LED + 220 ΩOutput
1Pushbutton + 10 kΩInput
LED on GPIO 2 with 220 ohm, button on GPIO 4 with 10 kilo-ohm
LED → GPIO 2 (220 Ω) · Button → GPIO 4 (10 kΩ, like Project 1).
GPIO 2 also drives the on‑board blue LED, so it blinks along with your external LED, a free indicator.

4Upload & use

Set your Wi‑Fi credentials, upload, open Serial Monitor at 115200, press EN/RESET:

==============================================
 Project 8: Output State Synchronization
==============================================
Output (LED) -> GPIO 2
Button       -> GPIO 4
Control it from the web page OR the button.
Connecting to Wi-Fi: MyNetwork
....
Wi-Fi connected!
Open this address in your browser:  http://192.168.1.42
----------------------------------------------
[BUTTON] Output -> ON
[WEB]    Output -> OFF

The improved logging tags which control changed the output ([BUTTON] vs [WEB]), so you can watch the sync happen.

5Code Walkthrough: Understanding the Sketch

This is Project 7's async server plus one new idea: the browser keeps asking what the real state is.

Step 1: what the sketch has to remember

const int output    = 2;      // LED -> GPIO 2 (also the on-board blue LED)
const int buttonPin = 4;      // physical button -> GPIO 4

int ledState = LOW;           // the state we intend the output to be in
int buttonState;              // the debounced reading
int lastButtonState = HIGH;   // the previous raw reading

unsigned long lastDebounceTime = 0;
unsigned long debounceDelay = 100;

ledState is the single source of truth. Both the web route and the button change this one variable, and loop() writes it to the pin. That is what stops the two controls fighting: they do not drive the pin directly, they agree on a variable.

Step 2: the page asks for the state once a second

setInterval(function () {                 // every 1000 ms
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      document.getElementById("output").checked = (this.responseText == 1);
    }
  };
  xhttp.open("GET", "/state", true);      // ask the ESP32
  xhttp.send();
}, 1000);

XMLHttpRequest fetches a URL without reloading the page, and setInterval repeats it every second. When the reply arrives, the toggle and label are updated to match. That is why pressing the physical button moves the on-screen switch a moment later.

Step 3: setup() registers three routes

server.on("/", HTTP_GET, ...);        // the page

server.on("/update", HTTP_GET, [](AsyncWebServerRequest *request){
  if (request->hasParam(PARAM_INPUT_1)) {       // ?state=0|1
    String inputMessage = request->getParam(PARAM_INPUT_1)->value();
    digitalWrite(output, inputMessage.toInt());
    ledState = !ledState;
  }
  request->send(200, "text/plain", "OK");
});

server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request){
  request->send(200, "text/plain", String(digitalRead(output)).c_str());
});

The third route is the whole trick, and it is tiny: /state reads the pin and replies with a single character, 0 or 1. No HTML, no formatting. That is all the browser needs to redraw its toggle.

A subtlety in /update: it writes the requested value and then flips ledState. That works only because the on-screen toggle always sends the opposite of what it is showing, and the one-second poll keeps it showing the truth. ledState = inputMessage.toInt() would express the intent more directly and survive a repeated request.

Step 4: loop() reads and debounces the physical button

int reading = digitalRead(buttonPin);

if (reading != lastButtonState) {
  lastDebounceTime = millis();          // input moved: restart the timer
}

if ((millis() - lastDebounceTime) > debounceDelay) {
  if (reading != buttonState) {         // stable long enough to trust
    buttonState = reading;
    if (buttonState == LOW) {           // act on one edge only
      ledState = !ledState;
    }
  }
}

digitalWrite(output, ledState);         // apply the agreed state
lastButtonState = reading;

This is the millis() pattern from Project 4 used as a debounce: every time the raw reading changes the timer restarts, so the sketch only trusts a reading that has held still for 100 ms. Project 1 used a blunt delay(50) for the same job, which is fine when the program has nothing else to do. Here it would stall the web server.

The sketch toggles when the debounced reading goes LOW, so it expects the button to read HIGH at rest and LOW when pressed (10 kΩ pull-up to 3.3 V, button to GND). Wired like Project 1 instead (pull-down), it still toggles once per press, just on the release.

Key concepts: what each line really does

CodeWhat it does
ledStateThe single variable both controls agree on. Neither writes the pin directly, which keeps them consistent.
setInterval(fn, 1000)Browser-side timer: runs fn every second, forever, without reloading the page.
XMLHttpRequestFetches a URL in the background from JavaScript. The page updates itself from the reply.
server.on("/state", ...)A minimal endpoint returning just 0 or 1, cheap enough to call every second.
String(digitalRead(output))Reads the actual pin, not the variable, so the browser is told the physical truth.
reading != lastButtonStateDetects that the input moved at all, restarting the debounce timer.
(millis() - lastDebounceTime) > debounceDelayNon-blocking debounce: trust the reading only once it has been stable for 100 ms.
digitalWrite(output, ledState)Applied every pass, so whichever source last changed ledState wins, with no conflict.
Predict, then check. Predict: after you press the physical button, roughly how long before the web page's toggle updates to match? Press it, time it, and compare against the poll interval in the JavaScript above.

6Demonstration

Animated demo: button press changes LED and the web toggle syncs within a second
Press the button → the LED changes and the web toggle flips within a second.

7Knowledge Check

Test your understanding.

1. How does the web page stay in sync when a physical button changes the LED state?

2. What does `setInterval()` do in the browser JavaScript?

3. Why is `loop()` not empty in this project unlike Project 7?

4. What does the `/state` endpoint return?

8Wrapping Up: What You've Learned

This project answers a question every real device faces: how does the interface know what the hardware is actually doing?

  • Route every control through one state variable rather than letting each input write the pin. Two sources of truth become two different answers.
  • A web page is a snapshot: it shows what was true when it loaded, and the ESP32 cannot push an update to it on its own.
  • Polling fixes that: the browser asks /state every second and redraws itself. Simple, and good enough for a dashboard.
  • Polling is also wasteful: one request per second per browser whether or not anything changed. That cost is why Project 11 moves to MQTT, where the device announces changes instead of being asked.
  • Going further: a WebSocket (a single connection both sides keep open, instead of one short-lived request per poll) would fix the same waste while staying plain HTTP: the ESP32 could push /state the instant it changes, with no polling interval to tune. MQTT, which Project 11 uses instead, is the same idea taken further: a broker in between that many devices can publish to and subscribe from, not just one browser talking to one board.
  • XMLHttpRequest plus setInterval updates part of a page without reloading it.
  • Debouncing with millis() instead of delay() matters once the program has another job. Project 7's empty loop() is exactly the room this project needed.

Now try

  • Third input source: Add a second physical button on a different GPIO and track which input (web, button A, or button B) last changed the LED state.
  • Simple password: Add a basic authentication check to the web page so only users who enter a password can toggle the LED.

9Troubleshooting

SymptomLikely causeFix
Compile error (async libs)Libraries missingInstall ESP Async WebServer + AsyncTCP
Page doesn't follow the button/state poll blockedKeep the tab open; same LAN
Button does nothingWiring / debounceCheck GPIO 4 + 10 kΩ; press firmly
Stuck on "Connecting…"Wrong credentials / 5 GHzFix credentials; use 2.4 GHz