Files
jessikitty 8fac5c893e Record the working configuration
- Pin the toolchain versions, with the Legoino/core 3.x error text.
- Note DIR_* direction constants and the corrected control table.
- Add the swapped-hub-address story to step 4, and the Arduino
  prototype-ordering trap to the gotchas list.
2026-09-10 11:40:34 +10:00

258 lines
11 KiB
Markdown

# 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.
**Status: working on hardware.** The configuration in this repo is the one that
runs, not the one that ought to.
## 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.)
## Toolchain versions — pin these
| 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 |
Board B is the fussy one. **Core 3.x will not build Legoino.** ESP-IDF 5 changed
`uint32_t` from `unsigned int` to `unsigned long`, so Legoino's own header and
.cpp no longer agree, and it never included `<string>` itself. The symptoms are:
```
'string' in namespace 'std' does not name a type
no declaration matches 'uint32_t LegoinoCommon::ReadUInt32LE(uint8_t*, int)'
```
Downgrade the core rather than patching the library — patching works, but it is
the first of several layers and any Library Manager update silently reverts it.
Board A is unaffected; it uses a separate package.
## 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, or 4 if you daisy-chain power.
```
Board A (Bluepad32) Board B (Legoino)
GPIO17 TX -------------> GPIO16 RX
GPIO16 RX <------------- GPIO17 TX
GND -------------- GND <- do not skip this one
5V -------------- 5V <- only in the model setup, see below
```
TX goes to RX, not TX to TX. If nothing arrives, that swap is the first thing
to check.
Without a common ground the UART has no shared voltage reference and you get
garbage or silence, so that wire is not optional even when both boards have
their own USB.
## Power
Two arrangements, and the choice matters more than it looks.
**Bench setup — separate USB, no 5V wire.** Both boards on their own USB cables.
You get both serial monitors and can reflash either board without unplugging
anything. Use this for everything up to step 5.
**Model setup — one supply, 5V jumper.** USB into board A, then a fourth wire
from **A's 5V pin to B's 5V pin** (labelled VIN on some boards). Ground is
already joined by the wire you have.
Use 5V to 5V, never 3.3V to 3.3V. Each board's onboard AMS1117 regulator makes
its own 3.3V, and tying the outputs together back-feeds one regulator from the
other.
**Never have both USB cables plugged in while the 5V wire is connected.** That
ties two host supplies together through your jumper. Some devkits have a Schottky
diode on VBUS that prevents it, plenty do not, and you cannot tell by looking.
Pull the 5V jumper before plugging in a USB cable to reflash.
**Current budget.** An ESP32 idles around 80-120mA and peaks at a few hundred mA
on Bluetooth transmit. Two of them off one USB 2.0 port sits right at the 500mA
that port promises. It usually works, but a marginal supply shows up as random
reboots that look exactly like a software fault. A 1A+ charger or power bank
removes the doubt.
**Diode drops.** Many boards put a diode between VBUS and the 5V pin, so board
A's 5V pin sits nearer 4.7V. Through board B's own diode you are at maybe 4.4V
into a regulator wanting roughly 1.1V of headroom. It works, but the margin is
thin. If you are running off a power bank anyway, prefer separate leads to each
board's 5V pin over chaining B off A — same wire count, no stacked drops.
## Test the power-loss case deliberately
The 400ms failsafe in `receiver.ino` only fires if the receiver is *running*. If
board B loses power mid-command — and on a shared supply that now happens
whenever board A does — the failsafe cannot fire, and whether the motors stop is
left to the hub's own behaviour on BLE disconnect.
Find that out on purpose rather than by accident. Set a track running slowly,
pull power from board B, and watch what the motor does. If it keeps running you
want a physical switch on the hubs within arm's reach before driving this
anywhere interesting.
Worth doing the same test by pulling the UART wire instead — that path *does*
hit the failsafe, and confirming it works takes ten seconds.
## 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, version 2.0.17.
Library Manager -> **NimBLE-Arduino** pinned to **1.4.x**, then **Legoino**. See
the toolchain table above for why the versions matter.
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`.
Nothing else may be holding the hubs. A hub accepts one connection at a time and
stops advertising once taken, so power down any other ESP32 and close the LEGO
Powered Up app before scanning.
**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 step is not optional busywork. On this build the two hub addresses were the
wrong way round, and the symptom — an arm moving when the track stick moved —
looks exactly like a software bug. It is not one, and no amount of reading the
code will find it.
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) |
| Cross / Triangle | 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.
**Motor directions** are the `DIR_*` constants at the top of `receiver.ino`. Flip
one to `-1` if an axis runs backwards — mirrored mountings are normal on a
symmetric model. `DIR_RIGHT_TRACK` is `-1` on this build.
**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.
## Link protocol
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
* **`'Frame' has not been declared`, or any type "not declared" at its own
definition.** The Arduino IDE injects generated prototypes immediately before
the first function definition in the file. Any type used in a signature must be
declared above that point — which is why `HubLink`, `PortState` and `Frame` all
sit in one block near the top. Do not move a function above them.
* **Legoino will not compile.** Core 3.x. See the toolchain table.
* **Nothing arrives at board B.** TX/RX swapped, or no common ground. Both are
silent failures.
* **Random reboots under load.** Supply, not software. See the current budget
above.
* **Hubs never connect.** Something else is holding them, or they went to sleep.
Power down other controllers, close the LEGO app, press the green buttons.
* **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)