# 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. - **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 `setAbsoluteMotorPosition` with `BrakingStyle::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-evolved`** for the new control scheme so `main` keeps 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_bluepad32` package starts BTstack **before `setup()`**. Any NimBLE sketch compiled under it aborts with `ESP_ERR_INVALID_STATE` at `esp_bt_controller_init`. Board B must use the plain `esp32` package. - Core 3.x Legoino errors: `'string' in namespace 'std' does not name a type` and `no declaration matches ... ReadUInt32LE`. Cause: ESP-IDF 5 made `uint32_t` `unsigned 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,,,,,,,,*\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-enters `NimBLEDevice::init()` continuously and crashes. Use a one-shot `initialised` flag. - 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_FRAMES` flag. - `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_TURN` and `DIR_HEAD_TILT` are +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-evolved` into `main` once 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)