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

  1. Lengthen the watchdog. Rejected. A 1-second timeout means a 1-second runaway on real link loss.
  2. 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.
  3. 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.

Before · cloud relay Phone Internet MQTT broker TCP, in-order Internet Robot gaps > 300 ms Watchdog brakes on every gap → motors stutter go-stop-go, 330 → 55 RPM. After · direct IPv6 UDP Phone UDP :8208 · drive / ping / imu · 20–25 Hz P50 RTT 10 ms · late packet simply superseded Robot steady 40 ms spacing MQTT (control plane) heartbeat carries ipv6 + ctrl_port for discovery · telemetry · gear · OTA · IR · emergency stop still accepted
Relay jitter versus direct UDP. MQTT stays for discovery, telemetry and OTA; the real-time stream goes point to point.

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"}

The same socket also carries the IMU subscription stream; packets are demultiplexed on the type field.

Results

What I would tell someone starting from scratch