Compare commits

...
9 Commits
Author SHA1 Message Date
jessikitty c21f804812 Lead with the separate-ground LED build
Keeps the shared-rail version documented as the alternative, since that is
what the current loom attempts, and links the new pinout reference.
2026-09-25 01:29:00 +10:00
jessikitty 602028ed01 Add ESP32 pinout reference
Which pins are in use, which are free, which are strapping or flash pins,
and the per-pin current limits.
2026-09-25 01:28:41 +10:00
jessikitty e1c5d00972 Add separate-ground LED wiring diagram
Four wires, no shared return junction, so the series-LED failure mode
cannot occur.
2026-09-25 01:28:22 +10:00
jessikitty 069e7ebdd5 Add wiring doc with the series-LED failure mode
Records the diagnosis: green only lights when red is on, because the green
return reaches the red LED's anode instead of the ground rail.
2026-09-25 01:24:39 +10:00
jessikitty 4f3087aedd Add full wiring pinout diagram
Both boards, the UART link, and both LED circuits with their returns going
to a ground rail rather than through each other.
2026-09-25 01:24:23 +10:00
jessikitty b2016565f7 Restart hub scans that find nothing
A Legoino scan that never locates its hub ends quietly - isConnecting()
never goes true, so the old code left 'initialised' set and never touched
retryAt. That hub then sat on a dead scan until the board was power
cycled, which is why first connects needed several attempts.

Adds scanExpiresAt per hub and restarts the scan after 12s. Also
documents the startup order and the LED grounding in the header.
2026-09-15 23:08:38 +10:00
jessikitty 0c149272bd Smoother arms, and swap head tilt/turn ports
- ARM_STEP_DEG 8 -> 3 and a dedicated ARM_MIN_GAP_MS of 60, so the arm
  tracks the trigger instead of jumping between targets. Speed and spans
  left as they were.
- hub1 port B is the tilt motor, C is the turn motor - swapped from what
  the first build assumed.
- Zero the arm encoders once per boot rather than on every reconnect. The
  hub keeps its preset, and re-zeroing mid-session would redefine zero at
  whatever position the arms were in when the link dropped.
2026-09-11 12:34:56 +10:00
jessikitty 343544510f Position-controlled arms
Trigger position now sets arm ANGLE rather than power, via
setAbsoluteMotorPosition with BrakingStyle::HOLD. Encoders are zeroed on
hub1 connect, so both arms must be down at that moment.

Spans measured with tools/arm_calibrate: roughly +280 left, -275 right,
both comfortably under one motor revolution so the absolute encoder is
unambiguous. Set to +/-250 to keep backlash clear of the end stop.

L3/R3 are no longer bound - releasing the trigger is lowering.
Also documents the async-init trap in serviceHub.
2026-09-10 21:31:30 +10:00
jessikitty f5e7517cbd Evolved control scheme
- Correct hub1 port map: A is the left arm, C is the head tilt.
- Arms: L2/R2 raise proportionally, L3/R3 lower at reduced power.
- Head turn onto L1/R1, head tilt onto d-pad left/right, body lift onto
  d-pad up/down. All move while held.
- Square is now the panic stop; Cross toggles 50/100% track speed.
- Circle and Triangle toggle two GPIO LED circuits on pins 25 and 26.
- Latching controls are edge-detected; frames arrive at ~50Hz so a held
  button would otherwise toggle continuously.
2026-09-10 16:20:44 +10:00
5 changed files with 537 additions and 72 deletions

No files matched your search

+75
View File
@@ -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)
+88
View File
@@ -0,0 +1,88 @@
# Wiring
![Wiring pinout](wiring.svg)
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.
![LED wiring with separate grounds](wiring-leds-separate.svg)
```
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)
+56
View File
@@ -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&#937;</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&#937;</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

+90
View File
@@ -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&#937;</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&#937;</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
View File
@@ -1,32 +1,49 @@
/* /*
* receiver.ino -- UART -> two LEGO Powered Up hubs (ESP32 "B") * 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 * Board package: esp32 (the normal Espressif one), core 2.0.17
* Libraries: Legoino + NimBLE-Arduino 1.4.x (both via Library Manager) * 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' * 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. * and a ReadUInt32LE declaration mismatch. Stay on 2.0.17 for this board.
* * Do NOT select the esp32_bluepad32 package here either: it starts BTstack
* Listens for gamepad frames from the Bluepad32 board on Serial2 and drives * before setup() runs and NimBLE aborts with ESP_ERR_INVALID_STATE.
* 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.
* *
* Wiring to the transmitter board: * Wiring to the transmitter board:
* RX GPIO16 <- TX GPIO17 on transmitter * RX GPIO16 <- TX GPIO17 on transmitter
* TX GPIO17 -> RX GPIO16 on transmitter * TX GPIO17 -> RX GPIO16 on transmitter
* GND -> GND (mandatory - common ground) * 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: * FILE ORDER MATTERS:
* The Arduino IDE injects generated function prototypes immediately before * The Arduino IDE injects generated function prototypes immediately before
* the FIRST function definition in the file. Any type used in a function * the FIRST function definition in the file. Any type used in a function
* signature must be declared above that point - which is why HubLink, * signature must be declared above that point - which is why HubLink,
* PortState and Frame all live in the types block. Move a function above * PortState, ArmState and Frame all live in the types block.
* them and you get "'Frame' has not been declared".
* *
* Created by: Jess Rogerson (yelling commands at Claude.AI) * 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 BLE addresses. Run tools/hub_scanner to find them - do not guess.
// //
// hub 0 - lower body hub 1 - upper body // hub 0 - lower body hub 1 - upper body
// A right track A head tilt // A right track A left arm
// B left track B head turn // B left track B head tilt
// D body lift C left arm // C (free) C head turn
// D right arm // D body lift D right arm
static const char *HUB0_ADDR = "90:84:2b:61:f2:d7"; static const char *HUB0_ADDR = "90:84:2b:61:f2:d7";
static const char *HUB1_ADDR = "90:84:2b:61:e6:8c"; 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_A = 0x00;
static const byte PORT_B = 0x01; static const byte PORT_B = 0x01;
static const byte PORT_C = 0x02; static const byte PORT_C = 0x02;
static const byte PORT_D = 0x03; static const byte PORT_D = 0x03;
// Technic / Control+ motors are tacho motors -> leave this at 1. // LED circuits. Safe GPIOs - no boot strapping or flash duties, unlike 0, 2,
// Plain train motors and the simple Powered Up motors -> set it to 0. // 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 #define USE_TACHO_MOTORS 1
// Set to 1 to log every motor command that goes out. Useful for proving which // Set to 1 to log every command that goes out. Noisy - turn it back off.
// port a command actually lands on. Noisy - turn it back off afterwards.
#define DEBUG_MOTORS 0 #define DEBUG_MOTORS 0
static const int DEADZONE = 40; // raw stick counts ignored around centre static const int DEADZONE = 40; // raw stick counts ignored around centre
// Motor directions. Flip to -1 if an axis runs backwards - mirrored mountings // Motor directions. Flip to -1 if an axis runs backwards.
// are normal on a symmetric model, and this is cheaper than editing signs
// scattered through applyFrame().
static const int DIR_LEFT_TRACK = 1; static const int DIR_LEFT_TRACK = 1;
static const int DIR_RIGHT_TRACK = -1; static const int DIR_RIGHT_TRACK = -1;
static const int DIR_BODY_LIFT = 1; static const int DIR_BODY_LIFT = 1;
static const int DIR_HEAD_TILT = 1; static const int DIR_HEAD_TILT = 1;
static const int DIR_HEAD_TURN = 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 // Arm travel in MOTOR degrees, measured with tools/arm_calibrate.
// stop and there is no position feedback, so holding a direction at a stop // Raw measurements were roughly +280 (left) and -275 (right). These are set
// stalls the motor. Lower these if an axis feels forceful. // 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 TRACK_MAX = 100; // LEGO speed range is -100..100
static const int HEAD_MAX = 45; static const int HEAD_MAX = 45;
static const int LIFT_MAX = 60; static const int LIFT_MAX = 60;
static const int ARM_MAX = 45;
// Track scaling: normal, L1 held (precision), R1 held (full). // Track speed, toggled by Cross.
static const int SCALE_NORMAL = 75; static const int SPEED_SLOW = 50;
static const int SCALE_PRECISION = 40; static const int SPEED_FAST = 100;
static const int SCALE_FULL = 100;
// Bluepad32 button masks. Verify against your own pad with DEBUG_BUTTONS in // Bluepad32 button masks. Verify with DEBUG_BUTTONS in the transmitter.
// the transmitter sketch if any of these seem wrong.
static const unsigned BTN_A = 0x0001; // Cross static const unsigned BTN_A = 0x0001; // Cross
static const unsigned BTN_B = 0x0002; // Circle static const unsigned BTN_B = 0x0002; // Circle
static const unsigned BTN_X = 0x0004; // Square 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_R = 0x04;
static const unsigned DPAD_L = 0x08; 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 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 HUB_MIN_GAP_MS = 25; // per hub, ~40 cmd/sec
static const unsigned long LINK_TIMEOUT_MS = 400; // failsafe static const unsigned long LINK_TIMEOUT_MS = 400; // failsafe
static const unsigned long RECONNECT_GAP_MS = 2000; 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_RX_PIN = 16;
static const int LINK_TX_PIN = 17; static const int LINK_TX_PIN = 17;
static const long LINK_BAUD = 115200; static const long LINK_BAUD = 115200;
@@ -118,7 +151,8 @@ struct HubLink {
const char *label; const char *label;
bool initialised; bool initialised;
unsigned long retryAt; 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 { struct PortState {
@@ -126,6 +160,11 @@ struct PortState {
unsigned long lastSentAt; unsigned long lastSentAt;
}; };
struct ArmState {
int32_t lastTarget;
unsigned long lastSentAt;
};
struct Frame { struct Frame {
int lx, ly, rx, ry; int lx, ly, rx, ry;
unsigned buttons, dpad; unsigned buttons, dpad;
@@ -135,18 +174,28 @@ struct Frame {
// ================================================================= globals // ================================================================= globals
static HubLink gHubs[2] = { static HubLink gHubs[2] = {
{Lpf2Hub(), HUB0_ADDR, "hub0", false, 0, 0}, {Lpf2Hub(), HUB0_ADDR, "hub0", false, 0, 0, 0},
{Lpf2Hub(), HUB1_ADDR, "hub1", false, 0, 0}, {Lpf2Hub(), HUB1_ADDR, "hub1", false, 0, 0, 0},
}; };
// Indexes: 0 rightTrack, 1 leftTrack, 2 bodyLift, // Indexes follow physical ports, not functions:
// 3 headTilt, 4 headTurn, 5 leftArm, 6 rightArm // 0 hub0/A 1 hub0/B 2 hub0/D 3 hub1/B 4 hub1/C
static PortState gPort[7] = {{999, 0}, {999, 0}, {999, 0}, {999, 0}, static PortState gPort[5] = {{999, 0}, {999, 0}, {999, 0}, {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 unsigned long lastFrameAt = 0;
static bool failsafeEngaged = true; 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 // ================================================================= helpers
// Deadzone, then rescale so the remaining travel still reaches full speed. // 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; 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) { static void driveMotor(HubLink &hl, byte port, int speed, PortState &st) {
if (!hl.hub.isConnected()) return; 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); bool stopping = (speed == 0 && st.lastSpeed != 0);
// Stops always go out immediately. Everything else is rate limited twice: // 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 // per port, and per hub - the per-port limit alone lets through more than
// limit alone lets through more than the hub will swallow. // a hub with several motors on it will swallow.
if (!stopping) { if (!stopping) {
if (speed == st.lastSpeed) return; if (speed == st.lastSpeed) return;
if ((now - st.lastSentAt) < MOTOR_MIN_GAP_MS) 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; 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() { static void stopEverything() {
driveMotor(gHubs[0], PORT_A, 0, gPort[0]); driveMotor(gHubs[0], PORT_A, 0, gPort[0]);
driveMotor(gHubs[0], PORT_B, 0, gPort[1]); driveMotor(gHubs[0], PORT_B, 0, gPort[1]);
driveMotor(gHubs[0], PORT_D, 0, gPort[2]); driveMotor(gHubs[0], PORT_D, 0, gPort[2]);
driveMotor(gHubs[1], PORT_A, 0, gPort[3]); driveMotor(gHubs[1], PORT_B, 0, gPort[3]);
driveMotor(gHubs[1], PORT_B, 0, gPort[4]); driveMotor(gHubs[1], PORT_C, 0, gPort[4]);
driveMotor(gHubs[1], PORT_C, 0, gPort[5]);
driveMotor(gHubs[1], PORT_D, 0, gPort[6]); // 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 // 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. // 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) { static void serviceHub(HubLink &hl) {
if (hl.hub.isConnected()) return; if (hl.hub.isConnected()) return;
@@ -217,13 +313,40 @@ static void serviceHub(HubLink &hl) {
return; 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) { if (!hl.initialised && millis() >= hl.retryAt) {
Serial.printf("[%s] scanning for %s\n", hl.label, hl.addr); Serial.printf("[%s] scanning for %s\n", hl.label, hl.addr);
hl.hub.init(std::string(hl.addr)); hl.hub.init(std::string(hl.addr));
hl.initialised = true; 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) { static uint8_t xorChecksum(const char *s, size_t len) {
uint8_t c = 0; uint8_t c = 0;
for (size_t i = 0; i < len; i++) c ^= (uint8_t)s[i]; 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; &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. // Tank drive. One input per motor - nothing is mixed.
static void applyFrame(const Frame &f) { static void applyFrame(const Frame &f) {
// Both shoulders together is the panic stop. handleLatchingButtons(f.buttons);
if ((f.buttons & BTN_L1) && (f.buttons & BTN_R1)) {
// Square is the panic stop. LEDs are left alone - they are not motion.
if (f.buttons & BTN_X) {
stopEverything(); stopEverything();
return; return;
} }
int scale = SCALE_NORMAL; int leftTrack = stickToSpeed(-f.ly, TRACK_MAX) * gTrackSpeed / 100
if (f.buttons & BTN_L1) scale = SCALE_PRECISION; * DIR_LEFT_TRACK;
if (f.buttons & BTN_R1) scale = SCALE_FULL; int rightTrack = stickToSpeed(-f.ry, TRACK_MAX) * gTrackSpeed / 100
* DIR_RIGHT_TRACK;
int leftTrack = stickToSpeed(-f.ly, TRACK_MAX) * scale / 100 * DIR_LEFT_TRACK; // L1 / R1 turn the head while held.
int rightTrack = stickToSpeed(-f.ry, TRACK_MAX) * scale / 100 * DIR_RIGHT_TRACK; 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 // D-pad: up/down lifts the body, left/right tilts the head.
: (f.dpad & DPAD_D) ? -HEAD_MAX : 0) * DIR_HEAD_TILT; int bodyLift = ((f.dpad & DPAD_U) ? LIFT_MAX
int headTurn = ((f.dpad & DPAD_R) ? HEAD_MAX : (f.dpad & DPAD_D) ? -LIFT_MAX : 0) * DIR_BODY_LIFT;
: (f.dpad & DPAD_L) ? -HEAD_MAX : 0) * DIR_HEAD_TURN; 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. // Arms: trigger position IS arm angle. Released means "go to zero", which
int bodyLift = constrain((f.r2 - f.l2) * LIFT_MAX / 1023, // gravity is already doing, so the motor mostly just catches it.
-LIFT_MAX, LIFT_MAX) * DIR_BODY_LIFT; int32_t leftTarget = triggerToAngle(f.l2, ARM_SPAN_LEFT);
int32_t rightTarget = triggerToAngle(f.r2, ARM_SPAN_RIGHT);
// 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;
driveMotor(gHubs[0], PORT_A, rightTrack, gPort[0]); driveMotor(gHubs[0], PORT_A, rightTrack, gPort[0]);
driveMotor(gHubs[0], PORT_B, leftTrack, gPort[1]); driveMotor(gHubs[0], PORT_B, leftTrack, gPort[1]);
driveMotor(gHubs[0], PORT_D, bodyLift, gPort[2]); driveMotor(gHubs[0], PORT_D, bodyLift, gPort[2]);
driveMotor(gHubs[1], PORT_A, headTilt, gPort[3]);
driveMotor(gHubs[1], PORT_B, headTurn, gPort[4]); // hub1 B is the tilt motor and C is the turn motor - the reverse of what
driveMotor(gHubs[1], PORT_C, leftArm, gPort[5]); // the first build assumed.
driveMotor(gHubs[1], PORT_D, rightArm, gPort[6]); 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 // ==================================================================== main
@@ -293,7 +440,13 @@ void setup() {
pinMode(STATUS_LED_PIN, OUTPUT); pinMode(STATUS_LED_PIN, OUTPUT);
digitalWrite(STATUS_LED_PIN, LOW); 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() { void loop() {
@@ -301,6 +454,9 @@ void loop() {
serviceHub(gHubs[0]); serviceHub(gHubs[0]);
if (gHubs[0].hub.isConnected()) serviceHub(gHubs[1]); 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(); bool ready = gHubs[0].hub.isConnected() && gHubs[1].hub.isConnected();
digitalWrite(STATUS_LED_PIN, ready ? HIGH : LOW); digitalWrite(STATUS_LED_PIN, ready ? HIGH : LOW);