Files
ps4-lego-onebrain/README.md
T

5.9 KiB

ps4-lego-onebrain

One ESP32. PS4 controller over Bluetooth Classic, two LEGO Powered Up hubs over BLE, both on the same Bluetooth stack.

This is the harder of the two routes. If you want the boring one that works first go, see ps4-lego-bridge — two boards and a UART between them.

Build and deploy instructions: docs/BUILD.md. This will not compile out of the box — the board package ships the BTstack headers but does not put them on the include path, and that has to be fixed first.

How it avoids the stack collision

The reason the obvious sketch fails is that Bluepad32 (BTstack) and Legoino (NimBLE) are two Bluetooth host stacks fighting over one radio. The fix here is to have only one stack: BTstack does the gamepad and the hubs.

Bluepad32 v4 already compiles BTstack with LE central and a GATT client, since it supports BLE gamepads like the Xbox v5 firmware pads. That machinery is sitting there unused for our purposes, so this sketch borrows it and speaks the LEGO Wireless Protocol directly. No Legoino, no NimBLE.

What you give up: everything Legoino did for you. Sensor callbacks, port device detection, tacho positioning, hub name reads. What you get back is one board and about 300 lines.

The two rules

1. Threading. BTstack is not thread-safe, and loop() does not run on the BTstack task. From the Bluepad32 docs, the only BTstack call that is safe from anywhere is btstack_run_loop_execute_on_main_thread().

So the LEGO side lives entirely inside a repeating BTstack timer, which is scheduled once at boot via that one safe call. loop() never touches BTstack — it reads the sticks and writes three plain ints into gDesired, and the timer picks them up. Any BTstack call added to loop() will crash at random places, which is the worst kind of bug to chase.

2. Boot order. Gamepad discovery starts disabled. Both hubs connect first, then BP32.enableNewBluetoothConnections(true) opens up for the pad. Bluepad32's inquiry and LE scan otherwise run at the same time as our LE create-connection and the hub connects unreliably or not at all.

Layout

ps4_lego_onebrain/ps4_lego_onebrain.ino   the sketch
docs/BUILD.md                             install, include-path fix, first run
docs/platform.local.txt                   the include-path override, for restoring
                                          after a Bluepad32 package update

The protocol, in full

Everything the motors need is one 8-byte write, no response, to characteristic 00001624-1212-EFDE-1623-785FEABCD123 inside service ...1623...:

08 00 81 <port> 11 51 00 <power>
│  │  │  │      │  │  │  └── int8, -100..100 (127 = brake)
│  │  │  │      │  │  └───── mode 0x00 = POWER
│  │  │  │      │  └──────── subcommand 0x51 = WriteDirectModeData
│  │  │  │      └─────────── 0x11 = execute immediately + command feedback
│  │  │  └────────────────── port: A=0x00 B=0x01 C=0x02 D=0x03, LED=0x32
│  │  └───────────────────── message type 0x81 = Port Output Command
│  └──────────────────────── hub ID, always 0x00
└─────────────────────────── message length

That same shape sets the hub LED — port 0x32, payload is a colour index (0x06 is green, which the sketch uses to show a hub came up).

Mode 0 is direct power, so it works on train motors and Technic motors alike. There is no tacho-vs-basic distinction to get wrong here. The trade-off is that you get no closed-loop speed control — power is power, and the motor slows under load. StartSpeed (subcommand 0x07) does regulate, if you want it later.

Full spec: https://lego.github.io/lego-ble-wireless-protocol-docs/

What to expect on the bench

Verified: the Bluepad32 board-package install, the threading contract and btstack_run_loop_execute_on_main_thread(), the BTstack GATT client call signatures, the LWP3 message encoding above, and — on Jess's machine — that package 4.1.0 ships every header this needs under tools\sdk\esp32\include\.

Not verified, because it needs the hardware powered up:

  • Address type. LEGO's OUI is 90:84:2B, a public range, so the sketch uses BD_ADDR_TYPE_LE_PUBLIC. If the connect times out repeatedly with no LE connection-complete event, switch HUB_ADDR_TYPE to BD_ADDR_TYPE_LE_RANDOM.
  • Whether BLE is enabled in your Bluepad32 build. If gap_connect never produces a connection-complete, this is the next thing to check.
  • The LE connection-complete event name. BTstack moved this from HCI_EVENT_LE_META/HCI_SUBEVENT_LE_CONNECTION_COMPLETE to HCI_EVENT_META_GAP/GAP_SUBEVENT_LE_CONNECTION_COMPLETE. The sketch handles both, with the newer one behind #ifdef, so whichever your version emits will land. Guarded by connection state so a BLE gamepad connecting can't be mistaken for a hub.

Turn on verbose BTstack logging before you start guessing. Nearly every failure here shows up as a specific HCI event you can read.

Known rough edges

  • Commands are throttled to one per port per 60ms (MOTOR_MIN_GAP_MS). Push that lower and the hub's BLE queue backs up and it drops the link. Stops are exempt and always go out immediately.
  • Write-without-response can fail silently when ACL buffers are full. The tick retries 25ms later, so a dropped command is invisible in practice, but that is why you should not treat a single write as a guarantee.
  • There is no failsafe timer on the gamepad side beyond isConnected(). If the pad goes out of range the stack can take 20 seconds to notice. The Bluepad32 FAQ has a hasData() plus timeout pattern worth adding if this ends up driving something that can fall off a table.

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