Privacy-first pandemic space intelligence using WiFi Channel State Information.
A bare ESP32-S3 acts as a passive radio sensor, detecting occupancy and crowd density from the way bodies disturb WiFi signal patterns. When crowding crosses a threshold, a webcam takes a single ephemeral snapshot, sends it to Claude Vision for spatial-only analysis, then deletes the image. What survives is metadata: "4 people clustered near entrance at 12:15 PM."
No persistent visual data. No identity tracking. No images stored. The schema literally has nowhere to put one.
For the full project context — system architecture, hardware list,
ethics framing — see ECHOLOCATE_V2_CLAUDE.md.
For the running build log + design decisions, see
PROGRESS_LOG.md.
echolocate/
├── firmware/ ESP-IDF C source for the ESP32-S3 sensor
├── sim/ Python ESP32 simulator (TCP + HTTP, no hardware needed)
├── backend/ FastAPI server (CSI parsing, Claude integration, REST + WS)
├── tests/ pytest suite (27 tests, includes e2e fixtures)
├── frontend/dist/ Vanilla HTML/JS PWA (served by FastAPI at /app/)
├── docs/ Ethics writeup + video notes
├── ECHOLOCATE_V2_CLAUDE.md Original spec (Claude Code project context)
└── PROGRESS_LOG.md Build log + decisions
./run-demo.sh # boots sim + backend, prints URLs
# Open http://localhost:8000/app/ — that's the PWA.
# Open http://localhost:8000/app/#diagnostics — green-light system check.
# Drive crowd levels from another terminal:
curl -X POST http://127.0.0.1:8088/control \
-H 'Content-Type: application/json' -d '{"level":"high"}'
# Other modes:
./run-demo.sh --scenario surge # auto-ramp empty→high→empty
./run-demo.sh --hardware /dev/cu.usbmodem14101 http://172.20.10.5If you'd rather wire it up by hand:
python3 -m venv .venv && source .venv/bin/activate
pip install -r backend/requirements.txt
python3 -m sim.esp32_sim --no-stdin --tcp-port 3333 --http-port 8088 &
SERIAL_PORT=tcp://127.0.0.1:3333 FIRMWARE_HTTP_URL=http://127.0.0.1:8088 \
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8000Drop a .env file at the project root and the backend auto-loads it:
ANTHROPIC_API_KEY=sk-ant-...
VAPID_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
VAPID_CLAIMS_EMAIL=mailto:you@example.comThe pipeline runs in stub mode if any of these are missing — the
/app/#diagnostics page tells you exactly which ones aren't set
and what to add to fix them.
Or run the test suite to validate end-to-end:
python3 -m pytest tests/ --timeout=60
# 36 passed in ~55sThe firmware is independently testable — once flashed, the WiFi side exposes an HTTP server you can hit directly without the Python backend.
cd firmware
idf.py set-target esp32s3
idf.py menuconfig # set Echolocate Configuration → SSID/Password
idf.py build flash monitor # flashes & opens serial monitor
# Boot log prints something like:
# I (5273) echolocate: WiFi connected. IP: 172.20.10.5
# I (5273) echolocate: curl http://172.20.10.5/health
# From any laptop on the same hotspot:
curl http://172.20.10.5/health
# {"ok":true,"firmware":"echolocate-csi-1.0",...,"packets_received":1247}
curl http://172.20.10.5/stats
# {"samples":1247,"rolling_variance":12.4,"occupancy_hint":"low",...}
# Or open a browser:
open http://172.20.10.5/ # auto-refreshing HTML status page
open http://echolocate.local/ # mDNS works tooIf packets_received is rising and rssi ≥ -75 dBm, the firmware is healthy.
This is the proof that the sensor's WiFi side actually works — totally
independent of whether the backend is running. See
firmware/README.md for full troubleshooting.
Phone hotspot (2.4 GHz) ESP32-S3
↑ │
│ 100ms UDP "pings" ───────────┘
│
│ WiFi packets pass through people
│
▼
ESP32 captures CSI from each rx packet
│
├── prints CSV line to UART ──── ─── ─── ┐
└── HTTP /stats, /csi/latest, ... │
│
▼
Backend (Python) connects via SERIAL_PORT={serial,tcp,file}
│
├─ csi_detector: parse → mean amplitude → windowed variance
│ ↓
│ classify (calibrating | empty | low | moderate | high)
│ ↓
│ threshold breach → camera.capture_and_encode (ephemeral)
│ → spatial_analyzer (Claude Vision)
│ → DEL image_b64 ← privacy invariant
│ → store metadata in SQLite
│
├─ token_manager: anonymous rotating IDs (15 min), zone overlap detection
├─ push_notifier: Web Push via VAPID
└─ FastAPI: REST + WebSocket
↓
Frontend PWA (vanilla HTML/JS, served at /app/)
├─ Individual view: gauge + push enrollment + report-positive
├─ Operator view: live chart + observations + report generator
├─ Chat: ask the AI anything
└─ Privacy: what is/isn't collected
These are tested in tests/test_storage.py:
def test_schema_has_no_image_column():
"""SQLite has nowhere to store an image. Privacy enforced at the
schema level, not just at the policy level."""And in backend/main.py::_capture_and_analyze:
spatial = await spatial_analyzer.analyze_snapshot(image_b64)
# PRIVACY INVARIANT: drop the base64 immediately after the network call returns.
del image_b64The camera handle is opened, one frame is read, the handle is released — then the cv2 capture goes out of scope. The base64 string lives only as long as the Vision request takes. The image never touches disk.
- ESP32-S3 firmware (ESP-IDF, ICMP-driven CSI, WiFi HTTP test server)
- Python simulator (TCP + HTTP, no hardware needed,
/controlfor tests) - Backend (CSI parser, FastAPI, all stub-friendly when API keys missing)
-
/api/diagnostics+/api/firmware-status— green/red system health - Auto-loaded
.envfrom project root — drop the key, it works - Governance surface: AI decision log, operator accept/reject loop, public transparency page, anonymous community feedback
- Consumer ↔ Business contact-tracing: check-in, anonymous report-sick → exposure alerts, business broadcast to visitors in a time window, persistent visit log + notification inbox
- PWA — three tabs only: Home / Consumer / Business
- One-command demo launcher (
./run-demo.sh) - 59 passing tests — privacy invariants enforced in test code, not just docs (e.g. notifications never name the reporter; visit stats never leak token_ids)
Open before hackathon day:
- Flash firmware on real hardware; confirm
curl http://<ip>/healthshowsping_repliesrising - Calibrate variance thresholds against the real room (Lodge @ Sixth)
- Provision VAPID keys + ANTHROPIC_API_KEY in
.env(auto-loaded) - Set up
ngrokso the PWA is reachable over HTTPS for iOS push