Firmware for a Seeed XIAO ESP32-C6 driving a C4001 24GHz radar, with a web UI for live data and calibration. - Auto-detects whether the C4001 is on I2C (0x2A/0x2B) or UART, so the board's mode switch position doesn't matter. Serves the probe log over HTTP when nothing is found. - Single-page UI inlined into flash (no CDN, so it works in AP mode): presence state, target count, range, speed, energy, rolling 60s chart, and mode-aware calibration controls. - Joins WiFi from a gitignored include/secrets.h, falls back to a "xiao-mmwave" AP, registers mmwave.local. - Poll rate adapts to the interface: UART at 9600 costs ~60ms per register round-trip, so polling it at the I2C rate starved the HTTP server. Two hardware gotchas documented in the README: - The sensor UART must not use D6/D7. Those are GPIO16/17, the C6's hard-wired U0TXD/U0RXD. Pointing Serial1 at them deadlocks the UART driver and trips the interrupt watchdog, boot-looping the board. UART1 has no fixed pins, so it routes through the GPIO matrix to D10/D9. - DFRobot's own wiring table pairs the sensor's RX with the MCU's RX, and their library swaps the rxpin/txpin parameter names between the header and the .cpp. Verified on ESP32-C6FH4 rev v0.1, 4MB flash: builds at 57.6% flash / 11.9% RAM, flashes over the native USB Serial/JTAG port, and reads a live C4001 on I2C @ 0x2A. Co-Authored-By: Claude <noreply@anthropic.com>
6.8 KiB
XIAO ESP32-C6 + DFRobot C4001 mmWave presence sensor
Live-data and calibration rig for a C4001 24 GHz radar on a Seeed XIAO ESP32-C6. The board hosts a web page showing presence, distance, velocity and signal energy in real time, with sliders for every sensor parameter.
Verified on this machine
| Thing | Value |
|---|---|
| Chip | ESP32-C6FH4 (QFN32) rev v0.1, single core + LP core, 160 MHz |
| Flash | 4 MB embedded (mfr 0x20, dev 0x4016) |
| Radios | WiFi 6, BT 5 LE, IEEE 802.15.4 |
| MAC | e4:b3:23:b4:7b:c4 |
| Port | /dev/ttyACM0 (native USB Serial/JTAG, VID 303a PID 1001) |
| Toolchain | PlatformIO 6.1.19, espressif32 2026.02.30, Arduino core 3.3.7 |
| Build | succeeds, 57.6% flash / 11.9% RAM |
Flashing was confirmed working: esptool connected, uploaded its stub loader, ran it, and read the flash chip ID back.
Arduino core version matters. ESP32-C6 needs Arduino-ESP32 3.x. The installed platform is the pioarduino fork (
2026.02.30), which ships 3.3.7 and includes theXIAO_ESP32C6variant. The old registryespressif32 @ 6.xhas no C6 support — don't downgrade.
Pin diagram
The XIAO's pads are unlabelled on the top side. Hold the board component side up with the USB-C connector at the top — pin 1 of each column is nearest the USB connector.
┌───┐ USB-C ┌───┐
┌─────┴───┴───────┴───┴─────┐
D0 ────┤ o o ├──── VBUS (5 V from USB)
GPIO0 │ │
D1 ────┤ o o ├──── GND
GPIO1 │ │
D2 ────┤ o seeed studio o ├──── 3V3 (3.3 V out)
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 └───────────────────────────┘
Full mapping, from Seeed's official pinout sheet cross-checked against the Arduino variant header:
| Silkscreen | GPIO | Alt function |
|---|---|---|
| D0 | 0 | ADC1 / A0, LP_GPIO0 |
| D1 | 1 | ADC1 / A1, LP_GPIO1 |
| D2 | 2 | ADC1 / A2, LP_GPIO2 |
| D3 | 21 | — |
| D4 | 22 | I2C SDA |
| D5 | 23 | I2C SCL |
| D6 | 16 | U0 TXD |
| D7 | 17 | U0 RXD |
| D8 | 19 | SPI SCK |
| D9 | 20 | SPI MISO |
| D10 | 18 | SPI MOSI |
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, HIGH = external u.FL).
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 the C6's GPIOs are not 5 V tolerant. Running the
sensor at 3.3 V makes every logic level match and removes the question entirely.
The firmware auto-detects the interface at boot, so wire whichever your board's mode switch is set to.
UART (works on every C4001 SKU):
| C4001 | XIAO | Note |
|---|---|---|
| VIN | 3V3 | |
| GND | GND | |
| TX | D9 (GPIO20) | sensor transmit → board receive |
| RX | D10 (GPIO18) | sensor receive ← board transmit |
| OUT | D2 (GPIO2) | optional |
I2C (if your board has an I2C/UART switch set to I2C):
| C4001 | XIAO |
|---|---|
| VIN | 3V3 |
| GND | GND |
| SCL | D5 (GPIO23) |
| SDA | D4 (GPIO22) |
| OUT | D2 (GPIO2), optional |
OUT is the sensor's own thresholded presence output. It is not required — the
firmware reads everything over the data link — but wiring it lets you compare the
sensor's internal decision against the raw numbers, which is genuinely useful
while calibrating.
Don't use D6/D7 for the sensor UART. They are GPIO16/GPIO17, this chip's hard-wired
U0TXD/U0RXD. UART0 already owns them, so pointingSerial1at those pins deadlocks the UART driver and trips the interrupt watchdog — the board boot-loops before it ever reaches WiFi. Found this the hard way; the UART pins above route UART1 through the GPIO matrix to pins with no default peripheral function.
Heads up on TX/RX. DFRobot's own example table pairs the sensor's RX with the ESP32's RX, which is wrong. Their library also swaps the
rxpin/txpinparameter names between the header and the.cpp. The table above is the electrically correct wiring: sensor TX → board RX, sensor RX → board TX.
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.
Build, flash, monitor
pio run # build
pio run -t upload # flash /dev/ttyACM0
pio device monitor # 115200 baud
If upload fails to auto-reset, hold B (boot), tap R (reset), release B, then upload.
Using it
On boot the firmware joins your WiFi; if that fails within 15 s it starts an access point:
- SSID
xiao-mmwave· passwordmmwave1234 - Web UI at the IP printed on serial, or
http://mmwave.local/
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. Serial output at boot tells you which interface was detected and prints the full probe log if nothing was found.
The two sensor modes
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, because it shows you the numbers behind the decision. Then switch to presence mode for normal operation. The in-page guide walks through it. Settings apply immediately but are volatile — Save to sensor flash to persist them.
Layout
platformio.ini board, framework, C4001 library from git
include/secrets.h.example template for WiFi creds (real one is gitignored)
src/main.cpp detection, polling, WiFi, HTTP API
src/web_ui.h the single-page UI, inlined into flash
HTTP API: GET /api/live, GET /api/config, POST /api/config,
POST /api/action (start/stop/save/reset/factory).