Embedded & Firmware
ESP32 Wi-Fi Control Pad
Eight buttons and a rotary encoder on an ESP32-S2. The board serves its own web UI, pushes input state over a WebSocket and has a JSON endpoint for polling.
- ESP32-S2
- C++
- PlatformIO
- ESPAsyncWebServer
- WebSockets
- ArduinoJson

What it is
A small input device: eight buttons and a rotary encoder on an ESP32-S2, readable over Wi-Fi. The ESP32 serves its own web page, and connected browsers get state changes pushed over a WebSocket. Other programs can poll GET /api/inputs for a JSON snapshot. It’s meant as a generic remote input for robotics and home-automation projects, with nothing to install on the client.
Hardware
ESP32-S2-SAOLA-1, 8 push buttons, a rotary encoder with push switch and 2 status LEDs. All inputs use the internal pull-ups, so there are no external resistors. Pin mappings are in config.h.
Firmware structure
- InputManager (singleton): configures GPIO for buttons and the encoder, and keeps the current input state. The encoder is read in an ISR.
- WebServer (singleton): ESPAsyncWebServer with a WebSocket endpoint. Serves the page, answers
/api/inputsand broadcasts state changes to all clients. - main.cpp: brings up serial and Wi-Fi, then starts the input manager and web server.
Encoder decoding
The ISR (InputManager.cpp) counts a step whenever channel A changes, and takes the direction from whether B matches A. Doing this in the interrupt instead of the main loop means steps aren’t lost while the web server is busy.
void IRAM_ATTR InputManager::encoderISR() {
int encA = digitalRead(PinMap::ENCODER_A);
int encB = digitalRead(PinMap::ENCODER_B);
if (encA != lastEncA) {
if (encB != encA) {
encoderPosition++;
} else {
encoderPosition--;
}
}
lastEncA = encA;
}
State broadcast
On a change, the full input state is serialised to a small JSON document and sent to every WebSocket client (WebServer.cpp). Sending the full state rather than deltas keeps clients simple: a newly connected browser is correct after the first message.
void WebServer::broadcastInputStates() {
auto& inputManager = InputManager::getInstance();
const auto& states = inputManager.getStates();
StaticJsonDocument<256> doc;
doc["button"] = states.button;
// ... (all other buttons) ...
doc["encoder_button"] = states.encoderButton;
doc["encoder_position"] = states.encoderPosition;
String jsonString;
serializeJson(doc, jsonString);
ws.textAll(jsonString);
}