Files
ps4-lego-bridge/.claude-context/sessions/2026-09-23-johnny5-ps4-control.md
T

7.7 KiB
Raw Blame History

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,<lx>,<ly>,<rx>,<ry>,<buttons>,<dpad>,<l2>,<r2>*<XX>\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


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