This project aims to build an ESP32-based robot car controlled remotely via a smartphone's web browser. To avoid the 2.4GHz interference of running multiple ESP32 Access Points in a single room during a workshop, the cars will connect as clients (Station Mode) to a central, offline router.
Instead of serving raw, hardcoded HTML from the ESP32, we are using a modern decoupled web development approach:
- Frontend Framework (Vue 3 + Vite): Allows for rapid UI development, hot-reloading, and component-based architecture. Vite will compile the app into highly optimized static files.
- Joystick Library (Nipple.js): A lightweight, zero-dependency vanilla JavaScript library specifically designed to handle multi-touch virtual joysticks and output
x/yand angle/force data. - Backend Server (ESPAsyncWebServer): Runs on the ESP32 to serve the web files and manage traffic asynchronously without blocking the motor control loop.
- Communication (WebSockets): Replaces traditional HTTP GET requests to provide a persistent, low-latency, two-way data stream (running at ~20Hz) for highly responsive motor control.
- Storage (LittleFS + GZIP): The compiled Vue application will be compressed into
.gzfiles and stored in the ESP32's 16MB Flash memory, ensuring fast transfer speeds over the local Wi-Fi.
- Setup: The ESP32 is flashed with firmware and connects to the developer's home Wi-Fi. It acts only as a WebSocket server and motor controller.
- Workflow: Engineers run the Vue app locally on their PC (
npm run dev). The Vue app's WebSocket connection is hardcoded to target the ESP32's local IP address (e.g.,ws://192.168.1.100/ws). - Benefit: Engineers can edit the Vue UI and see instant updates in their browser (Hot Module Replacement) while still driving the physical car in real-time, eliminating the need to reflash the ESP32 to test UI changes.
- Setup: The Vue app is built (
npm run build), gzipped, and uploaded directly to the ESP32's LittleFS. The ESP32 firmware is updated with the credentials of the workshop's central router. - Workflow: At the workshop, the ESP32 connects to the central router. Users connect their phones to the same router and navigate to the ESP32's IP address (or mDNS hostname like
http://car1.local). - Execution: The ESP32 serves the gzipped Vue application to the phone. The Vue app uses a dynamic WebSocket URL (
ws://${window.location.hostname}/ws) to seamlessly connect back to whichever ESP32 served it.
- Microcontroller: ESP32-S3 (MCN16R8) - 16MB Flash, 8MB PSRAM.
- Compiler settings: Flash: 16MB, PSRAM: OPI PSRAM.
- Motor Driver: (e.g., L298N, TB6612FNG, or MX1508) controlled via ESP32 hardware PWM (LEDC).
- ESP32 Dependencies (C++):
WiFi.h(Standard Wi-Fi library)ESPAsyncWebServer(For serving files and WebSocket handling)AsyncTCP(Required by ESPAsyncWebServer)LittleFS(File system for serving.gzweb files)ArduinoJson(For parsing incoming joystick payloads)
- Frontend Dependencies (Node/npm):
vuevitenipplejs
The repository should be split into two distinct environments to keep the frontend build process separate from the C++ firmware.
/project-root
│
├── /esp32_firmware # PlatformIO or Arduino IDE project
│ ├── /data # Files here get uploaded to LittleFS
│ │ ├── index.html.gz # Copied from web_app/dist after build
│ │ ├── assets/
│ │ │ ├── index-[hash].js.gz
│ │ │ └── index-[hash].css.gz
│ ├── /src
│ │ ├── main.cpp # Main loop, Wi-Fi setup, Web server setup
│ │ ├── motors.cpp # PWM initialization and movement logic
│ │ └── motors.h
│ └── platformio.ini # Build configurations and library dependencies
│
└── /web_app # Vue + Vite frontend project
├── /public # Static assets
├── /src
│ ├── /components
│ │ └── Joystick.vue # Wrapper for Nipple.js
│ ├── App.vue # Main application view and WebSocket logic
│ └── main.js # Vue initialization
├── package.json # npm dependencies
└── vite.config.js # Vite build config (add gzip plugin here)
The core interface between the frontend and backend is a WebSocket connection transmitting JSON payloads.
The Vue app must automatically determine its environment to route WebSockets correctly:
// Example implementation for App.vue
const dev_esp32_ip = "192.168.1.100"; // Used only during Phase 1
const gateway = window.location.hostname === 'localhost'
? `ws://${dev_esp32_ip}/ws`
: `ws://${window.location.hostname}/ws`; // Used during Phase 2
const websocket = new WebSocket(gateway);Data is transmitted from the phone to the ESP32 at roughly 10-20Hz while the user is actively touching the joystick.
Directional Payload:
{
"type": "drive",
"x": 45,
"y": 100
}x: Integer representing left/right axis (e.g., -100 to 100).y: Integer representing forward/reverse axis (e.g., -100 to 100).
Stop Payload: Sent exactly once when the user releases the screen to immediately halt the motors.
{
"type": "stop"
}sequenceDiagram
participant User as User (Smartphone)
participant Vue as Vue App (Browser)
participant WS as WebSocket (Network)
participant ESP as ESP32 (AsyncServer)
participant Motor as Motor Controller
User->>Vue: Drags Virtual Joystick
Vue->>Vue: Nipple.js calculates X/Y (e.g., X:0, Y:100)
Vue->>WS: Send JSON: {"type":"drive", "x":0, "y":100}
WS->>ESP: Trigger WebSocket Event (Data Rx)
ESP->>ESP: ArduinoJson parses payload
ESP->>ESP: Map X/Y to Left/Right PWM speeds
ESP->>Motor: Apply PWM signals to GPIO pins
Motor->>User: Physical car moves forward
User->>Vue: Releases Joystick
Vue->>WS: Send JSON: {"type":"stop"}
WS->>ESP: Trigger WebSocket Event (Data Rx)
ESP->>Motor: Set all PWM to 0 (Brake)