2026-09-20 18:55:16 +02:00
2026-09-20 18:55:16 +02:00
2026-09-20 18:55:16 +02:00
2026-09-20 18:55:16 +02:00
2026-09-20 18:55:16 +02:00

TranspOtter

A hoverboard mainboard driven as a differential-drive robot base, with an ESP32-C3 Supermini as the motion controller.

Folder What it is
hoverboard-firmware-hack-FOC/ The STM32 mainboard firmware (EFeru), configured here for heavy loads and programmatic moves
esp32/ PlatformIO project for the ESP32-C3 Supermini: jerk-limited motion planning, speed-dependent steering, closed-loop distance and rotation

The mainboard runs closed-loop speed control on each wheel. The ESP32 decides what the wheels should do and shapes every change in command so a loaded chassis never lurches.


Wiring

The ESP32 hangs off the left sideboard cable (USART2, the long one). Both ends are 3.3 V logic, so no level shifter is needed — just three signal-level connections and a regulator for power. Note that PA2/PA3 are not 5 V tolerant: nothing running at 5 V may touch these two wires.

   Hoverboard mainboard                          ESP32-C3 Supermini
   left sideboard cable (4-pin)
   ┌──────────────────────────┐
   │ red    15 V, 200 mA max  │──► buck 15V→5V ──► 5V pin
   │ blue   PA2  / USART2 TX  │───────────────────► GPIO20  (ESP32 RX)
   │ green  PA3  / USART2 RX  │◄──────────────────  GPIO10  (ESP32 TX)
   │ black  GND               │───────────────────► GND      (also buck GND)
   └──────────────────────────┘
Mainboard Wire MCU pin ESP32-C3 Note
USART2 TX blue PA2 GPIO20 mainboard talks, ESP32 listens
USART2 RX green PA3 GPIO10 ESP32 talks, mainboard listens
GND black — GND must be common, connect this first
15 V red — 5V via buck converter 200 mA max; never straight to the ESP32

Cross the two data wires: TX goes to RX. Pin numbering and wire colours are from hoverboard-firmware-hack-FOC/docs/pictures/mainboard_pinout.png.

GPIO20 and GPIO10 are adjacent on the Supermini header and neither is a strapping pin. Steer clear of GPIO2, GPIO8 and GPIO9 for this link — they decide boot mode, GPIO9 is wired to the BOOT button (which would short the mainboard's TX line every time you flash), and GPIO8 drives the onboard LED. GPIO21 is left free on purpose: the ROM bootloader prints its log there at every reset.

Power

The 15 V rail on the sideboard cable cannot feed the Supermini directly — its 5V pin expects 5 V. Put a small buck converter (MP1584, LM2596 or similar) in between and set it to 5.0 V before connecting it. The ESP32-C3 peaks around 350 mA at 3.3 V, which is roughly 90 mA off the 15 V rail, comfortably inside the 200 mA budget.

Three things worth getting right:

  • Do not power from USB and the buck at the same time. Most Supermini boards have no reverse-current protection on the 5V pin. Either unplug the buck while flashing over USB, or fit a Schottky diode in series with the buck output.
  • The 15 V rail is only live when the mainboard is on. The ESP32 loses power when the board latches off (power button, low battery, inactivity timeout). That is intentional: no mainboard, no controller.
  • Never feed 3.3 V or 5 V into the mainboard from the ESP32 side.

Choosing the other cable instead

To move the link to the right cable (USART3, PB10 = TX, PB11 = RX): swap the two CONTROL_SERIAL_USART2 / FEEDBACK_SERIAL_USART2 lines in the firmware's Inc/config.h for their USART3 equivalents and rebuild. The right cable is 5 V tolerant and shorter; the left cable is longer and leaves USART3 free, which is the only port that can do I²C if a character LCD is ever wanted.

Which cable the firmware listens on and which one you physically wire must match. They fail in a distinctive way if they don't: the mainboard is both deaf and mute, so you get three repeating low beeps (its serial-timeout code) and no feedback bytes at the ESP32. A single broken wire kills only one direction; losing both at once means wrong cable, swapped blue/green, or no common ground.


Mainboard firmware configuration

Configured in hoverboard-firmware-hack-FOC/Inc/config.h. Every deviation from upstream is tagged [robot], so grep -n '\[robot\]' Inc/config.h lists them.

Setting Value Why
VARIANT_USART enabled the ESP32 drives the board over UART
CONTROL_SERIAL_USART2 / FEEDBACK_SERIAL_USART2 enabled link on the left cable, bidirectional
TANK_STEERING enabled per-wheel commands; the ESP32 owns the kinematics
CTRL_MOD_REQ SPD_MODE closed-loop rpm — holds commanded speed under load
N_MOT_MAX 200 rpm ≈1.7 m/s on 6.5" wheels; low ceiling buys resolution and torque margin
I_MOT_MAX / I_DC_MAX 20 A / 22 A headroom for heavy loads and hill starts
ENABLE_ODOMETRY enabled hall tick counters in the feedback frame
RATE 160 on-board ramp limit, 2000 command units/s — a backstop under the ESP32's ramp
INACTIVITY_TIMEOUT 30 min a robot idling between jobs should not switch itself off
BEEPS_BACKWARD 0 programmatic reversing should not beep; fault beeps still work

Why speed mode. In VLT_MODE a command sets motor voltage, so the same command gives a different speed empty than loaded, and a slope changes it again. SPD_MODE closes the loop on measured rpm: the commanded speed is the speed you get, which is what makes a distance or rotation command repeatable. The trade is that the wheels actively hold zero — the robot resists being pushed, and a stalled wheel will pull up to I_MOT_MAX continuously. Keep an eye on boardTemp under sustained load.

Build and flash

cd hoverboard-firmware-hack-FOC
./build.sh                 # ~34 KB, uses the variant set in Inc/config.h
./flash.sh -u              # first flash of a stock board only: clears read-out protection
./flash.sh -b              # build and flash

Power the board from its battery and hold the power button for the whole operation, or feed 3.3 V from the ST-Link with the battery disconnected. An erased chip cannot hold its own power latch, so the board switches itself off the moment you let go of the button.

GigaDevice GD32F103 mainboards

Some boards carry a GD32F103 instead of the STM32. Same firmware, same wiring:

./flash-gd32.sh -b         # or: ./flash.sh -c gd32 -b
./flash-gd32.sh -s 0x20000 # if yours is a 128 KB part

There is no gd32f1x.cfg in OpenOCD; the GD32 is programmed through the same stm32f1x driver. The one thing that reliably goes wrong is flash size detection — GD32 parts often report the wrong size, and OpenOCD then refuses to write past the size it believes in. The GD32 path states the size outright rather than asking: 256 KB by default, which is the RCT6 these boards carry. st-flash has no equivalent override, so GD32 always goes via OpenOCD.


ESP32 controller

cd esp32
pio run                    # build
pio run -t upload          # flash over USB-C
pio device monitor         # console at 115200

What it does

Jerk-limited motion. Linear and angular velocity are each planned with a limit on acceleration and on how fast acceleration itself may change. A command step becomes an S-curve, so a loaded chassis starts and stops without the forward-backward rock you get from a step in torque. Tune with A_MAX_MPS2, J_MAX_MPS3, ALPHA_MAX and J_MAX_ALPHA in include/robot_config.h; lower jerk means gentler, slower transitions.

Speed-dependent steering. Angular authority is scaled by

gain(s) = STEER_GAIN_HIGH + (STEER_GAIN_LOW - STEER_GAIN_HIGH) · e^(-s / STEER_KNEE)

where s is the current speed as a fraction of maximum. With the defaults, a full steer deflection spins on the spot at a standstill (gain 1.00) and becomes a gentle curve at full speed (gain 0.22), with most of the change happening inside the first 15% of the speed range. Raise STEER_KNEE to spread the transition over a wider speed band; lower STEER_GAIN_HIGH for an even calmer feel at speed. The gain is evaluated against actual speed every tick, so it fades in and out as the robot accelerates.

Closed-loop moves. fwd and rot count hall ticks from the feedback frame (5.76 mm of travel per tick on 6.5" wheels) and brake on a profile that accounts for the jerk ramp, so they stop on the mark instead of sailing past it. A straight move holds its starting heading; a rotation holds station. Both finish inside ~10 mm / ~1°.

Console

Command Effect
drive <throttle> <steer> −1..1 each; steer goes through the speed curve
vel <m/s> <rad/s> physical units, no steering curve — the programmatic path
fwd <metres> closed-loop straight move
rot <degrees> closed-loop spin on the spot, + is left
stop ramp down through the jerk limits
estop zero the command immediately, then latch until arm
zero reset the odometry accumulators
speed <0..1> joystick throttle scale, saved to flash
resp <0..1> acceleration sharpness, saved to flash
tel [on|off], status telemetry stream / one-shot dump

drive and vel are standing orders with a 2 s deadman: refresh them or the controller ramps down. fwd and rot end by arriving.


Remote control

The ESP32 hosts its own WiFi network and serves the control page from flash — no router, no app, nothing to fetch.

  1. Join transpotter, password otterdrive (both in robot_config.h)
  2. Open http://192.168.4.1

The page gives a touch joystick, a speed limit, a response slider with three presets, STOP / E-STOP / ARM / ZERO, and live telemetry at 10 Hz.

The browser sends a drive frame every 100 ms whether or not the stick moved, and the firmware answers each one with telemetry — so the exchange doubles as a heartbeat. Background the tab or walk out of range and REMOTE_DEADMAN_MS (400 ms) ramps the machine down. The console keeps a relaxed 2 s deadman, since nobody types at 5 Hz. The speed limit is applied on the device rather than in the page, so a stale browser tab cannot ask for more than the saved limit.

Range is the weak point: the Supermini's onboard antenna measured a signal of 54 against a 27–100 spread of local networks. Fine across a room. If you need more, a C3 module with a u.FL connector beats any firmware change.

Response: acceleration sharpness

One setting scales acceleration and jerk on both axes between two tunings, live and saved to flash. Top speed is separate — speed caps how fast, response governs how hard it gets there.

Acceleration 0 → 0.8 m/s Use
HAUL (0.00) 0.50 m/s² 1.75 s Long distances at walking pace, loaded
NORMAL (0.45) ~0.95 m/s² ~1.0 s General purpose
INDOOR (1.00) 1.50 m/s² 0.60 s Tight, reactive spaces

Both ends are measured on hardware, not calculated. The bounds are A_MAX_MPS2/J_MAX_MPS3 (soft) and A_MAX_SHARP/J_MAX_SHARP (sharp) in robot_config.h; the closed-loop braking profile reads the live values, so fwd and rot stop correctly at any response setting.

Settings are written to NVS 1.5 s after the last change — dragging a slider does not hammer the flash.

Bring-up

Put the chassis on blocks with both wheels off the ground for steps 1–4.

  1. Flash both halves. Power the mainboard from its battery, press the power button, and open the ESP32 console. It holds a zero command for 1.5 s so the mainboard arms its motors, then prints armed.
  2. status — link=1 and a sane battery voltage mean the serial link is good in both directions. If link=0, the two data wires are probably not crossed.
  3. vel 0.2 0 — both wheels should turn forward. If one runs backwards, set INVERT_LEFT or INVERT_RIGHT in include/robot_config.h and reflash.
  4. zero, vel 0.2 0 for a second, stop, then status — dist should be positive and head near zero. A negative distance means both odometry channels are inverted; a large heading means one is. Fix with INVERT_ODOM_LEFT / INVERT_ODOM_RIGHT.
  5. Measure the distance between the wheel contact patches and set TRACK_WIDTH_M. On the floor, rot 360 should come back to where it started — if it overshoots, the track width is set too small.
  6. Check fwd 1 against a tape measure and adjust WHEEL_DIAMETER_M if needed.

Then raise the limits in robot_config.h to suit the load.

Protocol

100 Hz, 115200 8N1, little-endian, XOR checksum. The mainboard cuts the motors if no valid command arrives for 0.8 s.

command   (8 bytes)   uint16 0xABCD │ int16 left │ int16 right │ uint16 checksum
feedback (22 bytes)   uint16 0xABCD │ int16 cmd1, cmd2, speedR, speedL,
                                      wheelR_cnt, wheelL_cnt, batVoltage,
                                      boardTemp │ uint16 cmdLed │ uint16 checksum

left/right are ±1000, mapping linearly to ±N_MOT_MAX rpm in speed mode. The wheel counters wrap at 9000 (100 wheel revolutions), which hoverTickDelta() unwraps. The feedback layout above assumes ENABLE_ODOMETRY — without it the two counters are absent and the frame is four bytes shorter.

The console grammar is the natural interface for an app to drive over BLE or WiFi later: the same verbs, one line each.

Safety

  • Commission with the wheels off the ground. These motors have real torque.
  • estop is a software stop. Fit a physical battery disconnect for anything carrying a load.
  • I_MOT_MAX is raised to 20 A. Watch boardTemp during the first loaded runs; the mainboard's own overheat cutoff is disabled until calibrated (see the temperature section of Inc/config.h).
  • Raising N_MOT_MAX scales every speed in the system, including what a runaway would do.
S
Description
No description provided
Readme
84 KiB
Languages
C++ 89.3%
C 5.9%
Shell 3.6%
Python 1.2%