Stable Intermediate Presence sensing
WiFi CSI Presence Node
Detect people through walls using WiFi physics — no camera, no privacy worries.
Board
ESP32-S3 (T-Dongle-S3 or DevKitC-1)
ESP32-S3 (T-Dongle-S3 or DevKitC-1)
Est. cost
~$15
~$15
Install time
45 min
45 min
Sensors
WiFi CSI (56 subcarriers), RSSI
WiFi CSI (56 subcarriers), RSSI
Every WiFi packet carries Channel State Information: 56 numbers describing how the radio waves bent around bodies and furniture on their way to the antenna. This node captures that CSI at 50 Hz, joins an ESP-NOW mesh with sibling nodes, and streams JSON lines over USB — the raw material for presence detection, motion analysis, and everything the Latent ecosystem does. This is the exact firmware running in our own mesh deployments.
Maintained and field-tested by the Latent team.
Start guided installWhat you need
| ESP32-S3 board (T-Dongle-S3 or DevKitC-1) | ~$10 |
| USB-C cable | ~$3 |
| 2.4 GHz WiFi network (any router) | ~$0 |
| Est. cost | ~$15 |
Install steps
1
Get the external components
2
Set your node ID and WiFi
3
Flash the node
4
Watch the CSI stream
5
Check the health LED and grow the mesh
Device config
Full config — copy it, download it, or follow the guided install.
# LatentField CSI Mesh Node
# ========================
# Hardware: Qiheng T-Dongle-S3 (ESP32-S3)
# Purpose: WiFi CSI capture + ESP-NOW mesh broadcast
# Flash: esphome run csi_node.yaml
# Connect: USB-C to Mac, 921600 baud, JSON lines over UART0
#
# Architecture:
# 6 CSI nodes (this firmware) + 1 rotary collector
# Each node broadcasts ESP-NOW beacons at 50Hz.
# Other nodes extract CSI from received beacons.
# CSI data is streamed over USB serial as JSON lines.
#
# T-Dongle-S3 Pin Map:
# GPIO43 — USB UART TX (to Mac)
# GPIO44 — USB UART RX (from Mac)
# GPIO39 — Status LED (WS2812 / built-in)
# GPIO00 — BOOT button
#
# Serial output format (one JSON line per CSI frame):
# {"node_id":1,"ts":12345678,"rssi":-42,"csi_amp":[...],"csi_phase":[...]}
substitutions:
device_name: "latentfield-node-1"
friendly: "LatentField Node 1"
# Change per device: 1..6 for the six CSI nodes
node_id: "1"
esphome:
name: ${device_name}
friendly_name: ${friendly}
platformio_options:
board_build.flash_mode: dio
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
version: recommended
sdkconfig_options:
# Enable CSI in IDF kconfig
CONFIG_ESP_WIFI_CSI_ENABLED: "y"
# ESP-NOW requires WiFi
CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM: "7"
# ---------- External components ----------
# Reuses the same custom components as the rotary firmware
external_components:
- source:
type: local
path: external_components
components: [latentfield_csi, latentfield_espnow]
# ---------- WiFi ----------
# ESP-NOW can work without AP association, but CSI capture needs
# WiFi initialized. We connect to the same network as the rotary
# so all nodes share the same channel.
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none # CSI requires power save off
# ---------- Serial bridge to operator station ----------
# 921600 baud for high-throughput CSI streaming (50Hz * ~200B = 10KB/s)
uart:
id: usb_uart
tx_pin: GPIO43
rx_pin: GPIO44
baud_rate: 921600
rx_buffer_size: 1024
logger:
level: INFO
baud_rate: 0 # Disable default logger on UART0 — we own it for data
# ---------- LatentField CSI capture ----------
latentfield_csi:
id: csi
num_subcarriers: 56
ring_size: 8 # Deeper buffer for 50Hz streaming
min_rssi: -85
# filter_mac: AA:BB:CC:DD:EE:FF # Optional: restrict to specific tx node
# ---------- LatentField ESP-NOW mesh ----------
# Each node acts as broadcaster (sends beacons) + peer (receives CSI)
latentfield_espnow:
id: mesh
node_id: ${node_id}
role: broadcaster # Auto-sends beacons for other nodes to measure CSI from
channel: 0 # 0 = use WiFi manager's channel (must be same across all nodes)
ring_size: 16
# Pre-register other nodes if known:
# peers:
# - 30:AE:A4:XX:XX:X1
# - 30:AE:A4:XX:XX:X2
# - 30:AE:A4:XX:XX:X3
# - 30:AE:A4:XX:XX:X4
# - 30:AE:A4:XX:XX:X5
# - 30:AE:A4:XX:XX:X6
# ---------- Status LED (WS2812 on T-Dongle-S3) ----------
# T-Dongle-S3 has a single WS2812 LED on GPIO39
light:
- platform: esp32_rmt_led_strip
id: status_led
pin: GPIO39
num_leds: 1
rmt_channel: 0
chipset: WS2812
rgb_order: GRB
name: "Status LED"
# ---------- Globals ----------
globals:
- { id: frame_count, type: unsigned int, initial_value: '0' }
- { id: tx_count, type: unsigned int, initial_value: '0' }
# ---------- Intervals ----------
interval:
# Broadcast ESP-NOW beacon every 20ms (50Hz)
# Other nodes extract CSI from these packets
- interval: 20ms
then:
- lambda: |-
// Send a minimal beacon so other nodes can measure CSI from it.
// The beacon payload carries our node_id and frame counter.
uint8_t payload[4];
payload[0] = ${node_id};
payload[1] = (uint8_t)(id(frame_count) & 0xFF);
payload[2] = (uint8_t)((id(frame_count) >> 8) & 0xFF);
payload[3] = 0; // reserved
id(mesh).broadcast(4 /*LF_MSG_RAW*/, payload, sizeof(payload));
id(tx_count)++;
# Stream CSI data over USB serial at ~50Hz
# Each line is a JSON object with node_id, timestamp, RSSI, amplitude, phase
- interval: 20ms
then:
- lambda: |-
// Pop the latest CSI frame and send as JSON line
std::string csi_json = id(csi).get_latest_json();
if (csi_json.empty() || csi_json == "{}") return;
// Inject our node_id into the JSON
// csi_json format: {"rssi":-42,"wifi_csi":[...],"wifi_phase":[...]}
// We transform to: {"node_id":N,"ts":T,"rssi":-42,"csi_amp":[...],"csi_phase":[...]}
id(frame_count)++;
char header[64];
snprintf(header, sizeof(header),
"{\"node_id\":%d,\"ts\":%lu,",
${node_id}, (unsigned long)(esp_timer_get_time()));
// Replace the opening "{" of csi_json with our header
std::string out;
out.reserve(csi_json.size() + 64);
out += header;
// Strip leading "{" from csi_json and remap field names
// Input fields: "rssi", "wifi_csi", "wifi_phase"
// Output fields: "rssi", "csi_amp", "csi_phase"
std::string body = csi_json.substr(1); // skip '{'
// Simple string replace for field names
size_t pos;
pos = body.find("\"wifi_csi\"");
if (pos != std::string::npos)
body.replace(pos, 10, "\"csi_amp\"");
pos = body.find("\"wifi_phase\"");
if (pos != std::string::npos)
body.replace(pos, 12, "\"csi_phase\"");
out += body;
out += "\n";
id(usb_uart).write_array(
reinterpret_cast<const uint8_t*>(out.data()), out.size());
# Status LED: green blink = healthy, red = no CSI
- interval: 1000ms
then:
- lambda: |-
if (id(frame_count) > 0) {
// Green pulse — node is capturing CSI
auto call = id(status_led).turn_on();
call.set_rgb(0.0f, 0.3f, 0.0f);
call.set_brightness(0.3f);
call.perform();
} else {
// Red — no CSI frames yet
auto call = id(status_led).turn_on();
call.set_rgb(0.3f, 0.0f, 0.0f);
call.set_brightness(0.3f);
call.perform();
}
Reviews (0)
No reviews yet — be the first.
Sign in to use this feature — it takes 20 seconds and it’s free. Sign in