Status: active as of 2026-05-05. This document captures decisions that must not be relitigated without an explicit conversation. If you're picking up the v6 rewrite cold, read this first.
v5.1.1 (Arduino) works but is structurally constrained:
- Every subsystem runs cooperatively in
loop()— radar reads, LED rendering, HTTP serving, ESP-NOW. A slow web request stalls the LEDs; a heavy Fire-mode frame stalls radar reads. - EEPROM byte-offset layout (320 bytes across 6 sections) — fragile, no versioning, no atomicity.
- One radar driver hard-coded; no path to LD2450 multi-target tracking.
- Master/slave ESP-NOW with implicit topology — equal LED-segment splits break asymmetric stairs (3-step landing + 12-step main flight).
- WiFi channel pinned to 6 even when the master joins a router on channel 11 — silent failure.
- No OTA, no captive portal, no auth, plaintext WiFi creds in EEPROM.
v6 is an ESP-IDF + FreeRTOS rewrite that fixes all of the above while preserving the v5 visual modes, the user-facing API surface (most endpoints kept compatible), and the master-slave-style coordination behaviour for U/L stair installs.
| Profile | Validated | Default LED pin | Default radar pins |
|---|---|---|---|
esp32c3-supermini |
✅ yes | GPIO 10 | RX 20 / TX 21 |
esp32-devkit |
builds | GPIO 5 | RX 16 / TX 17 |
esp32s3-zero |
builds | GPIO 21 | RX 4 / TX 5 |
esp32c6-devkit |
builds | GPIO 8 | RX 4 / TX 5 |
Profiles ship in firmware/components/board/board.c. Each declares an
unsafe_pin_mask covering strapping pins, USB-Serial-JTAG D-/D+, and
internal SPI flash. The web UI (PR #5) will refuse pin remaps that fall
on unsafe pins.
The C3 SuperMini is the primary target for v6.0. Other boards are "compile-clean" until the user gets hardware in hand.
A driver registry compiled into components/radar/. All drivers linked
in; one selected at runtime via the board.radar_kind NVS key. Adding a
new sensor = +1 file + 1 dropdown entry, no firmware reflash needed.
| Driver id | Sensor | Tier | Notes |
|---|---|---|---|
ld2410 |
HiLink LD2410(B/C) | v1 | Single target; existing v5 hardware |
ld2412 |
HiLink LD2412 | v1 | Per-gate sensitivity |
ld2420 |
HiLink LD2420 | v1 | Presence only (no distance) |
ld2450 |
HiLink LD2450 | v1 | Up to 3 targets, x/y/speed |
sim |
Simulator | v1 | Synthetic traces for desk testing |
One radar per device. The C3 has only one usable UART beyond the console, and the user explicitly confirmed this constraint. Multi-radar fusion happens at the mesh level (each device contributes its sensor).
Every device:
- Has the full topology config in NVS (which segments exist, which device owns each segment, sensor positions in cm along the strip).
- Broadcasts its radar reading at 5 Hz to all peers via ESP-NOW.
- Runs the same fusion algorithm locally on the merged peer stream and arrives at the same global "active person position".
- Renders only its own LED segment on its own local strip.
Properties:
- No leader required for control. Any device can drop and the others keep working at degraded fusion accuracy.
- Web UI host = elected coordinator (lowest-MAC peer). User browses
to
ambisense.local, mDNS resolves to whoever is elected. No "which IP do I open?" confusion. - Config gossip. Any device accepts a config write; gossip propagates
with a
config_versionstamp. Lex tiebreaker on MAC for simultaneous writes.
LED strips need power injection on both ends for long runs and the data signal degrades over distance. We do not chain a single strip across multiple devices. Each device drives its own physical strip; the "global LED address space" is purely virtual:
device A (segment 0): physical pixels 0..99 == virtual 0..99
device B (segment 1): physical pixels 0..99 == virtual 100..199
device C (segment 2): physical pixels 0..99 == virtual 200..299
When the active person is at virtual position 250:
- A renders background only (250 not in 0..99).
- B renders background only (250 not in 100..199).
- C renders the moving light at local pixel 50 (250 − 200).
This shrinks ESP-NOW bandwidth dramatically — peers exchange ~24-byte
global_state_t frames (active position + mode + color + effect tick),
not pixel-level commands. ~600 B/s mesh chatter for a 5-device install.
typedef struct { // ESP-NOW, broadcast every 200 ms
uint8_t msg_type; // 1=TARGET, 2=HEARTBEAT, 3=CONFIG_GOSSIP, 4=CHAN_ANNOUNCE
uint8_t device_idx;
uint16_t config_version;
uint16_t distance_cm;
int16_t x_cm, y_cm; // valid for LD2450, else 0
int16_t velocity_cms;
uint8_t energy;
uint8_t flags; // bit0=primary_target_present, bit1=is_coordinator
uint64_t ts_us;
} peer_msg_t;Channel-follow-STA: when any device joins a router, it broadcasts
CHAN_ANNOUNCE; peers update via esp_wifi_set_channel(). Solves the
v5 "channel 6 forced, router on 11, mesh dies" silent failure.
Encrypted ESP-NOW: PMK/LMK pairing during a 3-second-button-hold pairing window. Stops a neighbour with the same firmware from joining your mesh accidentally.
firmware/
├── CMakeLists.txt # IDF project root
├── partitions.csv # NVS + 2x OTA + LittleFS + coredump
├── sdkconfig.defaults # Common knobs (WDT, brownout, coredump, …)
├── sdkconfig.defaults.esp32c3 # C3-specific (USB-Serial-JTAG console, brownout)
├── main/main.c # app_main: bring up tasks; no business logic
└── components/
├── board/ # PR #1 ✓ — board profile struct + 4 profiles
├── settings/ # PR #1 ✓ — NVS facade replacing v5 EEPROM
├── status_led/ # PR #1 ✓ — pattern-driven LED in own task
├── netmgr/ # PR #2 — Wi-Fi STA/AP + mDNS + captive DNS
├── webui/ # PR #2 — esp_http_server + LittleFS + auth
├── ota/ # PR #2 — esp_https_ota wrapper
├── radar/ # PR #3 — driver registry (LD2410/2412/2420/2450/sim)
├── motion/ # PR #3 — PI smoother (port of v5 algorithm)
├── led_engine/ # PR #3 — 11 modes via led_strip RMT
├── mesh/ # PR #4 — ESP-NOW peer mesh
└── topology/ # PR #4 — explicit L/U/asymmetric stair model
| Task | Pri | Stack | Period | Responsibility |
|---|---|---|---|---|
radar_task |
6 | 3 KB | UART event | Read radar bytes → radar_frame_t |
motion_task |
5 | 4 KB | 50 Hz | PI smoother → publishes target_t |
mesh_rx_task |
5 | 4 KB | event | ESP-NOW callbacks → target_q |
mesh_tx_task |
4 | 3 KB | 5 Hz | Broadcast our reading; topology gossip |
led_render_task |
4 | 6 KB | 60 Hz | Read fused target → framebuffer → led_strip_refresh() |
web_task |
3 | 6 KB | event | HTTPD handler thread |
health_task |
2 | 2 KB | 1 Hz | Heap watch, peer timeout, status LED |
The single integration point is target_q — radar local + ESP-NOW
remote both push into it; motion fusion + LED render both consume it.
This collapses v5's four ad-hoc paths in processRadarReading()
(master / slave / standalone / no-slaves) into one uniform pipeline.
| Namespace | Keys (representative) |
|---|---|
sys |
device_name |
board |
id, led_pin, radar_rx, radar_tx, button, status_led, radar_kind |
led |
count, brightness, r/g/b, mode, span, center_shift, trail, dir_light, bg_mode, effect_speed, effect_intensity |
dist |
min_cm, max_cm |
motion |
enabled, pos_smooth, vel_smooth, predict, p_gain, i_gain |
mesh |
peers (blob: count + 6×N MAC array), pmk, channel, priority_mode |
topo |
kind (straight/L/U/custom), total_leds, segments (blob) |
wifi |
ssid, pass (encrypted w/ chip-derived key), static_ip (optional), mdns_name |
auth |
admin_pass_hash (PBKDF2-SHA256, 250k rounds) |
NVS is journaled (atomic per-key writes), wear-levelled, typed (no manual hi/lo byte unpacking), and versionable. Replaces v5's manual XOR-CRC sectioned layout entirely.
No migration shim from v5 EEPROM — the user explicitly confirmed v6 is a clean cutover (no backwards compatibility with Arduino fleet).
The PR #1 design preserves every existing v5 endpoint (compatibility for
external integrations like Home Assistant), and adds the missing v6
surface. See docs/V6-ROADMAP.md for the per-PR endpoint deliveries.
New endpoints summary:
POST /api/auth/login → cookie session
WS /api/live → distance + RSSI + heap @ 5 Hz, replaces XHR polling
GET /api/board/profiles → board dropdown
POST /api/board → save board id + pin overrides; reboot
GET /api/radar/kinds → ld2410 | ld2412 | ld2420 | ld2450 | sim
POST /api/topology → persist L/U/custom + per-segment LED ranges
POST /api/ota → multipart firmware upload
POST /api/sim/trace → simulator-driver: replay a recorded distance trace
GET /api/version → app version + git sha + idf version + free heap
GET /api/logs → ring buffer of recent ESP_LOG output
These were resolved by the user with "decide for me":
| Decision | Choice |
|---|---|
| Boards in v1 | All four profiles ship; only C3 validated, others build-clean |
| Auth default | OFF; banner until configured; PBKDF2-SHA256 hash |
| OTA signing | Unsigned for v6.0; signed-OTA on the v6.x roadmap |
| UI framework | Preact + Tailwind + Vite + TypeScript; bundle target <80 KB gz |
| Mesh model | Peer mesh with elected coordinator (lowest-MAC) |
| MQTT | Off by default; HA auto-discovery format when enabled |
| Repo strategy | Same repo; legacy/v5-arduino archive; v6-idf-rewrite working branch; tagged releases v6.0.0-alpha.N |
- BLE Mesh / Thread / Matter (would require C6/S3 only — rules out C3)
- Simultaneous LD2410 + LD2450 on one board (C3 has only one usable UART)
- Signed OTA (defer until update flow is exercised)
- Multi-room / cross-house mesh (≤5 nodes per install is the design point)
- Anonymous telemetry / phone-home (never)
These can be added later without breaking the architecture.