# 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](https://github.com/EFeru/hoverboard-firmware-hack-FOC)), 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 ```bash 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: ```bash ./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 ```bash 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 ` | −1..1 each; `steer` goes through the speed curve | | `vel ` | physical units, no steering curve — the programmatic path | | `fwd ` | closed-loop straight move | | `rot ` | 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.