From 9c1c3fe1e89b7d23a5ad0b0d210bcfeb54358710 Mon Sep 17 00:00:00 2001 From: matt_ed Date: Fri, 14 Aug 2026 11:55:03 -0700 Subject: [PATCH] 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 --- README.md | 267 ++++++++++++++++++++++++++++------------------- platformio.ini | 59 +++++++---- src/board_pins.h | 84 +++++++++++++++ src/main.cpp | 226 +++++++++++++++++++++++++++++---------- src/web_ui.h | 18 +++- 5 files changed, 466 insertions(+), 188 deletions(-) create mode 100644 src/board_pins.h diff --git a/README.md b/README.md index e5e7811..a289bed 100644 --- a/README.md +++ b/README.md @@ -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. -The board hosts a web page showing presence, distance, velocity and signal -energy in real time, with sliders for every sensor parameter. +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. -## Verified on this machine +Two boards are supported. Both are kept working; nothing is deleted when you +switch. -| 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 | +## Choosing a board -Flashing was confirmed working: esptool connected, uploaded its stub loader, -ran it, and read the flash chip ID back. +Change one line in `platformio.ini`: -> **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 the `XIAO_ESP32C6` variant. The old registry `espressif32 @ 6.x` has no -> C6 support — don't downgrade. +```ini +[platformio] +default_envs = yd_esp32_s3 ; or: xiao_esp32c6 +``` -## Pin diagram +Or build one without editing the file: -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. +```bash +pio run -e xiao_esp32c6 -t upload +``` + +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 ┌───┐ ┌─────┴───┴───────┴───┴─────┐ - D0 ────┤ o o ├──── VBUS (5 V from USB) + D0 ────┤ o o ├──── VBUS (5 V) GPIO0 │ │ D1 ────┤ o o ├──── GND GPIO1 │ │ - D2 ────┤ o seeed studio o ├──── 3V3 (3.3 V out) + D2 ────┤ o seeed studio o ├──── 3V3 GPIO2 │ XIAO-ESP32-C6 │ D3 ────┤ o o ├──── D10 GPIO18 GPIO21 │ │ @@ -49,72 +123,19 @@ USB connector. 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 | +| 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 | -`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 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. +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 @@ -123,33 +144,59 @@ 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 +Skipping this is fine — the board then goes straight to AP mode +(`xiao-mmwave` / `mmwave1234`). ```bash -pio run # build -pio run -t upload # flash /dev/ttyACM0 -pio device monitor # 115200 baud +pio run # build the default env +pio run -t upload # flash +pio device monitor # 115200 ``` -If upload fails to auto-reset, hold **B** (boot), tap **R** (reset), release **B**, -then upload. +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 -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, 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 +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 @@ -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 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. +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, framework, C4001 library from git -include/secrets.h.example template for WiFi creds (real one is gitignored) +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 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`, diff --git a/platformio.ini b/platformio.ini index 4734cd8..b0fd562 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 -; MAC e4:b3:23:b4:7b:c4 on /dev/ttyACM0 -; -; NOTE: ESP32-C6 needs Arduino-ESP32 core 3.x. The installed platform -; (espressif32 @ 2026.02.30, the pioarduino fork) ships core 3.3.7 and has -; 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. +; ============================================================================ +; TO SWITCH BOARDS: change `default_envs` below to one of the [env:] names. +; yd_esp32_s3 -> YD-ESP32-S3 (ESP32-S3-WROOM-1 N16R8), CH343 COM port +; xiao_esp32c6 -> Seeed XIAO ESP32-C6, native USB Serial/JTAG +; Pin maps for each live in src/board_pins.h. +; Or build one explicitly without editing this file: pio run -e xiao_esp32c6 +; ============================================================================ -[env:seeed_xiao_esp32c6] -platform = espressif32 -board = seeed_xiao_esp32c6 +[platformio] +default_envs = yd_esp32_s3 + +; ---------------------------------------------------------------- shared ---- +[env] framework = arduino - monitor_speed = 115200 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 = + ; Registry copy lags behind; take it straight from DFRobot. 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 = - -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 diff --git a/src/board_pins.h b/src/board_pins.h new file mode 100644 index 0000000..151516e --- /dev/null +++ b/src/board_pins.h @@ -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 + +// --------------------------------------------------------------------------- +#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 +} diff --git a/src/main.cpp b/src/main.cpp index ffc19a2..74184c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 -// is wired for I2C or UART so you don't have to care which way the board's -// mode switch is set. +// Serves a live-data + calibration web UI. Auto-detects whether the C4001 is +// wired for I2C or UART so you don't have to care which way the board's mode +// switch is set. // -// Wiring (see README.md for the physical pin diagram): -// C4001 VIN -> XIAO 3V3 (3V3, not 5V -- keeps sensor logic at 3.3V -// so OUT can drive a C6 GPIO directly) -// C4001 GND -> XIAO GND -// 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) +// Board is chosen in platformio.ini (default_envs); pin maps live in +// src/board_pins.h. Power the sensor from 3V3 rather than 5V so its OUT pin +// can drive a GPIO directly -- these parts are not 5V tolerant. +// See README.md for the physical pin diagram for each board. #include #include #include #include #include +#include #include "DFRobot_C4001.h" +#include "board_pins.h" #include "web_ui.h" #if __has_include("secrets.h") @@ -28,29 +27,8 @@ #define WIFI_PASS "" #endif -// ---------------------------------------------------------------- pins ---- - -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 +// 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 char *AP_SSID = "xiao-mmwave"; static const char *AP_PASS = "mmwave1234"; // WPA2 needs >= 8 chars @@ -83,6 +61,30 @@ struct { uint32_t stamp = 0; } 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; // 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 @@ -97,24 +99,61 @@ static bool i2cProbe(uint8_t addr) { return Wire.endTransmission() == 0; } -// The C4001 UART begin() returns true unconditionally, so it proves nothing. -// Confirm the link by actually reading status back a few times. -static bool uartResponds(DFRobot_C4001_UART *r) { - for (int i = 0; i < 5; i++) { - sSensorStatus_t st = r->getStatus(); - if (st.initStatus == 1) return true; - // A live-but-still-initialising sensor reports a sane work mode. - if (st.workMode <= 1 && r->getMinRange() > 0) return true; - delay(200); +// With nothing wired (or a stuck slave holding the bus) SDA/SCL sit low, and +// the I2C peripheral can block forever waiting for an idle bus -- that hangs +// setup() before WiFi ever starts. Check the lines are actually high with +// pull-ups on before handing them to Wire. +static bool i2cBusIdle() { + pinMode(PIN_I2C_SDA, INPUT_PULLUP); + pinMode(PIN_I2C_SCL, INPUT_PULLUP); + delayMicroseconds(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; } static void detectSensor() { sensor.detectLog = ""; - Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); 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) { if (i2cProbe(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"; } } + } // end if (i2cBusIdle()) - radarUART = new DFRobot_C4001_UART(&Serial1, 9600, PIN_UART_RX, PIN_UART_TX); - radarUART->begin(); - if (uartResponds(radarUART)) { + Serial.println(" probing UART ..."); + if (uartSensorPresent()) { + radarUART = new DFRobot_C4001_UART(&Serial1, 9600, PIN_UART_RX, PIN_UART_TX); + radarUART->begin(); radar = radarUART; sensor.present = true; 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; } - sensor.detectLog += "UART: no response on D9(rx)/D10(tx) @ 9600\n"; - - delete radarUART; - radarUART = nullptr; + sensor.detectLog += "UART: no response on rx=GPIO" + String(PIN_UART_RX) + + " tx=GPIO" + String(PIN_UART_TX) + " @ 9600\n"; sensor.present = false; sensor.iface = "none"; } @@ -179,7 +219,7 @@ static void pollSensor() { live.energy = 0; } - digitalWrite(PIN_LED, live.presence ? LED_ON : LED_OFF); + ledSet(live.presence); } // --------------------------------------------------------- http handlers -- @@ -196,7 +236,8 @@ static void handleRoot() { static void handleLive() { 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 += ",\"mode\":" + String(sensor.mode); j += ",\"presence\":" + String(live.presence ? "true" : "false"); @@ -209,6 +250,13 @@ static void handleLive() { j += ",\"initStatus\":" + String(live.initStatus); j += ",\"uptime\":" + String(millis() / 1000); 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\":\""; for (size_t i = 0; i < sensor.detectLog.length(); i++) { char c = sensor.detectLog[i]; @@ -306,16 +354,20 @@ static void handleAction() { // ----------------------------------------------------------------- wifi --- static void startWiFi() { +#if HAS_RF_SWITCH // Bring the RF switch up before any radio work. pinMode(PIN_RF_ENABLE, OUTPUT); digitalWrite(PIN_RF_ENABLE, LOW); // enable RF switch pinMode(PIN_RF_ANT_SEL, OUTPUT); digitalWrite(PIN_RF_ANT_SEL, LOW); // LOW = on-board ceramic antenna +#endif const char *ssid = WIFI_SSID; if (strlen(ssid) > 0) { Serial.printf("WiFi: joining \"%s\" ...\n", ssid); WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.persistent(false); WiFi.begin(WIFI_SSID, WIFI_PASS); uint32_t t0 = millis(); while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) { @@ -324,6 +376,7 @@ static void startWiFi() { } Serial.println(); if (WiFi.status() == WL_CONNECTED) { + staMode = true; Serial.printf("WiFi: connected, IP http://%s/\n", WiFi.localIP().toString().c_str()); return; } @@ -332,23 +385,81 @@ static void startWiFi() { Serial.println("WiFi: no secrets.h credentials, starting AP"); } + staMode = false; WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID, AP_PASS); Serial.printf("WiFi: AP \"%s\" pass \"%s\" http://%s/\n", 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 --- void setup() { 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(); 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); - digitalWrite(PIN_LED, LED_OFF); + ledInit(); + ledSet(false); pinMode(PIN_SENSOR_OUT, INPUT); Serial.println("Detecting C4001 ..."); @@ -386,6 +497,7 @@ void setup() { void loop() { server.handleClient(); + maintainWiFi(); if (millis() - lastPoll >= POLL_MS) { lastPoll = millis(); diff --git a/src/web_ui.h b/src/web_ui.h index 1c67253..3734411 100644 --- a/src/web_ui.h +++ b/src/web_ui.h @@ -73,12 +73,17 @@ summary{cursor:pointer;color:var(--accent);font-size:13px}
-

XIAO ESP32-C6 · C4001 mmWave

+

C4001 mmWave

+ board - link - mode - init - rssi - up - + boots - + last reset - + wifi drops - + heap -
@@ -263,11 +268,18 @@ async function poll(){ try { d = await (await fetch('/api/live')).json(); } catch(e){ return; } + $('p-board').textContent = d.board; $('p-iface').textContent = d.iface; $('p-mode').textContent = d.mode ? 'speed' : 'presence'; $('p-init').textContent = d.initStatus ? 'ok' : 'not ready'; - $('p-rssi').textContent = d.rssi ? d.rssi+' dBm' : 'AP'; - $('p-up').textContent = d.uptime+' s'; + $('p-rssi').textContent = d.wifiMode === 'sta' ? d.rssi+' dBm' : 'AP mode'; + $('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); if (!d.present) $('detectlog').textContent = d.detectLog;