← Back
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)
Est. cost
~$15
Install time
45 min
Sensors
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 install

What 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