This article involves motors and moving machinery. Test with the wheels off the ground or the propellers removed, keep the failsafe code in, and never operate remote-controlled vehicles near people or in restricted airspace.
The idea: your phone is already a great remote control
Traditional hobby radios are wonderful, but they are closed systems: a fixed number of channels, no screen, no logging. A modern phone or laptop has a touch screen, gyroscope, gamepad support, a fast processor and Wi-Fi. If a microcontroller can accept commands over Wi-Fi, the browser becomes the transmitter — and you can build a dashboard with live telemetry, maps, charts and recording using ordinary web technology.
I use exactly this approach for scale RC models: a browser control panel on one side, an ESP32 on the model on the other, and a WebSocket connection between them. This article explains how to build it properly — including the parts that decide whether the project is fun or dangerous.
Architecture
┌────────────┐ Wi-Fi (WebSocket) ┌─────────────┐ PWM ┌────────────┐
│ Browser │ ────────────────────▶ │ ESP32 │ ───────▶ │ Servos/ESC │
│ (Next.js │ {steer, throttle} │ firmware │ └────────────┘
│ dashboard)│ ◀──────────────────── │ │ ADC ┌────────────┐
└────────────┘ {battery, rssi, …} └─────────────┘ ◀─────── │ Battery │
└────────────┘
Three design decisions shape everything else:
- The ESP32 hosts the network. In access point mode it creates its own Wi-Fi network, so the model works in a field with no router. In station mode it joins your home network, which is handy on the bench.
- WebSockets, not HTTP. An HTTP request costs a handshake and headers every time. A WebSocket stays open, so a 10-byte command arrives in a few milliseconds and the model can push telemetry back without being asked.
- The firmware is the authority on safety. The browser only requests movement; the ESP32 decides whether to obey. This single principle is what makes the system safe to reason about.
Hardware you need
- An ESP32 development board (any common dev kit).
- A servo or ESC (electronic speed controller) for throttle.
- A separate, adequate power supply for the motors and servos — usually a BEC (battery eliminator circuit) — with a common ground with the ESP32.
- A voltage divider (two resistors) if you want battery voltage telemetry, because the ESP32's ADC tolerates only about 3.3 V.
A frequent beginner failure: powering servos from the ESP32's 3.3 V or USB rail. A servo drawing current under load causes a voltage dip that resets the board (a brownout). Give the servos their own regulated 5–6 V supply.
Firmware setup with PlatformIO
PlatformIO gives you reproducible builds and dependency management. A minimal platformio.ini:
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
ESP32Async/ESPAsyncWebServer ; async HTTP + WebSocket server (maintained fork)
bblanchon/ArduinoJson ; JSON parsing/serialisation
madhephaestus/ESP32Servo ; servo PWM on the ESP32
The firmware: access point, WebSocket server, servos
The core is short. The important part is what happens around the happy path.
#include <Arduino.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <ESP32Servo.h>
const char* AP_SSID = "RC-Model";
const char* AP_PASS = "change-this-password"; // at least 8 characters
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
Servo steering;
Servo throttle;
const int PIN_STEER = 18;
const int PIN_THROTTLE = 19;
const int PIN_BATTERY = 34; // ADC1 pin (ADC2 conflicts with Wi-Fi)
unsigned long lastCommandMs = 0;
const unsigned long FAILSAFE_MS = 400; // no command for 400 ms → stop
// Map a -100..100 command to a standard 1000–2000 µs servo pulse.
int toMicros(int value) {
value = constrain(value, -100, 100);
return map(value, -100, 100, 1000, 2000);
}
void applyControls(int steer, int thr) {
steering.writeMicroseconds(toMicros(steer));
throttle.writeMicroseconds(toMicros(thr));
}
void failsafe() {
applyControls(0, 0); // centre steering, neutral throttle
}
void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
AwsEventType type, void* arg, uint8_t* data, size_t len) {
if (type == WS_EVT_DISCONNECT) {
failsafe(); // the controller vanished
return;
}
if (type != WS_EVT_DATA) return;
JsonDocument doc;
if (deserializeJson(doc, (const char*)data, len)) return; // ignore malformed input
applyControls(doc["s"] | 0, doc["t"] | 0); // "s" = steer, "t" = throttle
lastCommandMs = millis();
}
void setup() {
Serial.begin(115200);
steering.setPeriodHertz(50); // standard servo refresh rate
throttle.setPeriodHertz(50);
steering.attach(PIN_STEER, 1000, 2000);
throttle.attach(PIN_THROTTLE, 1000, 2000);
failsafe();
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.begin();
}
unsigned long lastTelemetryMs = 0;
void loop() {
const unsigned long now = millis();
// Safety net: if commands stop arriving, stop the model.
if (now - lastCommandMs > FAILSAFE_MS) failsafe();
// Telemetry at 5 Hz is plenty for a dashboard.
if (now - lastTelemetryMs > 200) {
lastTelemetryMs = now;
// 100k/47k divider example: convert the ADC reading to battery volts.
float adcVolts = analogReadMilliVolts(PIN_BATTERY) / 1000.0f;
float batteryVolts = adcVolts * (100.0f + 47.0f) / 47.0f;
JsonDocument out;
out["v"] = batteryVolts;
out["rssi"] = 0; // AP mode: report client RSSI if needed
out["up"] = now / 1000;
String payload;
serializeJson(out, payload);
ws.textAll(payload);
}
ws.cleanupClients(); // free dead connections
}
Read that loop() again, because it contains the most important idea in the whole article.
The failsafe is not optional
A wireless link will drop: the phone sleeps, you walk out of range, the browser tab is throttled in the background. If the last command was "full throttle" and nothing overrides it, the model simply keeps going. That is how RC crashes — and injuries — happen.
The firmware therefore enforces two independent safety behaviours:
- Watchdog timeout — if no valid command arrives for
FAILSAFE_MS, throttle goes to neutral and steering centres. Theloop()check makes this true even if the connection freezes silently. - Disconnect handler — closing the socket triggers the same failsafe instantly.
Good additions for anything larger or faster than a toy:
- Arming logic — throttle output is ignored until the controller sends an explicit "arm" message with the stick at neutral, so the model can never launch the moment it connects.
- Rate limiting the change (slew limiting) — cap how fast throttle can change to avoid violent jumps and brownouts.
- Range limits and calibration — never trust a client to send a sane value; the
constrain()above is the last line of defense. - A hardware kill switch on the battery lead.
The browser side: Gamepad API plus WebSocket
On the web page you need to do two things: read the controls and stream them at a steady, modest rate. The Gamepad API exposes Xbox/PlayStation controllers and many hobby joysticks with no libraries.
const socket = new WebSocket("ws://192.168.4.1/ws"); // the ESP32 access point's default IP
let lastSent = "";
let lastSentAt = 0;
const SEND_INTERVAL_MS = 33; // ~30 Hz
function loop(now: number) {
const pad = navigator.getGamepads()[0];
if (pad && socket.readyState === WebSocket.OPEN && now - lastSentAt >= SEND_INTERVAL_MS) {
const deadzone = (v: number) => (Math.abs(v) < 0.08 ? 0 : v);
const s = Math.round(deadzone(pad.axes[0]) * 100); // left stick X → steering
const t = Math.round(-deadzone(pad.axes[3]) * 100); // right stick Y → throttle
const payload = JSON.stringify({ s, t });
if (payload !== lastSent || now - lastSentAt > 200) { // send changes, plus a heartbeat
socket.send(payload);
lastSent = payload;
lastSentAt = now;
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
socket.onmessage = (e) => {
const t = JSON.parse(e.data); // { v: 7.9, rssi: -52, up: 1234 }
document.querySelector("#battery")!.textContent = `${t.v.toFixed(2)} V`;
};
Several decisions here are worth explaining:
- Fixed send rate (about 30 Hz). Sending on every animation frame floods the ESP32 and Wi-Fi. Around 20–50 Hz feels instant for RC.
- Dead zone. Analogue sticks never rest at exactly zero; without a dead zone the servo twitches constantly.
- Heartbeat. When the sticks are still, the payload does not change — but the firmware's failsafe needs to hear something. Re-sending every 200 ms keeps it satisfied.
- Small integers.
{ "s": 12, "t": -40 }is a dozen bytes. Compact keys and integers matter on a constrained device.
A subtle browser gotcha: background tabs are throttled and
requestAnimationFramecan pause entirely. That is good here — the stream stops and the failsafe stops the model — but design your UI to make it obvious when control is lost.
Measuring latency
Never assume your link is fast; measure it. Send a timestamped ping and time the echo:
setInterval(() => socket.send(JSON.stringify({ ping: performance.now() })), 1000);
// In the firmware's message handler, echo it back:
// if (doc["ping"].is<float>()) client->text(String("{\"pong\":") + String((float)doc["ping"]) + "}");
socket.addEventListener("message", (e) => {
const m = JSON.parse(e.data);
if (m.pong !== undefined) console.log(`RTT ${(performance.now() - m.pong).toFixed(1)} ms`);
});
On a clean, direct ESP32 access-point link, round trips of a few to a few tens of milliseconds are typical, but they degrade quickly with interference, distance and a busy 2.4 GHz band. If you see spikes, try a different Wi-Fi channel (WiFi.softAP(ssid, pass, channel)) and keep antennas clear of metal and carbon fibre.
Telemetry worth sending
Once the return channel exists, the dashboard becomes the fun part:
- Battery voltage — with a low-voltage warning long before the cells are damaged.
- Wi-Fi signal strength (RSSI) — a link-quality bar so you know when you are pushing range.
- Uptime and packet counters — invaluable when debugging dropouts.
- Sensor data — IMU orientation, temperature, GPS, current draw.
For chart-friendly, low-overhead data, keep the schema stable and version it, exactly as you would for any real-time protocol.
Debugging checklist
- ESP32 resets when the motor moves → power problem. Separate supplies, common ground, a bulk capacitor near the servo rail.
- Servos jitter → noisy power, or a missing dead zone, or too many updates per second.
- ADC readings are noisy or non-linear → the ESP32 ADC is imperfect; average several samples, use
analogReadMilliVolts, and calibrate against a multimeter. Use ADC1 pins, since ADC2 is unavailable while Wi-Fi is active. - Connection works on the bench but drops in the field → check antenna placement, Wi-Fi channel and battery sag during load.
- The browser cannot connect to
ws://from an HTTPS page → browsers block mixed content. Serve the dashboard over plain HTTP from the ESP32 itself, or run it locally, or use a secure tunnel.
Where to go from here
- Serve the dashboard directly from the ESP32's flash (LittleFS) so the model carries its own controller — no internet needed.
- Add binary frames for higher control rates and smaller packets.
- Move from Wi-Fi to ESP-NOW for lower latency and longer range when a browser is not required.
- Record every session (commands and telemetry) and replay it to tune the model.
Summary
- A browser plus an ESP32 gives you a programmable, logged, richly-instrumented remote — built entirely from web technology.
- Use a WebSocket for a persistent, low-overhead, bidirectional link, and send small compact messages at a fixed 20–50 Hz.
- Safety belongs in the firmware: watchdog timeout, disconnect failsafe, arming, clamping and a hardware kill switch. The browser is never trusted.
- Most "software" bugs in these projects are actually power and radio problems. Fix the electrons first.
Build it step by step, test with the wheels in the air, and the reward is a machine you can shape with the same tools you use to build websites.