Files
ps4-lego-onebrain/README.md
T

116 lines
5.7 KiB
Markdown

# ps4-lego-onebrain
One ESP32. PS4 controller over Bluetooth Classic, two LEGO Powered Up hubs over
BLE, both on the same Bluetooth stack.
Driving a Johnny 5 (Short Circuit) MOC — seven motors across two Technic hubs.
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: [docs/BUILD.md](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.
**Controls and port map: [docs/CONTROLS.md](docs/CONTROLS.md).**
## 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, tacho
positioning, hub name reads. What you get back is one board and about 450 lines.
Port detection we got back cheaply — see below.
## 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 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/CONTROLS.md port map, control layout, power caps
docs/platform.local.txt the include-path override, for restoring
after a Bluepad32 package update
```
## The protocol
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.
Going the other way, the sketch subscribes to notifications on the same
characteristic and decodes two inbound messages:
* **`0x04` Hub Attached I/O** — the hub announces every populated port the
instant notifications go live, so the serial log tells you what is actually
plugged in rather than what you think is. Device type IDs come from
[pybricks/technical-info](https://github.com/pybricks/technical-info/blob/master/assigned-numbers.md).
* **`0x05` Generic Error** — rejected commands used to vanish silently.
Full spec: https://lego.github.io/lego-ble-wireless-protocol-docs/
## Known rough edges
* **No end-stop protection.** Every axis except the tracks runs into a
mechanical stop and there is no position feedback, so holding a direction at
a stop stalls the motor. The power caps in `docs/CONTROLS.md` are the only
thing standing between you and stripped gears. Control+ motors have absolute
encoders, so this is fixable properly with Port Input Format Setup (`0x41`).
* 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 do not
treat a single write as a guarantee.
* 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.
* Rate limits are tuned by feel, not measurement — see `docs/CONTROLS.md`.
If something misbehaves, turn on verbose BTstack logging first. Nearly every
failure here shows up as a specific HCI event you can read.
---
Created by: Jess Rogerson (yelling commands at Claude.AI)