Mecanum Wheel Control on ESP32: Keep All Motion Logic in the Firmware

The app sends intent (strafe, drive, rotate); the ESP32 does the wheel mixing, gear limits, dead-band compensation and fail-safe. Why that split is right, and the numbers behind it.

One design decision on the robot has paid off more than any other: the wire protocol has no command that addresses an individual motor. The app sends what it wants the vehicle to do; the firmware decides what each wheel does. This article explains the split, the mixing maths, and the small details (dead-band, gears, watchdog) that turn a mixing formula into something that feels good to drive.

The protocol carries intent, not wheel speeds

The Android app reads two joysticks and normalises them to -1..1:

Field Source Meaning
drive.x left stick, horizontal strafe (right positive)
drive.y left stick, vertical forward/back (forward positive)
turret.x right stick, horizontal body rotation (clockwise positive)
turret.y right stick, vertical camera pitch (servo)

That is all. The app does not know how many wheels the robot has, which side each one is on, or which ones are wired backwards.

Mixing on the firmware side

For a mecanum chassis, given desired body velocities (vx, vy, wz):

front_left  = vy + vx + wz
front_right = vy - vx - wz
rear_left   = vy - vx + wz
rear_right  = vy + vx - wz
FRONT FL FR RL RR vy vx wz rollers form an X when seen from above FL = vy + vx + wz FR = vy − vx − wz RL = vy − vx + wz RR = vy + vx − wz then clamp to ±1, apply gear limit, map |v| onto [35 % start duty, limit], flip per-wheel INVERT flags. vx = strafe (right +) · vy = forward (+) · wz = spin (CW +) The app sends only these three numbers.
Roller orientation seen from above, the three body velocities the app sends, and the four-line mix.

After mixing, each value is clamped to ±1 and passed through the stages below. All of this lives in one function, ApplyMix(), in the board’s motor driver.

Because mixing is linear, combined motions come for free. Push both stick axes and the robot goes diagonally. Hold drive.x and turret.x together and the robot orbits a point while keeping its front — and the camera — pointed at it. That is the single most useful move in a combat match, and the app contains zero code for it.

Try it — this is the same arithmetic the firmware runs, including the dead-band remap from the next section:

Mecanum mixer — drag the stick

Wheelmixduty
FL0.000 %
FR0.000 %
RL0.000 %
RR0.000 %

vx 0.00 · vy 0.00 · stopped

duty = 35 % + |mix| × (gear − 35 %), sign = direction. Values clamp at ±1.

Dead-band: map the stick to the usable duty range

The first version mapped stick deflection directly to PWM duty. Small deflections produced duties below what the N20 motors need to overcome friction, so the stick had a large dead zone followed by a sudden jump.

The fix is to map the stick’s magnitude linearly onto [start duty, gear limit] instead of [0, gear limit]. For these motors the start duty is about 35 %:

duty = start_duty + |stick| * (gear_limit - start_duty)

Measured: stick at 0.4 gives 220 RPM, full deflection gives 470 RPM, and there is no dead zone. The requirement from the person driving it was “a small push should be slow, a hard push should be fast” — this is what delivers that.

Gears

Three limits, selectable over MQTT: 40 %, 70 %, 100 %. Changing gear mid-motion takes effect on the next packet, and the firmware answers with a status packet as an acknowledgement so the app can update its indicator.

Per-wheel configuration stays in one header

Pins, hall pulses-per-revolution, gear ratio, and a per-wheel MOTOR_xx_INVERT flag all live in config.h. When a motor gets wired backwards (it happens every rebuild), one flag flips and neither the mixing code nor the app changes.

If no drive packet arrives for 300 ms, all four wheels brake. This is what makes the robot safe to drive over a lossy link, and it is also what shaped the transport: the app must send at 20-25 Hz while moving, which in turn is why the control path moved from MQTT to direct UDP.

The watchdog caught one more design later on. A voice-command feature (“go forward”) issued a single drive command, and the robot moved for exactly 300 ms before the watchdog braked. The fix was a TimedDrive(vx, vy, wz, duration_ms) primitive that starts a timer to feed the watchdog internally, with a hard cap of 5 seconds. One-shot commands now get an explicit duration instead of fighting the safety mechanism.

Peripheral budget

Job Peripheral Note
Motor PWM, 4 motors × 2 inputs LEDC, 8 channels, 20 kHz All eight channels used
Hall speed sensing PCNT, 4 units A-phase only: speed, not direction
Camera pitch servo MCPWM, 50 Hz Because LEDC was full
Infrared TX/RX RMT

If you are adding a PWM device to a similar build: LEDC is gone; use MCPWM or RMT.

Why the split is right

  1. Combined moves are automatic. Anything expressible as (vx, vy, wz) works with no app changes.
  2. Hardware changes stay local. New motors, new wheels, a reversed connector — edit config.h.
  3. Safety is where it belongs. The watchdog brakes the motors in firmware regardless of what the app, the network, or the phone’s battery is doing.

The one thing the app does need to know is how fast to send. That is a property of the protocol, not of the chassis, and it is written down in the protocol doc where it belongs.