From b9162db707ac47f9d012b2f0fb4088bc7b3f5000 Mon Sep 17 00:00:00 2001 From: matt_ed Date: Fri, 14 Aug 2026 10:05:27 -0700 Subject: [PATCH] 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 --- .gitignore | 4 + README.md | 176 +++++++++++++++++ include/secrets.h.example | 15 ++ platformio.ini | 28 +++ src/main.cpp | 394 ++++++++++++++++++++++++++++++++++++++ src/web_ui.h | 376 ++++++++++++++++++++++++++++++++++++ 6 files changed, 993 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 include/secrets.h.example create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 src/web_ui.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59c25f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio/ +.vscode/ +# Real WiFi credentials. Only secrets.h.example is tracked. +include/secrets.h diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5e7811 --- /dev/null +++ b/README.md @@ -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`). diff --git a/include/secrets.h.example b/include/secrets.h.example new file mode 100644 index 0000000..fd81605 --- /dev/null +++ b/include/secrets.h.example @@ -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" diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..4734cd8 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ffc19a2 --- /dev/null +++ b/src/main.cpp @@ -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 +#include +#include +#include +#include +#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(); + } +} diff --git a/src/web_ui.h b/src/web_ui.h new file mode 100644 index 0000000..1c67253 --- /dev/null +++ b/src/web_ui.h @@ -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 + +static const char INDEX_HTML[] PROGMEM = R"HTMLPAGE( + + + + + +XIAO C6 - C4001 mmWave + + + +
+

XIAO ESP32-C6 · C4001 mmWave

+ link - + mode - + init - + rssi - + up - +
+ +
+ + +
+
+
+
+
--
+
waiting for data
+
+
+
+
Targets
-
+
Range
- m
+
Speed
- m/s
+
Energy
-
+
OUT pin
-
+
+
+ +
+

Last 60 s — range (blue) · energy (amber) · presence (green band)

+ +
+ +
+

Sensor mode

+
+ + +
+
+ Presence mode reports occupancy only. Speed mode gives you distance, + velocity and signal energy — use it to pick your thresholds, then + switch to presence mode for normal use. +
+
+ +
+

Calibration — presence mode

+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ +
+

Calibration — speed / ranging mode

+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ +
+

Actions

+
+ + + + + +
+
+
+ Applied settings are live but volatile. Save to sensor flash makes + them survive a power cycle. +
+
+ +
+

How to calibrate

+
    +
  1. Start in speed mode. It exposes range, velocity and energy, which is + what you actually need to choose thresholds. Presence mode hides all of it.
  2. +
  3. Empty the room and watch Energy. 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.
  4. +
  5. Walk the space. Note the Range readout where you want the boundary + to be, and the Energy value you reach at your furthest useful position.
  6. +
  7. Set max range just past that boundary, and min range past any + reflector right in front of the sensor.
  8. +
  9. Set the threshold between the two: 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.
  10. +
  11. Switch to presence mode and set the same min/max. Raise + trigger sensitivity until it catches you reliably entering, then raise + keep sensitivity until it stops dropping out when you sit still.
  12. +
  13. Tune the timing. Trigger delay suppresses brief blips. + Keep timeout is how long presence latches after the last detection — + for a room-occupancy sensor 30–120 s is typical, so set keep timeout + to 60–240.
  14. +
  15. Save to sensor flash once you are happy.
  16. +
+

Things that will bite you

+

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.

+
+
+ + + + +)HTMLPAGE";