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 run Outcome: 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

uvx creates an ephemeral env without pip, and PlatformIO shells out to pip install

PlatformIO installs esptool’s dependencies by invoking pip as a subprocess. In a uvx env there is no pip, so the package install dies half-done and then reports the confusing MissingPackageManifestError on the next run.

Fix: uvx --from platformio --with pip pio run Also required: rm -rf ~/.platformio/packages/tool-esptoolpy to clear the half-install — adding --with pip alone 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 >= 3

guard 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)

ImageOffset
bootloader.bin0x0
partitions.bin0x8000
boot_app0.bin0xe000
firmware.bin0x10000

The S3 puts its bootloader at 0x0, not 0x1000

The 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 with No serial data received on both --before default_reset and --before usb_reset once 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 output

The 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 initialized

The 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

CheckResult
Boot calibrationcentre=2065
Noise at rest±2 counts
Normalised output at restx=+0.00, no drift (deadband absorbs it)
Full sweep, rawmin=0, max=4095
Full sweep, normalisedmin=-1.00, max=+1.00 over 96 clean samples
BehaviourAngle integrates on deflection, holds on release

Reusable Lessons

  1. uvx --from platformio needs --with pip — and clear ~/.platformio/packages/tool-esptoolpy after a failed run.
  2. ESP32-S3 bootloader goes at 0x0, unlike the classic ESP32’s 0x1000.
  3. Only the CH343 UART port (1a86:55d3) can flash a running S3; the native USB/OTG port (303a:*) reports No serial data received.
  4. ESP32-S3 LEDC caps at 14-bit duty resolution; exceeding it silently disables PWM entirely.
  5. A rock-steady ADC zero means “held low”, not “floating” — floating reads noisy.
  6. Print the raw sensor value in telemetry. raw= + centre= turned an inference problem into a measurement.
  7. Plausibility guards should fail visibly. Rejecting an implausible calibration and printing the fallback is what surfaced the fault.