ESP32 + HLK-LD2410C — personal presence tracker: work sessions, sleep monitoring, radar gestures. A Python daemon on your Mac handles logging and UI; on-device TinyML runs inference on the ESP32. No SPT cloud.
Detailed spec: PLAN_EN.md · Edge AI: docs/EDGE_AI.md · Implementation decisions: docs/IMPLEMENTATION.md
Pre-trained models in firmware/src/model_data.h classify radar feature windows on the ESP32:
- Work — presence, static fatigue (stretch hint), environmental noise filter
- Media — swipe next/prev, hover volume (fallback: zone-hold next)
- Sleep — breathing vs restless; Mac estimates breath rate from nightly samples
Configure in web Settings → Edge AI. Train on your data: tools/ml/ (see docs/EDGE_AI.md).
ESP32 and Mac must be on the same Wi‑Fi.
| Node | Config |
|---|---|
| ESP32 WiFi | firmware/secrets.ini → WIFI_SSID, WIFI_PASS |
| ESP32 → hub | UDP discovery — ESP32 broadcasts, Mac replies with its current IP |
| Mosquitto | 0.0.0.0:18830 ([daemon/mosquitto.conf](daemon/mosquitto.conf)) |
| Discovery | UDP :18832 — response includes mqtt_host / ota_host |
You do not need to hard-code the Mac IP. The router may reassign it; the device re-discovers the hub on boot, after MQTT failure, and from periodic hub beacons. The last address is cached in ESP32 NVS.
Optional: HUB_LAN_IP in daemon/.env if automatic LAN IP detection fails on your Mac.
ESP32 always writes events to LittleFS, even without the Mac:
| Event | Mode |
|---|---|
presence on/off |
work |
mode |
all |
button |
all |
gesture next / prev / vol |
media |
sleep_start / sleep_end / sleep_movement |
sleep |
After Wi‑Fi: NTP → real timestamps. When the Mac is online: UDP discovery → MQTT → batched hub/sync/events → daemon restores sessions and sleep in SQLite → hub/sync/ack → device buffer cleared.
Mode is stored in NVS (survives ESP32 reboot).
| Component | Interface | GPIO |
|---|---|---|
| ESP32-D0WD-V3 | — | — |
| HLK-LD2410C | UART | RX=16, TX=17 (likely) |
| OLED SSD1306 | I2C | SDA=21, SCL=22, 0x3C (likely) |
| 2 buttons | GPIO | 18 (btn1), 5 (btn2), active LOW |
Pins: [firmware/src/pins.h](firmware/src/pins.h). Boot serial prints the pin map.
brew install mosquitto platformio
python3 -m venv venv && source venv/bin/activate
pip install -r daemon/requirements.txtcp firmware/secrets.ini.example firmware/secrets.ini— WiFi onlycp daemon/.env.example daemon/.env— Telegram tokens (optional)./daemon/run.sh— start daemon (mosquitto + web + MQTT)cd firmware && pio run— build./scripts/flash_firmware.sh— flash over USBpio device monitor— expect:Radar ready,WiFi OK,MQTT connected- http://127.0.0.1:18080 —
Online: yes ./scripts/verify_hub.sh— discovery round-trip, MQTT, radar, display track (media)
./daemon/run.sh| Service | URL / port |
|---|---|
| Web UI | http://127.0.0.1:18080 |
| Display layout | http://127.0.0.1:18080/static/display.html |
| Settings (radar, Telegram, media) | http://127.0.0.1:18080/static/settings.html |
| Gestures (near-zone calibration) | http://127.0.0.1:18080/static/gestures.html |
| Sensor log (live radar/events) | http://127.0.0.1:18080/static/sensor-log.html |
| OTA binary | http://LAN-IP:18081/firmware.bin |
| MQTT (ESP32) | LAN-IP:18830 |
Login autostart on Mac:
./scripts/install_launchd.sh./scripts/flash_firmware.sh # USB
cd firmware && pio device monitor # serial logs| Mode | Button 2 | Button 1 |
|---|---|---|
| work | cycle mode | short: pause; long: reset session |
| sleep | → media | btn1: went to bed; btn2: woke up + exit sleep |
| media | cycle mode | short: pause; long: reset; + near-zone gesture |
Media is work + gestures: work log and standup continue; the session closes only when entering sleep. Auto-sleep from stillness is disabled — sleep starts only after btn1 “went to bed”.
http://127.0.0.1:18080/static/display.html — up to 3 lines, widget + font size (large/medium/small), brightness. Save → MQTT → OLED without reflashing.
Widgets: clock, session, today, track, standup_timer, reminder, mode, status, sleep.
In work or media, if you stay present with an active session longer than the configured interval (default 120 min), the daemon shows a reminder on the display and sends Telegram (if configured). Interval, enable/disable, and message text: Settings web page. Not sent in sleep mode. The timer resets if you were away from the desk for at least 4 of the last 5 minutes (reduces false triggers from brief radar flicker). Manual reset: long press btn1.
Once per day, after sleep ends and local time is past the configured hour (default 8:00), Telegram receives: Good morning. Sleep: Xh, movements: N. Enable/disable and hour: Settings.
LD2410C for sleep watches 0–1.2 m (configurable in Settings). Nightstand 0.5–1.2 m from chest; avoid doors and windows. Screen at night: off or minimal (Display page). Data is buffered on ESP32 when offline.
The dashboard sleep chart shows estimated calm / restless / awake phases from radar motion energy — not clinical deep/light sleep (no EEG).
The LD2410 is one-dimensional: it sees distance along the beam, not true X/Y. In media mode, hold your hand in the configured near zone (default 12–28 cm) for ~400 ms → next track (Spotify or system media keys). Leave the zone to re-arm.
Calibration and live radar: http://127.0.0.1:18080/static/gestures.html — enable Debug for MQTT hub/debug/gesture.
Media backend (Spotify vs system keys): Settings.
Credentials (not editable in the web UI): daemon/.env
HUB_TELEGRAM_TOKENHUB_TELEGRAM_CHAT_ID
Web Settings: standup interval, standup message, morning summary enable/hour.
Bot commands:
/status
/today
/week
/sleep
/sleep week
/standup 120
/mode work|sleep|media
/update
/settings
Defaults in .env.example: HUB_STANDUP_MIN=120, HUB_MORNING_HOUR=8.
cd firmware && pio run- Daemon running (OTA URL uses current LAN IP)
- Trigger: web dashboard button, Telegram
/update, or MQTThub/ota/trigger - ESP32 downloads
http://<LAN-IP>:18081/firmware.binand reboots
Development / diagnostics under scripts/service/ (English only):
| Script | Purpose |
|---|---|
detect_device.sh |
Find USB serial port, print chip/flash info |
verify_hub.sh |
Discovery + MQTT + online + radar/display smoke test |
dump_firmware.sh |
Read full flash → dumps/ |
monitor_serial.sh |
Colorized serial monitor → logs/ |
capture_traffic.sh |
tshark / mitmproxy helpers → captures/, mitm_logs/ |
mitm_addon.py |
mitmdump addon for request/response logging |
./scripts/service/detect_device.sh
./scripts/service/monitor_serial.sh
./scripts/service/capture_traffic.sh helpRequires repo-root venv/ with esptool, pyserial, and optionally mitmproxy / system tshark.
./venv/bin/esptool --port /dev/cu.usbserial-0001 write_flash 0x0 dumps/firmware_20260605_135023.binesp32_spt/
├── firmware/ # PlatformIO
├── daemon/ # Python asyncio hub
├── scripts/
│ ├── flash_firmware.sh
│ ├── install_launchd.sh
│ └── service/ # detect, dump, serial, traffic tools
├── dumps/ # firmware backups (gitignored)
├── PLAN_EN.md
└── README.md
| Topic | Direction |
|---|---|
hub/radar |
ESP32 → daemon |
hub/mode |
bidirectional |
hub/button |
ESP32 → daemon |
hub/display |
daemon → ESP32 |
hub/config |
daemon → ESP32 |
hub/gesture |
ESP32 → daemon |
hub/debug/gesture |
ESP32 → daemon (when debug on) |
hub/ota/trigger |
daemon → ESP32 |
hub/status |
ESP32 → daemon |
hub/sync/events / hub/sync/ack |
offline sync |