Dashboard firmware for scooters with two controllers, running on a Guition JC3248W535 (ESP32-S3, 3.5" 320x480 IPS, capacitive touch).
I wanted to run two Xiaomi Pro 2 controllers on one scooter. The stock dash only talks to one, so this replaces it: it reads the throttle and brake, drives both controllers, shows everything on a touch screen, and both controllers stay reachable from ScooterHacking Utility over Bluetooth.
- Two controllers, each on its own half-duplex bus (no collisions between them)
- Throttle, brake, buttons and headlight on the display; throttle/brake go to both controllers
- Two Bluetooth devices,
72vT_ESC1and72vT_ESC2. Connect ScooterHacking to either one and everything (settings, current limits, stock firmware and SHFW flashing) goes to that controller only - Works with controllers on Xiaomi Pro 2 firmware (
55 AA), Ninebot G30 protocol firmware (5A A5) or Ninebot G2 (5A A5, beta, see below), switchable in the settings - Speed, battery, voltage, power, both controller temperatures, trip, odometer, ride mode, light, error codes of both controllers
- Settings on the touch screen: brightness, KERS, cruise control, units, screen rotation, throttle and brake calibration, Bluetooth options, power off
- Info page per controller: serial, firmware versions, live values, answer time, frame counters
- Bus sniffer and USB trace for debugging, PC simulator of the UI, host test of the bus code
The display acts like the stock dashboard, which I recorded with the built-in sniffer:
0x65(throttle/brake) every 20 ms, every 5th frame is a0x64that also asks for the display data (mode, battery, light, speed, error)- frames from the phone go to the controller unchanged, answers go back unchanged. While a phone is connected the display makes no reads of its own
- the controller asks the dashboard for its name after a restart (
21 01 00); the display answers - firmware updates: own traffic stops at
07, one08packet at a time with the same pacing as the stock dash, own traffic comes back right after0A - a controller running SHFW sends a status message every ~750 ms without checking the line. The display learns that rhythm and does not start long packets into it; a packet that still gets no answer is sent again (same packet, before the app times out)
In practice flashing through SimpleNineBotDash is a bit faster than through the stock dash.
| Function | GPIO | Notes |
|---|---|---|
| ESC1 data | 17 | single wire, 115200 8N1 |
| ESC2 data | 18 | single wire |
| Throttle | 9 | hall sensor powered from 3V3 |
| Brake | 6 | hall sensor powered from 3V3 |
| Button 1 | 7 | to GND: click = light, double click = mode, hold 2 s = power off |
| Button 2 (optional) | 15 | to GND: click = mode, hold 2 s = cruise control |
| Headlight | 16 | logic level MOSFET / driver, active high |
All pins are in src/config/board_config.h. They are on the P2 header (5, 6, 7, 15, 16, 46, 9, 14)
and the UART header. Don't use GPIO5 for anything: on this board it is connected to the battery
voltage divider.
Wiring notes:
- Common GND between the display and both controllers.
- Data line: check the idle voltage first, it should be around 3.3 V. A 1-2.2 kΩ pull-up to 3V3 and a 100-330 Ω series resistor between the ESP pin and the line are recommended. The pin is open drain while listening and push-pull while sending.
- Separate TX/RX instead of a single wire: set different
ESCx_TX_PIN/ESCx_RX_PIN. - Power: the board draws 300-400 mA with the backlight on. Use a small step-down (5 V, 1 A) from the switched battery line rather than the controller's 5 V output.
- Power button: on the Pro 2 the power button goes to the controller. One button wired to both controllers through two diodes turns both on together. Single/double clicks on that button change light and mode in the controllers; the display follows them.
Needs Arduino IDE 2.x (its bundled arduino-cli is used), the esp32 core 3.3.x and these
libraries: lvgl 9.1+, GFX Library for Arduino 1.5.x, NimBLE-Arduino 2.2.x.
./build.sh # build -> build/SimpleNineBotDash.ino.bin and build/SimpleNineBotDash_0x0.bin
./build.sh COM5 # build and upload
CLEAN=--clean ./build.sh # full rebuild (after renaming or deleting source files)LVGL is configured with -D flags in build.sh (LV_CONF_SKIP), so no lv_conf.h is needed and
other projects keep theirs. A plain "Upload" from the Arduino IDE doesn't pass those flags, use the
script. Board: ESP32S3 Dev Module, 16 MB flash, OPI PSRAM, 3 MB app partition, USB CDC on boot.
- First time:
flash.cmd(double click) writesbuild/SimpleNineBotDash_0x0.binat0x0. This also clears the saved settings. - Updates:
update.cmdwrites only the program at0x10000, settings and calibration stay. - Without the Arduino IDE: https://espressif.github.io/esptool-js/ , address
0x0, fileSimpleNineBotDash_0x0.bin.
If the upload doesn't start: hold BOOT, tap RESET, release BOOT.
- Settings (tap the top centre of the dash) → Controller firmware: Pro 2, G30 or G2 beta. Changing it restarts the display.
- Lever calibration: Both released, then Full throttle, then Full brake.
- With the wheel in the air, check the throttle. If the controller doesn't reach full power, raise
Throttle byte full; if the wheel turns by itself, lower Throttle byte released
(defaults
0x2C..0xC5, brake0x2C..0xB5). - Info page: both controllers online, serial and versions shown, counters running.
The throttle is only passed on after it has been seen released once after power on. A broken or shorted lever wire is treated as released and shown as a fault.
- Bottom row: tap LIGHT or MODE.
- Top centre: settings. Settings → Info: both controllers side by side.
- Warnings (controller errors first, then lever faults, offline controllers) take turns in the top line.
Two devices show up: 72vT_ESC1 and 72vT_ESC2. Connect to one, change what you want, disconnect,
connect to the other. Set both controllers the same way, especially speed and current limits.
In Xiaomi mode the display advertises like a stock Pro 2 dashboard (product id 0x0766, model
byte 0x28), in G30 mode like a stock G30 dashboard (24 .. .. .. .. D9), in G2 mode like a
stock G2 dashboard (model byte 0x83). It speaks the unencrypted protocol.
Written from the stock G2 dashboard BLE firmware 1.11.0, not from a recording of a real G2. What that firmware does:
-
Bus to the controller: plain Ninebot frames (
5A A5), same addresses and commands as the G30, nothing encrypted on the wire. Frames from the phone for the controller or the BMS are passed on unchanged, frames for the dashboard (0x21) are answered by the dashboard itself. -
The throttle/brake frames are longer than on the G30. The first data byte is the number of bytes that follow:
G30 G2 0x6504 TH BR 00 0005 TH BR FL EV ST0x6406 TH BR 00 00 verL verH08 TH BR FL EV ST verL verH 00FLflags (0x40= unlocked),EVbutton event,STscooter state (1unlocked / riding,4locked: both levers are sent as idle). The display always sendsFL 0x40,EV 0,ST 1and BLE version 1.11.0 (B0 01). Lever values are the same scale as the G30 (0x28idle). -
Bluetooth: the stock G2 dashboard advertises
4E 42 83 02 01 00 00 79and talks to the app with the Ninebot BLE encryption (AES/SHA1 with the usual Ninebot key), plus Apple Find My and a HID keyboard. The display does none of that: like in the other modes it clears the pairing byte (83 00 01 00 00) and speaks unencrypted.
Unknown until someone tries it on a G2:
- whether the G2 controller accepts the frames above and drives (if not, it stays at zero throttle, check TX/RX on the Info page)
- whether ScooterHacking accepts an unencrypted G2 dashboard
- the display data of the
0x64answer is decoded like on the G30
Test with the wheel in the air. Bus traces (Settings → USB trace) of a G2 are welcome.
- Settings → USB trace: every bus frame with a millisecond timestamp on the USB serial port
(115200).
Aphone,Ddisplay,Wlocal write,Rrepeated update packet,<controller,L/lcontroller asking the display / the display's answer. - Settings → Bus sniffer: the display only listens (no throttle!) and logs both directions. Connect GPIO17 in parallel to a stock dash to record what it does. Not saved, off after restart.
- Settings → Demo mode: two simulated controllers with a recorded ride, no scooter needed.
- Info → Answer time: average and maximum answer time of each controller.
sim/ builds the real UI code for Windows (MSYS2 ucrt64 gcc), with the demo controllers:
sim/build_sim.sh
sim/SimpleNineBotDashSim.exe # mouse = touch; L light, M mode, C cruise, B phone, 1/2 ESC offline,
# E error, P Pro 2/G30/G2
sim/SimpleNineBotDashSim.exe --shots DIR # screenshots of all pages (add --g30 / --g2)sim/bus_test.cpp runs the real bus code against a virtual controller on a simulated single-wire
line (115200 baud, echo, collisions, configurable answer time, flash write time, SHFW status
messages):
cd sim
g++ -std=gnu++17 -O1 -DP2D_SIM -DP2D_DEBUG=0 -Iinclude -o bus_test.exe bus_test.cpp \
../src/bus/esc_bus.cpp ../src/bus/esc_common.cpp ../src/bus/esc_demo.cpp ../src/bus/proto.cpp ../src/core/demo.cpp
./bus_test.exe 20 5 # 20 ms answer time, scenario 5 = stock firmware over SHFW
./bus_test.exe 20 1 2 # scenario 1 with a G2 controller (model 0 Pro 2, 1 G30, 2 G2)Scenarios: 0 no phone, 1 phone reads, 2 custom command, 3 firmware upload, 4 SHFW install flow, 5 stock firmware over SHFW (with status messages).
- G30 mode with real G30 controllers (only in the simulator so far)
- G2 mode (beta, see above)
- Ride mode values of register
0x75(ECO 1 / D 0 / S 2), check Mode raw on the Info page - Sign of the battery current (
CURRENT_SIGNinapp_config.h)
This firmware controls the throttle and brake of a vehicle and can flash motor controllers. Test everything with the wheel in the air, keep a way to recover a controller (ST-Link), and use it at your own risk.



