t4as

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
ESP32 Wi-Fi Control Pad

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/inputs and 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);
}