Board is now selected by `default_envs` in platformio.ini rather than being hardcoded. Both boards stay supported and both targets build. - src/board_pins.h holds one block per board plus an ledSet() abstraction, so main.cpp has no board-specific code. The C6's active-low LED and the S3's WS2812 RGB go through the same call, and the C6 antenna-switch lines are behind #if HAS_RF_SWITCH. - YD-ESP32-S3 env targets esp32-s3-devkitc1-n16r8 (verified against the actual part: ESP32-S3 rev v0.2, 16MB quad flash, 8MB octal PSRAM, CH343 bridge). Serial is UART0 -> CH343, not USB CDC. Fixes for hangs that left the board alive on USB but dead on the network: - The vendor library's UART path has loops with no timeout: sensorStop() is while(1) that only exits on a "sensorStop" reply, and the UART branch of getStatus() is while(len == 0). Calling either with no sensor attached hung setup() permanently. UART is now probed with a raw 1.5s read on Serial1, and the library only gets the port once bytes actually arrive. The I2C path is bounded, so prefer I2C. - Floating SDA/SCL can stall Wire.endTransmission() forever. Check both lines read high with pull-ups before touching Wire. - Serial.setTxTimeoutMs(0) is a trap: HWCDC::write seeds a uint32_t from it and does a bare tries--, so 0 underflows to 4.29e9 and spins for ~49 days. Use 10, and only on USB CDC boards. Diagnostics, so "did it crash?" is answerable without a serial cable: - Boot count in RTC memory, esp_reset_reason(), free/min heap and WiFi drop count exposed via /api/live and shown as header pills in the UI. - WiFi auto-reconnect, and retry of station mode from the AP fallback. Without it a single dropout left the board unreachable forever, which looks exactly like a crash. Co-Authored-By: Claude <noreply@anthropic.com>
DFRobot C4001 mmWave presence sensor on ESP32
Live-data and calibration rig for a C4001 24 GHz radar. The board hosts a web page showing presence, distance, velocity and signal energy in real time, with sliders for every sensor parameter.
Two boards are supported. Both are kept working; nothing is deleted when you switch.
Choosing a board
Change one line in platformio.ini:
[platformio]
default_envs = yd_esp32_s3 ; or: xiao_esp32c6
Or build one without editing the file:
pio run -e xiao_esp32c6 -t upload
Pin maps live in src/board_pins.h, one block per board.
To add a board: add an [env:...] with a new -DBOARD_xxx flag, then a
matching block in that header. Nothing else in the firmware is board-specific.
| YD-ESP32-S3 | XIAO ESP32-C6 | |
|---|---|---|
| env | yd_esp32_s3 |
xiao_esp32c6 |
| MCU | ESP32-S3-WROOM-1 N16R8, rev v0.2 | ESP32-C6FH4 rev v0.1 |
| Flash / PSRAM | 16 MB quad / 8 MB octal | 4 MB / none |
| MAC | 24:58:7c:cd:9a:d8 |
e4:b3:23:b4:7b:c4 |
| Serial | UART0 → CH343 (1a86:55d3), COM port |
native USB Serial/JTAG (303a:1001) |
| Free heap | ~301 KB | ~250 KB |
Both verified: builds clean, flashes, boots, joins WiFi, serves the UI.
Wiring the C4001
Power the sensor from 3V3, not 5V. The C4001 accepts 3.3–5 V, but at 5 V its
OUT pin can swing to 5 V and neither ESP32's GPIOs are 5 V tolerant.
Prefer I2C. See the vendor-library warning below — the UART path in DFRobot's driver has loops with no timeout. I2C is bounded and safe.
The firmware auto-detects which bus the sensor is on, so wire whichever your board's mode switch is set to.
YD-ESP32-S3
The YD board has GPIO numbers printed on the silkscreen, so read the board itself — the header order below is only for orientation.
| C4001 | Pin | Header |
|---|---|---|
| VIN | 3V3 | P1, top |
| GND | GND | P1 bottom, or either GND on P2 |
| SCL | GPIO9 | P1 |
| SDA | GPIO8 | P1 |
| TX (UART) | GPIO18 | P1 |
| RX (UART) | GPIO17 | P1 |
| OUT | GPIO16 | P1, optional |
Header order, USB-C end at the top:
P1 (left) P2 (right)
1 3V3 1 GND
2 3V3 2 GPIO43 U0TXD -> CH343
3 RST 3 GPIO44 U0RXD <- CH343
4 GPIO4 4 GPIO1
5 GPIO5 5 GPIO2
6 GPIO6 6 GPIO42
7 GPIO7 7 GPIO41
8 GPIO15 8 GPIO40
9 GPIO16 <- C4001 OUT 9 GPIO39
10 GPIO17 -> C4001 RX 10 GPIO38
11 GPIO18 <- C4001 TX 11 GPIO37 ** PSRAM **
12 GPIO8 <- C4001 SDA 12 GPIO36 ** PSRAM **
13 GPIO3 13 GPIO35 ** PSRAM **
14 GPIO46 strapping 14 GPIO0 BOOT button
15 GPIO9 <- C4001 SCL 15 GPIO45 strapping
16 GPIO10 16 GPIO48 RGB WS2812
17 GPIO11 17 GPIO47
18 GPIO12 18 GPIO21
19 GPIO13 19 GPIO20 USB D+
20 GPIO14 20 GPIO19 USB D-
21 5V 21 GND
22 GND 22 GND
Do not use for I/O: GPIO35/36/37 (octal PSRAM — the R8 in N16R8), GPIO43/44 (UART0 to the CH343), GPIO19/20 (native USB), GPIO0/45/46 (strapping), GPIO48 (RGB LED), GPIO26–32 (flash, not broken out).
The board has two USB-C sockets. Use the one marked COM (CH343) — that's
what default_envs = yd_esp32_s3 assumes, and it puts Serial on a real UART
instead of USB CDC.
XIAO ESP32-C6
The XIAO's pads are unlabelled. Component side up, USB-C at the top; pin 1 of each column is nearest the connector.
┌───┐ USB-C ┌───┐
┌─────┴───┴───────┴───┴─────┐
D0 ────┤ o o ├──── VBUS (5 V)
GPIO0 │ │
D1 ────┤ o o ├──── GND
GPIO1 │ │
D2 ────┤ o seeed studio o ├──── 3V3
GPIO2 │ XIAO-ESP32-C6 │
D3 ────┤ o o ├──── D10 GPIO18
GPIO21 │ │
D4 ────┤ o o ├──── D9 GPIO20
GPIO22 │ │
D5 ────┤ o o ├──── D8 GPIO19
GPIO23 │ │
D6 ────┤ o [u.FL] [ant] o ├──── D7 GPIO17
GPIO16 └───────────────────────────┘
| C4001 | XIAO pin |
|---|---|
| VIN | 3V3 |
| GND | GND |
| SCL | D5 (GPIO23) |
| SDA | D4 (GPIO22) |
| TX (UART) | D9 (GPIO20) |
| RX (UART) | D10 (GPIO18) |
| OUT | D2 (GPIO2), optional |
Not on the headers but used by the firmware: GPIO15 user LED (active LOW), GPIO3 RF switch enable (LOW = on), GPIO14 antenna select (LOW = on-board ceramic). Without the RF switch lines, WiFi range is centimetres.
Setup
cp include/secrets.h.example include/secrets.h
$EDITOR include/secrets.h # your WiFi; the file is gitignored
Skipping this is fine — the board then goes straight to AP mode
(xiao-mmwave / mmwave1234).
pio run # build the default env
pio run -t upload # flash
pio device monitor # 115200
Web UI at the IP printed on serial, or http://mmwave.local/.
Traps found the hard way
The vendor library's UART path can hang forever. In
DFRobot_C4001.cpp, sensorStop() is while(1){...} that only exits on a
"sensorStop" reply, and the UART branch of getStatus() is
while(len == 0){...} with no timeout. Calling either with no sensor attached
hangs setup() permanently and the board never reaches WiFi. This firmware
therefore probes UART with a raw 1.5 s read on Serial1 and only hands the port
to the library once bytes actually arrive. The I2C path is bounded and safe —
prefer I2C.
Never point the sensor UART at the console pins. GPIO43/44 on the S3 and
GPIO16/17 (D6/D7) on the C6 are the chips' hard-wired U0TXD/U0RXD. Attaching
a second UART to them deadlocks the UART driver and trips the interrupt
watchdog — the board boot-loops before reaching WiFi.
Floating I2C lines can stall the peripheral. With nothing wired, SDA/SCL sit
low and Wire.endTransmission() may never return. The firmware checks both
lines read high with pull-ups before touching Wire.
Serial.setTxTimeoutMs(0) is a trap on USB CDC boards. In HWCDC::write,
tries is a uint32_t seeded from that value and the no-progress path does a
bare tries--, so 0 underflows to 4.29 billion and spins for ~49 days. Use a
small non-zero value. Only relevant when Serial is USB CDC (the C6 env).
DFRobot's wiring table is wrong. It pairs the sensor's RX with the MCU's RX,
and the library swaps the rxpin/txpin parameter names between the header and
the .cpp. Correct is sensor TX → board RX.
Watch power on bus-powered boards. A 24 GHz radar plus WiFi TX bursts on one USB port is marginal. Symptoms are not a crash: no panic, no backtrace, the USB device simply disconnects and re-enumerates. If that happens, add a 100–470 µF bulk capacitor across the sensor's VIN/GND and use a direct port rather than a hub.
Using it
The page shows a presence indicator, live target count / range / speed / energy, a rolling 60-second chart, and a calibration panel that changes with the active sensor mode. Header pills report board, link type, uptime, boot count, last reset reason, WiFi drops and free heap — enough to answer "did it crash?" without a serial cable.
Presence mode (eExitMode) reports occupancy only. Parameters: min/max/trigger
range, trigger and keep sensitivity (0–9), trigger delay (×0.01 s), keep timeout
(×0.5 s).
Speed mode (eSpeedMode) reports target count, distance, velocity and signal
energy. Parameters: min/max range and a detection threshold (×0.1).
Calibrate in speed mode — it shows the numbers behind the decision — then switch to presence mode for normal use. Settings apply immediately but are volatile; Save to sensor flash to persist them.
Layout
platformio.ini board envs; default_envs selects one
src/board_pins.h per-board pin map + LED abstraction
src/main.cpp detection, polling, WiFi, HTTP API
src/web_ui.h single-page UI, inlined into flash
include/secrets.h.example WiFi template (real one is gitignored)
HTTP API: GET /api/live, GET /api/config, POST /api/config,
POST /api/action (start/stop/save/reset/factory).