Switching a pin from the page
A checkbox on a phone that turns on an LED across the room is the moment this stops feeling like a tutorial. It also introduces the bug every one of these projects has - the page believes whatever it last heard, and the pin is the only thing that actually knows.
Click the checkbox, then make the reply go missing and click it again.
The page is never the source of truth
The checkbox is a drawing of the pin. It is accurate for as long as nothing goes wrong, and the two things that go wrong are common: a reply that never arrives, and a second phone.
The fix is a contract, and it costs one line:
- The board answers with the state of the pin, read back after the write.
- The page draws what came back, not what was clicked.
- The page asks once on load, so a reload or a reconnect starts from the truth rather than from a default.
Do that and a dropped reply leaves the checkbox visibly wrong for one second instead of invisibly wrong forever.
GET is fine here, and it is a habit worth breaking later
A browser is allowed to fetch a GET twice, and a link preview or a prefetch will do exactly that. Toggling an LED that way is harmless. Anything that opens a door or spends money should be a POST, and the reason is not tidiness — it is that nothing in the network is allowed to repeat a POST on its own.
Where this stops scaling
One click is one connection, and each connection is a handshake. That is fine for a switch and it is not fine for a slider you drag. When the board needs to push state to several viewers at once, one persistent connection replaces all of it: WebSocket dashboards.
The code
One route serves the page, one takes the command, and both answer with the state the pin is in afterwards. The page never assumes it got what it asked for - it prints what came back.
#include <WiFi.h>
#include <WebServer.h>
const int LED = 23; // any output pin
WebServer server(80);
const char PAGE[] PROGMEM = R"html(
<!doctype html><meta charset=utf-8>
<h1>LED</h1>
<input type=checkbox id=c onchange=set()>
<span id=s>unknown</span>
<script>
const send = url => fetch(url).then(r => r.json()).then(j => {
c.checked = j.on; // the pin decides, not the click
s.textContent = j.on ? 'on' : 'off';
});
const set = () => send('/led?on=' + c.checked);
send('/led'); // ask on load, so a reload tells the truth
</script>
)html";
void reply() {
bool on = digitalRead(LED);
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json", on ? "{\"on\":true}" : "{\"on\":false}");
}
void setup() {
Serial.begin(115200);
pinMode(LED, OUTPUT);
WiFi.begin("your-network", "your-password");
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.println(WiFi.localIP());
server.on("/", HTTP_GET, [] { server.send_P(200, "text/html", PAGE); });
server.on("/led", HTTP_GET, [] {
if (server.hasArg("on")) digitalWrite(LED, server.arg("on") == "true");
reply(); // with or without the argument, report the pin
});
server.begin();
}
void loop() {
server.handleClient();
}Answer with the pin, not with the request. `digitalRead` after the write costs nothing and it is what makes a lost reply visible instead of silently wrong.
The same contract in fewer moving parts. `/led` with no argument is a question; `/led?on=true` is an instruction. Both answer with the pin.
import network, socket
from machine import Pin
led = Pin(23, Pin.OUT)
PAGE = """<!doctype html><meta charset=utf-8>
<h1>LED</h1>
<input type=checkbox id=c onchange=set()>
<span id=s>unknown</span>
<script>
const send = url => fetch(url).then(r => r.json()).then(j => {
c.checked = j.on;
s.textContent = j.on ? 'on' : 'off';
});
const set = () => send('/led?on=' + c.checked);
send('/led');
</script>"""
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect('your-network', 'your-password')
while not wlan.isconnected():
pass
print(wlan.ifconfig()[0])
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 80))
s.listen(2)
while True:
conn, _ = s.accept()
path = conn.recv(1024).decode().split(' ')[1]
if path.startswith('/led'):
if 'on=' in path:
led.value(1 if path.split('on=')[1].split('&')[0] == 'true' else 0)
head = 'HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nCache-Control: no-store\r\n\r\n'
body = '{"on":%s}' % ('true' if led.value() else 'false')
else:
head = 'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n'
body = PAGE
conn.send(head)
conn.send(body)
conn.close()Parse the query yourself and treat anything that is not the literal string true as off. A missing or misspelt argument then fails to a known state rather than to whatever the pin happened to be.
When it does not work
The request never arrived or never matched. Visit /led?on=true directly in the browser - if the LED comes on, the route is fine and the page's fetch is the problem. If it does not, the route or the pin number is.
The page is starting from its own default instead of asking. Fetch the state once when the page loads. A checkbox is a picture of the pin, and a picture drawn from nothing is a guess.
The argument is being read as a string and anything non-empty is being treated as true. Compare against the literal text true, and let every other value mean off.
They will, and there is nothing to fix in the sketch. Both are correct about what they asked for and neither is told when the other changes it. Either poll the state on a timer as well as on click, or move to a WebSocket, where the board can tell both of them at once.
Three pages of this all break the same way at the second phone. The next one is why, and the library that fixes it.
Async web server →Edit this page — content/esp32/web-server-led-control.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.