Files
ps4-lego-bridge/README.md
T
jessikitty 9dd74d9d60 Rewrite as a step-by-step build guide for the Johnny 5 setup
Adds bring-up order (pair the pad before wiring, verify the port map with
DEBUG_MOTORS before trusting it), the tank drive control table, the extended
link protocol, and the wiring failure modes.
2026-09-09 19:30:18 +10:00

6.5 KiB

ps4-lego-bridge

PS4 controller -> two LEGO Technic hubs, using two ESP32 boards.

Driving a Johnny 5 (Short Circuit) MOC — seven motors across two hubs.

Why two boards

The obvious sketch — Bluepad32 for the gamepad, Legoino for the hubs, one ESP32 — does not work, and it is not a library-version problem.

  • Bluepad32 needs Bluetooth Classic (BR/EDR), because a DualShock 4 is a BR/EDR device. It gets that from BTstack, and it ships as a whole replacement ESP32 board package rather than a normal library.
  • Legoino talks to the hubs over BLE using NimBLE-Arduino.

BTstack and NimBLE are both Bluetooth host stacks. There is one radio and one VHCI interface on the chip, and whichever stack registers second wins. You cannot run both in one firmware image.

So: board A runs Bluepad32 and nothing else. Board B runs Legoino and nothing else. Three jumper wires between them.

(The single-board version, which speaks the LEGO protocol directly against BTstack, lives in ps4-lego-onebrain. It works, but it is a lot more code and a lot more ways to be wrong.)

Hardware

  • 2x ESP32 dev boards. Both must be the original ESP32 (WROOM/WROVER). S3, C3, C6 and H2 have no Bluetooth Classic, so a PS4 pad will not pair.
  • 3 jumper wires.
  Board A (Bluepad32)          Board B (Legoino)
  GPIO17 TX  ------------->    GPIO16 RX
  GPIO16 RX  <-------------    GPIO17 TX
  GND        --------------    GND          <- do not skip this one

TX goes to RX, not TX to TX. If nothing arrives, that swap is the first thing to check.

Power both from the same supply if you can. Separate USB bricks are fine as long as the grounds are tied together — without a common ground the UART has no shared voltage reference and you get garbage or silence.

Step by step

1. Board A — the gamepad board.

Preferences -> Additional board manager URLs, add:

https://raw.githubusercontent.com/ricardoquesada/esp32-arduino-lib-builder/master/bluepad32_files/package_esp32_bluepad32_index.json

Boards Manager -> install esp32_bluepad32. Tools -> Board -> pick ESP32 Dev Module from under esp32_bluepad32, not the plain esp32 group. Getting that wrong is what produces fatal error: Bluepad32.h: No such file or directory.

Flash transmitter/transmitter.ino. Nothing else needs installing — Bluepad32 lives inside the board package. Do not install Legoino or NimBLE on this board.

2. Pair the pad, before wiring anything.

Open Serial Monitor at 115200. Hold SHARE + PS on the controller until the light bar flashes. You want Controller connected in slot 0. Once it pairs reliably, comment out BP32.forgetBluetoothKeys() in setup() — it is in there to clear stale pairings, and leaving it means re-pairing on every boot.

Set DEBUG_FRAMES to 1 temporarily and confirm frames stream past as you move the sticks. If they do, board A is finished.

3. Board B — the hub board.

Boards Manager -> the standard esp32 package by Espressif. Library Manager -> NimBLE-Arduino, pinned to 1.4.x (Legoino has not moved to the 2.x API, and 2.x gives a wall of compile errors), then Legoino.

Flash tools/hub_scanner/hub_scanner.ino first. Press each hub's green button and note the addresses it reports, then paste them into receiver.ino as HUB0_ADDR and HUB1_ADDR.

4. Verify the port map before you trust it.

Flash receiver/receiver.ino with DEBUG_MOTORS set to 1. Every motor command logs which hub and port it lands on. Move one control at a time and check the log matches what physically moves. This is worth doing properly — a swapped hub address or a motor in the wrong port looks exactly like a software bug and will waste an afternoon.

Set DEBUG_MOTORS back to 0 once it checks out.

5. Wire the boards together per the diagram above, power both, and drive it.

Onboard LED on board A is solid when the pad is connected. On board B it is solid when both hubs are connected.

Controls

Tank drive — every input drives exactly one motor.

Input Function
Left stick Y Left track (hub 0 port B)
Right stick Y Right track (hub 0 port A)
D-pad up / down Head tilt (hub 1 port A)
D-pad left / right Head turn (hub 1 port B)
R2 / L2 Body lift up / down, proportional (hub 0 port D)
Square / Circle Left arm up / down (hub 1 port C)
Triangle / Cross Right arm up / down (hub 1 port D)
L1 held Precision, 40% track speed
R1 held Full, 100% track speed
L1 + R1 All stop

Default track scale is 75%. Stick X axes are unused.

Everything except the tracks runs into a mechanical end stop, and there is no position feedback, so holding a direction at a stop stalls the motor. That is what HEAD_MAX, LIFT_MAX and ARM_MAX are for. Lower them if an axis feels forceful, and do not hold a direction once an axis has stopped moving.

ASCII, newline terminated, 115200 8N1:

G,<lx>,<ly>,<rx>,<ry>,<buttons>,<dpad>,<l2>,<r2>*<XX>\n
  • axes are Bluepad32 raw values, -512..511
  • buttons is the 16-bit mask, dpad the 8-bit mask, both decimal
  • l2/r2 are the analog triggers, 0..1023
  • XX is a two-digit hex XOR checksum of everything before the *

Plain text means you can watch the link with any USB-serial adapter when something misbehaves. The receiver drops any frame that fails the checksum, and stops all motors if nothing valid arrives for 400ms.

Things that bite

  • Nothing arrives at board B. TX/RX swapped, or no common ground. Both are silent failures.
  • One hub connects, the other does not. Legoino shares a single NimBLE scanner. receiver.ino connects them strictly one at a time for this reason — do not "optimise" that into two parallel init() calls.
  • Motors stutter or a hub drops out. Commands are outrunning the hub. Raise MOTOR_MIN_GAP_MS (per port) or HUB_MIN_GAP_MS (per hub). Hub 1 carries four motors, which is why the per-hub limit exists at all.
  • Wrong motor command. Technic/Control+ motors want setTachoMotorSpeed; train and simple PU motors want setBasicMotorSpeed. Toggle USE_TACHO_MOTORS.
  • Button masks. The values in receiver.ino are Bluepad32's standard layout. If a face button does the wrong thing, set DEBUG_BUTTONS in the transmitter, press each one, and correct the constants.
  • More than 3 hubs later on. Edit CONFIG_BT_NIMBLE_MAX_CONNECTIONS in NimBLE-Arduino/src/nimconfig.h, then restart the IDE to force a rebuild.

Created by: Jess Rogerson (yelling commands at Claude.AI)