ESP32-C3 Super Mini · board_v3 ·

0/5
Critical
0/7
High
0/5
Medium
0/5
Low
💥

Phase 1 — Critical

0 / 5
C1
GPIO 4 collision: buzzer + battery
Both CONFIG_PIN_BUZZER and CONFIG_PIN_BATTERY use GPIO 4. PWM output corrupts ADC reads. Move buzzer to GPIO 3.
src/00_config.h : lines 17, 21
C2
NeoPixel out-of-bounds writes
Animations index LEDs 0–15 / 0–31 but CONFIG_NEOPIXELS_NB_LEDS is 4. Memory corruption → random crashes.
src/06_neopixels.cpp
C3
Duplicate FastLED.addLeds()
Demo file calls addLeds<WS2812, 10> with hardcoded pin & 16 LEDs, conflicting with main init. Remove or gate behind a flag.
src/15_neopixels_demo.cpp : line 57
C4
Blocking bit-bang PWM in playTone()
Busy while(millis()) loop freezes BLE, sensors, motors. Replace with ledcWriteTone() + timer callback.
src/05_buzzer.cpp
C5
FRAMES_PER_SECOND redefined
Macro defined on lines 15 & 48. Also redefines NUM_LEDS, LED_PIN locally — use config.h values.
src/16_neopixels_waving_french_flag.cpp
⚠️

Phase 2 — High

0 / 7
H1
GPIO 8 = OLED SDA = built-in LED
On Super Mini, GPIO 8 drives the onboard LED. I²C and LED will conflict. Reassign OLED SDA.
src/00_config.h : line 24
H2
GPIO 9 = OLED SCL = BOOT button
Pulling SCL low during power-on enters bootloader. Reassign OLED SCL or add external pull-up.
src/00_config.h : line 25
H3
Wrong variable in events  FALSE POSITIVE
interval IS a valid global declared in 02_data.h and defined in 02_data.cpp as const int32_t interval = 500. Audit was wrong — not a bug.
src/11_events.cpp ✓ verified
H4
Hardcoded WiFi credentials
SSID "wdy_wifi_01" / password "12345678" in #else branch. Move to secrets.h (gitignored).
src/03_remotexy.cpp
H5
LED pins redefined locally
LED_PIN_1=2, LED_PIN_2=6 shadow config.h values. Use config defines only.
src/14_leds.cpp : lines 3–4
H6
Typo: DEF_DERIAL_DEBUG
Should be DEF_SERIAL_DEBUG. The #ifdef never matches — error message is dead code.
src/13_oled.cpp : line 20
H7
500ms gate in loop()
All tasks run every 500ms only. Motors, servos, and NeoPixel animations are extremely choppy.
src/src.ino
🔧

Phase 3 — Medium

0 / 5
M1
Blocking delay(80) in neopixels_star()
Use millis()-based timing like neopixels_waving_french_flag() already does.
src/06_neopixels.cpp
M2
String concatenation fragments heap
String(...) + "V (" allocates on heap. Use sprintf() — already used elsewhere in same function.
src/13_oled.cpp : line 78
M3
CRGB constants shadow FastLED names
const CRGB BLUE(...), RED(...) collide with FastLED built-ins. Add a prefix.
src/16_neopixels_waving_french_flag.cpp : lines 53–64
M4
leds[] array ownership unclear
Multiple files use extern for leds[]. Verify single definition with consistent size.
src/06_neopixels.cpp, 15_*.cpp, 16_*.cpp
M5
FastLED.delay() blocks loop
Demo uses FastLED.delay() which is blocking. Convert to non-blocking pattern.
src/15_neopixels_demo.cpp : line 75

Phase 4 — Low (cleanup)

0 / 5
L1
Mystery comment in buzzer
// ????????????? around a commented return;. Remove or document intent.
src/05_buzzer.cpp
L2
Magic numbers everywhere
sin8(wave + section * 85), 128 +, brightness %=150. Extract as named constants.
src/06_neopixels.cpp, 16_*.cpp
L3
Inconsistent naming convention
g_ prefix used sometimes, not others. dutyCycle is global without prefix.
src/14_leds.cpp : line 13
L4
#if 0 / #if 1 toggle blocks
Use proper feature flags in config.h instead of raw preprocessor toggles.
src/00_config.h, 13_oled.cpp
L5
Potential dead code
Multiple animation functions defined but never called. Audit call graph & remove unused.
src/06_neopixels.cpp, 16_*.cpp

🐛 Bug Reports

BUG-001 Guru Meditation crash on boot — Instruction access fault 2026-05-05 ✅ Fixed
Symptom
Board reboots immediately after flashing with Guru Meditation Error: Core 0 panic'ed (Instruction access fault). Infinite reboot loop. MEPC: 0x3fc9027c points to RAM (not executable flash).
Root cause
Stack overflow from runaway FastLED RMT interrupt re-entry. addr2line on the crash addresses pointed to ESP32RMTController::interruptHandler at FastLED/src/platforms/esp/32/rmt_4/idf4_rmt_impl.cpp:827, calling vPortEnterCritical repeatedly with 192-byte stack frames. The decrementing T4 counter (9→4) is the recursion depth.

The ESP-IDF 4.x RMT driver bundled with PlatformIO espressif32 ~6.4.0 has a known race on ESP32-C3 (only 2 RMT channels): the ISR doesn't reliably clear its interrupt flag before iret, so it immediately re-fires and overflows the stack within ~9 calls.
What is RMT?
RMT = Remote Control Transceiver — a hardware peripheral built into every ESP32 that generates or receives precise pulse trains without any CPU involvement. Originally designed for IR remote controls (38 kHz carrier, variable pulse lengths), it turned out to be perfect for driving addressable LEDs.

Why FastLED uses RMT for WS2812 NeoPixels
WS2812 LEDs speak a one-wire self-clocking protocol: each bit is a fixed-width pulse at 800 kHz, and the 0 vs 1 distinction is in the duty cycle:
Bit 0: ▄___ 400 ns HIGH → 850 ns LOW (tolerance ±150 ns) Bit 1: ▄▄▄_ 800 ns HIGH → 450 ns LOW (tolerance ±150 ns) Reset: _________ > 50 µs LOW (latch all data)
Software bit-bang would need to toggle a GPIO with <150 ns jitter while running an OS, handling Wi-Fi ISRs, and doing BLE stack work — impossible on a non-RTOS core. RMT encodes the entire LED frame as a waveform table and streams it out in hardware, bit-perfect, zero CPU load.

ESP32 family RMT channel count
ESP32 classic → 8 RMT channels (plenty of headroom) ESP32-S2 → 4 RMT channels ESP32-S3 → 4 RMT channels ESP32-C3 → 2 RMT channels ← this board ⚠️ ESP32-C6 → 2 RMT channels
With only 2 channels, any additional concurrent RMT user (e.g. IR receive, second LED strip) directly competes with FastLED.

The ESP-IDF 4.x race condition
In the IDF 4.x RMT driver (idf4_rmt_impl.cpp), the ISR handler that fires at the end of each LED frame didn't reliably clear the RMT interrupt status register before executing iret (interrupt return). On ESP32-C3's RISC-V core, the hardware re-checks the pending interrupt line during iret itself — so the ISR re-entered immediately. Each re-entry consumed ~192 bytes of stack (one vPortEnterCritical frame), and with ~9 recursive calls the stack pointer hit non-executable RAM → MCAUSE=0x01 Instruction access fault.

ESP-IDF 5.1 fix
The entire RMT driver was rewritten in IDF 5.x as a DMA-backed transaction model. The ISR-based ping-pong buffer loop (the source of the race) was replaced by the DMA engine, eliminating the re-entry window entirely. PlatformIO espressif32 @ 6.7.0 bundles IDF 5.1 — pinning to that version is the fix.
Initial wrong diagnosis
First suspected an out-of-bounds write into the 4-element leds[] array via duplicate FastLED.addLeds() with hardcoded NUM_LEDS=16 in demo files. That WAS a latent bug (would crash if the dead code ran), but wasn't the trigger — sizing the array to 256 didn't stop the crash, and the crash address only shifted (to 0x3fc9027c) because the larger global pushed RAM layout around.
Evidence
Stack dump showed repeating frames with decrementing counter (9→8→7→6→5→4) = FastLED's LED index loop stomping through memory. MCAUSE=0x01 confirmed execution of non-executable address.
Guru Meditation Error: Core 0 panic'ed (Instruction access fault) Core 0 register dump: MEPC : 0x3fc90000 RA : 0x4038c53c SP : 0x3fc8f5e0 GP : 0x3fc8de00 TP : 0x3fc9090c T0 : 0x4038c538 T1 : 0x600c2140 T2 : 0x00000005 S0/FP : 0x3fc8f4f0 S1 : 0x8000000b A0 : 0x3fc8ff8c A1 : 0x3fc8f694 A2 : 0x00000000 A3 : 0x00000004 A4 : 0x600c2000 A5 : 0x3fc90000 MSTATUS : 0x00001881 MTVEC : 0x40380001 MCAUSE : 0x00000001 MTVAL : 0x3fc90000 Stack memory (repeating pattern — decrementing T4 = LED index): 3fc8f5e0: ...T4=0x09 T5=0x0a... ← frame with index 9 3fc8f660: ...T4=0x08 T5=0x09... ← frame with index 8 3fc8f740: ...T4=0x07 T5=0x08... ← frame with index 7 3fc8f820: ...T4=0x06 T5=0x07... ← frame with index 6 3fc8f8e0: ...T4=0x05 T5=0x06... ← frame with index 5 3fc8f9a0: ...T4=0x04 T5=0x05... ← frame with index 4 Each frame 0xC0 bytes apart — stack consumed until overflow
Real fix
1. Pinned platform = espressif32 @ 6.7.0 (ships ESP-IDF 5.1 with the rewritten RMT driver that fixes the ISR re-entry race).
2. Pinned fastled/FastLED @ ^3.7.0 for reproducibility.
3. Added belt-and-suspenders build flags: FASTLED_RMT_MAX_CHANNELS=1 (C3 has only 2) and FASTLED_RMT_BUILTIN_DRIVER=1 (use IDF's tested driver instead of FastLED's custom one).
Latent fixes (still useful)
1. Sized leds[] to CONFIG_NEOPIXELS_MAX_LEDS (256).
2. Removed duplicate FastLED.addLeds() from demo + french flag files.
3. Removed hardcoded NUM_LEDS, LED_PIN, BRIGHTNESS redefinitions.
4. Wrapped dead code with hardcoded indices (leds[16+pos2], leds[24], etc.) in #if NB_LEDS >= 32 guards — uncommenting them on a small strip will no longer crash.
Files
src/00_config.h src/06_neopixels.cpp src/06_neopixels.h src/15_neopixels_demo.cpp src/16_neopixels_waving_french_flag.cpp
Audit refs
C2 (out-of-bounds), C3 (duplicate addLeds), C5 (macro redefinitions), M3 (CRGB name shadows)
BUG-002 Ctrl+C doesn't quit serial monitor in MSYS2/MinGW 2026-05-05 🔍 Open
Symptom
pio device monitor launched via launch.sh option 8 ignores Ctrl+C in MSYS2/MinGW terminals. The monitor hangs and cannot be exited normally.
Root cause
MSYS2/MinGW intercepts Ctrl+C at the shell level before it reaches PlatformIO's monitor process. The signal is caught by bash instead of being forwarded to the serial monitor.
Workaround
1. Use Ctrl+] — PlatformIO's alternate quit shortcut
2. Use Ctrl+T then Q — PlatformIO's menu quit sequence
3. Close and reopen the terminal window
Proposed fix
Add monitor_flags = --raw to platformio.ini or configure a different exit key. Alternatively, wrap the monitor call with proper signal forwarding in launch.sh.
Files
launch.sh (act_monitor) platformio.ini
BUG-003 Unpinned platform & libs — non-reproducible builds 2026-05-05 ✅ Fixed
Symptom
platformio.ini had unpinned platform = espressif32 and lib_deps with no version constraints. Different machines / different times = different ESP-IDF, different FastLED, different bugs. Direct cause of why BUG-001's RMT crash showed up.
Fix
Pinned platform = espressif32 @ 6.7.0, fastled/FastLED @ ^3.7.0, qualified all Adafruit libs with author prefix, added monitor_speed = 115200, moved FastLED build flags into build_flags.
Files
platformio.ini src/01_includes.h
BUG-004 RemoteXY "No PRO license" dialog — phone refuses to connect 2026-05-05 🔍 Open
Symptom
RemoteXY mobile app shows No PRO license dialog and refuses to connect:
A PRO license is required to connect to this device. Elements count: 5 Variables count: 8 (more than 5) PRO elements: NO [Get PRO] [Preview]
Initial hypothesis (wrong)
First suspected the unpinned RemoteXY library jumped from 3.1.144.1.10 on a clean build, and that 4.x had introduced the PRO enforcement. Pinned to ~3.1.14, wiped the cache, reflashed.

Result: same dialog. Pinning the library does NOT bypass the check. Reverted the pin back to unconstrained RemoteXY.
Real root cause
The PRO check is enforced by the RemoteXY mobile app, not by the library on the device. The firmware sends its RemoteXY_CONF[] blob over BLE; the app parses it, counts elements + variables, and if either exceeds the free quota it short-circuits the connection regardless of which library version generated the blob. Downgrading the firmware library has no effect because the gatekeeper isn't on the device.
Variable inventory (8 — need ≤ 5)
INPUT (3 — all needed): joystick_01_x int8_t drive left/right joystick_01_y int8_t drive forward/back button_01 uint8_t action button OUTPUT (5 — drop 3): onlineGraph_01_distance float distance chart keep onlineGraph_02_speed float speed chart drop (= joystick_y) onlineGraph_03_battery float battery chart drop (use circularBar) circularBar_01 int8_t battery gauge keep sound_01 int16_t buzzer trigger drop (button → buzzer locally) connect_flag is implicit — doesn't count.
Fix paths
A. Shrink the UI — regenerate RemoteXY_CONF[] in the editor with only 5 variables (joystick_x, joystick_y, button_01, distance graph, circularBar battery). Replace the blob + struct in src/03_remotexy.cpp. Compute speed locally from joystick_y, drive buzzer locally from button_01.
B. Buy PRO license — paid, removes the limit.
C. Switch UI library — Blynk, Bluetooth Electronics, or a custom web UI over Wi-Fi.
Status
Library pin reverted to unconstrained RemoteXY (latest). Awaiting decision on which fix path to take.
Files
platformio.ini src/03_remotexy.cpp
BUG-005 esptool: "Could not open COM8, the port doesn't exist" — Windows/MSYS2 2026-05-05 🔍 Open
Symptom
pio run --target upload fails immediately with:
esptool.py v4.5.1 Serial port COM8 A fatal error occurred: Could not open COM8, the port doesn't exist *** [upload] Error 2
But pio device list clearly shows the port:
COM8 ---- Hardware ID: USB VID:PID=303A:1001 SER=8C:D0:B2:A8:98:57 Description: Périphérique série USB (COM8)
Suspected cause
ESP32-C3 Super Mini uses native USB CDC (VID 303A:1001 = built-in USB-Serial/JTAG), not an external UART chip. When esptool toggles DTR/RTS to enter bootloader mode, the CDC port disappears from Windows for a few hundred ms while the chip re-enumerates as the bootloader's USB-Serial/JTAG interface. esptool then tries to reopen "COM8" but Windows hasn't finished re-enumerating, OR the bootloader has come back on a different COM port number.

Compounded on MSYS2: the bash environment's Python/serial layer may handle Windows COM port access differently than native CMD.
Things tried (none worked)
1. upload_flags = --connect-attempts 10 — wrong syntax, esptool rejected.
2. Manual bootloader entry (BOOT + RESET + release BOOT before upload).
3. Removed hardcoded upload_port = COM8, let auto-detect find it.
4. Verified port exists with pio device list right before flashing.
Things to try next
A. Run from native cmd.exe instead of MSYS2 — rules out MSYS2 Python COM access.
B. Device Manager → Disable/Re-enable COM8 → unplug + replug USB.
C. Check what COM port the bootloader presents (different VID:PID maybe) — run pio device list AFTER the BOOT+RESET sequence.
D. Switch to upload_protocol = esp-builtin (uses built-in USB JTAG, bypasses CDC reset entirely). Requires OpenOCD path setup.
E. mode COM8 in CMD to verify nothing else is holding the port (RemoteXY mobile app can do this via BLE-bridge).
Files
platformio.ini launch.sh (act_upload)
Workaround
When stuck: close every program that touches serial (RemoteXY, VS Code, Arduino IDE), then unplug/replug USB and retry. Eventually catches.
BUG-006 Geolocation permission popup on every page load 2026-05-06 07:24 ✅ Fixed
Symptom
Browser shows "Allow this site to know your location?" dialog every time the launcher opens. Three buttons: Allow this visit · Allow once · Never. Hits all 5 pages (index, start-here, flash, monitor, audit) because they share wdiy-script.js.
Root cause
Workshop-DIY template's initNightMode() in 02_web/assets/wdiy-script.js (lines 928–944) called navigator.geolocation.getCurrentPosition() to compute precise local sunset, so it could auto-switch the theme to dark after dusk. The simple version (new Date().getHours() >= 21) ran first; the geolocation block ran on top to refine. Net effect: every page load triggered the OS-level location prompt for a feature 99% of users never notice.
Fix
Removed the geolocation block entirely (lines 928–944). Kept the cheap clock-only check: 21:00–06:00 still auto-applies lab-dark if the user hasn't picked a theme.
calcSunset() kept in source as dead code (API compat) but no longer called.
Files
01_software/01_app/02_web/assets/wdiy-script.js
Lesson
Vendored UI templates can ship feel-good features (auto-night-mode, ghost cursors, AR mode, etc.) that quietly request elevated permissions. Audit any imported navigator.*, fetch() to external hosts, or localStorage writes before deploying.
BUG-007 Splash screen renders as flex sibling instead of full-screen overlay 2026-05-06 07:27 ✅ Fixed
Symptom
On index.html the splash screen (logo + "ESP32-C3 ROBOT APP" title + tap-to-skip) appeared beside the main app on the left half of the page, not as a full-screen overlay. It also never auto-dismissed.
Root cause
Selector mismatch between markup and CSS in the Workshop-DIY template:
// markup (in shell.js + start-here.html) <div id="splash" onclick="dismissSplash()"> ... </div> // CSS (in wdiy-template.css) .splash { position: fixed; inset: 0; z-index: 10000; ... } .splash.hidden { opacity: 0; pointer-events: none; }
The element used id="splash" but the CSS targeted the .splash class. Selector never matched → no position: fixed → splash inherited display: flex from body and rendered as a sibling flex item next to the app. Even worse, dismissSplash() adds a hidden class but .splash.hidden never matched either, so the dismiss had no visual effect.
Why we didn't see it on start-here.html first
Because initSplash() calls setTimeout(dismissSplash, 2500) and dismissSplash() calls setTimeout(() => s.remove(), 600). After ~3.1 s the element is removed from the DOM regardless of CSS. So on most loads we never noticed the misplacement — until the user happened to look at index.html within the first 3 s.
Fix
Two-character change in wdiy-template.css:
.splash → #splash .splash.hidden → #splash.hidden
Inner classes (.splash-inner, .splash-logo, .splash-title, .splash-sub, .splash-hint) stayed as-is — they always matched correctly because those elements have proper class= attributes.
Files
01_software/01_app/02_web/assets/wdiy-template.css
Lesson
When porting a vendored template, run a quick id vs class audit: pull every id="..." in the markup and check the CSS targets each one consistently. Mixed-mode templates (id on outer container, class on children) are a common source of phantom selectors.
BUG-008 Pixel pet appeared twice in the app footer 2026-05-06 06:40 ✅ Fixed
Symptom
Two robot icons rendered side-by-side at the start of the .app-footer on start-here.html. Only one was animated; the other was a static duplicate.
Root cause
initPixelPet() in wdiy-script.js always created and inserted a new <div id="pixelPet"> regardless of whether one already existed. Any double-init scenario (hot-reload, theme toggle re-running init, etc.) appended another one instead of reusing.
Fix
Idempotency guard at the top of initPixelPet():
function initPixelPet() { if (document.getElementById('pixelPet')) return; // ← added const pet = document.createElement('div'); ... }
Files
01_software/01_app/02_web/assets/wdiy-script.js
BUG-009 Light-theme polish: muddy cards · invisible footer · display fonts illegible at small sizes 2026-05-06 07:00 ✅ Fixed
Symptom
On the three light themes (lab-light, solarized, bot-pop) — and later kapow, dino — multiple visual issues:
  • Mission cards (.m-cell) and stat blocks rendered with a dirty grey wash instead of clean white panels.
  • Footer text "workshop-diy.org · Robot-01 ·" was nearly invisible on the pale-blue bot-pop background.
  • Bismillah Arabic calligraphy was ghost-pale at opacity: .45.
  • On dino: Lilita One chunky display font applied to every heading (mission summaries, card titles, sidebar labels) — squashed letterforms, hard to read at 14 px.
  • On kapow: same issue with Bangers font cascading to all UI labels.
  • On bot-pop: initial Bangers heading font for everything → comic-book lettering on UI chrome.
Root causes (3 distinct)
1. Hardcoded dark backgrounds. Mission-specific styles in start-here.html used background: rgba(0,0,0,.22) for cards, statbar, <pre>, file tree, pipeline, bootreset. Designed for dark themes only. On light themes that 22% black wash turned crisp panels into wet concrete.

2. Footer/bismillah opacity. Template CSS hardcoded .app-footer { opacity: .45 } and .bismillah { opacity: .45 }. On dark backgrounds .45 reads as muted-but-visible. On bright backgrounds it reads as not-there.

3. Display-font scope. Themes set --font-h: 'Bangers' or 'Lilita One' as the heading font, and the template applies var(--font-h) to every heading-class element: h1, .card-title, details.collapsible > summary, .sidebar-title, .help-tab, .section-title. Display fonts are designed for hero moments at large sizes — not 14 px UI labels with text-transform: uppercase already on top.
Fix
Three layered patches:

A. Added a .light-theme override block in start-here.html's inline <style>:
.light-theme .m-cell, .light-theme .m-statusbar, .light-theme pre, .light-theme .m-tree, .light-theme .m-pipeline, .light-theme .m-bootreset { background: rgba(0,0,0,.04); color: var(--text); } .light-theme .app-footer { opacity: .7; } .light-theme .bismillah { opacity: .65; } .light-theme code { color: var(--text); }
The terminal mockup (.m-term) was deliberately not overridden — its dark CRT aesthetic is intentional even on light themes.

B. Per-theme display-font scope in wdiy-template.css for kapow and dino:
[data-theme="dino"] details.collapsible > summary, [data-theme="dino"] .card-title, [data-theme="dino"] .sidebar-title, [data-theme="dino"] .help-tab, [data-theme="dino"] .section-title, [data-theme="dino"] .m-h3, [data-theme="dino"] .m-cell h3 { font-family: 'Fredoka', 'Nunito', system-ui, sans-serif; font-weight: 700; letter-spacing: .02em; }
Result: Bangers / Lilita One stay on the hero h1 + splash title (where they look heroic), Fredoka 700 takes over for sub-headings (where it reads chunky-but-legible).

C. Switched bot-pop's --font-h from Bangers → 'Fredoka', 'Nunito' outright. Bangers turned out to only fit kapow's comic-book aesthetic.
Files
01_software/01_app/02_web/start-here.html 01_software/01_app/02_web/assets/wdiy-template.css
Lesson
When designing a multi-theme system, the test matrix is N×M: every component × every theme. Backgrounds, opacities, and display fonts that look right on one theme can break on another. Three rules emerged:
  1. No hardcoded rgba(0,0,0,.X) backgrounds for component fills — use var(--panel) + a .light-theme override if needed.
  2. Opacity for chrome ≠ opacity for content. Footer + decoration may be muted, but never invisible.
  3. Display fonts are hero-only. Scope to h1, splash title, banners — never cascade to UI labels.
BUG-010 Firmware audit pin assertions don't match the v3 schematic 2026-05-06 09:58 🔍 Investigating
Symptom
The firmware-side audit items (C1, H1, H2) describe pin conflicts that don't exist on the v3 board. Discovered when the v3 schematic was finally pulled into the repo at 02_hardware/v3/.
v3 schematic — what's actually on the board
U1 = TB6612FNG dual H-bridge. Drives 2 DC motors (J8, J9). Pins:
  • GPIO 1 = AIN1 + LEDs D5/D6 + J2
  • GPIO 2 = AIN2
  • GPIO 3 = PWMA or J3 (via JP2 solder jumper)
  • GPIO 6 = BIN1 + J4 servo
  • GPIO 7 = BIN2
  • GPIO 10 = PWMB + LEDs D7/D8 + J5 servo
GPIO 0 = SW1 push-button (10 kΩ pull-up R2). GPIO 4 = buzzer with transistor stage (R7/R8/R9). GPIO 8/9 = OLED I²C with 10 kΩ pull-ups (R10/R11). GPIO 20/21 = expansion (J6, J7).
Pre-v3 audit items — re-verify
C1 (buzzer/battery on GPIO 4): v3 puts the buzzer on GPIO 4 with transistor amp; no battery sense is wired to GPIO 4. If firmware 00_config.h still says CONFIG_PIN_BATTERY = 4, the firmware is reading random ADC noise — bug remains, but it's a firmware problem not a wiring problem.

H1 (OLED SDA = onboard LED on GPIO 8): on the C3 SuperMini, GPIO 8 does double-duty as the onboard LED. The schematic uses GPIO 8 as I²C SDA with a 10 kΩ pull-up. Pull-up wins at boot; the LED behavior depends on whether the firmware actively pulls GPIO 8 low between I²C transactions. Likely benign in practice but worth confirming.

H2 (OLED SCL = BOOT button on GPIO 9): v3 has GPIO 9 as I²C SCL with a 10 kΩ pull-up (R11). Pull-up keeps it high at boot, which is the BOOT-button-released state, so the chip boots normally. Hardware resolves the concern. This audit item can be marked done provided the pull-up is populated on the assembled board.
Schematic-only items found
  1. C1' (new): Firmware 05_buzzer.cpp needs to be on GPIO 4 (matches schematic) — NOT GPIO 3 as the earlier audit C1 fix proposed. GPIO 3 is now PWMA for motor A.
  2. JP2 jumper state matters: firmware should NOT assume GPIO 3 is free for general use. If JP2 is in motor mode, GPIO 3 is PWMA. If JP2 reroutes to J3, GPIO 3 is whatever's plugged in (likely a NeoPixel strip).
  3. 1000 µF bulk cap missing: per the 2025-07-28 schematic note, populate this if servos are connected to J2-J5, or stall current will brown out the C3.
  4. Protection diode missing: per the 2025-06-21 note, a reverse-biased 1N4148 on the PWMA / J3 line protects against servo back-EMF.
Action
  1. Re-read 01_src/00_config.h against the new pin map. Update any CONFIG_PIN_* defines that conflict.
  2. Mark C1, H1, H2 as either fixed-by-hardware or amended once verified.
  3. Add the 1000 µF cap + 1N4148 to the BOM (already done in build-guide).
  4. Re-run a full-board boot test on a v3-fab'd PCB before declaring resolution.
Files
01_software/01_app/01_src/00_config.h 01_software/01_app/01_src/05_buzzer.cpp 02_hardware/v3/37_rich_light_move_v3.kicad_sch 02_web/hardware.html 02_web/build-guide.html
Lesson
Audit pin assertions need two-source verification — the firmware 00_config.h defines, and the schematic. Whichever you write first, validate against the other before publishing. The earlier audit was firmware-only and made guesses about the hardware that turned out wrong on at least 5 GPIOs.
BUG-011 Web Bluetooth picker shows no devices — NUS UUID lives in scan-response, not advertisement 2026-05-06 15:09 ✅ Fixed
Symptom
In the new esp32c3-lab sister project, clicking Connect in any lab page opened the Chrome BLE picker and immediately showed "Aucun appareil compatible détecté" ("No compatible device detected") — even with the ESP32-C3 powered, advertising, and within range. Multiple browser refreshes, BT toggles, and re-pair attempts didn't help.
Initial filter (broken)
device = await navigator.bluetooth.requestDevice({ filters: [{ services: [NUS_SERVICE] }], optionalServices: [NUS_SERVICE], });
Filtering by Nordic UART Service (6e400001-…ca9e) is the documented pattern — that's what maqueen-lab uses, and the firmware did add the UUID to the advertisement via adv->addServiceUUID(NUS_SERVICE_UUID).
Root cause
BLE 4.x advertisement packet capacity = 31 bytes. When the device name (esp32c3-lab = 11 bytes wrapped in length+type = 13 bytes) plus standard flags (3 bytes) plus the 128-bit NUS UUID (16 bytes wrapped = 18 bytes) plus advertising-data overhead all add up, you exceed 31 bytes.

NimBLE-Arduino's silent fallback: when the primary advertisement payload would overflow, the library moves the long 128-bit service UUID into the scan-response packet (a separate 31-byte packet returned only when the central actively scans).

Web Bluetooth's filter behaviour: Chrome's requestDevice() matches its filters against the primary advertisement only, not the scan response. The browser sees the device's name, sees no NUS UUID in the advertisement payload, and rejects it during filter evaluation. Picker shows nothing.
Why maqueen-lab didn't hit this
A micro:bit advertising as BBC micro:bit [xxxxx] (~22 bytes including formatting) leaves enough room for the NUS UUID in the primary advertisement. The Workshop-DIY team's chosen device name was just short enough to squeeze in. ESP32-C3 + the chosen esp32c3-lab name overflows the budget.
Fix
Extend the filter list to also match by name prefix:
filters: [ { services: [NUS_SERVICE] }, { namePrefix: 'esp32c3' }, // ← new fallback { name: 'esp32c3-lab' }, ], optionalServices: [NUS_SERVICE],
Multiple filter entries are OR'd. The browser shows any device matching any of them. optionalServices ensures NUS is still accessible after pairing, regardless of which filter triggered the match.
Files
esp32c3-lab/js/ble.js esp32c3-lab/firmware/esp32c3-lab/esp32c3-lab.ino
Lessons
  1. Don't filter only by 128-bit UUID when your device name + UUID exceeds the 31-byte advertisement budget. Always provide a namePrefix fallback.
  2. The 128-bit NUS UUID is the worst-case for advertisement budgets. 16-bit UUIDs (Bluetooth-SIG-assigned) take only 2 bytes in advertising data and don't have this problem.
  3. To diagnose: install nRF Connect on a phone and scan — it shows both advertisement and scan-response packets explicitly. If the UUID appears under "Scan Response Data" but not "Advertisement Data", you have this problem.
  4. Alternative firmware fix (untaken): set a shorter device name (c3lab = 5 bytes) so UUID + name both fit in advertisement. Less user-friendly.
BUG-012 esp32c3-lab firmware: PlatformIO layout + Serial-symbol-missing on C3 SuperMini 2026-05-06 17:30 ✅ Fixed
Symptom
Three sequential failures while building the new esp32c3-lab firmware sketch from scratch:
# Failure 1 Warning! Ignore unknown configuration option `build_flags_extra` in section [env:esp32-c3-devkitm-1] # Failure 2 Error: Nothing to build. Please put your source code files to the 'D:\…\firmware\src' folder # Failure 3 (after fixing layout) esp32c3-lab.ino: In function 'void setup()': esp32c3-lab.ino:387:3: error: 'Serial' was not declared in this scope Serial.begin(115200); ^~~~~~ note: suggested alternative: 'Serial1'
Root cause 1 — invalid PIO directive
build_flags_extra is not a real PlatformIO option. Originally invented by me to "append" without overwriting the board defaults, but the real way to do that is just put everything in build_flags (it's additive to the board's defaults already).

Fix: collapsed the build_flags_extra + build_unflags dance into a single build_flags list.
Root cause 2 — src_dir convention
PlatformIO's default src_dir is src (relative to the directory containing platformio.ini). The sketch was at firmware/esp32c3-lab.ino, so PIO scanned firmware/src/, found nothing, refused to build.

Solutions considered:
  • src_dir = . — keep .ino at firmware/; PIO would also scan README.md etc.
  • Move .ino to firmware/src/esp32c3-lab.ino — works for PIO but Arduino IDE complains "folder name must match .ino name".
  • Move .ino to firmware/esp32c3-lab/esp32c3-lab.ino + set src_dir = esp32c3-lab — works for both. Folder name matches sketch name (Arduino IDE happy); PIO follows src_dir.
Root cause 3 — Serial symbol missing (deeper than first thought)
On arduino-esp32 2.0.16 (shipped by espressif32 @ 6.7.0), the esp32-c3-devkitm-1 board profile has an inconsistent macro state when ARDUINO_USB_CDC_ON_BOOT=1 is set without a paired ARDUINO_USB_MODE=0:
// HardwareSerial.h (framework) #if SOC_RX0 != -1 && !ARDUINO_USB_CDC_ON_BOOT extern HardwareSerial Serial; // ← skipped when CDC ON BOOT #endif // HWCDC.h / HWCDC.cpp (framework) #if !ARDUINO_USB_MODE && ARDUINO_USB_CDC_ON_BOOT extern HWCDC USBSerial; // (#define Serial USBSerial happens elsewhere conditionally) #endif
On the esp32-c3-devkitm-1 profile, ARDUINO_USB_MODE is undefined → preprocessor treats it as 0 → the HWCDC block should fire and define Serial. But due to a header-ordering / macro-evaluation quirk in 2.0.16, the #define Serial USBSerial doesn't propagate to the framework's own HardwareSerial.cpp compilation unit — only to user code.

Phase 1 fix (insufficient): dropped Serial.* calls from the .ino. This stopped user-code from referencing Serial, but the framework's own HardwareSerial.cpp:60 calls Serial.available() in serialEventRun() — that file still failed to compile.

Phase 2 fix (correct): remove the -DARDUINO_USB_CDC_ON_BOOT=1 flag entirely. The board's default (CDC OFF) makes the framework declare Serial as HardwareSerial(0) via the standard SOC_RX0 path. Framework compiles cleanly. User code doesn't reference Serial anyway. esptool upload still works via the chip's built-in USB JTAG/Serial peripheral — that path is independent of Arduino's HWCDC class.

Fixes not taken (for reference):
  • Pair -DARDUINO_USB_CDC_ON_BOOT=1 with -DARDUINO_USB_MODE=0 — would in theory fire the HWCDC declaration cleanly. Untested; CDC-off default is simpler.
  • Switch to arduino-esp32 3.x (via espressif32 @ 53.x) — fixes the Serial issue but breaks NimBLE-Arduino 1.x compatibility (NimBLE 2.x has API breakage).
  • Use USBSerial.begin() directly — would work for user code but not the framework's HardwareSerial.cpp internal call.
Final layout
esp32c3-lab/firmware/ ├── platformio.ini ← src_dir = esp32c3-lab ├── README.md └── esp32c3-lab/ └── esp32c3-lab.ino ← matches Arduino IDE folder rule matches PIO src_dir setting
Files
esp32c3-lab/firmware/platformio.ini esp32c3-lab/firmware/esp32c3-lab/esp32c3-lab.ino esp32c3-lab/firmware/README.md
Lessons
  1. Verify every platformio.ini directive against the official docs — typos / made-up options only emit warnings, not errors.
  2. The firmware/<sketch-name>/<sketch-name>.ino + src_dir = <sketch-name> pattern is the cleanest way to satisfy both PlatformIO and Arduino IDE simultaneously. Recommend for any hybrid-build project.
  3. Don't trust Serial on ESP32-C3 with arduino-esp32 2.0.x. Either use USBSerial directly, or drop the dependency entirely (the BLE log can substitute for diagnostics).
  4. When in doubt, pio run -v shows the exact compiler invocation including which -D macros are defined. Helps disambiguate "is this flag actually being applied?" questions.