XIAO ESP32-C6 + DFRobot C4001 mmWave presence sensor

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>
This commit is contained in:
mattandClaude committed 2026-08-14 10:05:27 -07:00
commit b9162db707
6 files changed
+993

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.pio/
.vscode/
# Real WiFi credentials. Only secrets.h.example is tracked.
include/secrets.h
+176
View File
@@ -0,0 +1,176 @@
# 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 the `XIAO_ESP32C6` variant. The old registry `espressif32 @ 6.x` has 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 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
```bash
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
```bash
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` · **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
**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`).
+15
View File
@@ -0,0 +1,15 @@
// Copy to include/secrets.h and fill in your own network.
//
// cp include/secrets.h.example include/secrets.h
// $EDITOR include/secrets.h
//
// include/secrets.h is gitignored. Don't paste the password into a chat
// window or a terminal transcript -- edit the file directly.
//
// If you skip this entirely the firmware still works: it goes straight to
// Access Point mode and hosts its own "xiao-mmwave" hotspot.
#pragma once
#define WIFI_SSID "your-network-name"
#define WIFI_PASS "your-network-password"
+28
View File
@@ -0,0 +1,28 @@
; XIAO ESP32-C6 + 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.
[env:seeed_xiao_esp32c6]
platform = espressif32
board = seeed_xiao_esp32c6
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 =
https://github.com/DFRobot/DFRobot_C4001.git
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1 ; Serial -> USB CDC, so the monitor works over the native port
-DCORE_DEBUG_LEVEL=1
+394
View File
@@ -0,0 +1,394 @@
// XIAO ESP32-C6 + DFRobot C4001 24GHz mmWave presence sensor
//
// 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)
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Wire.h>
#include "DFRobot_C4001.h"
#include "web_ui.h"
#if __has_include("secrets.h")
#include "secrets.h"
#endif
#ifndef WIFI_SSID
#define WIFI_SSID ""
#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
static const char *AP_SSID = "xiao-mmwave";
static const char *AP_PASS = "mmwave1234"; // WPA2 needs >= 8 chars
static const char *MDNS_HOST = "mmwave";
// ------------------------------------------------------------- globals ----
WebServer server(80);
DFRobot_C4001 *radar = nullptr;
DFRobot_C4001_I2C *radarI2C = nullptr;
DFRobot_C4001_UART *radarUART = nullptr;
struct {
bool present = false; // did we find a sensor at all
String iface = "none"; // "I2C @ 0x2A" / "UART"
String detectLog = ""; // what we tried, shown in the UI on failure
uint8_t mode = 0; // 0 = presence (eExitMode), 1 = speed (eSpeedMode)
} sensor;
struct {
bool presence = false;
uint8_t targets = 0;
float range_m = 0;
float speed_ms = 0;
uint32_t energy = 0;
bool outPin = false;
uint8_t workStatus = 0;
uint8_t initStatus = 0;
uint32_t stamp = 0;
} live;
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
// hard would starve the HTTP server.
static uint32_t POLL_MS = 100;
static uint8_t statusDivider = 0;
// ------------------------------------------------------- sensor helpers ----
static bool i2cProbe(uint8_t addr) {
Wire.beginTransmission(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);
}
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};
for (uint8_t a : addrs) {
if (i2cProbe(a)) {
radarI2C = new DFRobot_C4001_I2C(&Wire, a);
if (radarI2C->begin()) {
radar = radarI2C;
sensor.present = true;
sensor.iface = "I2C @ 0x" + String(a, HEX);
sensor.detectLog += "I2C: found device at 0x" + String(a, HEX) + "\n";
return;
}
sensor.detectLog += "I2C: 0x" + String(a, HEX) + " ACKed but begin() failed\n";
delete radarI2C;
radarI2C = nullptr;
} else {
sensor.detectLog += "I2C: no ACK at 0x" + String(a, HEX) + "\n";
}
}
radarUART = new DFRobot_C4001_UART(&Serial1, 9600, PIN_UART_RX, PIN_UART_TX);
radarUART->begin();
if (uartResponds(radarUART)) {
radar = radarUART;
sensor.present = true;
sensor.iface = "UART";
sensor.detectLog += "UART: sensor responded on D9(rx)/D10(tx)\n";
return;
}
sensor.detectLog += "UART: no response on D9(rx)/D10(tx) @ 9600\n";
delete radarUART;
radarUART = nullptr;
sensor.present = false;
sensor.iface = "none";
}
static void pollSensor() {
live.outPin = digitalRead(PIN_SENSOR_OUT);
live.stamp = millis();
if (!sensor.present) return;
// Mode and init state barely ever change, so don't spend a round-trip on
// them every cycle -- once a second is plenty.
if (statusDivider++ % 10 == 0) {
sSensorStatus_t st = radar->getStatus();
live.workStatus = st.workStatus;
live.initStatus = st.initStatus;
sensor.mode = st.workMode;
}
if (sensor.mode == eSpeedMode) {
live.targets = radar->getTargetNumber();
live.range_m = radar->getTargetRange();
live.speed_ms = radar->getTargetSpeed();
live.energy = radar->getTargetEnergy();
live.presence = live.targets > 0;
} else {
live.presence = radar->motionDetection();
// Presence mode reports occupancy only -- no range/speed/energy.
live.targets = live.presence ? 1 : 0;
live.range_m = 0;
live.speed_ms = 0;
live.energy = 0;
}
digitalWrite(PIN_LED, live.presence ? LED_ON : LED_OFF);
}
// --------------------------------------------------------- http handlers --
static void sendJson(const String &body) {
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json", body);
}
static void handleRoot() {
server.sendHeader("Cache-Control", "no-store");
server.send_P(200, "text/html", INDEX_HTML);
}
static void handleLive() {
String j = "{";
j += "\"present\":" + String(sensor.present ? "true" : "false");
j += ",\"iface\":\"" + sensor.iface + "\"";
j += ",\"mode\":" + String(sensor.mode);
j += ",\"presence\":" + String(live.presence ? "true" : "false");
j += ",\"targets\":" + String(live.targets);
j += ",\"range\":" + String(live.range_m, 2);
j += ",\"speed\":" + String(live.speed_ms, 2);
j += ",\"energy\":" + String(live.energy);
j += ",\"outPin\":" + String(live.outPin ? "true" : "false");
j += ",\"workStatus\":" + String(live.workStatus);
j += ",\"initStatus\":" + String(live.initStatus);
j += ",\"uptime\":" + String(millis() / 1000);
j += ",\"rssi\":" + String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0);
j += ",\"detectLog\":\"";
for (size_t i = 0; i < sensor.detectLog.length(); i++) {
char c = sensor.detectLog[i];
if (c == '\n') j += "\\n"; else if (c == '"') j += "\\\""; else j += c;
}
j += "\"}";
sendJson(j);
}
// Reads the sensor's *current* stored settings. Both parameter sets are
// returned; the UI shows whichever matches the active mode.
static void handleGetConfig() {
if (!sensor.present) { sendJson("{\"error\":\"no sensor\"}"); return; }
String j = "{";
j += "\"mode\":" + String(sensor.mode);
// Presence-mode parameters
j += ",\"minRange\":" + String(radar->getMinRange());
j += ",\"maxRange\":" + String(radar->getMaxRange());
j += ",\"trigRange\":" + String(radar->getTrigRange());
j += ",\"trigSens\":" + String(radar->getTrigSensitivity());
j += ",\"keepSens\":" + String(radar->getKeepSensitivity());
j += ",\"trigDelay\":" + String(radar->getTrigDelay());
j += ",\"keepTimeout\":" + String(radar->getKeepTimerout());
// Speed-mode parameters
j += ",\"tMinRange\":" + String(radar->getTMinRange());
j += ",\"tMaxRange\":" + String(radar->getTMaxRange());
j += ",\"thres\":" + String(radar->getThresRange());
j += ",\"fretting\":" + String(radar->getFrettingDetection() == eON ? 1 : 0);
j += "}";
sendJson(j);
}
static bool argInt(const char *name, long &out) {
if (!server.hasArg(name)) return false;
out = server.arg(name).toInt();
return true;
}
static void handleSetConfig() {
if (!sensor.present) { sendJson("{\"ok\":false,\"msg\":\"no sensor\"}"); return; }
String msg;
long v, v2, v3;
if (argInt("mode", v)) {
radar->setSensorMode(v == 1 ? eSpeedMode : eExitMode);
sensor.mode = v;
msg += "mode set; ";
delay(200);
}
// Presence mode: min/max/trig detection range, cm.
if (argInt("minRange", v) && argInt("maxRange", v2) && argInt("trigRange", v3)) {
msg += radar->setDetectionRange(v, v2, v3) ? "range ok; " : "range FAILED; ";
}
if (argInt("trigSens", v)) {
msg += radar->setTrigSensitivity(v) ? "trigSens ok; " : "trigSens FAILED; ";
}
if (argInt("keepSens", v)) {
msg += radar->setKeepSensitivity(v) ? "keepSens ok; " : "keepSens FAILED; ";
}
if (argInt("trigDelay", v) && argInt("keepTimeout", v2)) {
msg += radar->setDelay(v, v2) ? "delay ok; " : "delay FAILED; ";
}
// Speed mode: min/max range + detection threshold.
if (argInt("tMinRange", v) && argInt("tMaxRange", v2) && argInt("thres", v3)) {
msg += radar->setDetectThres(v, v2, v3) ? "thres ok; " : "thres FAILED; ";
}
if (argInt("fretting", v)) {
radar->setFrettingDetection(v ? eON : eOFF);
msg += "fretting set; ";
}
sendJson("{\"ok\":true,\"msg\":\"" + msg + "\"}");
}
static void handleAction() {
if (!sensor.present) { sendJson("{\"ok\":false,\"msg\":\"no sensor\"}"); return; }
String a = server.arg("do");
String msg;
if (a == "start") { radar->setSensor(eStartSen); msg = "sensor started"; }
else if (a == "stop") { radar->setSensor(eStopSen); msg = "sensor stopped"; }
else if (a == "save") { radar->setSensor(eSaveParams); msg = "params saved to sensor flash"; }
else if (a == "reset") { radar->setSensor(eResetSen); msg = "sensor reset"; }
else if (a == "factory") { radar->setSensor(eRecoverSen); msg = "factory defaults restored"; }
else { sendJson("{\"ok\":false,\"msg\":\"unknown action\"}"); return; }
delay(300);
sendJson("{\"ok\":true,\"msg\":\"" + msg + "\"}");
}
// ----------------------------------------------------------------- wifi ---
static void startWiFi() {
// 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
const char *ssid = WIFI_SSID;
if (strlen(ssid) > 0) {
Serial.printf("WiFi: joining \"%s\" ...\n", ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
delay(250);
Serial.print('.');
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("WiFi: connected, IP http://%s/\n", WiFi.localIP().toString().c_str());
return;
}
Serial.println("WiFi: join failed, falling back to AP");
} else {
Serial.println("WiFi: no secrets.h credentials, starting AP");
}
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());
}
// ---------------------------------------------------------------- setup ---
void setup() {
Serial.begin(115200);
uint32_t t0 = millis();
while (!Serial && millis() - t0 < 2000) delay(10); // don't block if headless
Serial.println("\n=== XIAO ESP32-C6 + C4001 mmWave ===");
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LED_OFF);
pinMode(PIN_SENSOR_OUT, INPUT);
Serial.println("Detecting C4001 ...");
detectSensor();
Serial.print(sensor.detectLog);
if (sensor.present) {
Serial.printf("Sensor found on %s\n", sensor.iface.c_str());
sSensorStatus_t st = radar->getStatus();
Serial.printf(" workStatus=%u workMode=%u initStatus=%u\n",
st.workStatus, st.workMode, st.initStatus);
sensor.mode = st.workMode;
live.initStatus = st.initStatus;
live.workStatus = st.workStatus;
POLL_MS = sensor.iface.startsWith("I2C") ? 100 : 500;
Serial.printf(" polling every %u ms\n", POLL_MS);
} else {
Serial.println("No C4001 found -- web UI will still start and show the probe log.");
}
startWiFi();
if (MDNS.begin(MDNS_HOST)) {
MDNS.addService("http", "tcp", 80);
Serial.printf("mDNS: http://%s.local/\n", MDNS_HOST);
}
server.on("/", HTTP_GET, handleRoot);
server.on("/api/live", HTTP_GET, handleLive);
server.on("/api/config", HTTP_GET, handleGetConfig);
server.on("/api/config", HTTP_POST, handleSetConfig);
server.on("/api/action", HTTP_POST, handleAction);
server.begin();
Serial.println("HTTP server up on port 80");
}
void loop() {
server.handleClient();
if (millis() - lastPoll >= POLL_MS) {
lastPoll = millis();
pollSensor();
}
}
+376
View File
@@ -0,0 +1,376 @@
// Single-page UI served from flash. No external assets: the board has no
// internet access in AP mode, so everything is inline.
#pragma once
#include <pgmspace.h>
static const char INDEX_HTML[] PROGMEM = R"HTMLPAGE(
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>XIAO C6 - C4001 mmWave</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a;
--fg:#e6e9ef; --dim:#98a2b3; --accent:#4da3ff; --ok:#3ddc84; --warn:#ffb020; --bad:#ff5c5c;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
header{padding:14px 18px;border-bottom:1px solid var(--line);
display:flex;flex-wrap:wrap;gap:10px;align-items:center}
h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.2px}
.pill{background:var(--panel2);border:1px solid var(--line);border-radius:999px;
padding:3px 10px;font-size:12px;color:var(--dim)}
.pill b{color:var(--fg);font-weight:600}
main{padding:18px;max-width:1100px;margin:0 auto;display:grid;gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
.card h2{font-size:13px;margin:0 0 12px;color:var(--dim);text-transform:uppercase;
letter-spacing:.6px;font-weight:600}
.presence{display:flex;align-items:center;gap:16px}
.dot{width:56px;height:56px;border-radius:50%;background:#333;flex:none;
transition:background .15s, box-shadow .15s}
.dot.on{background:var(--ok);box-shadow:0 0 0 8px rgba(61,220,132,.15)}
.pstate{font-size:26px;font-weight:700}
.pnote{color:var(--dim);font-size:13px}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px}
.tile{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:12px}
.tile .k{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
.tile .v{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:2px}
.tile .u{font-size:12px;color:var(--dim);font-weight:400}
canvas{width:100%;height:170px;display:block;background:var(--panel2);
border:1px solid var(--line);border-radius:10px}
.row{display:grid;grid-template-columns:180px 1fr 74px;gap:10px;align-items:center;
padding:7px 0;border-bottom:1px solid var(--line)}
.row:last-child{border-bottom:0}
.row label{font-size:13px}
.row .hint{font-size:11px;color:var(--dim);display:block}
input[type=range]{width:100%;accent-color:var(--accent)}
input[type=number]{width:100%;background:var(--panel2);border:1px solid var(--line);
color:var(--fg);border-radius:6px;padding:5px 7px;font-variant-numeric:tabular-nums}
button{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px}
button:hover{border-color:var(--accent)}
button.primary{background:var(--accent);border-color:var(--accent);color:#04121f;font-weight:600}
button.danger:hover{border-color:var(--bad);color:var(--bad)}
.btns{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{border:0;border-radius:0;padding:7px 16px}
.seg button.sel{background:var(--accent);color:#04121f;font-weight:600}
#msg{margin-top:10px;font-size:12px;color:var(--dim);min-height:16px}
.banner{border-radius:10px;padding:12px 14px;margin-bottom:4px;font-size:13px}
.banner.bad{background:rgba(255,92,92,.12);border:1px solid var(--bad);color:#ffc9c9}
.banner pre{margin:8px 0 0;font-size:12px;white-space:pre-wrap;color:#ffb0b0}
details{margin-top:10px}
summary{cursor:pointer;color:var(--accent);font-size:13px}
.guide{font-size:13px;color:var(--dim)}
.guide h3{color:var(--fg);font-size:13px;margin:14px 0 4px}
.guide code{background:var(--panel2);padding:1px 5px;border-radius:4px;color:var(--fg)}
.guide ol{padding-left:18px} .guide li{margin:4px 0}
.hide{display:none}
</style>
</head>
<body>
<header>
<h1>XIAO ESP32-C6 &middot; C4001 mmWave</h1>
<span class="pill">link <b id="p-iface">-</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">rssi <b id="p-rssi">-</b></span>
<span class="pill">up <b id="p-up">-</b></span>
</header>
<main>
<div id="nosensor" class="banner bad hide">
<b>No C4001 detected.</b> Check power and wiring, then reset the board.
<pre id="detectlog"></pre>
</div>
<section class="card">
<div class="presence">
<div class="dot" id="dot"></div>
<div>
<div class="pstate" id="pstate">--</div>
<div class="pnote" id="pnote">waiting for data</div>
</div>
</div>
<div class="tiles" style="margin-top:16px">
<div class="tile"><div class="k">Targets</div><div class="v" id="t-targets">-</div></div>
<div class="tile"><div class="k">Range</div><div class="v"><span id="t-range">-</span><span class="u"> m</span></div></div>
<div class="tile"><div class="k">Speed</div><div class="v"><span id="t-speed">-</span><span class="u"> m/s</span></div></div>
<div class="tile"><div class="k">Energy</div><div class="v" id="t-energy">-</div></div>
<div class="tile"><div class="k">OUT pin</div><div class="v" id="t-out">-</div></div>
</div>
</section>
<section class="card">
<h2>Last 60 s &mdash; range (blue) &middot; energy (amber) &middot; presence (green band)</h2>
<canvas id="chart"></canvas>
</section>
<section class="card">
<h2>Sensor mode</h2>
<div class="seg">
<button id="m0" onclick="setMode(0)">Presence / occupancy</button>
<button id="m1" onclick="setMode(1)">Speed + ranging</button>
</div>
<div class="pnote" style="margin-top:8px">
Presence mode reports occupancy only. Speed mode gives you distance,
velocity and signal energy &mdash; use it to pick your thresholds, then
switch to presence mode for normal use.
</div>
</section>
<section class="card" id="cfg-exist">
<h2>Calibration &mdash; presence mode</h2>
<div class="row">
<label>Min range<span class="hint">ignore anything closer, cm (30&ndash;2000)</span></label>
<input type="range" id="minRange" min="30" max="1200" step="10" oninput="sync('minRange')">
<input type="number" id="minRange_n" oninput="sync('minRange_n')">
</div>
<div class="row">
<label>Max range<span class="hint">detection edge, cm (240&ndash;2000)</span></label>
<input type="range" id="maxRange" min="240" max="1200" step="10" oninput="sync('maxRange')">
<input type="number" id="maxRange_n" oninput="sync('maxRange_n')">
</div>
<div class="row">
<label>Trigger range<span class="hint">must be within min/max, cm</span></label>
<input type="range" id="trigRange" min="240" max="1200" step="10" oninput="sync('trigRange')">
<input type="number" id="trigRange_n" oninput="sync('trigRange_n')">
</div>
<div class="row">
<label>Trigger sensitivity<span class="hint">0 = hardest to trip, 9 = easiest</span></label>
<input type="range" id="trigSens" min="0" max="9" step="1" oninput="sync('trigSens')">
<input type="number" id="trigSens_n" oninput="sync('trigSens_n')">
</div>
<div class="row">
<label>Keep sensitivity<span class="hint">how easily presence is held once tripped</span></label>
<input type="range" id="keepSens" min="0" max="9" step="1" oninput="sync('keepSens')">
<input type="number" id="keepSens_n" oninput="sync('keepSens_n')">
</div>
<div class="row">
<label>Trigger delay<span class="hint">&times;0.01 s, 0&ndash;200 (0&ndash;2 s)</span></label>
<input type="range" id="trigDelay" min="0" max="200" step="1" oninput="sync('trigDelay')">
<input type="number" id="trigDelay_n" oninput="sync('trigDelay_n')">
</div>
<div class="row">
<label>Keep timeout<span class="hint">&times;0.5 s, 4&ndash;3000 (2&ndash;1500 s)</span></label>
<input type="range" id="keepTimeout" min="4" max="3000" step="1" oninput="sync('keepTimeout')">
<input type="number" id="keepTimeout_n" oninput="sync('keepTimeout_n')">
</div>
<div class="btns">
<button class="primary" onclick="applyExist()">Apply to sensor</button>
<button onclick="loadCfg()">Reload from sensor</button>
</div>
</section>
<section class="card hide" id="cfg-speed">
<h2>Calibration &mdash; speed / ranging mode</h2>
<div class="row">
<label>Min range<span class="hint">cm</span></label>
<input type="range" id="tMinRange" min="30" max="1200" step="10" oninput="sync('tMinRange')">
<input type="number" id="tMinRange_n" oninput="sync('tMinRange_n')">
</div>
<div class="row">
<label>Max range<span class="hint">cm</span></label>
<input type="range" id="tMaxRange" min="240" max="1200" step="10" oninput="sync('tMaxRange')">
<input type="number" id="tMaxRange_n" oninput="sync('tMaxRange_n')">
</div>
<div class="row">
<label>Detection threshold<span class="hint">&times;0.1, 0&ndash;65535. Compare against live Energy.</span></label>
<input type="range" id="thres" min="0" max="3000" step="10" oninput="sync('thres')">
<input type="number" id="thres_n" oninput="sync('thres_n')">
</div>
<div class="row">
<label>Micro-motion detect<span class="hint">catches breathing / small shifts</span></label>
<input type="range" id="fretting" min="0" max="1" step="1" oninput="sync('fretting')">
<input type="number" id="fretting_n" oninput="sync('fretting_n')">
</div>
<div class="btns">
<button class="primary" onclick="applySpeed()">Apply to sensor</button>
<button onclick="loadCfg()">Reload from sensor</button>
</div>
</section>
<section class="card">
<h2>Actions</h2>
<div class="btns">
<button onclick="act('save')">Save to sensor flash</button>
<button onclick="act('start')">Start</button>
<button onclick="act('stop')">Stop</button>
<button onclick="act('reset')">Reset sensor</button>
<button class="danger" onclick="if(confirm('Restore C4001 factory defaults?'))act('factory')">Factory defaults</button>
</div>
<div id="msg"></div>
<div class="pnote" style="margin-top:6px">
Applied settings are live but volatile. <b>Save to sensor flash</b> makes
them survive a power cycle.
</div>
</section>
<section class="card guide">
<h2>How to calibrate</h2>
<ol>
<li><b>Start in speed mode.</b> It exposes range, velocity and energy, which is
what you actually need to choose thresholds. Presence mode hides all of it.</li>
<li><b>Empty the room and watch Energy.</b> That trace is your noise floor.
A still room should sit low and flat. Spikes with nobody there mean a
reflective surface, a fan, or moving water in pipes is being seen.</li>
<li><b>Walk the space.</b> Note the Range readout where you want the boundary
to be, and the Energy value you reach at your furthest useful position.</li>
<li><b>Set max range just past that boundary</b>, and min range past any
reflector right in front of the sensor.</li>
<li><b>Set the threshold between the two:</b> above the empty-room noise floor,
below the energy of a real person at max range. Halfway in log terms is a
good first guess.</li>
<li><b>Switch to presence mode</b> and set the same min/max. Raise
<i>trigger sensitivity</i> until it catches you reliably entering, then raise
<i>keep sensitivity</i> until it stops dropping out when you sit still.</li>
<li><b>Tune the timing.</b> Trigger delay suppresses brief blips.
Keep timeout is how long presence latches after the last detection &mdash;
for a room-occupancy sensor 30&ndash;120 s is typical, so set keep timeout
to 60&ndash;240.</li>
<li><b>Save to sensor flash</b> once you are happy.</li>
</ol>
<h3>Things that will bite you</h3>
<p>24 GHz passes through drywall and glass, so the sensor sees the next room
and outside the window. If you get phantom presence, shrink max range before
touching sensitivity. Metal and mirrors create ghost targets at multiples of
the true distance. Ceiling fans and HVAC read as continuous motion. Mount the
sensor upright, facing the space, away from large metal surfaces.</p>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
let curMode = 0, hist = [];
function sync(id){
const base = id.endsWith('_n') ? id.slice(0,-2) : id;
const src = $(id), dst = $(id.endsWith('_n') ? base : base+'_n');
if (dst) dst.value = src.value;
}
function setPair(id, v){
const r = $(id), n = $(id+'_n');
if(!r) return;
if (+v > +r.max) r.max = v; // sensor may hold a value past our default cap
r.value = v; if (n) n.value = v;
}
async function poll(){
let d;
try { d = await (await fetch('/api/live')).json(); }
catch(e){ return; }
$('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';
$('nosensor').classList.toggle('hide', d.present);
if (!d.present) $('detectlog').textContent = d.detectLog;
if (d.mode !== curMode){ curMode = d.mode; showMode(); loadCfg(); }
$('dot').classList.toggle('on', d.presence);
$('pstate').textContent = d.presence ? 'PRESENT' : 'clear';
$('pnote').textContent = d.mode
? (d.targets ? d.targets+' target(s), nearest '+d.range.toFixed(2)+' m' : 'no targets in range')
: (d.presence ? 'occupancy detected' : 'no occupancy');
$('t-targets').textContent = d.targets;
$('t-range').textContent = d.range.toFixed(2);
$('t-speed').textContent = d.speed.toFixed(2);
$('t-energy').textContent = d.energy;
$('t-out').textContent = d.outPin ? 'HIGH' : 'LOW';
hist.push({r:d.range, e:d.energy, p:d.presence});
if (hist.length > 300) hist.shift(); // 300 samples @ 5 Hz = 60 s
draw();
}
function draw(){
const c = $('chart'), dpr = devicePixelRatio || 1;
const w = c.clientWidth, h = c.clientHeight;
c.width = w*dpr; c.height = h*dpr;
const x = c.getContext('2d'); x.scale(dpr,dpr);
x.clearRect(0,0,w,h);
if (!hist.length) return;
const n = 300, step = w/n;
const maxR = Math.max(1, ...hist.map(p=>p.r));
const maxE = Math.max(1, ...hist.map(p=>p.e));
// presence band along the bottom
x.fillStyle = 'rgba(61,220,132,.18)';
hist.forEach((p,i)=>{ if(p.p) x.fillRect(i*step, h-10, Math.ceil(step)+1, 10); });
const line = (key, max, col) => {
x.beginPath(); x.strokeStyle = col; x.lineWidth = 1.5;
hist.forEach((p,i)=>{
const y = h-12 - (p[key]/max)*(h-24);
i ? x.lineTo(i*step, y) : x.moveTo(i*step, y);
});
x.stroke();
};
line('e', maxE, '#ffb020');
line('r', maxR, '#4da3ff');
x.fillStyle = '#98a2b3'; x.font = '11px sans-serif';
x.fillText('range max '+maxR.toFixed(1)+' m', 6, 13);
x.fillText('energy max '+maxE, 6, 27);
}
function showMode(){
$('m0').classList.toggle('sel', curMode===0);
$('m1').classList.toggle('sel', curMode===1);
$('cfg-exist').classList.toggle('hide', curMode!==0);
$('cfg-speed').classList.toggle('hide', curMode!==1);
}
async function post(url, params){
const body = new URLSearchParams(params);
const r = await fetch(url, {method:'POST', body});
const j = await r.json();
$('msg').textContent = (j.ok===false?'FAILED: ':'') + (j.msg||'');
return j;
}
async function loadCfg(){
try{
const c = await (await fetch('/api/config')).json();
if (c.error) return;
['minRange','maxRange','trigRange','trigSens','keepSens','trigDelay','keepTimeout',
'tMinRange','tMaxRange','thres','fretting'].forEach(k=>setPair(k, c[k]));
}catch(e){}
}
const val = id => $(id).value;
function applyExist(){
post('/api/config', {
minRange:val('minRange'), maxRange:val('maxRange'), trigRange:val('trigRange'),
trigSens:val('trigSens'), keepSens:val('keepSens'),
trigDelay:val('trigDelay'), keepTimeout:val('keepTimeout')
});
}
function applySpeed(){
post('/api/config', {
tMinRange:val('tMinRange'), tMaxRange:val('tMaxRange'),
thres:val('thres'), fretting:val('fretting')
});
}
async function setMode(m){
await post('/api/config', {mode:m});
curMode = m; showMode(); setTimeout(loadCfg, 400);
}
async function act(a){ await post('/api/action', {do:a}); setTimeout(loadCfg, 500); }
showMode(); loadCfg(); poll();
setInterval(poll, 200);
addEventListener('resize', draw);
</script>
</body>
</html>
)HTMLPAGE";