Files
mattandClaude 9b5f98ae7b Add I2C bus scan and pull-up presence test; fix S3 board orientation in README
Detection previously reported only "no ACK at 0x2a/0x2b", which cannot tell a
sensor at an unexpected address from one that is not wired at all.

- Sweep the full 1..126 address range and log any responders.
- Before enabling the internal pull-ups, sample SDA/SCL as plain inputs. A
  powered C4001 breakout holds them high through its own pull-ups, so an
  all-low reading means nothing powered is on those pins -- a wiring answer
  rather than a protocol one. This immediately identified a miswire that the
  ACK-only log had left ambiguous.
- Drop the \r\n from the raw UART probe. DFRobot_C4001_UART::writeReg sends the
  bare command string with no terminator, so the probe should match it.

README had the YD-ESP32-S3 header order right but the orientation wrong: it
said USB-C at the top when the module is at the top and both USB-C sockets are
at the bottom. Read upside down that walks the sensor into the PSRAM pins.
Also note the separate plain LED on GPIO38 vs the RGB on GPIO48.

Verified on hardware: C4001 detected at I2C 0x2a, init ok, streaming range,
velocity and energy in speed mode.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 12:07:26 -07:00

9.5 KiB
Raw Permalink Blame History

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. Orientation matters: hold the board with the ESP32-S3-WROOM-1 module and its antenna at the TOP, and both USB-C sockets at the BOTTOM. Pin 1 of each column is the end nearest the module, furthest from the USB sockets. (Getting this upside down is an easy way to wire the sensor into the PSRAM pins.)

        [ ESP32-S3-WROOM-1 module + antenna ]   <- 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
        [ USB-C: COM (CH343) | USB ]            <- BOTTOM

Reference pinout: https://mischianti.org/vcc-gnd-studio-yd-esp32-s3-devkitc-1-clone-high-resolution-pinout-and-specs/ Note that board also has a plain built-in LED on GPIO38, separate from the RGB WS2812 on GPIO48 that this firmware drives.

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