Why I Replaced MQTT with Direct IPv6 UDP for Robot Joystick Control
Cloud MQTT relay jitter kept tripping a 300 ms link-loss watchdog and made the motors stutter. A direct UDP channel over IPv6, discovered via the MQTT heartbeat, fixed it. Design notes and packet format.
The first version of the robot’s remote control sent joystick values from the Android app to a cloud MQTT broker, which relayed them to the ESP32. It worked in the sense that the wheels turned. It did not work in the sense that anyone would want to drive it.
The symptom
The firmware has a link-loss watchdog: if no drive command arrives for 300 ms, the motors brake. That is the correct safety behaviour; a robot that keeps driving after losing contact is a robot that drives into a wall.
Through the cloud relay, packet spacing was not stable. Most arrived within 50 ms of each other, but often enough one would take 300-600 ms. Every time that happened the watchdog braked, the next packet released the brake, and the result was a motor that ran in a go-stop-go-stop stutter. Wheel speed in the telemetry bounced between 330 RPM and 55 RPM and repeatedly hit zero. It felt broken, and no amount of tuning the app’s send rate helped, because the jitter was in the relay.
Options considered
- Lengthen the watchdog. Rejected. A 1-second timeout means a 1-second runaway on real link loss.
- Run a local MQTT broker. Removes the internet leg, but MQTT is still TCP with in-order delivery; one lost segment stalls everything behind it. Also adds a broker to maintain.
- Direct UDP from phone to robot. No relay, no head-of-line blocking, a late packet is simply superseded by the next one. This is what game controllers and drone links do.
Option 3 was the obvious choice for the data path. The only real question was addressing.
Why IPv6 only
Consumer IPv4 in the deployment environment is behind carrier-grade NAT; the phone on cellular cannot reach the robot on home Wi-Fi without a relay or a VPN. IPv6 removes that problem: both ends have globally routable addresses, and the only thing in the way is the home router’s inbound firewall, which is a one-time configuration.
So the firmware was made IPv6-only for motion commands (v2.2.17):
| Path | Result |
|---|---|
| IPv6 UDP to port 8208 | Accepted — the only motion input |
| IPv4 UDP | Refused; the socket is IPV6_V6ONLY, the board answers ICMP port-unreachable |
MQTT drive / turret |
Rejected and logged |
MQTT stop |
Still accepted — a brake should work from any channel |
The last row matters. Tightening the input paths must never remove a way to make the robot stop.
Discovery and packet format
The robot still publishes an MQTT heartbeat every few seconds. That heartbeat now carries the fields the app needs to open the direct channel:
{"ipv6": "2409:...:abcd", "ctrl_port": 8208, "version": "2.2.21", ...}
MQTT stays as the control plane (discovery, telemetry, OTA, gear selection, infrared). UDP is the data plane.
A drive packet is small JSON:
{"type": "drive", "x": 0.0, "y": 0.35, "k": "243639"}
x,yare the normalised stick position, -1 to 1.kis a shared secret (the MQTT password) so a stray packet cannot drive the robot. Not cryptographically strong, but it stops accidents; a proper HMAC is on the list.- The app sends at 20-25 Hz while the stick is deflected, one
stopwhen it returns to centre, andpingat 20 Hz when idle (for reasons covered in the Wi-Fi power-save article). pingis answered with the full status packet, including per-wheel RPM, so the app gets RTT and telemetry from one exchange.
The same socket also carries the IMU subscription stream; packets are demultiplexed on the type field.
Results
- Stutter gone completely at 25 Hz.
- Round-trip P50 = 10 ms on the local network once the link is kept awake.
- No change to the motor-control code; only the transport changed.
What I would tell someone starting from scratch
- Put a link-loss watchdog in the firmware from day one, then design the transport to satisfy it. Do not weaken the watchdog to accommodate a bad transport.
- Use MQTT for what it is good at (discovery, low-rate state, commands that must not be lost) and UDP for the real-time stream.
- If you go IPv6-only, make sure the phone side actually has IPv6 — most mobile carriers do, but not every Wi-Fi network. Plan the failure message in the app.
- Keep an emergency stop that works over every channel, including the ones you have otherwise closed.