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:
commit
b9162db707
6 files changed
+993
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
# Real WiFi credentials. Only secrets.h.example is tracked.
|
||||
include/secrets.h
|
||||
@@ -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`).
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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 · 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 — range (blue) · energy (amber) · 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 — use it to pick your thresholds, then
|
||||
switch to presence mode for normal use.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="cfg-exist">
|
||||
<h2>Calibration — presence mode</h2>
|
||||
<div class="row">
|
||||
<label>Min range<span class="hint">ignore anything closer, cm (30–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–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">×0.01 s, 0–200 (0–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">×0.5 s, 4–3000 (2–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 — 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">×0.1, 0–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 —
|
||||
for a room-occupancy sensor 30–120 s is typical, so set keep timeout
|
||||
to 60–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";
|
||||
Reference in new issue
Block a user