Add YD-ESP32-S3 support, boot diagnostics, and hang guards

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>
This commit is contained in:
mattandClaude committed 2026-08-14 11:55:03 -07:00
1 parent b9162db707
commit 9c1c3fe1e8
5 files changed
+466 -188

No files matched your search

+157 -110
View File
@@ -1,43 +1,117 @@
# XIAO ESP32-C6 + DFRobot C4001 mmWave presence sensor # DFRobot C4001 mmWave presence sensor on ESP32
Live-data and calibration rig for a C4001 24 GHz radar on a Seeed XIAO ESP32-C6. Live-data and calibration rig for a C4001 24 GHz radar. The board hosts a web
The board hosts a web page showing presence, distance, velocity and signal page showing presence, distance, velocity and signal energy in real time, with
energy in real time, with sliders for every sensor parameter. sliders for every sensor parameter.
## Verified on this machine Two boards are supported. Both are kept working; nothing is deleted when you
switch.
| Thing | Value | ## Choosing a board
|---|---|
| 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, Change one line in `platformio.ini`:
ran it, and read the flash chip ID back.
> **Arduino core version matters.** ESP32-C6 needs Arduino-ESP32 **3.x**. The ```ini
> installed platform is the pioarduino fork (`2026.02.30`), which ships 3.3.7 and [platformio]
> includes the `XIAO_ESP32C6` variant. The old registry `espressif32 @ 6.x` has no default_envs = yd_esp32_s3 ; or: xiao_esp32c6
> C6 support — don't downgrade. ```
## Pin diagram Or build one without editing the file:
The XIAO's pads are unlabelled on the top side. Hold the board **component side ```bash
up with the USB-C connector at the top** — pin 1 of each column is nearest the pio run -e xiao_esp32c6 -t upload
USB connector. ```
Pin maps live in [src/board_pins.h](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 ┌───┐ ┌───┐ USB-C ┌───┐
┌─────┴───┴───────┴───┴─────┐ ┌─────┴───┴───────┴───┴─────┐
D0 ────┤ o o ├──── VBUS (5 V from USB) D0 ────┤ o o ├──── VBUS (5 V)
GPIO0 │ │ GPIO0 │ │
D1 ────┤ o o ├──── GND D1 ────┤ o o ├──── GND
GPIO1 │ │ GPIO1 │ │
D2 ────┤ o seeed studio o ├──── 3V3 (3.3 V out) D2 ────┤ o seeed studio o ├──── 3V3
GPIO2 │ XIAO-ESP32-C6 │ GPIO2 │ XIAO-ESP32-C6 │
D3 ────┤ o o ├──── D10 GPIO18 D3 ────┤ o o ├──── D10 GPIO18
GPIO21 │ │ GPIO21 │ │
@@ -49,72 +123,19 @@ USB connector.
GPIO16 └───────────────────────────┘ GPIO16 └───────────────────────────┘
``` ```
Full mapping, from Seeed's official pinout sheet cross-checked against the | C4001 | XIAO pin |
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 | | VIN | 3V3 |
| GND | GND | | GND | GND |
| SCL | **D5** (GPIO23) | | SCL | **D5** (GPIO23) |
| SDA | **D4** (GPIO22) | | SDA | **D4** (GPIO22) |
| TX (UART) | **D9** (GPIO20) |
| RX (UART) | **D10** (GPIO18) |
| OUT | D2 (GPIO2), optional | | OUT | D2 (GPIO2), optional |
`OUT` is the sensor's own thresholded presence output. It is not required — the Not on the headers but used by the firmware: GPIO15 user LED (active LOW),
firmware reads everything over the data link — but wiring it lets you compare the GPIO3 RF switch enable (LOW = on), GPIO14 antenna select (LOW = on-board
sensor's internal decision against the raw numbers, which is genuinely useful ceramic). Without the RF switch lines, WiFi range is centimetres.
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 pointing `Serial1` at
> 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`/`txpin`
> parameter names between the header and the `.cpp`. The table above is the
> electrically correct wiring: **sensor TX → board RX**, sensor RX → board TX.
## Setup ## Setup
@@ -123,33 +144,59 @@ cp include/secrets.h.example include/secrets.h
$EDITOR include/secrets.h # your WiFi; the file is gitignored $EDITOR include/secrets.h # your WiFi; the file is gitignored
``` ```
Skipping this is fine — the board then goes straight to AP mode. Skipping this is fine — the board then goes straight to AP mode
(`xiao-mmwave` / `mmwave1234`).
## Build, flash, monitor
```bash ```bash
pio run # build pio run # build the default env
pio run -t upload # flash /dev/ttyACM0 pio run -t upload # flash
pio device monitor # 115200 baud pio device monitor # 115200
``` ```
If upload fails to auto-reset, hold **B** (boot), tap **R** (reset), release **B**, Web UI at the IP printed on serial, or `http://mmwave.local/`.
then upload.
## 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 ## Using it
On boot the firmware joins your WiFi; if that fails within 15 s it starts an
access point:
- **SSID** `xiao-mmwave` · **password** `mmwave1234`
- Web UI at the IP printed on serial, or `http://mmwave.local/`
The page shows a presence indicator, live target count / range / speed / energy, 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 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 sensor mode. Header pills report board, link type, uptime, boot count, last
prints the full probe log if nothing was found. reset reason, WiFi drops and free heap — enough to answer "did it crash?"
without a serial cable.
### The two sensor modes
**Presence mode** (`eExitMode`) reports occupancy only. Parameters: min/max/trigger **Presence mode** (`eExitMode`) reports occupancy only. Parameters: min/max/trigger
range, trigger and keep sensitivity (0–9), trigger delay (×0.01 s), keep timeout range, trigger and keep sensitivity (0–9), trigger delay (×0.01 s), keep timeout
@@ -158,18 +205,18 @@ range, trigger and keep sensitivity (0–9), trigger delay (×0.01 s), keep time
**Speed mode** (`eSpeedMode`) reports target count, distance, velocity and signal **Speed mode** (`eSpeedMode`) reports target count, distance, velocity and signal
energy. Parameters: min/max range and a detection threshold (×0.1). energy. Parameters: min/max range and a detection threshold (×0.1).
Calibrate in speed mode, because it shows you the numbers behind the decision. Calibrate in speed mode — it shows the numbers behind the decision — then switch
Then switch to presence mode for normal operation. The in-page guide walks to presence mode for normal use. Settings apply immediately but are volatile;
through it. Settings apply immediately but are volatile — **Save to sensor flash** **Save to sensor flash** to persist them.
to persist them.
## Layout ## Layout
``` ```
platformio.ini board, framework, C4001 library from git platformio.ini board envs; default_envs selects one
include/secrets.h.example template for WiFi creds (real one is gitignored) src/board_pins.h per-board pin map + LED abstraction
src/main.cpp detection, polling, WiFi, HTTP API src/main.cpp detection, polling, WiFi, HTTP API
src/web_ui.h the single-page UI, inlined into flash 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`, HTTP API: `GET /api/live`, `GET /api/config`, `POST /api/config`,
+41 -18
View File
@@ -1,28 +1,51 @@
; XIAO ESP32-C6 + DFRobot C4001 24GHz mmWave presence sensor ; XIAO ESP32-C6 / YD-ESP32-S3 + DFRobot C4001 24GHz mmWave presence sensor
; ;
; Board detected on this machine: ; ============================================================================
; ESP32-C6FH4 (QFN32) rev v0.1, 4MB embedded flash, USB-Serial/JTAG ; TO SWITCH BOARDS: change `default_envs` below to one of the [env:] names.
; MAC e4:b3:23:b4:7b:c4 on /dev/ttyACM0 ; yd_esp32_s3 -> YD-ESP32-S3 (ESP32-S3-WROOM-1 N16R8), CH343 COM port
; ; xiao_esp32c6 -> Seeed XIAO ESP32-C6, native USB Serial/JTAG
; NOTE: ESP32-C6 needs Arduino-ESP32 core 3.x. The installed platform ; Pin maps for each live in src/board_pins.h.
; (espressif32 @ 2026.02.30, the pioarduino fork) ships core 3.3.7 and has ; Or build one explicitly without editing this file: pio run -e xiao_esp32c6
; the XIAO_ESP32C6 variant, so this builds as-is. The old espressif32 6.x ; ============================================================================
; from PlatformIO's registry does NOT support C6 -- don't downgrade.
[env:seeed_xiao_esp32c6] [platformio]
platform = espressif32 default_envs = yd_esp32_s3
board = seeed_xiao_esp32c6
; ---------------------------------------------------------------- shared ----
[env]
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time monitor_filters = esp32_exception_decoder, time
upload_port = /dev/ttyACM0
monitor_port = /dev/ttyACM0
; Pulled straight from DFRobot's repo. The registry copy lags behind.
lib_deps = lib_deps =
; Registry copy lags behind; take it straight from DFRobot.
https://github.com/DFRobot/DFRobot_C4001.git https://github.com/DFRobot/DFRobot_C4001.git
; ----------------------------------------------------- YD-ESP32-S3 N16R8 ----
; Detected on this machine: ESP32-S3 (QFN56) rev v0.2, 16MB quad flash,
; 8MB octal PSRAM, MAC 24:58:7c:cd:9a:d8, CH343 bridge (1a86:55d3).
;
; The board has two USB-C sockets. This env assumes the one marked COM (the
; CH343 UART bridge). Serial is therefore real UART0, not USB CDC, which also
; sidesteps the CDC-blocking behaviour that bit us on the C6.
[env:yd_esp32_s3]
platform = espressif32
board = esp32-s3-devkitc1-n16r8
build_flags = build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1 ; Serial -> USB CDC, so the monitor works over the native port -DBOARD_YD_ESP32_S3
-DARDUINO_USB_CDC_ON_BOOT=0 ; Serial -> UART0 -> CH343 -> /dev/ttyACM0
-DCORE_DEBUG_LEVEL=1
; ------------------------------------------------------ Seeed XIAO ESP32-C6 --
; Detected earlier: ESP32-C6FH4 (QFN32) rev v0.1, 4MB flash,
; MAC e4:b3:23:b4:7b:c4, native USB Serial/JTAG (303a:1001).
;
; Needs Arduino-ESP32 core 3.x. The installed platform is the pioarduino fork
; (2026.02.30, core 3.3.7) which has the XIAO_ESP32C6 variant. The old registry
; espressif32 6.x has no C6 support -- don't downgrade.
[env:xiao_esp32c6]
platform = espressif32
board = seeed_xiao_esp32c6
build_flags =
-DBOARD_XIAO_C6
-DARDUINO_USB_CDC_ON_BOOT=1 ; Serial -> native USB CDC
-DCORE_DEBUG_LEVEL=1 -DCORE_DEBUG_LEVEL=1
+84
View File
@@ -0,0 +1,84 @@
// Per-board pin map and LED abstraction.
//
// The board is selected in platformio.ini via `default_envs` -- see the big
// comment at the top of that file. Each env defines exactly one BOARD_* macro,
// and everything board-specific lives here so main.cpp stays generic.
//
// Adding a board: add an env in platformio.ini with a new -DBOARD_xxx flag,
// then add a matching block below.
#pragma once
#include <Arduino.h>
// ---------------------------------------------------------------------------
#if defined(BOARD_YD_ESP32_S3)
#define BOARD_NAME "YD-ESP32-S3 (ESP32-S3-WROOM-1 N16R8)"
// P1 header. GPIO8/9 are the S3's documented Arduino I2C defaults.
static const uint8_t PIN_I2C_SDA = 8;
static const uint8_t PIN_I2C_SCL = 9;
// Adjacent on P1, and clear of every reserved pin below.
static const uint8_t PIN_UART_TX = 17; // -> C4001 RX
static const uint8_t PIN_UART_RX = 18; // <- C4001 TX
static const uint8_t PIN_SENSOR_OUT = 16; // <- C4001 OUT (optional)
// Reserved on this board, do not reuse:
// GPIO26-32 SPI flash GPIO35-37 octal PSRAM (the R8 in N16R8)
// GPIO43/44 UART0 -> CH343 GPIO19/20 native USB D-/D+
// GPIO0 boot button GPIO45/46 strapping
// GPIO48 WS2812 RGB LED
// The GPIO43/44 exclusion is the same trap that boot-looped the C6 on D6/D7:
// pointing a second UART at the console pins deadlocks the driver.
#define LED_IS_RGB 1
static const uint8_t PIN_LED = 48;
#define HAS_RF_SWITCH 0
// ---------------------------------------------------------------------------
#elif defined(BOARD_XIAO_C6)
#define BOARD_NAME "Seeed XIAO ESP32-C6"
static const uint8_t PIN_I2C_SDA = D4; // GPIO22
static const uint8_t PIN_I2C_SCL = D5; // GPIO23
// NOT D6/D7 -- those are GPIO16/17, the C6's hard-wired U0TXD/U0RXD. Serial1
// on them deadlocks the UART driver and boot-loops the board.
static const uint8_t PIN_UART_TX = D10; // GPIO18 -> C4001 RX
static const uint8_t PIN_UART_RX = D9; // GPIO20 <- C4001 TX
static const uint8_t PIN_SENSOR_OUT = D2; // GPIO2 <- C4001 OUT (optional)
#define LED_IS_RGB 0
static const uint8_t PIN_LED = LED_BUILTIN; // GPIO15, active LOW
#define LED_ACTIVE_LOW 1
// The XIAO routes its antenna through an RF switch that must be enabled in
// software or WiFi range is centimetres.
#define HAS_RF_SWITCH 1
static const uint8_t PIN_RF_ENABLE = 3; // LOW = enable switch
static const uint8_t PIN_RF_ANT_SEL = 14; // LOW = on-board ceramic, HIGH = u.FL
// ---------------------------------------------------------------------------
#else
#error "No board selected. Set default_envs in platformio.ini to one of the [env:...] names."
#endif
// ------------------------------------------------------------- LED helper ---
static inline void ledInit() {
#if !LED_IS_RGB
pinMode(PIN_LED, OUTPUT);
#endif
}
// One call site in main.cpp regardless of whether the board has a plain LED
// or an addressable RGB one.
static inline void ledSet(bool on) {
#if LED_IS_RGB
rgbLedWrite(PIN_LED, 0, on ? 40 : 0, 0); // dim green = presence
#elif LED_ACTIVE_LOW
digitalWrite(PIN_LED, on ? LOW : HIGH);
#else
digitalWrite(PIN_LED, on ? HIGH : LOW);
#endif
}
+169 -57
View File
@@ -1,23 +1,22 @@
// XIAO ESP32-C6 + DFRobot C4001 24GHz mmWave presence sensor // DFRobot C4001 24GHz mmWave presence sensor on an ESP32 dev board.
// //
// Serves a live-data + calibration web UI. Auto-detects whether the C4001 // Serves a live-data + calibration web UI. Auto-detects whether the C4001 is
// is wired for I2C or UART so you don't have to care which way the board's // wired for I2C or UART so you don't have to care which way the board's mode
// mode switch is set. // switch is set.
// //
// Wiring (see README.md for the physical pin diagram): // Board is chosen in platformio.ini (default_envs); pin maps live in
// C4001 VIN -> XIAO 3V3 (3V3, not 5V -- keeps sensor logic at 3.3V // src/board_pins.h. Power the sensor from 3V3 rather than 5V so its OUT pin
// so OUT can drive a C6 GPIO directly) // can drive a GPIO directly -- these parts are not 5V tolerant.
// C4001 GND -> XIAO GND // See README.md for the physical pin diagram for each board.
// I2C mode: C4001 SCL -> D5 (GPIO23) C4001 SDA -> D4 (GPIO22)
// UART mode: C4001 TX -> D9 (GPIO20) C4001 RX -> D10 (GPIO18)
// C4001 OUT -> D2 (GPIO2) (optional, the sensor's own thresholded output)
#include <Arduino.h> #include <Arduino.h>
#include <WiFi.h> #include <WiFi.h>
#include <WebServer.h> #include <WebServer.h>
#include <ESPmDNS.h> #include <ESPmDNS.h>
#include <Wire.h> #include <Wire.h>
#include <esp_system.h>
#include "DFRobot_C4001.h" #include "DFRobot_C4001.h"
#include "board_pins.h"
#include "web_ui.h" #include "web_ui.h"
#if __has_include("secrets.h") #if __has_include("secrets.h")
@@ -28,29 +27,8 @@
#define WIFI_PASS "" #define WIFI_PASS ""
#endif #endif
// ---------------------------------------------------------------- pins ---- // Pin map and the LED helper come from board_pins.h, selected by the
// -DBOARD_* flag that platformio.ini's active env sets.
static const uint8_t PIN_SENSOR_OUT = D2; // GPIO2 <- C4001 OUT
static const uint8_t PIN_I2C_SDA = D4; // GPIO22
static const uint8_t PIN_I2C_SCL = D5; // GPIO23
// NOT D6/D7. Those are GPIO16/17, which are this chip's hard-wired
// U0TXD/U0RXD (see soc/uart_pins.h). UART0 already owns them, so pointing
// Serial1 at them deadlocks the UART driver and trips the interrupt
// watchdog -> boot loop. UART1 has no fixed pins on C6, so route it through
// the GPIO matrix to pins with no default peripheral instead.
static const uint8_t PIN_UART_TX = D10; // GPIO18 -> C4001 RX
static const uint8_t PIN_UART_RX = D9; // GPIO20 <- C4001 TX
// XIAO ESP32-C6 RF switch. Without these the on-board antenna is not
// connected and WiFi range is a few centimetres. Confirmed against Seeed's
// official pinout: GPIO3 Low=Enable, GPIO14 Low=internal / High=external.
static const uint8_t PIN_RF_ENABLE = 3;
static const uint8_t PIN_RF_ANT_SEL = 14;
// User LED is GPIO15 and is active LOW on this board.
static const uint8_t PIN_LED = LED_BUILTIN;
#define LED_ON LOW
#define LED_OFF HIGH
static const char *AP_SSID = "xiao-mmwave"; static const char *AP_SSID = "xiao-mmwave";
static const char *AP_PASS = "mmwave1234"; // WPA2 needs >= 8 chars static const char *AP_PASS = "mmwave1234"; // WPA2 needs >= 8 chars
@@ -83,6 +61,30 @@ struct {
uint32_t stamp = 0; uint32_t stamp = 0;
} live; } live;
// Survives a reboot (kept in RTC memory), so we can tell "this board has
// restarted 5 times" from "this board has been up since you flashed it".
RTC_DATA_ATTR static uint32_t bootCount = 0;
static const char *bootReason = "?";
static bool staMode = false; // true = joined your WiFi, false = own AP
static uint32_t wifiDrops = 0; // times the STA link has dropped since boot
static uint32_t lastWifiChk = 0;
static const char *resetReasonName(esp_reset_reason_t r) {
switch (r) {
case ESP_RST_POWERON: return "power-on";
case ESP_RST_EXT: return "external pin";
case ESP_RST_SW: return "software restart";
case ESP_RST_PANIC: return "PANIC (crash)";
case ESP_RST_INT_WDT: return "interrupt watchdog";
case ESP_RST_TASK_WDT: return "task watchdog";
case ESP_RST_WDT: return "watchdog";
case ESP_RST_BROWNOUT: return "BROWNOUT (supply dipped)";
case ESP_RST_DEEPSLEEP: return "deep-sleep wake";
default: return "unknown";
}
}
static uint32_t lastPoll = 0; static uint32_t lastPoll = 0;
// Set once the interface is known. I2C is fast; UART at 9600 baud costs ~60 ms // Set once the interface is known. I2C is fast; UART at 9600 baud costs ~60 ms
// per register round-trip, and speed mode reads four of them, so polling it // per register round-trip, and speed mode reads four of them, so polling it
@@ -97,24 +99,61 @@ static bool i2cProbe(uint8_t addr) {
return Wire.endTransmission() == 0; return Wire.endTransmission() == 0;
} }
// The C4001 UART begin() returns true unconditionally, so it proves nothing. // With nothing wired (or a stuck slave holding the bus) SDA/SCL sit low, and
// Confirm the link by actually reading status back a few times. // the I2C peripheral can block forever waiting for an idle bus -- that hangs
static bool uartResponds(DFRobot_C4001_UART *r) { // setup() before WiFi ever starts. Check the lines are actually high with
for (int i = 0; i < 5; i++) { // pull-ups on before handing them to Wire.
sSensorStatus_t st = r->getStatus(); static bool i2cBusIdle() {
if (st.initStatus == 1) return true; pinMode(PIN_I2C_SDA, INPUT_PULLUP);
// A live-but-still-initialising sensor reports a sane work mode. pinMode(PIN_I2C_SCL, INPUT_PULLUP);
if (st.workMode <= 1 && r->getMinRange() > 0) return true; delayMicroseconds(200);
delay(200); bool sda = digitalRead(PIN_I2C_SDA);
bool scl = digitalRead(PIN_I2C_SCL);
if (!sda || !scl) {
sensor.detectLog += "I2C: bus not idle (sda=" + String(sda) +
" scl=" + String(scl) + "), skipping I2C\n";
return false;
} }
return true;
}
// Raw, time-bounded liveness check -- deliberately does NOT touch
// DFRobot_C4001.
//
// The vendor library's UART path has two loops with no timeout:
// sensorStop() -> while(1){...} exits only on a "sensorStop" reply
// getStatus() -> while(len == 0){...} spins forever if nothing arrives
// so calling any of it with no sensor attached hangs setup() permanently and
// the board never reaches WiFi. Prove there is something on the wire first;
// only then hand the port to the library.
//
// The I2C path is fine -- its readReg is bounded -- which is another reason to
// prefer I2C with this sensor.
static bool uartSensorPresent() {
Serial1.begin(9600, SERIAL_8N1, PIN_UART_RX, PIN_UART_TX);
delay(50);
while (Serial1.available()) Serial1.read(); // flush boot noise
// A running C4001 streams frames continuously; nudge it in case it's stopped.
Serial1.print("sensorStart\r\n");
uint32_t t0 = millis();
while (millis() - t0 < 1500) {
if (Serial1.available() > 0) return true;
delay(10);
}
Serial1.end();
return false; return false;
} }
static void detectSensor() { static void detectSensor() {
sensor.detectLog = ""; sensor.detectLog = "";
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
const uint8_t addrs[] = {DEVICE_ADDR_0, DEVICE_ADDR_1}; const uint8_t addrs[] = {DEVICE_ADDR_0, DEVICE_ADDR_1};
if (i2cBusIdle()) {
Serial.println(" probing I2C ...");
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
Wire.setTimeOut(100); // a stuck bus must not block forever
for (uint8_t a : addrs) { for (uint8_t a : addrs) {
if (i2cProbe(a)) { if (i2cProbe(a)) {
radarI2C = new DFRobot_C4001_I2C(&Wire, a); radarI2C = new DFRobot_C4001_I2C(&Wire, a);
@@ -132,20 +171,21 @@ static void detectSensor() {
sensor.detectLog += "I2C: no ACK at 0x" + String(a, HEX) + "\n"; sensor.detectLog += "I2C: no ACK at 0x" + String(a, HEX) + "\n";
} }
} }
} // end if (i2cBusIdle())
radarUART = new DFRobot_C4001_UART(&Serial1, 9600, PIN_UART_RX, PIN_UART_TX); Serial.println(" probing UART ...");
radarUART->begin(); if (uartSensorPresent()) {
if (uartResponds(radarUART)) { radarUART = new DFRobot_C4001_UART(&Serial1, 9600, PIN_UART_RX, PIN_UART_TX);
radarUART->begin();
radar = radarUART; radar = radarUART;
sensor.present = true; sensor.present = true;
sensor.iface = "UART"; sensor.iface = "UART";
sensor.detectLog += "UART: sensor responded on D9(rx)/D10(tx)\n"; sensor.detectLog += "UART: sensor responded on rx=GPIO" + String(PIN_UART_RX) +
" tx=GPIO" + String(PIN_UART_TX) + "\n";
return; return;
} }
sensor.detectLog += "UART: no response on D9(rx)/D10(tx) @ 9600\n"; sensor.detectLog += "UART: no response on rx=GPIO" + String(PIN_UART_RX) +
" tx=GPIO" + String(PIN_UART_TX) + " @ 9600\n";
delete radarUART;
radarUART = nullptr;
sensor.present = false; sensor.present = false;
sensor.iface = "none"; sensor.iface = "none";
} }
@@ -179,7 +219,7 @@ static void pollSensor() {
live.energy = 0; live.energy = 0;
} }
digitalWrite(PIN_LED, live.presence ? LED_ON : LED_OFF); ledSet(live.presence);
} }
// --------------------------------------------------------- http handlers -- // --------------------------------------------------------- http handlers --
@@ -196,7 +236,8 @@ static void handleRoot() {
static void handleLive() { static void handleLive() {
String j = "{"; String j = "{";
j += "\"present\":" + String(sensor.present ? "true" : "false"); j += "\"board\":\"" + String(BOARD_NAME) + "\"";
j += ",\"present\":" + String(sensor.present ? "true" : "false");
j += ",\"iface\":\"" + sensor.iface + "\""; j += ",\"iface\":\"" + sensor.iface + "\"";
j += ",\"mode\":" + String(sensor.mode); j += ",\"mode\":" + String(sensor.mode);
j += ",\"presence\":" + String(live.presence ? "true" : "false"); j += ",\"presence\":" + String(live.presence ? "true" : "false");
@@ -209,6 +250,13 @@ static void handleLive() {
j += ",\"initStatus\":" + String(live.initStatus); j += ",\"initStatus\":" + String(live.initStatus);
j += ",\"uptime\":" + String(millis() / 1000); j += ",\"uptime\":" + String(millis() / 1000);
j += ",\"rssi\":" + String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0); j += ",\"rssi\":" + String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0);
j += ",\"wifiMode\":\"" + String(staMode ? "sta" : "ap") + "\"";
j += ",\"ip\":\"" + (staMode ? WiFi.localIP().toString() : WiFi.softAPIP().toString()) + "\"";
j += ",\"wifiDrops\":" + String(wifiDrops);
j += ",\"boots\":" + String(bootCount);
j += ",\"bootReason\":\"" + String(bootReason) + "\"";
j += ",\"heap\":" + String(ESP.getFreeHeap());
j += ",\"minHeap\":" + String(ESP.getMinFreeHeap());
j += ",\"detectLog\":\""; j += ",\"detectLog\":\"";
for (size_t i = 0; i < sensor.detectLog.length(); i++) { for (size_t i = 0; i < sensor.detectLog.length(); i++) {
char c = sensor.detectLog[i]; char c = sensor.detectLog[i];
@@ -306,16 +354,20 @@ static void handleAction() {
// ----------------------------------------------------------------- wifi --- // ----------------------------------------------------------------- wifi ---
static void startWiFi() { static void startWiFi() {
#if HAS_RF_SWITCH
// Bring the RF switch up before any radio work. // Bring the RF switch up before any radio work.
pinMode(PIN_RF_ENABLE, OUTPUT); pinMode(PIN_RF_ENABLE, OUTPUT);
digitalWrite(PIN_RF_ENABLE, LOW); // enable RF switch digitalWrite(PIN_RF_ENABLE, LOW); // enable RF switch
pinMode(PIN_RF_ANT_SEL, OUTPUT); pinMode(PIN_RF_ANT_SEL, OUTPUT);
digitalWrite(PIN_RF_ANT_SEL, LOW); // LOW = on-board ceramic antenna digitalWrite(PIN_RF_ANT_SEL, LOW); // LOW = on-board ceramic antenna
#endif
const char *ssid = WIFI_SSID; const char *ssid = WIFI_SSID;
if (strlen(ssid) > 0) { if (strlen(ssid) > 0) {
Serial.printf("WiFi: joining \"%s\" ...\n", ssid); Serial.printf("WiFi: joining \"%s\" ...\n", ssid);
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.persistent(false);
WiFi.begin(WIFI_SSID, WIFI_PASS); WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis(); uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) { while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
@@ -324,6 +376,7 @@ static void startWiFi() {
} }
Serial.println(); Serial.println();
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
staMode = true;
Serial.printf("WiFi: connected, IP http://%s/\n", WiFi.localIP().toString().c_str()); Serial.printf("WiFi: connected, IP http://%s/\n", WiFi.localIP().toString().c_str());
return; return;
} }
@@ -332,23 +385,81 @@ static void startWiFi() {
Serial.println("WiFi: no secrets.h credentials, starting AP"); Serial.println("WiFi: no secrets.h credentials, starting AP");
} }
staMode = false;
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS); WiFi.softAP(AP_SSID, AP_PASS);
Serial.printf("WiFi: AP \"%s\" pass \"%s\" http://%s/\n", Serial.printf("WiFi: AP \"%s\" pass \"%s\" http://%s/\n",
AP_SSID, AP_PASS, WiFi.softAPIP().toString().c_str()); AP_SSID, AP_PASS, WiFi.softAPIP().toString().c_str());
} }
// Without this the board stays alive but permanently off-network after a
// single dropout -- indistinguishable from a crash, and the reason a
// "did it crash?" question had no answer.
static void maintainWiFi() {
if (millis() - lastWifiChk < 20000) return;
lastWifiChk = millis();
if (!staMode) {
// Fell back to AP at boot. Retry the real network rather than sitting on
// an island forever; the AP stays up in the meantime.
if (strlen(WIFI_SSID) == 0) return;
Serial.println("WiFi: retrying station join from AP fallback");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 8000) delay(100);
if (WiFi.status() == WL_CONNECTED) {
staMode = true;
Serial.printf("WiFi: recovered, IP http://%s/\n", WiFi.localIP().toString().c_str());
MDNS.begin(MDNS_HOST);
} else {
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
}
return;
}
if (WiFi.status() != WL_CONNECTED) {
wifiDrops++;
Serial.printf("WiFi: link down (drop #%u), reconnecting\n", wifiDrops);
WiFi.disconnect();
WiFi.begin(WIFI_SSID, WIFI_PASS);
}
}
// ---------------------------------------------------------------- setup --- // ---------------------------------------------------------------- setup ---
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
// Critical on a headless board. Serial here is USB CDC. Its default 100 ms
// write timeout re-arms every time the buffer drains a little, so with the
// port enumerated but nobody reading (USB in a PC, no monitor open) boot
// logging can stall setup() before WiFi ever starts -- the board looks dead
// on the network while still enumerating over USB.
//
// Do NOT set this to 0. In HWCDC::write the no-progress path does a bare
// `tries--` on a uint32_t seeded from this value, so 0 underflows to
// 0xFFFFFFFF and spins for ~49 days instead of giving up. A small non-zero
// value is the actual "don't block" setting.
//
// Only applies when Serial is USB CDC. On a board wired through a UART
// bridge (YD-ESP32-S3 via CH343) Serial is a plain HardwareSerial, which
// has no such method and cannot stall this way.
#if ARDUINO_USB_CDC_ON_BOOT
Serial.setTxTimeoutMs(10);
#endif
uint32_t t0 = millis(); uint32_t t0 = millis();
while (!Serial && millis() - t0 < 2000) delay(10); // don't block if headless while (!Serial && millis() - t0 < 2000) delay(10); // don't block if headless
Serial.println("\n=== XIAO ESP32-C6 + C4001 mmWave ==="); bootCount++;
bootReason = resetReasonName(esp_reset_reason());
Serial.println("\n=== " BOARD_NAME " + C4001 mmWave ===");
Serial.printf("Boot #%u, last reset: %s\n", bootCount, bootReason);
Serial.printf("I2C sda=%u scl=%u | UART rx=%u tx=%u | OUT=%u\n",
PIN_I2C_SDA, PIN_I2C_SCL, PIN_UART_RX, PIN_UART_TX, PIN_SENSOR_OUT);
pinMode(PIN_LED, OUTPUT); ledInit();
digitalWrite(PIN_LED, LED_OFF); ledSet(false);
pinMode(PIN_SENSOR_OUT, INPUT); pinMode(PIN_SENSOR_OUT, INPUT);
Serial.println("Detecting C4001 ..."); Serial.println("Detecting C4001 ...");
@@ -386,6 +497,7 @@ void setup() {
void loop() { void loop() {
server.handleClient(); server.handleClient();
maintainWiFi();
if (millis() - lastPoll >= POLL_MS) { if (millis() - lastPoll >= POLL_MS) {
lastPoll = millis(); lastPoll = millis();
+15 -3
View File
@@ -73,12 +73,17 @@ summary{cursor:pointer;color:var(--accent);font-size:13px}
</head> </head>
<body> <body>
<header> <header>
<h1>XIAO ESP32-C6 &middot; C4001 mmWave</h1> <h1>C4001 mmWave</h1>
<span class="pill">board <b id="p-board">-</b></span>
<span class="pill">link <b id="p-iface">-</b></span> <span class="pill">link <b id="p-iface">-</b></span>
<span class="pill">mode <b id="p-mode">-</b></span> <span class="pill">mode <b id="p-mode">-</b></span>
<span class="pill">init <b id="p-init">-</b></span> <span class="pill">init <b id="p-init">-</b></span>
<span class="pill">rssi <b id="p-rssi">-</b></span> <span class="pill">rssi <b id="p-rssi">-</b></span>
<span class="pill">up <b id="p-up">-</b></span> <span class="pill">up <b id="p-up">-</b></span>
<span class="pill">boots <b id="p-boots">-</b></span>
<span class="pill">last reset <b id="p-reason">-</b></span>
<span class="pill">wifi drops <b id="p-drops">-</b></span>
<span class="pill">heap <b id="p-heap">-</b></span>
</header> </header>
<main> <main>
@@ -263,11 +268,18 @@ async function poll(){
try { d = await (await fetch('/api/live')).json(); } try { d = await (await fetch('/api/live')).json(); }
catch(e){ return; } catch(e){ return; }
$('p-board').textContent = d.board;
$('p-iface').textContent = d.iface; $('p-iface').textContent = d.iface;
$('p-mode').textContent = d.mode ? 'speed' : 'presence'; $('p-mode').textContent = d.mode ? 'speed' : 'presence';
$('p-init').textContent = d.initStatus ? 'ok' : 'not ready'; $('p-init').textContent = d.initStatus ? 'ok' : 'not ready';
$('p-rssi').textContent = d.rssi ? d.rssi+' dBm' : 'AP'; $('p-rssi').textContent = d.wifiMode === 'sta' ? d.rssi+' dBm' : 'AP mode';
$('p-up').textContent = d.uptime+' s'; $('p-up').textContent = d.uptime > 3600
? (d.uptime/3600).toFixed(1)+' h'
: (d.uptime > 90 ? Math.round(d.uptime/60)+' min' : d.uptime+' s');
$('p-boots').textContent = d.boots;
$('p-reason').textContent= d.bootReason;
$('p-drops').textContent = d.wifiDrops;
$('p-heap').textContent = Math.round(d.heap/1024)+' KB';
$('nosensor').classList.toggle('hide', d.present); $('nosensor').classList.toggle('hide', d.present);
if (!d.present) $('detectlog').textContent = d.detectLog; if (!d.present) $('detectlog').textContent = d.detectLog;