7.7 KiB
PS4 Controller to Two LEGO Hubs — Johnny 5 Build
Date: 2026-09-23 Project: ps4-lego-bridge (also ps4-lego-onebrain) Status: ongoing Tags: esp32, bluepad32, legoino, nimble, btstack, lwp3, lego-powered-up, ps4, arduino
Context
Goal was to drive a Johnny 5 (Short Circuit) LEGO MOC — seven motors across two
Technic (Control+) hubs — from a PS4 DualShock 4 via ESP32. The starting sketch
combined Bluepad32 and Legoino on one ESP32 and failed to compile
(Bluepad32.h: No such file or directory). The session went through a
single-board attempt, then settled on two boards, then iterated the control
scheme on a separate branch.
Key Decisions
- Two ESP32 boards, not one. Bluepad32 uses BTstack (needed for BR/EDR —
the DS4 is Bluetooth Classic); Legoino uses NimBLE. Two host stacks cannot
share one radio/VHCI. Board A runs Bluepad32 only, board B runs Legoino only,
linked by UART.
- Rejected: single-board via hand-rolled LWP3 GATT client on BTstack
(
ps4-lego-onebrain). It compiled and connected, but was far more code, an unresolved routing fault appeared, and the pivot made that moot. Kept as a separate repo, not maintained. - Rejected: PS4-esp32 (Bluedroid) + BLE on one board — PS4-esp32 depends on internal Bluedroid symbols and is broken on modern cores.
- Rejected: single-board via hand-rolled LWP3 GATT client on BTstack
(
- ASCII UART protocol with XOR checksum over binary — debuggable with any USB-serial adapter. Receiver drops bad frames and stops all motors after 400ms of silence.
- Tank drive, one input per motor. Arcade drive was tried first and rejected: mixing put one stick on both track motors.
- Direction via
DIR_*constants rather than editing signs inline — mirrored mountings are normal and flips should be one-line changes. - Board B pinned to arduino-esp32 core 2.0.17. Core 3.x breaks Legoino. Downgrading chosen over patching the library (patch is the first of several layers and gets reverted by Library Manager updates).
- Arms moved to position control on the evolved branch: trigger position =
arm angle, via
setAbsoluteMotorPositionwithBrakingStyle::HOLD. Rejected: power-based arm control with separate lower buttons — works, but the user wanted angle-proportional behaviour. - Arm encoders zeroed once per boot, not per reconnect. The hub retains its encoder preset; re-zeroing mid-session would redefine zero wherever the arms happened to be.
- Separate branch
johnny5-evolvedfor the new control scheme somainkeeps the proven configuration.
Technical Details
Toolchain
| Board | Package / library | Version |
|---|---|---|
| A | esp32_bluepad32 | 4.1.0 |
| B | esp32 (Espressif) | 2.0.17 |
| B | NimBLE-Arduino | 1.4.x |
| B | Legoino | latest |
- Bluepad32 board manager URL:
https://raw.githubusercontent.com/ricardoquesada/esp32-arduino-lib-builder/master/bluepad32_files/package_esp32_bluepad32_index.json - Selecting the
esp32_bluepad32package starts BTstack beforesetup(). Any NimBLE sketch compiled under it aborts withESP_ERR_INVALID_STATEatesp_bt_controller_init. Board B must use the plainesp32package. - Core 3.x Legoino errors:
'string' in namespace 'std' does not name a typeandno declaration matches ... ReadUInt32LE. Cause: ESP-IDF 5 madeuint32_tunsigned long.
Wiring: A GPIO17 TX → B GPIO16 RX; A GPIO16 RX ← B GPIO17 TX; common GND (mandatory). Optional 5V→5V daisy-chain for single supply — never with both USB cables connected. LEDs on board B: GPIO25 and GPIO26, each via resistor to board B GND (not tapped off the UART ground wire).
Link frame: G,<lx>,<ly>,<rx>,<ry>,<buttons>,<dpad>,<l2>,<r2>*<XX>\n,
115200 8N1. Axes −512..511, triggers 0..1023, XX = XOR of payload.
Port map (verified on hardware)
- hub0
90:84:2b:61:f2:d7— A right track, B left track, C free, D body lift - hub1
90:84:2b:61:e6:8c— A left arm, B head tilt, C head turn, D right arm - Note: the two addresses were originally the wrong way round, and hub1 A/C and B/C were both mislabelled at points. All found by observation, not code.
Arm calibration: travel ≈ +280 (left) / −275 (right) motor degrees — under one revolution, so the single-turn absolute encoder is unambiguous. Spans set to ±250 to keep backlash clear of the end stop.
Rate limits: MOTOR_MIN_GAP_MS 100 (per port), HUB_MIN_GAP_MS 25 (per
hub, ~40 cmd/s), ARM_MIN_GAP_MS 60. Hub1 carries four motors, hence the
per-hub cap.
Arduino gotchas hit this session
- The IDE injects generated prototypes before the first function definition.
Types used in signatures (
HubLink,PortState,ArmState,Frame) must be declared above it — broke the build twice. Lpf2Hub::init()starts an asynchronous scan. Guarding it on!isConnected() && !isConnecting()re-entersNimBLEDevice::init()continuously and crashes. Use a one-shotinitialisedflag.- A scan that finds nothing ends silently; without a timeout the hub sits dead
until power cycle. Fixed with
scanExpiresAt/SCAN_TIMEOUT_MS(12s). - Bluepad32 keeps pairing keys in NVS; reflashing doesn't clear them.
BP32.forgetBluetoothKeys()must be commented out after first pairing.
Startup order: wake both hubs (green button, blinking) → arms physically down → power board B. hub1 is only serviced after hub0 connects.
Code Changes
transmitter/transmitter.ino— frame extended with d-pad and analog triggers;DEBUG_FRAMESflag.receiver/receiver.ino(main) — seven motors, tank drive,DIR_*constants, swapped hub addresses, right-arm button swap, types hoisted, core 2.0.17 noted in header.receiver/receiver.ino(johnny5-evolved) — evolved scheme, position- controlled arms, GPIO LED toggles, head tilt/turn port swap, once-per-boot arm zeroing, scan timeout.tools/arm_calibrate/arm_calibrate.ino— measures arm span in motor degrees (delivered as a file; Gitea push timed out — verify it's on the branch).README.md(main) — build guide, toolchain table, power section, power-loss test, gotchas.
Evolved control scheme (johnny5-evolved)
| Input | Action |
|---|---|
| Left / right stick Y | Left / right track |
| L2 / R2 analog | Left / right arm angle |
| L1 / R1 | Head turn left / right |
| D-pad ↑/↓ | Body lift |
| D-pad ←/→ | Head tilt |
| Square | All stop (arms go limp) |
| Cross | Toggle 50/100% track speed |
| Circle / Triangle | Toggle LED pair 1 / 2 |
Open Questions / Next Steps
- Head direction untested.
DIR_HEAD_TURNandDIR_HEAD_TILTare +1 by default; flip if L1/R1 or d-pad ←/→ run backwards. - Scan timeout tuning. 12s is a guess; don't go below ~6s.
- Power-loss test not yet run. The 400ms failsafe only fires if board B is alive. Cut board B's power with a track running and confirm whether the hub stops on BLE disconnect.
- Heavy-load test. Both arms held mid-travel plus head swinging is the peak hub1 command load — check it doesn't drop.
- Arm calibrate sketch — confirm it landed on
johnny5-evolved. - Merge decision —
johnny5-evolvedintomainonce verified. - Boot track speed defaults to 50%; user may want 100%.
Artifacts
- Repo: https://gitea.hideawaygaming.com.au/jessikitty/ps4-lego-bridge
(branches
main,johnny5-evolved) - Single-board attempt: https://gitea.hideawaygaming.com.au/jessikitty/ps4-lego-onebrain
- Bluepad32 docs: https://bluepad32.readthedocs.io/
- LEGO Wireless Protocol 3.0: https://lego.github.io/lego-ble-wireless-protocol-docs/
- Device type IDs: https://github.com/pybricks/technical-info/blob/master/assigned-numbers.md
- Legoino: https://github.com/corneliusmunz/legoino
Created by: Jess Rogerson (yelling commands at Claude.AI)