Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c21f804812 | ||
|
|
602028ed01 | ||
|
|
e1c5d00972 | ||
|
|
069e7ebdd5 | ||
|
|
4f3087aedd | ||
|
|
b2016565f7 | ||
|
|
0c149272bd | ||
|
|
343544510f | ||
|
|
f5e7517cbd |
No files matched your search
@@ -1,27 +0,0 @@
|
||||
# ps4-lego-bridge — Overview
|
||||
|
||||
PS4 DualShock 4 controller driving a Johnny 5 (Short Circuit) LEGO MOC: seven
|
||||
motors across two LEGO Technic (Control+) hubs, plus two GPIO LED circuits.
|
||||
|
||||
## Architecture in one paragraph
|
||||
Two ESP32 boards. **Board A** (`transmitter/`) runs Bluepad32 on the
|
||||
`esp32_bluepad32` 4.1.0 package, reads the pad over Bluetooth Classic, and
|
||||
streams checksummed ASCII frames over UART. **Board B** (`receiver/`) runs
|
||||
Legoino on plain `esp32` core 2.0.17 with NimBLE-Arduino 1.4.x, parses frames,
|
||||
and drives both hubs over BLE. Two boards because BTstack and NimBLE are both
|
||||
Bluetooth host stacks and cannot share one radio.
|
||||
|
||||
## Branches
|
||||
- `main` — proven configuration: tank drive, power-controlled arms.
|
||||
- `johnny5-evolved` — current development: position-controlled arms on the
|
||||
triggers, head on L1/R1 and d-pad, LED toggles, scan timeout.
|
||||
|
||||
## Where to start
|
||||
- `README.md` — build guide, toolchain versions, wiring, power, gotchas.
|
||||
- `.claude-context/sessions/` — per-session notes.
|
||||
|
||||
## Session Log
|
||||
- 2026-09-23 — PS4 Controller to Two LEGO Hubs — Johnny 5 Build — Pivoted from single to dual ESP32, got all seven motors working on main, then built position-controlled arms, LED toggles and scan retry on johnny5-evolved.
|
||||
|
||||
---
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
@@ -1,151 +0,0 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,75 @@
|
||||
# ESP32 pinout reference
|
||||
|
||||
For the original **ESP32-WROOM-32 / WROVER** devkit, which is what both boards
|
||||
in this project are. S3, C3, C6 and H2 have different maps entirely — and no
|
||||
Bluetooth Classic, so they cannot run board A at all.
|
||||
|
||||
## Already in use
|
||||
|
||||
| Pin | Board | Used for |
|
||||
| --- | --- | --- |
|
||||
| GPIO2 | A and B | onboard status LED |
|
||||
| GPIO16 | A and B | UART2 RX (link) |
|
||||
| GPIO17 | A and B | UART2 TX (link) |
|
||||
| GPIO25 | B | LED circuit 1 (Circle) |
|
||||
| GPIO26 | B | LED circuit 2 (Triangle) |
|
||||
|
||||
## Free and safe for output
|
||||
|
||||
Use these first. No strapping duties, no boot-time side effects.
|
||||
|
||||
```
|
||||
4 13 14 18 19 21 22 23 27 32 33
|
||||
```
|
||||
|
||||
That is eleven spare pins, which is plenty for more LEDs, a buzzer, servos or
|
||||
switches. GPIO21 and GPIO22 are the default I2C pins and GPIO18/19/23 the
|
||||
default SPI pins, but nothing in this project uses either bus, so they are free
|
||||
here.
|
||||
|
||||
## Never use
|
||||
|
||||
| Pin | Why |
|
||||
| --- | --- |
|
||||
| GPIO6–11 | wired to the on-board SPI flash chip. Touching these bricks the running firmware. |
|
||||
| GPIO12 | strapping pin (MTDI). Pulled high at boot it sets the flash voltage to 1.8V and the board will not boot. |
|
||||
| GPIO1, GPIO3 | UART0, the USB serial console. Using them breaks uploads and the serial monitor. |
|
||||
|
||||
## Usable, with a catch
|
||||
|
||||
| Pin | Catch |
|
||||
| --- | --- |
|
||||
| GPIO0 | strapping. Must be high at boot or the board enters flash-download mode. Has a button on most devkits. |
|
||||
| GPIO2 | strapping, and the onboard LED. Must be low or floating at boot. Fine as an output once running — this project uses it. |
|
||||
| GPIO5 | strapping. Outputs a PWM burst during boot, so anything attached will twitch at power-on. |
|
||||
| GPIO15 | strapping. Emits boot log noise unless pulled low. |
|
||||
|
||||
## Input only — cannot drive an LED
|
||||
|
||||
```
|
||||
34 35 36 39
|
||||
```
|
||||
|
||||
These have no output drivers and no internal pull-ups. Fine for buttons with an
|
||||
external pull-up, or for analog input. Connect an LED and nothing happens.
|
||||
|
||||
## Current limits
|
||||
|
||||
A single pin is comfortable at about **12mA** and hard-limited near **40mA**.
|
||||
The whole chip should stay under roughly 200mA across all pins at once.
|
||||
|
||||
Two or three small LEDs per pin is fine. Anything beyond about 20mA per circuit
|
||||
— which pre-wired "3V" LEDs sized for a coin cell often draw — wants a small
|
||||
NPN transistor or a logic-level MOSFET, with the pin driving the base or gate
|
||||
rather than the LED directly.
|
||||
|
||||
## ADC note
|
||||
|
||||
ADC2 pins (0, 2, 4, 12–15, 25–27) cannot be read with `analogRead()` while
|
||||
WiFi is active. Irrelevant to this project, which uses no WiFi and no analog
|
||||
input, but worth knowing before adding a potentiometer. ADC1 pins (32–39) work
|
||||
regardless.
|
||||
|
||||
---
|
||||
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
@@ -0,0 +1,88 @@
|
||||
# Wiring
|
||||
|
||||

|
||||
|
||||
Pin availability and limits: [ESP32-PINOUT.md](ESP32-PINOUT.md).
|
||||
|
||||
## Board to board
|
||||
|
||||
| Board A (esp32_bluepad32) | | Board B (esp32 2.0.17) |
|
||||
| --- | --- | --- |
|
||||
| GPIO17 TX | → | GPIO16 RX |
|
||||
| GPIO16 RX | ← | GPIO17 TX |
|
||||
| GND | — | GND |
|
||||
| 5V | — | 5V (model setup only, see README) |
|
||||
|
||||
TX goes to RX, not TX to TX. The ground is a shared voltage reference so the
|
||||
two boards agree on what a logic 1 is — without it you get garbage or silence.
|
||||
It is not a return path for anything else, so do not hang LED current off it.
|
||||
|
||||
## LED circuits — recommended build
|
||||
|
||||
Each LED gets its own wire out and its own wire back to its own GND pin. Four
|
||||
wires, no shared junction anywhere.
|
||||
|
||||

|
||||
|
||||
```
|
||||
GPIO25 ──[220Ω]──▶|── RED pair ──── GND (a)
|
||||
GPIO26 ──[100Ω]──▶|── GREEN pair ── GND (b)
|
||||
```
|
||||
|
||||
GND (a) and GND (b) are different physical pins but the same net inside the
|
||||
board. That is fine, and it is exactly what makes this safe: there is no shared
|
||||
connection point to wire incorrectly.
|
||||
|
||||
Both circuits live entirely on **board B**. LED long leg (anode) faces the
|
||||
resistor and GPIO side. A reversed LED simply stays dark, so it looks like a
|
||||
dead circuit rather than a wiring error.
|
||||
|
||||
Resistor values are a starting point: roughly 220Ω for red, 100Ω for blue or
|
||||
white. A pin is comfortable at about 12mA and hard-limited near 40mA. If a pair
|
||||
draws more than ~20mA — which pre-wired "3V" LEDs sized for a coin cell often
|
||||
do — drive it through a small NPN transistor or MOSFET instead of off the pin.
|
||||
|
||||
## Shared ground rail — the other way
|
||||
|
||||
Also valid, one wire fewer, but it has a junction that can be wired wrong:
|
||||
|
||||
```
|
||||
GPIO25 ──[220Ω]──▶|── RED pair ────┐
|
||||
├── ground rail ── board B GND
|
||||
GPIO26 ──[100Ω]──▶|── GREEN pair ──┘
|
||||
```
|
||||
|
||||
The two returns must meet **only at the ground rail**, and the rail goes
|
||||
straight to a GND pin. See the failure mode below for what happens when that
|
||||
junction lands in the wrong place.
|
||||
|
||||
## Known failure mode: LEDs wired in series
|
||||
|
||||
Symptom seen on this build — green only lights when red is already on, and
|
||||
green never lights alone:
|
||||
|
||||
| Software state | Observed |
|
||||
| --- | --- |
|
||||
| red on, green off | red on ✓ |
|
||||
| red off, green on | **both off** ✗ |
|
||||
| red on, green on | both on ✓ |
|
||||
| red off, green off | both off ✓ |
|
||||
|
||||
Cause: the green return lands on the *anode* side of the red LED rather than on
|
||||
the ground rail, putting the two LEDs in series. Green alone then has to push
|
||||
through both — roughly 2.1V plus 1.9V — and a 3.3V pin cannot do it, so neither
|
||||
lights. With GPIO25 also high, the junction is lifted and both conduct.
|
||||
|
||||
Two rows of that table are impossible from a software fault, which is what
|
||||
identifies it as wiring.
|
||||
|
||||
**Test:** with both LEDs off, meter from the shared return wire to a GND pin.
|
||||
It must read 0V. Press Circle and measure again — if it rises above 0V, that
|
||||
wire is not ground.
|
||||
|
||||
**Fix:** switch to the separate-ground build above. It removes the junction
|
||||
entirely, so this cannot recur.
|
||||
|
||||
---
|
||||
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 460" width="700" height="460" role="img" aria-labelledby="t2 d2">
|
||||
<title id="t2">LED wiring with separate ground pins - recommended</title>
|
||||
<desc id="d2">Board B GPIO25 goes through a 220 ohm resistor to the red LED pair and returns to its own GND pin. Board B GPIO26 goes through a 100 ohm resistor to the green LED pair and returns to a different GND pin. Four wires total and the two circuits never touch each other, so neither can end up in series with the other.</desc>
|
||||
<style>
|
||||
.bg{fill:#fbfaf8}
|
||||
.box{fill:#fff;stroke-width:1.2}
|
||||
.t{stroke:#2E8B8B;fill:#eef7f7}
|
||||
.r{stroke:#D85A30;fill:#fdf1ec}
|
||||
.g{stroke:#3D9A50;fill:#eef8f0}
|
||||
.n{stroke:#888780;fill:#f4f4f2}
|
||||
text{font-family:ui-sans-serif,system-ui,'Segoe UI',Helvetica,Arial,sans-serif;fill:#20201d}
|
||||
.h{font-size:15px;font-weight:600}
|
||||
.s{font-size:12px}
|
||||
.xs{font-size:11px;fill:#5a5a54}
|
||||
.w{stroke-width:2;fill:none}
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="700" height="460"/>
|
||||
<defs><marker id="ar2" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>
|
||||
|
||||
<rect class="box t" x="24" y="30" width="118" height="300" rx="10"/>
|
||||
<text class="h" x="83" y="59" text-anchor="middle">BOARD B</text>
|
||||
<text class="xs" x="83" y="76" text-anchor="middle">receiver.ino</text>
|
||||
|
||||
<rect class="box r" x="36" y="100" width="94" height="26" rx="4"/><text class="s" x="83" y="118" text-anchor="middle">GPIO 25</text>
|
||||
<rect class="box n" x="36" y="150" width="94" height="26" rx="4"/><text class="s" x="83" y="168" text-anchor="middle">GND (a)</text>
|
||||
<rect class="box g" x="36" y="216" width="94" height="26" rx="4"/><text class="s" x="83" y="234" text-anchor="middle">GPIO 26</text>
|
||||
<rect class="box n" x="36" y="266" width="94" height="26" rx="4"/><text class="s" x="83" y="284" text-anchor="middle">GND (b)</text>
|
||||
|
||||
<line class="w" x1="132" y1="113" x2="180" y2="113" stroke="#D85A30"/>
|
||||
<rect class="box r" x="182" y="100" width="86" height="26" rx="4"/><text class="s" x="225" y="118" text-anchor="middle">220Ω</text>
|
||||
<line class="w" x1="270" y1="113" x2="318" y2="113" stroke="#D85A30"/>
|
||||
<rect class="box r" x="320" y="94" width="168" height="38" rx="6"/>
|
||||
<text class="s" x="404" y="111" text-anchor="middle">RED pair</text>
|
||||
<text class="xs" x="404" y="126" text-anchor="middle">long leg faces this side</text>
|
||||
<line class="w" x1="490" y1="113" x2="556" y2="113" stroke="#D85A30"/>
|
||||
<line class="w" x1="558" y1="113" x2="558" y2="163" stroke="#888780"/>
|
||||
<line class="w" x1="556" y1="163" x2="134" y2="163" stroke="#888780" marker-end="url(#ar2)"/>
|
||||
|
||||
<line class="w" x1="132" y1="229" x2="180" y2="229" stroke="#3D9A50"/>
|
||||
<rect class="box g" x="182" y="216" width="86" height="26" rx="4"/><text class="s" x="225" y="234" text-anchor="middle">100Ω</text>
|
||||
<line class="w" x1="270" y1="229" x2="318" y2="229" stroke="#3D9A50"/>
|
||||
<rect class="box g" x="320" y="210" width="168" height="38" rx="6"/>
|
||||
<text class="s" x="404" y="227" text-anchor="middle">GREEN pair</text>
|
||||
<text class="xs" x="404" y="242" text-anchor="middle">long leg faces this side</text>
|
||||
<line class="w" x1="490" y1="229" x2="620" y2="229" stroke="#3D9A50"/>
|
||||
<line class="w" x1="622" y1="229" x2="622" y2="279" stroke="#888780"/>
|
||||
<line class="w" x1="620" y1="279" x2="134" y2="279" stroke="#888780" marker-end="url(#ar2)"/>
|
||||
|
||||
<text class="s" x="404" y="318" text-anchor="middle">Four wires. The two loops never touch each other anywhere.</text>
|
||||
|
||||
<rect class="box g" x="24" y="346" width="652" height="92" rx="8"/>
|
||||
<text class="h" x="42" y="373">Why this version cannot fail the same way</text>
|
||||
<text class="s" x="42" y="395">There is no shared junction to get wrong. Each LED has one path out and one path back, so</text>
|
||||
<text class="s" x="42" y="413">neither can end up in series with the other. GND (a) and GND (b) are different physical pins</text>
|
||||
<text class="s" x="42" y="431">but the same net inside the board - that is fine, and it is what makes this safe to do.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,90 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 740" width="760" height="740" role="img" aria-labelledby="t d">
|
||||
<title id="t">Johnny 5 wiring pinout - both ESP32 boards and both LED circuits</title>
|
||||
<desc id="d">Board A runs Bluepad32 and pairs with the PS4 controller. Board A GPIO17 goes to Board B GPIO16; Board A GPIO16 goes to Board B GPIO17; the grounds are joined as a shared reference. Board B runs Legoino, connects to two LEGO hubs over BLE, and drives two independent LED circuits: GPIO25 through a 220 ohm resistor to the red LED pair and back to a ground rail, and GPIO26 through a 100 ohm resistor to the green LED pair and back to the same ground rail. Each LED has its own complete path; they are never in series.</desc>
|
||||
<style>
|
||||
.bg{fill:#fbfaf8}
|
||||
.box{fill:#fff;stroke-width:1.2}
|
||||
.p{stroke:#7F77DD;fill:#f3f2fd}
|
||||
.t{stroke:#2E8B8B;fill:#eef7f7}
|
||||
.r{stroke:#D85A30;fill:#fdf1ec}
|
||||
.g{stroke:#3D9A50;fill:#eef8f0}
|
||||
.b{stroke:#3B6FB5;fill:#eef3fb}
|
||||
.n{stroke:#888780;fill:#f4f4f2}
|
||||
text{font-family:ui-sans-serif,system-ui,'Segoe UI',Helvetica,Arial,sans-serif;fill:#20201d}
|
||||
.h{font-size:15px;font-weight:600}
|
||||
.s{font-size:12px}
|
||||
.xs{font-size:11px;fill:#5a5a54}
|
||||
.w{stroke-width:2;fill:none}
|
||||
</style>
|
||||
<rect class="bg" x="0" y="0" width="760" height="740"/>
|
||||
<defs><marker id="ar" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>
|
||||
|
||||
<rect class="box r" x="30" y="20" width="150" height="40" rx="8"/>
|
||||
<text class="s" x="105" y="39" text-anchor="middle">PS4 controller</text>
|
||||
<text class="xs" x="105" y="54" text-anchor="middle">Bluetooth Classic</text>
|
||||
<line class="w" x1="105" y1="60" x2="105" y2="94" stroke="#888780" marker-end="url(#ar)"/>
|
||||
|
||||
<rect class="box p" x="30" y="98" width="230" height="182" rx="12"/>
|
||||
<text class="h" x="145" y="124" text-anchor="middle">BOARD A</text>
|
||||
<text class="xs" x="145" y="141" text-anchor="middle">esp32_bluepad32 4.1.0</text>
|
||||
<text class="xs" x="145" y="156" text-anchor="middle">transmitter.ino</text>
|
||||
<rect class="box p" x="170" y="170" width="84" height="24" rx="4"/><text class="s" x="212" y="187" text-anchor="middle">TX 17</text>
|
||||
<rect class="box p" x="170" y="200" width="84" height="24" rx="4"/><text class="s" x="212" y="217" text-anchor="middle">RX 16</text>
|
||||
<rect class="box n" x="170" y="230" width="84" height="24" rx="4"/><text class="s" x="212" y="247" text-anchor="middle">GND</text>
|
||||
|
||||
<rect class="box t" x="440" y="98" width="230" height="182" rx="12"/>
|
||||
<text class="h" x="555" y="124" text-anchor="middle">BOARD B</text>
|
||||
<text class="xs" x="555" y="141" text-anchor="middle">esp32 core 2.0.17</text>
|
||||
<text class="xs" x="555" y="156" text-anchor="middle">receiver.ino</text>
|
||||
<rect class="box t" x="446" y="170" width="84" height="24" rx="4"/><text class="s" x="488" y="187" text-anchor="middle">RX 16</text>
|
||||
<rect class="box t" x="446" y="200" width="84" height="24" rx="4"/><text class="s" x="488" y="217" text-anchor="middle">TX 17</text>
|
||||
<rect class="box n" x="446" y="230" width="84" height="24" rx="4"/><text class="s" x="488" y="247" text-anchor="middle">GND</text>
|
||||
<rect class="box r" x="576" y="170" width="88" height="24" rx="4"/><text class="s" x="620" y="187" text-anchor="middle">GPIO 25</text>
|
||||
<rect class="box g" x="576" y="200" width="88" height="24" rx="4"/><text class="s" x="620" y="217" text-anchor="middle">GPIO 26</text>
|
||||
<rect class="box n" x="576" y="230" width="88" height="24" rx="4"/><text class="s" x="620" y="247" text-anchor="middle">GND 2</text>
|
||||
|
||||
<line class="w" x1="256" y1="182" x2="442" y2="182" stroke="#D85A30" marker-end="url(#ar)"/>
|
||||
<line class="w" x1="444" y1="212" x2="258" y2="212" stroke="#7F77DD" marker-end="url(#ar)"/>
|
||||
<line class="w" x1="256" y1="242" x2="444" y2="242" stroke="#888780"/>
|
||||
<text class="xs" x="350" y="166" text-anchor="middle">UART link 115200 8N1</text>
|
||||
<text class="xs" x="350" y="262" text-anchor="middle">shared voltage reference only - do NOT hang LED current off this</text>
|
||||
|
||||
<rect class="box b" x="440" y="300" width="108" height="40" rx="8"/>
|
||||
<text class="s" x="494" y="318" text-anchor="middle">Hub 0 (BLE)</text><text class="xs" x="494" y="333" text-anchor="middle">A B D</text>
|
||||
<rect class="box b" x="558" y="300" width="108" height="40" rx="8"/>
|
||||
<text class="s" x="612" y="318" text-anchor="middle">Hub 1 (BLE)</text><text class="xs" x="612" y="333" text-anchor="middle">A B C D</text>
|
||||
<line class="w" x1="555" y1="282" x2="555" y2="296" stroke="#888780" marker-end="url(#ar)"/>
|
||||
|
||||
<line x1="30" y1="380" x2="730" y2="380" stroke="#c9c8c3" stroke-width="1" stroke-dasharray="4 4"/>
|
||||
<text class="h" x="30" y="408">LED circuits - two separate loops, board B only</text>
|
||||
|
||||
<rect class="box r" x="30" y="430" width="88" height="26" rx="4"/><text class="s" x="74" y="448" text-anchor="middle">GPIO 25</text>
|
||||
<line class="w" x1="120" y1="443" x2="164" y2="443" stroke="#D85A30"/>
|
||||
<rect class="box r" x="166" y="430" width="96" height="26" rx="4"/><text class="s" x="214" y="448" text-anchor="middle">220Ω</text>
|
||||
<line class="w" x1="264" y1="443" x2="308" y2="443" stroke="#D85A30"/>
|
||||
<rect class="box r" x="310" y="424" width="150" height="38" rx="6"/>
|
||||
<text class="s" x="385" y="441" text-anchor="middle">RED pair</text>
|
||||
<text class="xs" x="385" y="456" text-anchor="middle">long leg faces this side</text>
|
||||
<line class="w" x1="462" y1="443" x2="596" y2="443" stroke="#D85A30"/>
|
||||
|
||||
<rect class="box g" x="30" y="510" width="88" height="26" rx="4"/><text class="s" x="74" y="528" text-anchor="middle">GPIO 26</text>
|
||||
<line class="w" x1="120" y1="523" x2="164" y2="523" stroke="#3D9A50"/>
|
||||
<rect class="box g" x="166" y="510" width="96" height="26" rx="4"/><text class="s" x="214" y="528" text-anchor="middle">100Ω</text>
|
||||
<line class="w" x1="264" y1="523" x2="308" y2="523" stroke="#3D9A50"/>
|
||||
<rect class="box g" x="310" y="504" width="150" height="38" rx="6"/>
|
||||
<text class="s" x="385" y="521" text-anchor="middle">GREEN pair</text>
|
||||
<text class="xs" x="385" y="536" text-anchor="middle">long leg faces this side</text>
|
||||
<line class="w" x1="462" y1="523" x2="596" y2="523" stroke="#3D9A50"/>
|
||||
|
||||
<line class="w" x1="598" y1="443" x2="598" y2="523" stroke="#888780"/>
|
||||
<line class="w" x1="598" y1="483" x2="658" y2="483" stroke="#888780" marker-end="url(#ar)"/>
|
||||
<rect class="box n" x="662" y="470" width="68" height="26" rx="4"/><text class="s" x="696" y="488" text-anchor="middle">GND 2</text>
|
||||
<text class="xs" x="598" y="562" text-anchor="middle">ground rail - the two returns join ONLY here</text>
|
||||
|
||||
<rect class="box r" x="30" y="596" width="700" height="112" rx="8"/>
|
||||
<text class="h" x="48" y="622">The fault to look for</text>
|
||||
<text class="s" x="48" y="645">The green return is reaching the RED side of the red LED instead of the ground rail, putting</text>
|
||||
<text class="s" x="48" y="663">the two LEDs in series. Green alone then needs about 4V through both, and 3.3V cannot supply it.</text>
|
||||
<text class="s" x="48" y="686">TEST: with both LEDs off, meter from the shared return wire to GND. It must read 0V. Press</text>
|
||||
<text class="s" x="48" y="704">Circle and measure again - if it rises above 0V that wire is not ground, and that is the fault.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
+228
-72
@@ -1,32 +1,49 @@
|
||||
/*
|
||||
* receiver.ino -- UART -> two LEGO Powered Up hubs (ESP32 "B")
|
||||
*
|
||||
* Model: Johnny 5 (Short Circuit) MOC, 7 motors across 2 Technic hubs.
|
||||
* Model: Johnny 5 (Short Circuit) MOC - "Evolved" control scheme.
|
||||
* 7 motors across 2 Technic hubs, plus 2 GPIO-driven LED circuits.
|
||||
*
|
||||
* Board package: esp32 (the normal Espressif one), core 2.0.17
|
||||
* Libraries: Legoino + NimBLE-Arduino 1.4.x (both via Library Manager)
|
||||
*
|
||||
* Core 3.x will not build Legoino - you get 'std::string does not name a type'
|
||||
* and a ReadUInt32LE declaration mismatch. Stay on 2.0.17 for this board.
|
||||
*
|
||||
* Listens for gamepad frames from the Bluepad32 board on Serial2 and drives
|
||||
* both hubs over BLE using Legoino. This board must NOT have the Bluepad32
|
||||
* board package selected - keeping BTstack and NimBLE on separate chips is
|
||||
* the entire reason there are two boards.
|
||||
* Do NOT select the esp32_bluepad32 package here either: it starts BTstack
|
||||
* before setup() runs and NimBLE aborts with ESP_ERR_INVALID_STATE.
|
||||
*
|
||||
* Wiring to the transmitter board:
|
||||
* RX GPIO16 <- TX GPIO17 on transmitter
|
||||
* TX GPIO17 -> RX GPIO16 on transmitter
|
||||
* GND -> GND (mandatory - common ground)
|
||||
*
|
||||
* Control scheme is tank drive: every input drives exactly one motor.
|
||||
* LED circuits - both on THIS board, returning to THIS board's GND:
|
||||
* GPIO25 -> resistor -> LED pair -> GND
|
||||
* GPIO26 -> resistor -> LED pair -> GND
|
||||
* Pins output 3.3V, not 3V. ~12mA per pin is comfortable, 40mA is the hard
|
||||
* limit. Anything drawing more than ~20mA per circuit needs a transistor.
|
||||
* Do not tap the LED return off the UART ground wire to the other board -
|
||||
* that reference needs to stay clean.
|
||||
*
|
||||
* STARTUP ORDER:
|
||||
* Wake BOTH hubs with their green buttons and check both are blinking,
|
||||
* THEN power this board. hub1 is only serviced once hub0 is connected, so a
|
||||
* sleeping hub0 blocks the whole sequence. Hubs stop advertising after a
|
||||
* couple of minutes idle.
|
||||
*
|
||||
* ARMS ARE POSITION CONTROLLED:
|
||||
* The triggers set an ANGLE, not a power level. Trigger released holds the
|
||||
* arm at 0, fully depressed holds it at ARM_SPAN_*. The encoders are zeroed
|
||||
* once, on the first hub1 connect after boot, so BOTH ARMS MUST BE DOWN at
|
||||
* that moment. They are deliberately NOT re-zeroed on a reconnect - the hub
|
||||
* keeps its encoder preset, and re-zeroing mid-session would redefine zero
|
||||
* at whatever position the arms happened to be in.
|
||||
*
|
||||
* FILE ORDER MATTERS:
|
||||
* The Arduino IDE injects generated function prototypes immediately before
|
||||
* the FIRST function definition in the file. Any type used in a function
|
||||
* signature must be declared above that point - which is why HubLink,
|
||||
* PortState and Frame all live in the types block. Move a function above
|
||||
* them and you get "'Frame' has not been declared".
|
||||
* PortState, ArmState and Frame all live in the types block.
|
||||
*
|
||||
* Created by: Jess Rogerson (yelling commands at Claude.AI)
|
||||
*/
|
||||
@@ -38,55 +55,64 @@
|
||||
// Hub BLE addresses. Run tools/hub_scanner to find them - do not guess.
|
||||
//
|
||||
// hub 0 - lower body hub 1 - upper body
|
||||
// A right track A head tilt
|
||||
// B left track B head turn
|
||||
// D body lift C left arm
|
||||
// D right arm
|
||||
// A right track A left arm
|
||||
// B left track B head tilt
|
||||
// C (free) C head turn
|
||||
// D body lift D right arm
|
||||
static const char *HUB0_ADDR = "90:84:2b:61:f2:d7";
|
||||
static const char *HUB1_ADDR = "90:84:2b:61:e6:8c";
|
||||
|
||||
// Port numbers are just bytes in the LEGO protocol, same on every hub type.
|
||||
static const byte PORT_A = 0x00;
|
||||
static const byte PORT_B = 0x01;
|
||||
static const byte PORT_C = 0x02;
|
||||
static const byte PORT_D = 0x03;
|
||||
|
||||
// Technic / Control+ motors are tacho motors -> leave this at 1.
|
||||
// Plain train motors and the simple Powered Up motors -> set it to 0.
|
||||
// LED circuits. Safe GPIOs - no boot strapping or flash duties, unlike 0, 2,
|
||||
// 12 and 15.
|
||||
static const int LED_1_PIN = 25; // Circle toggles this pair
|
||||
static const int LED_2_PIN = 26; // Triangle toggles this pair
|
||||
|
||||
#define USE_TACHO_MOTORS 1
|
||||
|
||||
// Set to 1 to log every motor command that goes out. Useful for proving which
|
||||
// port a command actually lands on. Noisy - turn it back off afterwards.
|
||||
// Set to 1 to log every command that goes out. Noisy - turn it back off.
|
||||
#define DEBUG_MOTORS 0
|
||||
|
||||
static const int DEADZONE = 40; // raw stick counts ignored around centre
|
||||
|
||||
// Motor directions. Flip to -1 if an axis runs backwards - mirrored mountings
|
||||
// are normal on a symmetric model, and this is cheaper than editing signs
|
||||
// scattered through applyFrame().
|
||||
// Motor directions. Flip to -1 if an axis runs backwards.
|
||||
static const int DIR_LEFT_TRACK = 1;
|
||||
static const int DIR_RIGHT_TRACK = -1;
|
||||
static const int DIR_BODY_LIFT = 1;
|
||||
static const int DIR_HEAD_TILT = 1;
|
||||
static const int DIR_HEAD_TURN = 1;
|
||||
static const int DIR_LEFT_ARM = 1;
|
||||
static const int DIR_RIGHT_ARM = 1;
|
||||
|
||||
// Per-axis power caps. 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. Lower these if an axis feels forceful.
|
||||
// Arm travel in MOTOR degrees, measured with tools/arm_calibrate.
|
||||
// Raw measurements were roughly +280 (left) and -275 (right). These are set
|
||||
// slightly short so backlash cannot stall the motor against the top stop.
|
||||
// The sign carries the direction - there is no DIR_ constant for the arms.
|
||||
static const int32_t ARM_SPAN_LEFT = 250;
|
||||
static const int32_t ARM_SPAN_RIGHT = -250;
|
||||
|
||||
static const int ARM_SPEED = 60; // how fast it travels to the target
|
||||
static const byte ARM_MAX_POWER = 40; // torque cap - keeps a jam survivable
|
||||
static const int ARM_STEP_DEG = 3; // ignore target changes smaller than this
|
||||
|
||||
// Arms get their own command interval. They are position controlled, so the
|
||||
// target moves continuously with the trigger and needs updating more often
|
||||
// than a velocity axis does - at the per-port 100ms the arm sprints to each
|
||||
// target then sits idle, which feels like stepping.
|
||||
static const unsigned long ARM_MIN_GAP_MS = 60;
|
||||
|
||||
// Per-axis power caps for the velocity-controlled axes.
|
||||
static const int TRACK_MAX = 100; // LEGO speed range is -100..100
|
||||
static const int HEAD_MAX = 45;
|
||||
static const int LIFT_MAX = 60;
|
||||
static const int ARM_MAX = 45;
|
||||
|
||||
// Track scaling: normal, L1 held (precision), R1 held (full).
|
||||
static const int SCALE_NORMAL = 75;
|
||||
static const int SCALE_PRECISION = 40;
|
||||
static const int SCALE_FULL = 100;
|
||||
// Track speed, toggled by Cross.
|
||||
static const int SPEED_SLOW = 50;
|
||||
static const int SPEED_FAST = 100;
|
||||
|
||||
// Bluepad32 button masks. Verify against your own pad with DEBUG_BUTTONS in
|
||||
// the transmitter sketch if any of these seem wrong.
|
||||
// Bluepad32 button masks. Verify with DEBUG_BUTTONS in the transmitter.
|
||||
static const unsigned BTN_A = 0x0001; // Cross
|
||||
static const unsigned BTN_B = 0x0002; // Circle
|
||||
static const unsigned BTN_X = 0x0004; // Square
|
||||
@@ -99,11 +125,18 @@ static const unsigned DPAD_D = 0x02;
|
||||
static const unsigned DPAD_R = 0x04;
|
||||
static const unsigned DPAD_L = 0x08;
|
||||
|
||||
// Triggers rest at 0 and are noisy near the bottom of their travel.
|
||||
static const int TRIGGER_DEADZONE = 60;
|
||||
|
||||
static const unsigned long MOTOR_MIN_GAP_MS = 100; // per port
|
||||
static const unsigned long HUB_MIN_GAP_MS = 25; // per hub, ~40 cmd/sec
|
||||
static const unsigned long LINK_TIMEOUT_MS = 400; // failsafe
|
||||
static const unsigned long RECONNECT_GAP_MS = 2000;
|
||||
|
||||
// A Legoino scan that never finds its hub expires silently, leaving the hub
|
||||
// stuck waiting forever. This is how long we give it before starting over.
|
||||
static const unsigned long SCAN_TIMEOUT_MS = 12000;
|
||||
|
||||
static const int LINK_RX_PIN = 16;
|
||||
static const int LINK_TX_PIN = 17;
|
||||
static const long LINK_BAUD = 115200;
|
||||
@@ -118,7 +151,8 @@ struct HubLink {
|
||||
const char *label;
|
||||
bool initialised;
|
||||
unsigned long retryAt;
|
||||
unsigned long lastCmdAt; // per-hub rate limit, shared across its ports
|
||||
unsigned long lastCmdAt; // per-hub rate limit, shared across ports
|
||||
unsigned long scanExpiresAt; // when to give up on the current scan
|
||||
};
|
||||
|
||||
struct PortState {
|
||||
@@ -126,6 +160,11 @@ struct PortState {
|
||||
unsigned long lastSentAt;
|
||||
};
|
||||
|
||||
struct ArmState {
|
||||
int32_t lastTarget;
|
||||
unsigned long lastSentAt;
|
||||
};
|
||||
|
||||
struct Frame {
|
||||
int lx, ly, rx, ry;
|
||||
unsigned buttons, dpad;
|
||||
@@ -135,18 +174,28 @@ struct Frame {
|
||||
// ================================================================= globals
|
||||
|
||||
static HubLink gHubs[2] = {
|
||||
{Lpf2Hub(), HUB0_ADDR, "hub0", false, 0, 0},
|
||||
{Lpf2Hub(), HUB1_ADDR, "hub1", false, 0, 0},
|
||||
{Lpf2Hub(), HUB0_ADDR, "hub0", false, 0, 0, 0},
|
||||
{Lpf2Hub(), HUB1_ADDR, "hub1", false, 0, 0, 0},
|
||||
};
|
||||
|
||||
// Indexes: 0 rightTrack, 1 leftTrack, 2 bodyLift,
|
||||
// 3 headTilt, 4 headTurn, 5 leftArm, 6 rightArm
|
||||
static PortState gPort[7] = {{999, 0}, {999, 0}, {999, 0}, {999, 0},
|
||||
{999, 0}, {999, 0}, {999, 0}};
|
||||
// Indexes follow physical ports, not functions:
|
||||
// 0 hub0/A 1 hub0/B 2 hub0/D 3 hub1/B 4 hub1/C
|
||||
static PortState gPort[5] = {{999, 0}, {999, 0}, {999, 0}, {999, 0}, {999, 0}};
|
||||
|
||||
// Arms are position controlled, so they get their own state.
|
||||
static ArmState gArmLeft = {INT32_MIN, 0};
|
||||
static ArmState gArmRight = {INT32_MIN, 0};
|
||||
static bool gArmsZeroed = false;
|
||||
|
||||
static unsigned long lastFrameAt = 0;
|
||||
static bool failsafeEngaged = true;
|
||||
|
||||
// Latched state, changed on button press rather than while held.
|
||||
static int gTrackSpeed = SPEED_SLOW;
|
||||
static bool gLed1On = false;
|
||||
static bool gLed2On = false;
|
||||
static unsigned gPrevButtons = 0;
|
||||
|
||||
// ================================================================= helpers
|
||||
|
||||
// Deadzone, then rescale so the remaining travel still reaches full speed.
|
||||
@@ -159,6 +208,15 @@ static int stickToSpeed(int raw, int maxSpeed) {
|
||||
return sign * (int)scaled;
|
||||
}
|
||||
|
||||
// Analog trigger, 0..1023, to a target angle between 0 and span.
|
||||
static int32_t triggerToAngle(int raw, int32_t span) {
|
||||
if (raw <= TRIGGER_DEADZONE) return 0;
|
||||
long travel = (long)raw - TRIGGER_DEADZONE;
|
||||
long full = 1023L - TRIGGER_DEADZONE;
|
||||
if (travel > full) travel = full;
|
||||
return (int32_t)((travel * span) / full);
|
||||
}
|
||||
|
||||
static void driveMotor(HubLink &hl, byte port, int speed, PortState &st) {
|
||||
if (!hl.hub.isConnected()) return;
|
||||
|
||||
@@ -166,8 +224,8 @@ static void driveMotor(HubLink &hl, byte port, int speed, PortState &st) {
|
||||
bool stopping = (speed == 0 && st.lastSpeed != 0);
|
||||
|
||||
// Stops always go out immediately. Everything else is rate limited twice:
|
||||
// per port, and per hub - hub 1 has four motors on it, and the per-port
|
||||
// limit alone lets through more than the hub will swallow.
|
||||
// per port, and per hub - the per-port limit alone lets through more than
|
||||
// a hub with several motors on it will swallow.
|
||||
if (!stopping) {
|
||||
if (speed == st.lastSpeed) return;
|
||||
if ((now - st.lastSentAt) < MOTOR_MIN_GAP_MS) return;
|
||||
@@ -189,18 +247,56 @@ static void driveMotor(HubLink &hl, byte port, int speed, PortState &st) {
|
||||
hl.lastCmdAt = now;
|
||||
}
|
||||
|
||||
// Position control. HOLD keeps the motor actively at the target rather than
|
||||
// letting gravity drag the arm back down.
|
||||
static void driveArm(HubLink &hl, byte port, int32_t target, ArmState &st) {
|
||||
if (!hl.hub.isConnected() || !gArmsZeroed) return;
|
||||
|
||||
unsigned long now = millis();
|
||||
if (labs((long)target - (long)st.lastTarget) < ARM_STEP_DEG) return;
|
||||
if ((now - st.lastSentAt) < ARM_MIN_GAP_MS) return;
|
||||
if ((now - hl.lastCmdAt) < HUB_MIN_GAP_MS) return;
|
||||
|
||||
#if DEBUG_MOTORS
|
||||
Serial.printf("TX %s port %u angle %ld\n", hl.label, port, (long)target);
|
||||
#endif
|
||||
|
||||
hl.hub.setAbsoluteMotorPosition(port, ARM_SPEED, target, ARM_MAX_POWER,
|
||||
BrakingStyle::HOLD);
|
||||
|
||||
st.lastTarget = target;
|
||||
st.lastSentAt = now;
|
||||
hl.lastCmdAt = now;
|
||||
}
|
||||
|
||||
static void stopEverything() {
|
||||
driveMotor(gHubs[0], PORT_A, 0, gPort[0]);
|
||||
driveMotor(gHubs[0], PORT_B, 0, gPort[1]);
|
||||
driveMotor(gHubs[0], PORT_D, 0, gPort[2]);
|
||||
driveMotor(gHubs[1], PORT_A, 0, gPort[3]);
|
||||
driveMotor(gHubs[1], PORT_B, 0, gPort[4]);
|
||||
driveMotor(gHubs[1], PORT_C, 0, gPort[5]);
|
||||
driveMotor(gHubs[1], PORT_D, 0, gPort[6]);
|
||||
driveMotor(gHubs[1], PORT_B, 0, gPort[3]);
|
||||
driveMotor(gHubs[1], PORT_C, 0, gPort[4]);
|
||||
|
||||
// Arms: a plain speed command overrides the position hold and goes limp.
|
||||
// Reset the cached targets so the next trigger movement re-commands.
|
||||
if (gHubs[1].hub.isConnected()) {
|
||||
gHubs[1].hub.setTachoMotorSpeed(PORT_A, 0);
|
||||
gHubs[1].hub.setTachoMotorSpeed(PORT_D, 0);
|
||||
}
|
||||
gArmLeft.lastTarget = INT32_MIN;
|
||||
gArmRight.lastTarget = INT32_MIN;
|
||||
}
|
||||
|
||||
// Connect the hubs one at a time. Kicking off two scans at once upsets the
|
||||
// shared NimBLE scanner and you end up with one hub connected and one sulking.
|
||||
//
|
||||
// Note the 'initialised' one-shot. init() starts an ASYNCHRONOUS scan, so
|
||||
// immediately afterwards isConnected() and isConnecting() are both still
|
||||
// false. Guarding on those alone re-enters NimBLEDevice::init() thousands of
|
||||
// times a second and the Bluetooth controller aborts.
|
||||
//
|
||||
// The scanExpiresAt deadline exists because a scan that finds nothing just
|
||||
// ends quietly - isConnecting() never goes true, so without a timeout the hub
|
||||
// sits on a dead scan until the board is power cycled.
|
||||
static void serviceHub(HubLink &hl) {
|
||||
if (hl.hub.isConnected()) return;
|
||||
|
||||
@@ -217,13 +313,40 @@ static void serviceHub(HubLink &hl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hl.initialised && millis() >= hl.scanExpiresAt) {
|
||||
Serial.printf("[%s] scan timed out, restarting\n", hl.label);
|
||||
hl.initialised = false;
|
||||
hl.retryAt = millis() + RECONNECT_GAP_MS;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hl.initialised && millis() >= hl.retryAt) {
|
||||
Serial.printf("[%s] scanning for %s\n", hl.label, hl.addr);
|
||||
hl.hub.init(std::string(hl.addr));
|
||||
hl.initialised = true;
|
||||
hl.scanExpiresAt = millis() + SCAN_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
// Define "arms down" as angle zero. Runs once per boot, after hub1 connects,
|
||||
// with the arms physically at the bottom of their travel. Not repeated on a
|
||||
// reconnect: the hub keeps its encoder preset, and re-zeroing mid-session
|
||||
// would redefine zero wherever the arms happened to be sitting.
|
||||
static void zeroArms() {
|
||||
if (gArmsZeroed || !gHubs[1].hub.isConnected()) return;
|
||||
|
||||
delay(500); // let the hub finish reporting its ports
|
||||
gHubs[1].hub.setAbsoluteMotorEncoderPosition(PORT_A, 0);
|
||||
delay(200);
|
||||
gHubs[1].hub.setAbsoluteMotorEncoderPosition(PORT_D, 0);
|
||||
delay(200);
|
||||
|
||||
gArmsZeroed = true;
|
||||
gArmLeft.lastTarget = INT32_MIN;
|
||||
gArmRight.lastTarget = INT32_MIN;
|
||||
Serial.println("Arms zeroed at current position");
|
||||
}
|
||||
|
||||
static uint8_t xorChecksum(const char *s, size_t len) {
|
||||
uint8_t c = 0;
|
||||
for (size_t i = 0; i < len; i++) c ^= (uint8_t)s[i];
|
||||
@@ -244,44 +367,68 @@ static bool parseFrame(char *line, Frame &f) {
|
||||
&f.buttons, &f.dpad, &f.l2, &f.r2) == 8;
|
||||
}
|
||||
|
||||
// Latching controls fire once per press, not continuously while held. Frames
|
||||
// arrive at ~50 Hz, so without edge detection a single press would toggle
|
||||
// twenty times.
|
||||
static void handleLatchingButtons(unsigned buttons) {
|
||||
unsigned pressed = buttons & ~gPrevButtons;
|
||||
gPrevButtons = buttons;
|
||||
|
||||
if (pressed & BTN_A) { // Cross - alternate track speed
|
||||
gTrackSpeed = (gTrackSpeed == SPEED_FAST) ? SPEED_SLOW : SPEED_FAST;
|
||||
Serial.printf("track speed %d%%\n", gTrackSpeed);
|
||||
}
|
||||
if (pressed & BTN_B) { // Circle - LED pair 1
|
||||
gLed1On = !gLed1On;
|
||||
digitalWrite(LED_1_PIN, gLed1On ? HIGH : LOW);
|
||||
}
|
||||
if (pressed & BTN_Y) { // Triangle - LED pair 2
|
||||
gLed2On = !gLed2On;
|
||||
digitalWrite(LED_2_PIN, gLed2On ? HIGH : LOW);
|
||||
}
|
||||
}
|
||||
|
||||
// Tank drive. One input per motor - nothing is mixed.
|
||||
static void applyFrame(const Frame &f) {
|
||||
// Both shoulders together is the panic stop.
|
||||
if ((f.buttons & BTN_L1) && (f.buttons & BTN_R1)) {
|
||||
handleLatchingButtons(f.buttons);
|
||||
|
||||
// Square is the panic stop. LEDs are left alone - they are not motion.
|
||||
if (f.buttons & BTN_X) {
|
||||
stopEverything();
|
||||
return;
|
||||
}
|
||||
|
||||
int scale = SCALE_NORMAL;
|
||||
if (f.buttons & BTN_L1) scale = SCALE_PRECISION;
|
||||
if (f.buttons & BTN_R1) scale = SCALE_FULL;
|
||||
int leftTrack = stickToSpeed(-f.ly, TRACK_MAX) * gTrackSpeed / 100
|
||||
* DIR_LEFT_TRACK;
|
||||
int rightTrack = stickToSpeed(-f.ry, TRACK_MAX) * gTrackSpeed / 100
|
||||
* DIR_RIGHT_TRACK;
|
||||
|
||||
int leftTrack = stickToSpeed(-f.ly, TRACK_MAX) * scale / 100 * DIR_LEFT_TRACK;
|
||||
int rightTrack = stickToSpeed(-f.ry, TRACK_MAX) * scale / 100 * DIR_RIGHT_TRACK;
|
||||
// L1 / R1 turn the head while held.
|
||||
int headTurn = ((f.buttons & BTN_R1) ? HEAD_MAX
|
||||
: (f.buttons & BTN_L1) ? -HEAD_MAX : 0) * DIR_HEAD_TURN;
|
||||
|
||||
int headTilt = ((f.dpad & DPAD_U) ? HEAD_MAX
|
||||
: (f.dpad & DPAD_D) ? -HEAD_MAX : 0) * DIR_HEAD_TILT;
|
||||
int headTurn = ((f.dpad & DPAD_R) ? HEAD_MAX
|
||||
: (f.dpad & DPAD_L) ? -HEAD_MAX : 0) * DIR_HEAD_TURN;
|
||||
// D-pad: up/down lifts the body, left/right tilts the head.
|
||||
int bodyLift = ((f.dpad & DPAD_U) ? LIFT_MAX
|
||||
: (f.dpad & DPAD_D) ? -LIFT_MAX : 0) * DIR_BODY_LIFT;
|
||||
int headTilt = ((f.dpad & DPAD_R) ? HEAD_MAX
|
||||
: (f.dpad & DPAD_L) ? -HEAD_MAX : 0) * DIR_HEAD_TILT;
|
||||
|
||||
// R2 raises, L2 lowers. Both analog 0..1023, so the lift stays proportional.
|
||||
int bodyLift = constrain((f.r2 - f.l2) * LIFT_MAX / 1023,
|
||||
-LIFT_MAX, LIFT_MAX) * DIR_BODY_LIFT;
|
||||
|
||||
// Square raises the left arm, Circle lowers it.
|
||||
int leftArm = ((f.buttons & BTN_X) ? ARM_MAX
|
||||
: (f.buttons & BTN_B) ? -ARM_MAX : 0) * DIR_LEFT_ARM;
|
||||
// Cross raises the right arm, Triangle lowers it.
|
||||
int rightArm = ((f.buttons & BTN_A) ? ARM_MAX
|
||||
: (f.buttons & BTN_Y) ? -ARM_MAX : 0) * DIR_RIGHT_ARM;
|
||||
// Arms: trigger position IS arm angle. Released means "go to zero", which
|
||||
// gravity is already doing, so the motor mostly just catches it.
|
||||
int32_t leftTarget = triggerToAngle(f.l2, ARM_SPAN_LEFT);
|
||||
int32_t rightTarget = triggerToAngle(f.r2, ARM_SPAN_RIGHT);
|
||||
|
||||
driveMotor(gHubs[0], PORT_A, rightTrack, gPort[0]);
|
||||
driveMotor(gHubs[0], PORT_B, leftTrack, gPort[1]);
|
||||
driveMotor(gHubs[0], PORT_D, bodyLift, gPort[2]);
|
||||
driveMotor(gHubs[1], PORT_A, headTilt, gPort[3]);
|
||||
driveMotor(gHubs[1], PORT_B, headTurn, gPort[4]);
|
||||
driveMotor(gHubs[1], PORT_C, leftArm, gPort[5]);
|
||||
driveMotor(gHubs[1], PORT_D, rightArm, gPort[6]);
|
||||
|
||||
// hub1 B is the tilt motor and C is the turn motor - the reverse of what
|
||||
// the first build assumed.
|
||||
driveMotor(gHubs[1], PORT_B, headTilt, gPort[3]);
|
||||
driveMotor(gHubs[1], PORT_C, headTurn, gPort[4]);
|
||||
|
||||
driveArm(gHubs[1], PORT_A, leftTarget, gArmLeft);
|
||||
driveArm(gHubs[1], PORT_D, rightTarget, gArmRight);
|
||||
}
|
||||
|
||||
// ==================================================================== main
|
||||
@@ -293,7 +440,13 @@ void setup() {
|
||||
pinMode(STATUS_LED_PIN, OUTPUT);
|
||||
digitalWrite(STATUS_LED_PIN, LOW);
|
||||
|
||||
Serial.println("LEGO hub receiver starting");
|
||||
pinMode(LED_1_PIN, OUTPUT);
|
||||
pinMode(LED_2_PIN, OUTPUT);
|
||||
digitalWrite(LED_1_PIN, LOW);
|
||||
digitalWrite(LED_2_PIN, LOW);
|
||||
|
||||
Serial.println("LEGO hub receiver starting (Johnny 5 Evolved)");
|
||||
Serial.println("Wake both hubs first. Both arms must be DOWN.");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
@@ -301,6 +454,9 @@ void loop() {
|
||||
serviceHub(gHubs[0]);
|
||||
if (gHubs[0].hub.isConnected()) serviceHub(gHubs[1]);
|
||||
|
||||
// Zeroes once per boot. Deliberately not reset when the hub drops.
|
||||
if (gHubs[1].hub.isConnected()) zeroArms();
|
||||
|
||||
bool ready = gHubs[0].hub.isConnected() && gHubs[1].hub.isConnected();
|
||||
digitalWrite(STATUS_LED_PIN, ready ? HIGH : LOW);
|
||||
|
||||
|
||||
Reference in new issue
Block a user