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

152 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- 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)