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.

⬇ Download the sketch Save Project_8_Output_State_Synchronization_Web_Server.ino, then open it in the Arduino IDE (setup: Foundation 3). Needs the ESPAsyncWebServer + 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.

🎯 Objective

By the end of this project, you will:

1 · The 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);

2 · Install the required libraries

Same async stack as Project 7: install both: ESPAsyncWebServer and AsyncTCP.

3 · Parts & 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.

4 · Upload & 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.

5 · Code 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.

6 · Demonstration

Breadboard LED and button with the web toggle on a phone
Press the button → the LED changes and the web toggle flips within a second.

7 · Wrapping 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?

8 · Troubleshooting

SymptomLikely causeFix
Compile error (async libs)Libraries missingInstall ESPAsyncWebServer + 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
← Project 7 · Relay Web Server Project 9 · DHT11 Web Server →