A web interface lets us control an ESP32 from the browser by combining HTML, JavaScript, WebSockets, and JSON messages.
The example is oriented to ESP32. In many cases it can also be adapted to ESP8266 by changing libraries and a few pin details.
To follow this example, it is useful to already understand the ideas of web servers, WebSocket communication, and JSON messages. We will also use Material Design-like styling so the interface does not feel like a pure test page.
The idea is to build a web interface to control the ESP32 from the browser.
In this interface, we will display the state of digital inputs on the ESP32.

Furthermore, we will simulate acting on pins D8, D9 as outputs and on D10 as a PWM output.

Plus a “bonus” where we can trigger actions, i.e., functions that we will have defined in the ESP8266 and in which you would do… well, whatever you wanted to do.

In this example, the readings of the digital input states will be “real”. However, we keep the outputs and actions “simulated”, meaning we will display them on the serial port. We do this for safety, so you can test the example regardless of what you have connected to the ESP8266 and avoid “sparking” something.
If you are sure, simply replace the code with what is needed to set the digital output or PWM to the appropriate value.
Ambitious? Well, it’s about combining the parts we have seen before to illustrate how we could set up a Web interface to control the ESP8266. Although we have quite a bit of code ahead of us, so let’s get to work!
Our main program looks like this. We have used the “ReactiveArduino” library to respond to pin changes, started the server and WebSockets, and included the necessary ‘includes’.
#include <ESP8266WiFi.h>
#include <ESPAsyncWebServer.h>
#include <FS.h>
#include <ArduinoJson.h>
#include "config.h" // Replace with your network data
#include "API.hpp"
#include "WebSockets.hpp"
#include "Server.hpp"
#include "ESP8266_Utils.hpp"
#include "ESP8266_Utils_AWS.hpp"
#include "ReactiveArduinoLib.h"
auto obsD0 = Reactive::FromDigitalInput(D0);
auto obsD5 = Reactive::FromDigitalInput(D5);
auto obsD6 = Reactive::FromDigitalInput(D6);
auto obsD7 = Reactive::FromDigitalInput(D7);
void setup(void)
{
Serial.begin(115200);
SPIFFS.begin();
ConnectWiFi_STA();
InitServer();
InitWebSockets();
obsD0.Distinct().Do([](int i) { updateGPIO("D0", i); });
obsD5.Distinct().Do([](int i) { updateGPIO("D5", i); });
obsD6.Distinct().Do([](int i) { updateGPIO("D6", i); });
obsD7.Distinct().Do([](int i) { updateGPIO("D7", i); });
}
void loop(void)
{
obsD0.Next();
obsD5.Next();
obsD6.Next();
obsD7.Next();
}
As for our server definition in the ‘Server.hpp’ file, it’s very simple since we are going to use websockets and don’t need to define endpoints.
AsyncWebServer server(80);
void InitServer()
{
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
server.onNotFound([](AsyncWebServerRequest *request) {
request->send(400, "text/plain", "Not found");
});
server.begin();
Serial.println("HTTP server started");
}
On the other hand, our ‘API.hpp’ file, where we only have three callback actions: one to set a digital output, another to set a PWM, and another to perform generic actions.
These functions are the ones that, for the example, we leave as simply printing to the serial port. Change them for your project as needed.
void setGPIO(String id, bool state)
{
Serial.print("Set GPIO ");
Serial.print(id);
Serial.print(": ");
Serial.println(state);
}
void setPWM(String id, int pwm)
{
Serial.print("Set PWM ");
Serial.print(id);
Serial.print(": ");
Serial.println(pwm);
}
void doAction(String actionId)
{
Serial.print("Doing action: ");
Serial.println(actionId);
}
Regarding our ‘WebSockets.hpp’ file, it is responsible for receiving requests via websocket and performing the appropriate function based on the type of action received.
On the other hand, it also contains the updateGPIO function, which is called when a pin’s state changes and is responsible for broadcasting to all clients, informing them of the new state.
AsyncWebSocket ws("/ws");
void ProcessRequest(AsyncWebSocketClient *client, String request)
{
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, request);
if (error) { return; }
String command = doc["command"];
if(command == "setGPIO")
setGPIO(doc["id"], (bool)doc["status"]);
else if(command == "setPWM")
setPWM(doc["id"], (int)doc["pwm"]);
else if(command == "doAction")
doAction(doc["id"]);
}
void updateGPIO(String input, bool value)
{
String response;
StaticJsonDocument<300> doc;
doc["command"] = "updateGPIO";
doc["id"] = input;
doc["status"] = value ? String("ON") : String("OFF");
serializeJson(doc, response);
ws.textAll(response);
Serial.print(input);
Serial.println(value ? String(" ON") : String(" OFF"));
}
So far, for the backend. On the client side, our web page is a bit longer than the previous one (the price to pay for being less ugly),
<!DOCTYPE html>
<html class="no-js" lang="">
<head>
<title>ESP8266 Async GPIO</title>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<link rel="stylesheet" href="vendor/google-fonts.css">
<link rel="stylesheet" href="vendor/material.css">
<link rel="stylesheet" href="css/main.css">
<div class="mdl-card mdl-shadow--2dp">
<div class="mdl-card__title mdl-card--expand">
<h2 class="mdl-card__title-text">Async GPIO</h2>
</div>
<div class="mdl-card__supporting-text">
<h6>Input example:</h6>
<ul class="mdl-list mdl-shadow--2dp">
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D0</span>
<span class="mdl-list__item-secondary-action">
<label id="input-label-D0" class="label-big Off-style">OFF</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D5</span>
<span class="mdl-list__item-secondary-action">
<label id="input-label-D5" class="label-big Off-style">OFF</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D6</span>
<span class="mdl-list__item-secondary-action">
<label id="input-label-D6" class="label-big Off-style">OFF</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D7</span>
<span class="mdl-list__item-secondary-action">
<label id="input-label-D7" class="label-big Off-style">OFF</label>
</span>
</li>
</ul>
</div>
<div class="mdl-card__supporting-text">
<h6>Ouput example:</h6>
<ul class="mdl-list mdl-shadow--2dp">
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D8</span>
<span class="mdl-list__item-secondary-action">
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
<input id="output-switch-D8" data-id="D8" type="checkbox" class="mdl-switch__input" checked
onchange="sendGPIO(this.dataset.id, this.checked)"/>
</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D9</span>
<span class="mdl-list__item-secondary-action">
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
<input id="output-switch-D9" data-id="D9" type="checkbox" class="mdl-switch__input" checked
onchange="sendGPIO(this.dataset.id, this.checked)"/>
</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D10</span>
<span class="mdl-list__item-secondary-action">
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
<input id="output-switch-D10" data-id="D10" type="checkbox" class="mdl-switch__input" checked
onchange="sendGPIO(this.dataset.id, this.checked)"/>
</label>
</span>
</li>
<li class="mdl-list__item">
<span class="mdl-list__item-primary-content">D10</span>
<span class="mdl-list__item-secondary-action">
<div class="mdl-grid">
<div class="mdl-cell mdl-cell--10-col">
<input id="slider-pwm-1" data-id="1" class="mdl-slider mdl-js-slider"
type="range" min="0" max="255" value="25"
onchange="sendPWM(this.dataset.id, this.value);" >
</div>
<div class="mdl-cell mdl-cell--2-col">
<input id="slider-text-pwm-1" data-id="1" style="width:35px;"
onchange="sendPWM(this.dataset.id, this.value);" value="25"></input>
</div>
</div>
</span>
</li>
</ul>
</div>
<div class="mdl-card__supporting-text ">
<div>
<h6>Example</h6>
<button class="mdl-button mdl-js-button mdl-button--primary mdl-js-ripple-effect" style="width: 160px;"
data-id="action-1" onclick="sendAction(this.dataset.id)">
Do something
</button>
</div>
</div>
</div>
</body>
<script type="text/javascript" src="./js/main.js"></script>
<script type="text/javascript" src="vendor/material.js"></script>
</html>
var connection = new WebSocket('ws://' + location.hostname + '/ws', ['arduino']);
connection.onopen = function () {
connection.send('Received from Client');
console.log('Connected');
};
connection.onerror = function (error) {
console.log('WebSocket Error', error);
};
connection.onmessage = function (e) {
console.log('Received from server: ', e.data);
processReceived(e.data);
};
connection.onclose = function () {
console.log('WebSocket connection closed');
};
function processReceived(data)
{
json = JSON.parse(data)
if(json.command == 'updateGPIO')
{
updateGPIO(json.id, json.status);
}
}
function sendGPIO(id, status)
{
let data = {
command : "setGPIO",
id: id,
status: status
}
let json = JSON.stringify(data);
connection.send(json);
}
function sendPWM(id, pwm)
{
updateSliderText(id, pwm);
let data = {
command : "setPWM",
id: id,
pwm: pwm
}
let json = JSON.stringify(data);
connection.send(json);
}
function sendAction(id)
{
let data = {
command : "doAction",
id: id,
}
let json = JSON.stringify(data);
connection.send(json);
}
function updateGPIO(id, status)
{
document.getElementById('input-label-' + id).textContent = status;
if(status == 'ON')
{
document.getElementById('input-label-' + id).classList.add('On-style');
document.getElementById('input-label-' + id).classList.remove('Off-style');
}
else
{
document.getElementById('input-label-' + id).classList.add('Off-style');
document.getElementById('input-label-' + id).classList.remove('On-style');
}
}
function updateSliderText(id, value) {
document.getElementById('slider-pwm-' + id).value = value;
document.getElementById('slider-text-pwm-'+ id).value = value;
}
.label-big {
border-radius: 3px;
font-size: 16px;
font-weight: 600;
line-height: 2;
padding: 0 8px;
transition: opacity .2s linear;
color: #fff
}
.On-style {
background-color: #006b75;
}
.Off-style {
background-color: #b60205;
}
Now we upload all this code to the ESP32 and verify that the web interface updates immediately when the state changes and, when executing actions on the buttons, the serial port correctly displays the received actions.
This is already a fairly complete example of what we can do with an ESP32 acting as a web backend. So far, we have used vanilla JavaScript for the client logic.
If the interface grows, we can later move to declarative solutions such as VueJS. The ESP32 backend will keep exposing the same data and actions, but the frontend becomes easier to maintain.
Download the code
All the code from this post is available for download on Github.
Version for ESP8266: https://github.com/luisllamasbinaburo/ESP8266-Examples
Version for ESP32: https://github.com/luisllamasbinaburo/ESP32-Examples

