Understand customer behavior without cameras or consent forms. Count queues, map foot traffic, track table turnover, measure shelf engagement -- all from WiFi signals that are already there.
| Module | File | What It Does | Event IDs | Frame Budget |
|---|---|---|---|---|
| Queue Length | ret_queue_length.rs |
Estimates queue length and wait time using Little's Law | 400-403 | ~0.5 us/frame |
| Dwell Heatmap | ret_dwell_heatmap.rs |
Tracks dwell time per spatial zone (3x3 grid) | 410-413 | ~1 us/frame |
| Customer Flow | ret_customer_flow.rs |
Directional foot traffic counting (ingress/egress) | 420-423 | ~1.5 us/frame |
| Table Turnover | ret_table_turnover.rs |
Restaurant table lifecycle tracking with turnover rate | 430-433 | ~0.3 us/frame |
| Shelf Engagement | ret_shelf_engagement.rs |
Detects and classifies customer shelf interaction | 440-443 | ~1 us/frame |
All modules target the ESP32-S3 running WASM3 (ADR-040 Tier 3). They receive pre-processed CSI signals from Tier 2 DSP and emit structured events via csi_emit_event().
What it does: Estimates the number of people waiting in a queue, computes arrival and service rates, estimates wait time using Little's Law (L = lambda x W), and fires alerts when the queue exceeds a configurable threshold.
How it works: The module tracks person count changes frame-to-frame to detect arrivals (count increased or new presence with variance spike) and departures (count decreased or presence edge with low motion). Over 30-second windows, it computes arrival rate (lambda) and service rate (mu) in persons-per-minute. The queue length is smoothed via EMA on the raw person count. Wait time is estimated as queue_length / (arrival_rate / 60).
| Event ID | Name | Value | When Emitted |
|---|---|---|---|
| 400 | QUEUE_LENGTH |
Estimated queue length (0-20) | Every 20 frames (1s) |
| 401 | WAIT_TIME_ESTIMATE |
Estimated wait in seconds | Every 600 frames (30s window) |
| 402 | SERVICE_RATE |
Service rate (persons/min, smoothed) | Every 600 frames (30s window) |
| 403 | QUEUE_ALERT |
Current queue length | When queue >= 5 (once, resets below 4) |
use wifi_densepose_wasm_edge::ret_queue_length::QueueLengthEstimator;
let mut q = QueueLengthEstimator::new();
// Per-frame: presence (0/1), person count, variance, motion energy
let events = q.process_frame(presence, n_persons, variance, motion_energy);
// Queries
q.queue_length() // -> u8 (0-20, smoothed)
q.arrival_rate() // -> f32 (persons/minute, EMA-smoothed)
q.service_rate() // -> f32 (persons/minute, EMA-smoothed)| Constant | Value | Description |
|---|---|---|
REPORT_INTERVAL |
20 frames (1s) | Queue length report interval |
SERVICE_WINDOW_FRAMES |
600 frames (30s) | Window for rate computation |
QUEUE_EMA_ALPHA |
0.1 | EMA smoothing for queue length |
RATE_EMA_ALPHA |
0.05 | EMA smoothing for arrival/service rates |
JOIN_VARIANCE_THRESH |
0.05 | Variance spike threshold for join detection |
DEPART_MOTION_THRESH |
0.02 | Motion threshold for departure detection |
QUEUE_ALERT_THRESH |
5.0 | Queue length that triggers alert |
MAX_QUEUE |
20 | Maximum tracked queue length |
# React to queue events
if event_id == 400: # QUEUE_LENGTH
queue_len = int(value)
dashboard.update_queue(register_id, queue_len)
elif event_id == 401: # WAIT_TIME_ESTIMATE
wait_seconds = value
signage.show(f"Estimated wait: {int(wait_seconds / 60)} min")
elif event_id == 403: # QUEUE_ALERT
staff_pager.send(f"Register {register_id}: {int(value)} in queue")What it does: Divides the sensing area into a 3x3 grid (9 zones) and tracks how long customers spend in each zone. Identifies "hot zones" (highest dwell time) and "cold zones" (lowest dwell time). Emits session summaries when the space empties, enabling store layout optimization.
How it works: Subcarriers are divided into 9 groups, one per zone. Each zone's variance is smoothed via EMA and compared against a threshold. When variance exceeds the threshold and presence is detected, dwell time accumulates at 0.05 seconds per frame. Sessions start when someone enters and end after 100 frames (5 seconds) of empty space.
| Event ID | Name | Value Encoding | When Emitted |
|---|---|---|---|
| 410 | DWELL_ZONE_UPDATE |
zone_id * 1000 + dwell_seconds |
Every 600 frames (30s) per occupied zone |
| 411 | HOT_ZONE |
zone_id + dwell_seconds/1000 |
Every 600 frames (30s) |
| 412 | COLD_ZONE |
zone_id + dwell_seconds/1000 |
Every 600 frames (30s) |
| 413 | SESSION_SUMMARY |
Session duration in seconds | When space empties after occupancy |
Value decoding for DWELL_ZONE_UPDATE: The zone ID is encoded in the thousands place. For example, value = 2015.5 means zone 2 with 15.5 seconds of dwell time.
use wifi_densepose_wasm_edge::ret_dwell_heatmap::DwellHeatmapTracker;
let mut t = DwellHeatmapTracker::new();
// Per-frame: presence (0/1), per-subcarrier variances, motion energy, person count
let events = t.process_frame(presence, &variances, motion_energy, n_persons);
// Queries
t.zone_dwell(zone_id) // -> f32 (seconds in current session)
t.zone_total_dwell(zone_id) // -> f32 (seconds across all sessions)
t.is_zone_occupied(zone_id) // -> bool
t.is_session_active() // -> bool| Constant | Value | Description |
|---|---|---|
NUM_ZONES |
9 | Spatial zones (3x3 grid) |
REPORT_INTERVAL |
600 frames (30s) | Heatmap update interval |
ZONE_OCCUPIED_THRESH |
0.015 | Variance threshold for zone occupancy |
ZONE_EMA_ALPHA |
0.12 | EMA smoothing for zone variance |
EMPTY_FRAMES_FOR_SUMMARY |
100 frames (5s) | Vacancy duration before session end |
MAX_EVENTS |
12 | Maximum events per frame |
The 3x3 grid maps to the physical space:
+-------+-------+-------+
| Z0 | Z1 | Z2 |
| | | |
+-------+-------+-------+
| Z3 | Z4 | Z5 |
| | | |
+-------+-------+-------+
| Z6 | Z7 | Z8 |
| | | |
+-------+-------+-------+
Near Mid Far
Subcarriers are divided evenly: with 27 subcarriers, each zone gets 3 subcarriers. Lower-index subcarriers correspond to nearer Fresnel zones.
What it does: Counts people entering and exiting through a doorway or passage using directional phase gradient analysis. Maintains cumulative ingress/egress counts and reports net occupancy (in - out, clamped to zero). Emits hourly traffic summaries.
How it works: Subcarriers are split into two groups: low-index (near entrance) and high-index (far side). A person walking through the sensing area causes an asymmetric phase velocity pattern -- the near-side group's phase changes before the far-side group for ingress, and vice versa for egress. The directional gradient (low_gradient - high_gradient) is smoothed via EMA and thresholded. Combined with motion energy and amplitude spike detection, this discriminates genuine crossings from noise.
Ingress: positive smoothed gradient (low-side phase leads)
Egress: negative smoothed gradient (high-side phase leads)
| Event ID | Name | Value | When Emitted |
|---|---|---|---|
| 420 | INGRESS |
Cumulative ingress count | On each detected entry |
| 421 | EGRESS |
Cumulative egress count | On each detected exit |
| 422 | NET_OCCUPANCY |
Current net occupancy (>= 0) | On crossing + every 100 frames |
| 423 | HOURLY_TRAFFIC |
ingress * 1000 + egress |
Every 72000 frames (1 hour) |
Decoding HOURLY_TRAFFIC: ingress = int(value / 1000), egress = int(value % 1000).
use wifi_densepose_wasm_edge::ret_customer_flow::CustomerFlowTracker;
let mut cf = CustomerFlowTracker::new();
// Per-frame: per-subcarrier phases, amplitudes, variance, motion energy
let events = cf.process_frame(&phases, &litudes, variance, motion_energy);
// Queries
cf.net_occupancy() // -> i32 (ingress - egress, clamped to 0)
cf.total_ingress() // -> u32 (cumulative entries)
cf.total_egress() // -> u32 (cumulative exits)
cf.current_gradient() // -> f32 (smoothed directional gradient)| Constant | Value | Description |
|---|---|---|
PHASE_GRADIENT_THRESH |
0.15 | Minimum gradient magnitude for crossing |
MOTION_THRESH |
0.03 | Minimum motion energy for valid crossing |
AMPLITUDE_SPIKE_THRESH |
1.5 | Amplitude change scale factor |
CROSSING_DEBOUNCE |
10 frames (0.5s) | Debounce between crossing events |
GRADIENT_EMA_ALPHA |
0.2 | EMA smoothing for gradient |
OCCUPANCY_REPORT_INTERVAL |
100 frames (5s) | Net occupancy report interval |
# Real-time occupancy counter at store entrance
if event_id == 422: # NET_OCCUPANCY
occupancy = int(value)
display.show(f"Currently in store: {occupancy}")
if occupancy >= max_capacity:
door_signal.set("WAIT")
else:
door_signal.set("ENTER")
elif event_id == 423: # HOURLY_TRAFFIC
ingress = int(value / 1000)
egress = int(value % 1000)
analytics.log_hourly(hour, ingress, egress)What it does: Tracks the full lifecycle of a restaurant table -- from guests sitting down, through eating, to departing and cleanup. Measures seating duration and computes a rolling turnover rate (turnovers per hour). Designed for one ESP32 node per table or table group.
How it works: A five-state machine processes presence, motion energy, and person count:
Empty --> Eating --> Departing --> Cooldown --> Empty
| (2s (motion (30s |
| debounce) increase) cleanup) |
| |
+----------------------------------------------+
(brief absence: stays in Eating)
The Seating state exists in the enum for completeness but transitions are handled directly (Empty -> Eating after debounce). The Departing state detects when guests show increased motion and reduced person count. Vacancy requires 5 seconds of confirmed absence to avoid false triggers from brief bathroom breaks.
| Event ID | Name | Value | When Emitted |
|---|---|---|---|
| 430 | TABLE_SEATED |
Person count at seating | After 40-frame debounce |
| 431 | TABLE_VACATED |
Seating duration in seconds | After 100-frame absence debounce |
| 432 | TABLE_AVAILABLE |
1.0 | After 30-second cleanup cooldown |
| 433 | TURNOVER_RATE |
Turnovers per hour (rolling) | Every 6000 frames (5 min) |
use wifi_densepose_wasm_edge::ret_table_turnover::TableTurnoverTracker;
let mut tt = TableTurnoverTracker::new();
// Per-frame: presence (0/1), motion energy, person count
let events = tt.process_frame(presence, motion_energy, n_persons);
// Queries
tt.state() // -> TableState (Empty|Seating|Eating|Departing|Cooldown)
tt.total_turnovers() // -> u32 (cumulative turnovers)
tt.session_duration_s() // -> f32 (current session length in seconds)
tt.turnover_rate() // -> f32 (turnovers/hour, rolling window)| State | Entry Condition | Exit Condition |
|---|---|---|
Empty |
Table is free | 40 frames (2s) of continuous presence |
Eating |
Guests confirmed seated | 100 frames (5s) of absence -> Cooldown; high motion + fewer people -> Departing |
Departing |
High motion with dropping count | 100 frames absence -> Cooldown; motion settles -> back to Eating |
Cooldown |
Table vacated, cleanup period | 600 frames (30s) -> Empty; presence during cooldown -> Eating (fast re-seat) |
| Constant | Value | Description |
|---|---|---|
SEATED_DEBOUNCE_FRAMES |
40 frames (2s) | Confirmation before marking seated |
VACATED_DEBOUNCE_FRAMES |
100 frames (5s) | Absence confirmation before vacating |
AVAILABLE_COOLDOWN_FRAMES |
600 frames (30s) | Cleanup time before marking available |
EATING_MOTION_THRESH |
0.1 | Motion below this = settled/eating |
ACTIVE_MOTION_THRESH |
0.3 | Motion above this = arriving/departing |
TURNOVER_REPORT_INTERVAL |
6000 frames (5 min) | Rate report interval |
MAX_TURNOVERS |
50 | Rolling window buffer for rate |
# Restaurant table management
if event_id == 430: # TABLE_SEATED
party_size = int(value)
kitchen.notify(f"Table {table_id}: {party_size} guests seated")
pos.start_timer(table_id)
elif event_id == 431: # TABLE_VACATED
duration_s = value
analytics.log_seating(table_id, duration_s, peak_persons)
staff.alert(f"Table {table_id}: needs bussing ({duration_s/60:.0f} min use)")
elif event_id == 432: # TABLE_AVAILABLE
hostess_display.mark_available(table_id)
elif event_id == 433: # TURNOVER_RATE
rate = value
manager_dashboard.update(table_id, turnovers_per_hour=rate)What it does: Detects when a customer stops in front of a shelf and classifies their engagement level: Browse (under 5 seconds), Consider (5-30 seconds), or Deep Engagement (over 30 seconds). Also detects reaching gestures (hand/arm movement toward the shelf). Uses the principle that a person standing still but interacting with products produces high-frequency phase perturbations with low translational motion.
How it works: The key insight is distinguishing two types of CSI phase changes:
- Translational motion (walking): Large uniform phase shifts across all subcarriers
- Localized interaction (reaching, examining): High spatial variance in frame-to-frame phase differences
The module computes the standard deviation of per-subcarrier phase differences. High std-dev with low overall motion indicates shelf interaction. A reach gesture produces a burst of high-frequency perturbation exceeding a higher threshold.
| Level | Duration | Description | Event ID |
|---|---|---|---|
| None | -- | No engagement (absent or walking) | -- |
| Browse | < 5s | Brief glance, passing interest | 440 |
| Consider | 5-30s | Examining, reading label, comparing | 441 |
| Deep Engage | > 30s | Extended interaction, decision-making | 442 |
The REACH_DETECTED event (443) fires independently whenever a sudden high-frequency phase burst is detected while the customer is standing still.
| Event ID | Name | Value | When Emitted |
|---|---|---|---|
| 440 | SHELF_BROWSE |
Engagement duration in seconds | On classification (with cooldown) |
| 441 | SHELF_CONSIDER |
Engagement duration in seconds | On level upgrade |
| 442 | SHELF_ENGAGE |
Engagement duration in seconds | On level upgrade |
| 443 | REACH_DETECTED |
Phase perturbation magnitude | Per reach burst |
use wifi_densepose_wasm_edge::ret_shelf_engagement::ShelfEngagementDetector;
let mut se = ShelfEngagementDetector::new();
// Per-frame: presence (0/1), motion energy, variance, per-subcarrier phases
let events = se.process_frame(presence, motion_energy, variance, &phases);
// Queries
se.engagement_level() // -> EngagementLevel (None|Browse|Consider|DeepEngage)
se.engagement_duration_s() // -> f32 (seconds)
se.total_browse_events() // -> u32
se.total_consider_events() // -> u32
se.total_engage_events() // -> u32
se.total_reach_events() // -> u32| Constant | Value | Description |
|---|---|---|
BROWSE_THRESH_S |
5.0s (100 frames) | Engagement time for Browse |
CONSIDER_THRESH_S |
30.0s (600 frames) | Engagement time for Consider |
STILL_MOTION_THRESH |
0.08 | Motion below this = standing still |
PHASE_PERTURBATION_THRESH |
0.04 | Phase variance for interaction |
REACH_BURST_THRESH |
0.15 | Phase burst for reach detection |
STILL_DEBOUNCE |
10 frames (0.5s) | Stillness confirmation before counting |
ENGAGEMENT_COOLDOWN |
60 frames (3s) | Cooldown between engagement events |
# Shelf performance analytics
shelf_stats = defaultdict(lambda: {"browse": 0, "consider": 0, "engage": 0, "reaches": 0})
if event_id == 440: # SHELF_BROWSE
shelf_stats[shelf_id]["browse"] += 1
elif event_id == 441: # SHELF_CONSIDER
shelf_stats[shelf_id]["consider"] += 1
elif event_id == 442: # SHELF_ENGAGE
shelf_stats[shelf_id]["engage"] += 1
duration_s = value
if duration_s > 60:
analytics.flag_decision_difficulty(shelf_id)
elif event_id == 443: # REACH_DETECTED
shelf_stats[shelf_id]["reaches"] += 1
# Conversion funnel: Browse -> Consider -> Engage
# Low consider-to-engage ratio = poor shelf placement or pricingDeploy ESP32 nodes at key locations:
- Entrance: Customer Flow module counts foot traffic and peak hours
- Checkout lanes: Queue Length module monitors wait times, triggers "open register" alerts
- Aisles: Dwell Heatmap identifies high-traffic zones for premium product placement
- Endcaps/displays: Shelf Engagement measures which displays convert attention to interaction
Entrance
(CustomerFlow)
|
+--------------+--------------+
| | |
Aisle 1 Aisle 2 Aisle 3
(DwellHeatmap) (DwellHeatmap) (DwellHeatmap)
| | |
[Shelf A] [Shelf B] [Shelf C]
(ShelfEngage) (ShelfEngage) (ShelfEngage)
| | |
+--------------+--------------+
|
Checkout Area
(QueueLength x3)
Deploy per-table ESP32 nodes plus entrance/exit nodes:
- Entrance: Customer Flow tracks customer arrivals
- Each table: Table Turnover monitors seating lifecycle
- Host stand: Queue Length estimates wait time for walk-ins
- Kitchen view: Dwell Heatmap identifies server traffic patterns
Key metrics:
- Average seating duration per table
- Turnovers per hour (efficiency)
- Peak vs. off-peak utilization
- Wait time vs. party size correlation
Multi-floor, multi-zone deployment:
- Mall entrances (4-8 nodes): Customer Flow for total foot traffic + directionality
- Food court: Table Turnover + Queue Length per restaurant
- Anchor store entrances: Customer Flow per store
- Common areas: Dwell Heatmap for seating area utilization
- Kiosks/pop-ups: Shelf Engagement for promotional display effectiveness
- Gates: Customer Flow for entry/exit counting, capacity monitoring
- Concession stands: Queue Length with staff dispatch alerts
- Seating sections: Dwell Heatmap for section utilization
- Merchandise areas: Shelf Engagement for product interest
ESP32 Nodes (per zone)
|
v UDP events (port 5005)
Sensing Server (wifi-densepose-sensing-server)
|
v REST API + WebSocket
+---+---+---+---+
| | | | |
v v v v v
POS Dashboard Staff Analytics
Pager Backend
Each event is a (event_type: i32, value: f32) pair. Multiple events per frame are packed into a single UDP packet. The sensing server deserializes and exposes them via:
GET /api/v1/sensing/latest-- latest raw eventsGET /api/v1/sensing/events?type=400-403-- filtered by event type- WebSocket
/ws/events-- real-time stream
These modules process WiFi CSI data (channel amplitude and phase), not video or personally identifiable information. No MAC addresses, device identifiers, or individual tracking data leaves the ESP32. All output is aggregate metrics: counts, durations, zone labels. This makes WiFi sensing suitable for jurisdictions with strict privacy requirements (GDPR, CCPA) where camera-based analytics would require consent forms or impact assessments.