What actually happened bringing lightaim up on the bench, in order. Four reusable gotchas came out of it: uvx needs --with pip for PlatformIO, the S3’s bootloader is at 0x0, only the UART USB port can flash, and SERVO_RES_BITS is capped at 14 on the S3.
For Agents
Code:
/Users/levander/vitara/lightaim/Build:uvx --from platformio --with pip pio runOutcome: bench-working end to end. Design, pin map and tuning live in lightaim — this note is the process trail. All four reusable gotchas below are consolidated in esp32, alongside the same lessons from the other ESP32 projects. Go there first if you are not specifically debugging lightaim.
1. Toolchain: uvx + PlatformIO needs pip
First build failed:
uvx --from platformio pio run
→ No module named pip
→ MissingPackageManifestError
uvxcreates an ephemeral env without pip, and PlatformIO shells out topip installPlatformIO installs esptool’s dependencies by invoking
pipas a subprocess. In auvxenv there is no pip, so the package install dies half-done and then reports the confusingMissingPackageManifestErroron the next run.Fix:
uvx --from platformio --with pip pio runAlso required:rm -rf ~/.platformio/packages/tool-esptoolpyto clear the half-install — adding--with pipalone does not repair it.This is a permanent gotcha for any PlatformIO-via-uvx use, not specific to this project.
2. Arduino core landed at 2.0.17
The platform pulled framework-arduinoespressif32 @ 3.20017 = Arduino core 2.0.17. So the firmware’s
#if ESP_ARDUINO_VERSION_MAJOR >= 3guard takes the 2.x LEDC branch (ledcSetup / ledcAttachPin) rather than core 3.x’s ledcAttach. Worth keeping the guard — the platform will eventually roll to 3.x.
3. Build on the Mac, flash on the mainframe
Binaries were built on the Mac and scp’d to ~/lightaim-flash/ on the mainframe, then flashed from the ESP-IDF Docker container with --device /dev/ttyACM0. Passing the device through Docker also sidesteps levander not being in dialout.
Flash offsets (PlatformIO Arduino build, ESP32-S3)
| Image | Offset |
|---|---|
bootloader.bin | 0x0 |
partitions.bin | 0x8000 |
boot_app0.bin | 0xe000 |
firmware.bin | 0x10000 |
The S3 puts its bootloader at
0x0, not0x1000The classic ESP32 uses
0x1000. Copying an old flash command onto an S3 silently writes the bootloader to the wrong place.
4. The USB port matters — again
Flashing needs the UART port, not the native USB/OTG port
Use the CH343 UART bridge (
VID:PID 1a86:55d3). The native USB/OTG port (303a:4001) fails withNo serial data receivedon both--before default_resetand--before usb_resetonce firmware is already running.Same lesson as the RuView CSI node — see 2026-08-11-ruview-esp32-csi-real-hardware. An ESP32-S3 dev board’s two USB-C ports are not interchangeable: only the UART bridge drives DTR/RTS to force the bootloader.
5. The LEDC bug: no servo output at all
SERVO_RES_BITS = 16→ LEDC never initialises → zero servo outputThe ESP32-S3’s LEDC peripheral caps duty resolution at 14 bits (the original ESP32 does 20). At 16:
ledcSetup(): No more LEDC channels available! (maximum 8) or bit width too big (maximum 14) ledc: ledc_get_duty(745): LEDC is not initializedThe failure mode is nasty: there is no servo output whatsoever, while every other part of the firmware looks perfectly healthy — serial telemetry streams, the joystick reads, the angle integrates. Nothing points at LEDC except that one line at boot.
Fix:
SERVO_RES_BITS = 14. At 14 bits a 20 ms period gives 1.22 µs duty resolution, far finer than any servo can resolve. Nothing was lost.
6. Debugging the joystick: raw=0 is not “floating”
The joystick read a rock-solid raw=0.
A stuck-at-zero ADC reading means the pin is actively held at ground
A genuinely floating ADC pin reads noisy — wandering by tens of counts. Dead-still zero means something is pulling it down. The cause here was joystick wiring, and the constant reading is what proved it wasn’t a floating input.
Adding raw= and centre= to the serial telemetry is what made this diagnosable with numbers instead of inference. Keep them.
7. The calibration guard proved its worth
calibrateCentre() averages 32 samples but rejects anything outside 1200–2900 counts and falls back to centre = 2048. With the joystick unpowered this printed centre=2048 alongside x=-1.00 — i.e. it degraded visibly instead of quietly calibrating to a garbage centre and appearing to work. That visible failure is what pointed at power/wiring.
8. Verified working
| Check | Result |
|---|---|
| Boot calibration | centre=2065 |
| Noise at rest | ±2 counts |
| Normalised output at rest | x=+0.00, no drift (deadband absorbs it) |
| Full sweep, raw | min=0, max=4095 |
| Full sweep, normalised | min=-1.00, max=+1.00 over 96 clean samples |
| Behaviour | Angle integrates on deflection, holds on release |
Reusable Lessons
uvx --from platformioneeds--with pip— and clear~/.platformio/packages/tool-esptoolpyafter a failed run.- ESP32-S3 bootloader goes at
0x0, unlike the classic ESP32’s0x1000. - Only the CH343 UART port (
1a86:55d3) can flash a running S3; the native USB/OTG port (303a:*) reportsNo serial data received. - ESP32-S3 LEDC caps at 14-bit duty resolution; exceeding it silently disables PWM entirely.
- A rock-steady ADC zero means “held low”, not “floating” — floating reads noisy.
- Print the raw sensor value in telemetry.
raw=+centre=turned an inference problem into a measurement. - Plausibility guards should fail visibly. Rejecting an implausible calibration and printing the fallback is what surfaced the fault.
Related
- esp32 — where lessons 1–5 below live as cross-project rules
- lightaim — project overview, pin map, tuning table, wiring, open decisions
- 2026-08-11-ruview-esp32-csi-real-hardware — same ESP32-S3 USB-port trap, same Docker-flash workaround
- 2026-07-29-hardcut-design — the other ESP32-S3 project on this vehicle
- LOG
- TOPICS