11 Commits

Author SHA1 Message Date
user
551225d308 fix: Remove unused app_size variable (cppcheck)
All checks were successful
Lint & Build / C/C++ Static Analysis (push) Successful in 29s
Lint & Build / Security Flaw Analysis (push) Successful in 22s
Lint & Build / Secret Scanning (push) Successful in 8s
Lint & Build / Shell Script Analysis (push) Successful in 10s
Lint & Build / Build Firmware (push) Successful in 2m21s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m20s
2026-02-05 22:08:30 +01:00
user
7f2e3f6dad ci: Add ccache for faster builds + parallel OTA deployment
Some checks failed
Lint & Build / C/C++ Static Analysis (push) Failing after 32s
Lint & Build / Security Flaw Analysis (push) Successful in 21s
Lint & Build / Secret Scanning (push) Successful in 8s
Lint & Build / Shell Script Analysis (push) Successful in 10s
Lint & Build / Build Firmware (push) Successful in 2m7s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m0s
Build improvements:
- Enable ccache via IDF_CCACHE_ENABLE=1
- Mount /var/cache/ccache volume for persistent cache
- Show ccache stats after build

Deployment improvements:
- Deploy to all sensors in parallel (max 3)
- Reduced total deploy time from ~2.5min to ~1min

Note: Runner needs /var/cache/ccache directory with write permissions
2026-02-05 22:02:29 +01:00
user
a85a2d776b ci: Use host network for deploy container
Some checks failed
Lint & Build / C/C++ Static Analysis (push) Failing after 34s
Lint & Build / Security Flaw Analysis (push) Successful in 21s
Lint & Build / Secret Scanning (push) Successful in 7s
Lint & Build / Shell Script Analysis (push) Successful in 10s
Lint & Build / Build Firmware (push) Successful in 1m56s
Lint & Build / Deploy to ESP Fleet (push) Successful in 4m18s
2026-02-05 21:55:21 +01:00
user
6dbab23329 ci: Serve firmware from runner for OTA deployment
Some checks failed
Lint & Build / C/C++ Static Analysis (push) Failing after 37s
Lint & Build / Security Flaw Analysis (push) Successful in 21s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 6s
Lint & Build / Build Firmware (push) Successful in 2m12s
Lint & Build / Deploy to ESP Fleet (push) Successful in 4m19s
Instead of having ESP devices download from Gitea (TLS cert issues),
the runner now serves firmware via local HTTP server and triggers
OTA with the local URL.
2026-02-05 21:48:53 +01:00
user
4b3697c8e6 feat: Add NVS and partition info to STATUS response
Some checks failed
Lint & Build / C/C++ Static Analysis (push) Failing after 38s
Lint & Build / Security Flaw Analysis (push) Successful in 20s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 7s
Lint & Build / Build Firmware (push) Successful in 2m12s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m31s
- nvs_used: NVS entries in use
- nvs_free: free NVS entries
- nvs_total: total NVS entries
- part_size: running partition size in bytes
2026-02-05 21:33:20 +01:00
user
f87ddec742 docs: Add v2.0 Flask API backend roadmap
Plan for REST API backend (purple team/OPSEC/OSINT):
- Phase 1: Project scaffold, SQLAlchemy models, podman
- Phase 2: UDP collector for sensor data streams
- Phase 3: Core API endpoints (sensors, devices, alerts, probes, events)
- Phase 4: OSINT features (OUI lookup, BLE company ID mapping)
- Phase 5: Fleet management API

Port allocation:
- TCP 5500: HTTP REST API
- UDP 5500: Sensor data collector
- UDP 5501: Sensor commands (outbound)

Also documents completed v1.8 (HTTPS OTA) and v1.9 (multi-channel
scanning, BLE fingerprinting) milestones.
2026-02-05 20:41:24 +01:00
user
d58b6dd814 feat: v1.9 — multi-channel scanning, BLE fingerprinting
Multi-channel scanning (CHANSCAN command):
- Periodic channel hopping (1-13) with 100ms dwell for broader probe capture
- CHANSCAN ON/OFF/NOW/INTERVAL subcommands
- New NVS keys: chanscan (i8), chanscan_int (i32)
- Emits EVENT,hostname,chanscan=done channels=13 on completion
- PROBE_DATA now includes channel number

BLE fingerprinting:
- Extended BLE_DATA format with company_id, tx_power, flags
- Extracts manufacturer data from BLE advertisements
- Common IDs: 0x004C (Apple), 0x00E0 (Google), 0x0075 (Samsung)

STATUS output now includes chanscan=on/off field.
2026-02-05 17:38:08 +01:00
user
9234ff00de feat: Support HTTPS URLs for OTA updates
All checks were successful
Lint & Build / C/C++ Static Analysis (push) Successful in 35s
Lint & Build / Security Flaw Analysis (push) Successful in 19s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 7s
Lint & Build / Build Firmware (push) Successful in 2m18s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m32s
2026-02-05 13:57:08 +01:00
user
a1074319f2 ci: Upload firmware to Gitea releases for OTA
All checks were successful
Lint & Build / C/C++ Static Analysis (push) Successful in 35s
Lint & Build / Security Flaw Analysis (push) Successful in 19s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 7s
Lint & Build / Build Firmware (push) Successful in 2m17s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m29s
2026-02-05 13:48:08 +01:00
user
9ece83bac0 ci: Simplify deploy script to pure POSIX sh
All checks were successful
Lint & Build / C/C++ Static Analysis (push) Successful in 35s
Lint & Build / Security Flaw Analysis (push) Successful in 18s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 7s
Lint & Build / Build Firmware (push) Successful in 2m17s
Lint & Build / Deploy to ESP Fleet (push) Successful in 3m28s
2026-02-05 13:28:28 +01:00
user
cfa22e9e2a ci: Fix deploy script for POSIX sh, use explicit bash
Some checks failed
Lint & Build / C/C++ Static Analysis (push) Successful in 35s
Lint & Build / Security Flaw Analysis (push) Successful in 19s
Lint & Build / Secret Scanning (push) Successful in 5s
Lint & Build / Shell Script Analysis (push) Successful in 7s
Lint & Build / Build Firmware (push) Successful in 2m18s
Lint & Build / Deploy to ESP Fleet (push) Failing after 1m51s
2026-02-05 13:21:38 +01:00
6 changed files with 514 additions and 77 deletions

View File

@@ -23,18 +23,32 @@ jobs:
runs-on: anvil
container:
image: docker.io/espressif/idf:v5.3
volumes:
- /var/cache/ccache:/ccache
env:
CCACHE_DIR: /ccache
IDF_CCACHE_ENABLE: 1
steps:
- name: Checkout
run: |
git clone --depth=1 --branch=${{ github.ref_name }} \
https://oauth2:${{ github.token }}@git.mymx.me/${{ github.repository }}.git .
- name: Setup ccache
run: |
apt-get update && apt-get install -y --no-install-recommends ccache
ccache --zero-stats
ccache --show-config | grep -E "(cache_dir|max_size)"
- name: Build firmware
run: |
. /opt/esp/idf/export.sh
cd get-started/csi_recv_router
idf.py build
- name: Show ccache stats
run: ccache --show-stats
- name: Show binary size
run: |
ls -lh get-started/csi_recv_router/build/*.bin
@@ -57,10 +71,15 @@ jobs:
container:
image: docker.io/espressif/idf:v5.3
options: --network host
volumes:
- /var/cache/ccache:/ccache
env:
CCACHE_DIR: /ccache
IDF_CCACHE_ENABLE: 1
steps:
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends netcat-openbsd avahi-utils
apt-get update && apt-get install -y --no-install-recommends netcat-openbsd curl jq ccache
- name: Checkout
run: |
@@ -72,44 +91,95 @@ jobs:
. /opt/esp/idf/export.sh
cd get-started/csi_recv_router
idf.py build
ccache --show-stats | head -5
- name: Create release and upload firmware
env:
GITEA_TOKEN: ${{ github.token }}
run: |
TAG="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_URL="https://git.mymx.me/api/v1"
echo "Creating release for tag: $TAG"
# Check if release exists
RELEASE=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$API_URL/repos/$REPO/releases/tags/$TAG")
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
# Create new release
RELEASE=$(curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release from CI\"}" \
"$API_URL/repos/$REPO/releases")
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id')
echo "Created release ID: $RELEASE_ID"
else
echo "Release exists with ID: $RELEASE_ID"
fi
# Upload firmware binary
echo "Uploading firmware..."
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@get-started/csi_recv_router/build/csi_recv_router.bin" \
"$API_URL/repos/$REPO/releases/$RELEASE_ID/assets?name=csi_recv_router.bin"
- name: Deploy via OTA
run: |
# Sensor IPs (update if DHCP changes)
declare -A SENSORS
SENSORS["muddy-storm"]="192.168.129.29"
SENSORS["amber-maple"]="192.168.129.30"
SENSORS["hollow-acorn"]="192.168.129.31"
PORT=8070
SENSORS="muddy-storm:192.168.129.29 amber-maple:192.168.129.30 hollow-acorn:192.168.129.31"
OTA_PORT=8899
MAX_PARALLEL=3
# Get host IP - try multiple methods
HOST_IP=$(ip route get 1 2>/dev/null | awk '{print $7; exit}')
if [ -z "$HOST_IP" ]; then
HOST_IP=$(hostname -I | awk '{print $1}')
fi
echo "Host IP: $HOST_IP"
# Get runner IP (first non-loopback interface)
RUNNER_IP=$(hostname -I | awk '{print $1}')
echo "Runner IP: $RUNNER_IP"
# Start HTTP server in background
# Start HTTP server to serve firmware
cd get-started/csi_recv_router/build
python3 -m http.server $PORT --bind 0.0.0.0 &
python3 -m http.server $OTA_PORT &
HTTP_PID=$!
sleep 2
# Deploy to each sensor
for sensor in "${!SENSORS[@]}"; do
SENSOR_IP="${SENSORS[$sensor]}"
echo "=== Deploying to $sensor ($SENSOR_IP) ==="
FIRMWARE_URL="http://${RUNNER_IP}:${OTA_PORT}/csi_recv_router.bin"
echo "Firmware URL: $FIRMWARE_URL"
# Send OTA command via UDP
echo "OTA http://${HOST_IP}:${PORT}/csi_recv_router.bin" | nc -u -w 2 $SENSOR_IP 5501
echo "OTA command sent to $sensor"
# Verify server is running
curl -sI "http://localhost:${OTA_PORT}/csi_recv_router.bin" | head -1
# Wait for device to download and reboot
sleep 30
# Deploy function
deploy_sensor() {
NAME="$1"
IP="$2"
URL="$3"
echo "=== Starting OTA on $NAME ($IP) ==="
RESPONSE=$(echo "OTA $URL" | nc -u -w 2 "$IP" 5501 2>/dev/null || echo "no response")
echo "$NAME: $RESPONSE"
}
# Deploy to all sensors in parallel (max $MAX_PARALLEL)
echo "=== Deploying to all sensors in parallel ==="
PIDS=""
for entry in $SENSORS; do
NAME="${entry%%:*}"
IP="${entry##*:}"
deploy_sensor "$NAME" "$IP" "$FIRMWARE_URL" &
PIDS="$PIDS $!"
done
# Cleanup
# Wait for all OTA commands to be sent
for PID in $PIDS; do
wait $PID 2>/dev/null || true
done
echo "=== OTA commands sent, waiting for devices to update (60s) ==="
sleep 60
# Stop HTTP server
kill $HTTP_PID 2>/dev/null || true
echo "=== Deployment complete ==="
cppcheck:

View File

@@ -1,47 +1,97 @@
# ESP32 Hacking Project
## Overview
Firmware customization and experimentation for ESP32 CSI (Channel State Information) sensors used in the [wifi-sensing](~/git/wifi-sensing/) project.
Firmware and tooling for ESP32 CSI (Channel State Information) sensors used for passive wireless sensing, device tracking, and security monitoring (OPSEC/OSINT/Purple team).
## Goals
- Document current firmware and configuration
- Add remote management commands (reboot, identify, status)
- Implement OTA firmware updates
- Explore BLE scanning for complementary presence detection
- Optimize CSI data pipeline (adaptive rate, on-device processing)
## Current State
- 3x ESP32-DevKitC V1 deployed with stock `csi_recv_router` firmware
- Firmware sends raw CSI data via UDP at ~100 pkt/s per device
- No remote management capability (must physically access USB)
- Custom ESP32 firmware with remote management, OTA updates, adaptive sampling
- BLE scanning and WiFi probe/deauth detection for device intelligence
- CSI-based presence detection without cameras
- Flask REST API backend for data aggregation and OSINT queries
- Fleet management for multiple sensors
## Components
| Component | Location | Description |
|-----------|----------|-------------|
| Firmware | `get-started/csi_recv_router/` | ESP32 sensor firmware (C, ESP-IDF) |
| CLI Tools | `~/git/esp-tools/` | `esp-ctl`, `esp-fleet`, `esp-ota` |
| Flask API | `~/git/esp32-web/` | REST API backend (Python, Flask) |
## Current State (v1.9)
- 3x ESP32-DevKitC V1 deployed with custom firmware
- UDP data streams: CSI_DATA, BLE_DATA, PROBE_DATA, ALERT_DATA, EVENT
- Remote management via UDP commands (port 5501)
- OTA firmware updates (HTTP/HTTPS)
- Presence detection via CSI baseline calibration
- Multi-channel scanning for broader WiFi coverage
- BLE fingerprinting (company_id, tx_power, flags)
## Hardware
| Device | Chip | IP Address | Location |
|--------|------|------------|----------|
| muddy-storm | ESP32-WROOM-32 | 192.168.129.29 | Living Room |
| amber-maple | ESP32-WROOM-32 | 192.168.129.30 | Office |
| hollow-acorn | ESP32-WROOM-32 | 192.168.129.31 | Kitchen |
## Network Ports
| Port | Protocol | Direction | Description |
|------|----------|-----------|-------------|
| 5500 | UDP | Sensors → Flask | Sensor data streams (CSI, BLE, probes, alerts, events) |
| 5500 | TCP | Clients → Flask | REST API (HTTP) |
| 5501 | UDP | Flask → Sensors | Sensor commands |
## Data Streams (UDP 5500)
| Stream | Format | Use Case |
|--------|--------|----------|
| CSI_DATA | hostname,count,mac,rssi,... | Motion/presence detection |
| BLE_DATA | hostname,mac,rssi,type,name,company_id,tx_power,flags | Device fingerprinting |
| PROBE_DATA | hostname,mac,rssi,ssid,channel | Device tracking, SSID enumeration |
| ALERT_DATA | hostname,type,source,target,rssi | Wireless IDS (deauth detection) |
| EVENT | hostname,key=value pairs | Motion, presence, calibration events |
## Software Stack
| Component | Version | Purpose |
|-----------|---------|---------|
| ESP-IDF | v5.5.2 | Development framework (`~/esp/esp-idf/`) |
| esp-csi | latest | CSI extraction library |
| FreeRTOS | (bundled) | RTOS kernel |
| ESP-IDF | v5.5.2 | Firmware development framework |
| Flask | 3.x | REST API backend |
| SQLAlchemy | 2.x | Database ORM |
| SQLite/PostgreSQL | - | Data storage |
| podman | - | Container runtime |
## Key Paths
| Path | Description |
|------|-------------|
| `~/git/esp32-hacking/` | This project (firmware sources, docs) |
| `~/git/wifi-sensing/` | Server application (receives CSI data) |
| `~/git/esp32-web/` | Flask API backend (planned) |
| `~/git/esp-tools/` | CLI tools (esp-ctl, esp-fleet, esp-ota) |
| `~/esp/esp-idf/` | ESP-IDF toolchain |
| `~/esp/esp-csi/` | Original esp-csi repo (build from here) |
## Dependencies
- ESP-IDF toolchain (`~/esp/esp-idf/`)
- USB cable for initial flash (OTA planned)
## API Endpoints (Planned)
Base URL: `http://<host>:5500/api/v1`
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/sensors` | List sensors with status |
| GET | `/devices` | List discovered devices |
| GET | `/alerts` | Alert feed with filters |
| GET | `/probes` | Probe requests |
| GET | `/events` | Sensor events |
| POST | `/sensors/<id>/command` | Send command to sensor |
| GET | `/stats` | Aggregate statistics |
## References
- [ESP-IDF Docs](https://docs.espressif.com/projects/esp-idf/en/latest/)
- [ESP-CSI GitHub](https://github.com/espressif/esp-csi)
- [ESP32 WiFi CSI API](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html)
- [IEEE OUI Database](https://standards-oui.ieee.org/)
- [Bluetooth Company Identifiers](https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/)

View File

@@ -131,9 +131,77 @@ Note: Promiscuous mode (probe/deauth capture) disabled on original ESP32 — bre
- [x] presence= and pr_score= fields in STATUS reply
- [x] NVS persistence for baseline (bl_amps blob, bl_nsub) and presence config
- [ ] Tune presence threshold per room with real-world testing
- [ ] Pi-side presence event handling in watch daemon
## v2.0 - Hardware Upgrade (ESP32-S3/C6)
## v1.8 - HTTPS OTA Support [DONE]
- [x] Support HTTPS URLs for OTA updates (esp_https_ota)
- [x] CI: Upload firmware to Gitea releases for OTA
- [x] CI: Simplify deploy script to pure POSIX sh
## v1.9 - Multi-Channel Scanning & BLE Fingerprinting [DONE]
- [x] CHANSCAN command (ON/OFF/NOW/INTERVAL)
- [x] Channel hopping (1-13) with 100ms dwell, pause CSI during scan
- [x] chanscan= field in STATUS, chanscan_int NVS persistence
- [x] BLE fingerprinting: company_id, tx_power, adv_flags in BLE_DATA
- [x] Historical presence sessions support
## v2.0 - Flask API Backend (Purple Team)
REST API backend for OPSEC/OSINT/Purple team operations using ESP32 sensor fleet.
API-first design; frontend dashboard deferred to v2.1+.
- **HTTP API:** TCP 5500
- **UDP Collector:** UDP 5500 (sensor data)
- **Sensor Commands:** UDP 5501 (outbound)
### Phase 1: Project Setup
- [ ] Project scaffold (`~/git/esp32-web/`) with Flask + SQLAlchemy + Blueprints
- [ ] Database schema: sensors, devices, sightings, alerts, events, probes
- [ ] Containerfile for podman deployment
- [ ] Makefile (build, run, dev, stop, logs)
- [ ] pytest setup with fixtures
### Phase 2: UDP Collector
- [ ] Async UDP listener daemon (threading or asyncio)
- [ ] Parse all sensor streams: CSI_DATA, BLE_DATA, PROBE_DATA, ALERT_DATA, EVENT
- [ ] Store to database with timestamps
- [ ] Sensor heartbeat tracking (online/offline status)
- [ ] Run as background thread alongside Flask
### Phase 3: Core API Endpoints
- [ ] `GET /api/v1/sensors` — list sensors with status, uptime, last_seen
- [ ] `GET /api/v1/sensors/<id>/status` — detailed sensor info
- [ ] `POST /api/v1/sensors/<id>/command` — send UDP command (proxy)
- [ ] `GET /api/v1/devices` — list all discovered devices (BLE + WiFi)
- [ ] `GET /api/v1/devices/<mac>` — device profile (sightings, zones, vendor)
- [ ] `GET /api/v1/alerts` — alert feed with pagination + filters
- [ ] `GET /api/v1/probes` — probe requests with SSID enumeration
- [ ] `GET /api/v1/events` — sensor events (motion, presence, calibration)
### Phase 4: OSINT Features
- [ ] MAC vendor lookup (IEEE OUI database)
- [ ] BLE company_id to manufacturer mapping
- [ ] `GET /api/v1/devices/<mac>/profile` — enriched device intel
- [ ] `GET /api/v1/stats` — aggregate statistics (device counts, alert counts)
- [ ] Export endpoints: `GET /api/v1/export/devices.csv`, `.json`
### Phase 5: Fleet Management API
- [ ] `GET /api/v1/sensors/<id>/config` — current sensor configuration
- [ ] `PUT /api/v1/sensors/<id>/config` — update sensor settings
- [ ] `POST /api/v1/sensors/<id>/ota` — trigger OTA update
- [ ] `POST /api/v1/sensors/<id>/calibrate` — trigger baseline calibration
- [ ] `GET /api/v1/sensors/<id>/history` — historical metrics
## v2.1 - Web Dashboard (Future)
Frontend dashboard using htmx + Pico CSS, built on top of v2.0 API.
- [ ] Live sensor status dashboard
- [ ] Device inventory table with search/filter
- [ ] Alert timeline with severity badges
- [ ] Presence heatmap per zone
- [ ] Sensor fleet management UI
## v3.0 - Hardware Upgrade (ESP32-S3/C6)
Requires replacing current ESP32 (original) DevKitC V1 boards with ESP32-S3
or ESP32-C6 modules. The original ESP32 lacks FTM and has CSI/promiscuous
@@ -152,10 +220,11 @@ mode conflicts.
## Future
- AP+STA config portal (WIFI_MODE_APSTA, captive portal for initial setup)
- ESP-NOW mesh (ESP32-to-ESP32 CSI)
- Multi-channel scanning (hop across WiFi channels)
- BLE device fingerprinting (identify phone models by advertisement patterns)
- Historical presence logging (who was here, when, how long)
- External sensor support (PIR, temp/humidity via GPIO)
- Pin mapping for ESP32-DevKitC V1
- Compare CSI quality: passive (router) vs active (ESP-NOW)
- Multi-sensor deployment guide (placement, zones, triangulation)
- Home Assistant integration (MQTT discovery, entity creation)
- Grafana dashboards for long-term analytics
- ML-based device classification (phone vs laptop vs IoT)
- RSSI triangulation with 3+ sensors (approximate device location)

View File

@@ -2,15 +2,41 @@
**Last Updated:** 2026-02-05
## Current Sprint: v1.7+ — Presence Tuning & Integration
## Current Sprint: v2.0 — Flask API Backend
### P2 - Normal
### P0 - Critical (Phase 1: Project Setup)
- [ ] Create project scaffold `~/git/esp32-web/`
- [ ] Flask app factory pattern with Blueprints
- [ ] HTTP API on TCP 5500, UDP collector on UDP 5500
- [ ] SQLAlchemy models: Sensor, Device, Sighting, Alert, Event, Probe
- [ ] Containerfile for podman
- [ ] Makefile (build, run, dev, stop, logs)
- [ ] Basic pytest setup
### P1 - High (Phase 2: UDP Collector)
- [ ] UDP listener thread (parse CSI_DATA, BLE_DATA, PROBE_DATA, ALERT_DATA, EVENT)
- [ ] Store parsed data to SQLite/PostgreSQL
- [ ] Sensor heartbeat tracking (mark online/offline)
- [ ] Integrate collector with Flask app lifecycle
### P1 - High (Phase 3: Core API)
- [ ] `GET /api/v1/sensors` — list sensors
- [ ] `GET /api/v1/devices` — list devices (BLE + WiFi MACs)
- [ ] `GET /api/v1/alerts` — alert feed with pagination
- [ ] `GET /api/v1/probes` — probe requests
- [ ] `GET /api/v1/events` — sensor events
- [ ] `POST /api/v1/sensors/<id>/command` — send command to sensor
### P2 - Normal (Phase 4: OSINT)
- [ ] MAC vendor lookup (OUI database)
- [ ] BLE company_id mapping
- [ ] `GET /api/v1/stats` — aggregate statistics
- [ ] Export endpoints (CSV, JSON)
### P2 - Normal (Backlog from v1.x)
- [ ] Tune presence threshold per room with real-world testing
- [x] Pi-side presence event handling in watch daemon
- [x] Firmware security audit (cppcheck + manual review — no issues found)
- [ ] Power consumption measurements using POWERTEST + external meter
- [ ] Test OTA rollback (flash bad firmware, verify auto-revert)
- [ ] Create HA webhook automations for deauth_flood / unknown_probe
### P3 - Low
- [ ] Deep sleep mode with wake-on-CSI-motion
@@ -19,6 +45,20 @@
- [ ] Document esp-radar console features
- [ ] Pin mapping for ESP32-DevKitC V1
## Completed: v1.9 - Multi-Channel Scanning & BLE Fingerprinting
- [x] CHANSCAN command (ON/OFF/NOW/INTERVAL)
- [x] Channel hopping (1-13) with 100ms dwell, pause CSI during scan
- [x] chanscan= field in STATUS, chanscan_int NVS persistence
- [x] BLE fingerprinting: company_id, tx_power, adv_flags in BLE_DATA
- [x] Historical presence sessions support
## Completed: v1.8 - HTTPS OTA Support
- [x] Support HTTPS URLs for OTA updates (esp_https_ota)
- [x] CI: Upload firmware to Gitea releases for OTA
- [x] CI: Simplify deploy script to pure POSIX sh
## Completed: v1.7 - Baseline Calibration & Presence Detection
- [x] CALIBRATE command (capture N seconds of CSI, average per-subcarrier amplitudes)
@@ -172,7 +212,7 @@
## Notes
- Adaptive threshold varies by environment; 0.001-0.01 is a good starting range
- NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count`, `csi_mode`, `hybrid_n`, `auth_secret`, `flood_thresh`, `flood_window`, `scan_rate`, `probe_rate`, `powersave`, `presence`, `pr_thresh`, `bl_nsub`, `bl_amps`
- NVS keys (24 total): `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count`, `csi_mode`, `hybrid_n`, `auth_secret`, `flood_thresh`, `flood_window`, `scan_rate`, `probe_rate`, `powersave`, `presence`, `pr_thresh`, `bl_nsub`, `bl_amps`, `chanscan`, `chanscan_int`
- EVENT packets include sensor hostname: `EVENT,<hostname>,motion=... rate=... wander=...`
- ALERT_DATA format: `ALERT_DATA,<hostname>,<deauth|disassoc>,<sender_mac>,<target_mac>,<rssi>` or `ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>`
- STATUS fields: `uptime=`, `uptime_s=`, `heap=`, `rssi=`, `channel=`, `tx_power=`, `rate=`, `csi_rate=`, `hostname=`, `version=`, `adaptive=`, `motion=`, `ble=`, `target=`, `temp=`, `csi_count=`, `boots=`, `rssi_min=`, `rssi_max=`, `csi_mode=`, `hybrid_n=`, `auth=`, `flood_thresh=`, `powersave=`, `presence=`, `pr_score=`

65
TODO.md
View File

@@ -1,28 +1,79 @@
# ESP32 Hacking TODO
## Flask API (`~/git/esp32-web/`)
### Architecture
- [ ] App factory pattern (`create_app()`)
- [ ] Blueprints: `api`, `collector`
- [ ] SQLAlchemy with migrations (Flask-Migrate)
- [ ] Background UDP collector (threading or Celery)
- [ ] Config from environment variables
- [ ] Port 5500: HTTP API (TCP) + UDP collector (UDP) on same port number
### Database Schema
- [ ] `sensors` — id, hostname, ip, last_seen, status, config_json
- [ ] `devices` — mac, type (ble/wifi), vendor, first_seen, last_seen
- [ ] `sightings` — device_id, sensor_id, rssi, timestamp
- [ ] `alerts` — sensor_id, type, source_mac, target_mac, rssi, timestamp
- [ ] `probes` — device_id, sensor_id, ssid, rssi, channel, timestamp
- [ ] `events` — sensor_id, event_type, payload_json, timestamp
### API Endpoints
- [ ] Sensors: list, detail, status, command, config, history
- [ ] Devices: list, detail, profile, sightings
- [ ] Alerts: list with filters (type, sensor, time range)
- [ ] Probes: list, group by SSID, group by MAC
- [ ] Events: list with filters
- [ ] Stats: counts, activity graphs data
- [ ] Export: CSV, JSON for devices/alerts/probes
### UDP Collector
- [ ] Parse CSI_DATA (hostname, count, mac, rssi, features)
- [ ] Parse BLE_DATA (hostname, mac, rssi, type, name, company_id, tx_power, flags)
- [ ] Parse PROBE_DATA (hostname, mac, rssi, ssid, channel)
- [ ] Parse ALERT_DATA (hostname, type, source, target, rssi OR flood count)
- [ ] Parse EVENT (hostname, key=value pairs)
- [ ] Heartbeat timeout detection (mark sensor offline)
### OSINT
- [ ] IEEE OUI database (download + parse)
- [ ] BLE company ID database (Bluetooth SIG)
- [ ] Device fingerprinting by BLE advertisement patterns
- [ ] Probe request SSID profiling (home networks, corporate, etc.)
## Firmware
- [ ] On-device CSI processing (send metrics, not raw)
- [ ] Deep sleep mode with wake-on-CSI-motion
- [ ] Battery-optimized duty cycling
- [ ] AP+STA config portal (captive portal for initial setup)
## Tools (esp-ctl)
- [ ] Multi-sensor BLE correlation (zone tracking by source sensor)
- [ ] Migrate OSINT database to Flask API (esp-ctl becomes thin client)
- [ ] `esp-ctl api` subcommand (query Flask API)
## Testing
- [ ] Benchmark: CSI callback latency
- [ ] Benchmark: UDP throughput at different rates
- [ ] Power consumption measurements (per-mode: idle, CSI, BLE, probe)
- [ ] API load testing (concurrent requests)
## Documentation
- [ ] Flask API: OpenAPI/Swagger spec
- [ ] Deployment guide (podman, systemd)
- [ ] Pin mapping for ESP32-DevKitC V1
- [ ] Compare CSI quality: passive (router) vs active (ESP-NOW)
- [ ] Multi-sensor deployment guide (placement, zones, triangulation)
## Ideas
- ESP-NOW mesh for direct ESP32-to-ESP32 CSI
- External PIR sensor for CSI ground truth validation
- AP+STA config portal (captive portal for initial setup)
- Multi-channel scanning (hop across WiFi channels)
- RSSI triangulation with 3 sensors (approximate device location)
- BLE device fingerprinting (identify phone models by advertisement patterns)
- Historical presence logging (who was here, when, how long)
- RSSI triangulation with 3+ sensors (approximate device location)
- Home Assistant MQTT discovery integration
- Grafana dashboards for long-term analytics
- ML-based device classification (phone vs laptop vs IoT)
- Webhook callbacks for alerts (Slack, Discord, ntfy)
- Rate limiting and API authentication (JWT)

View File

@@ -35,6 +35,7 @@
#include "esp_heap_caps.h"
#include "esp_ota_ops.h"
#include "esp_https_ota.h"
#include "esp_partition.h"
#include "esp_http_client.h"
#include "driver/gpio.h"
#include "soc/soc_caps.h"
@@ -193,6 +194,14 @@ static int s_calib_nsub = 0;
static float s_pr_scores[PRESENCE_WINDOW];
static uint32_t s_pr_score_idx = 0;
/* Multi-channel scanning */
#define CHANSCAN_CHANNELS 13
#define CHANSCAN_DWELL_MS 100
static bool s_chanscan_enabled = false;
static int s_chanscan_interval_s = 300; /* 5 min default */
static volatile bool s_chanscan_active = false;
static int64_t s_chanscan_last = 0;
/* Probe dedup rate (moved before config_load_nvs for NVS access) */
#define PROBE_DEDUP_DEFAULT_US 10000000LL
static int64_t s_probe_dedup_us = PROBE_DEDUP_DEFAULT_US;
@@ -283,6 +292,14 @@ static void config_load_nvs(void)
s_baseline_nsub = (int)bl_nsub;
}
}
int8_t chanscan;
if (nvs_get_i8(h, "chanscan", &chanscan) == ESP_OK) {
s_chanscan_enabled = (chanscan != 0);
}
int32_t chanscan_int;
if (nvs_get_i32(h, "chanscan_int", &chanscan_int) == ESP_OK && chanscan_int >= 60 && chanscan_int <= 3600) {
s_chanscan_interval_s = (int)chanscan_int;
}
nvs_close(h);
ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d csi_mode=%d hybrid_n=%d powersave=%d presence=%d pr_thresh=%.4f baseline_nsub=%d",
s_hostname, s_send_frequency, s_tx_power_dbm, s_adaptive, s_motion_threshold, s_ble_enabled,
@@ -769,27 +786,50 @@ static int ble_gap_event_cb(struct ble_gap_event *event, void *arg)
disc->rssi, disc->addr.val[5], disc->addr.val[4], disc->addr.val[3],
disc->addr.val[2], disc->addr.val[1], disc->addr.val[0]);
/* Parse advertisement for device name */
/* Parse advertisement for device name, manufacturer data, TX power, flags */
struct ble_hs_adv_fields fields;
int rc = ble_hs_adv_parse_fields(&fields, disc->data, disc->length_data);
char name[32] = "";
if (rc == 0 && fields.name != NULL && fields.name_len > 0) {
int nlen = fields.name_len < (int)sizeof(name) - 1 ? fields.name_len : (int)sizeof(name) - 1;
memcpy(name, fields.name, nlen);
name[nlen] = '\0';
uint16_t company_id = 0;
int8_t tx_power = 127; /* 127 = not present */
uint8_t adv_flags = 0;
if (rc == 0) {
/* Device name */
if (fields.name != NULL && fields.name_len > 0) {
int nlen = fields.name_len < (int)sizeof(name) - 1 ? fields.name_len : (int)sizeof(name) - 1;
memcpy(name, fields.name, nlen);
name[nlen] = '\0';
}
/* Manufacturer-specific data: first 2 bytes = company ID (little-endian) */
if (fields.mfg_data != NULL && fields.mfg_data_len >= 2) {
company_id = fields.mfg_data[0] | (fields.mfg_data[1] << 8);
}
/* TX power level */
if (fields.tx_pwr_lvl_is_present) {
tx_power = fields.tx_pwr_lvl;
}
/* Advertisement flags (always present in struct, 0 if not in advert) */
adv_flags = fields.flags;
}
/* Send BLE_DATA via UDP */
char buf[160];
/* Send BLE_DATA via UDP with extended format */
char buf[192];
int len = snprintf(buf, sizeof(buf),
"BLE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%s\n",
"BLE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%s,0x%04X,%d,%u\n",
s_hostname,
disc->addr.val[5], disc->addr.val[4], disc->addr.val[3],
disc->addr.val[2], disc->addr.val[1], disc->addr.val[0],
disc->rssi,
disc->addr.type == BLE_ADDR_PUBLIC ? "pub" : "rnd",
name);
name,
company_id,
(int)tx_power,
(unsigned)adv_flags);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, buf, len, 0,
@@ -847,6 +887,9 @@ static void ble_host_task(void *param)
nimble_port_freertos_deinit();
}
/* --- Forward declarations --- */
static void channel_scan_run(void);
/* --- Adaptive sampling --- */
static void adaptive_task(void *arg)
@@ -908,6 +951,15 @@ static void adaptive_task(void *arg)
}
}
/* Periodic channel scanning */
if (s_chanscan_enabled && !s_chanscan_active) {
int64_t now = esp_timer_get_time();
int64_t interval_us = (int64_t)s_chanscan_interval_s * 1000000LL;
if (s_chanscan_last == 0 || (now - s_chanscan_last) >= interval_us) {
channel_scan_run();
}
}
if (!s_adaptive || s_energy_idx < WANDER_WINDOW) continue;
/* Compute mean */
@@ -966,6 +1018,56 @@ static void adaptive_task(void *arg)
}
}
/* --- Channel scanning --- */
static void channel_scan_run(void)
{
if (s_chanscan_active) return;
s_chanscan_active = true;
/* Get current AP info for return */
wifi_ap_record_t ap;
uint8_t home_channel = 1;
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
home_channel = ap.primary;
}
ESP_LOGI(TAG, "CHANSCAN: starting scan, home channel=%d", home_channel);
/* Stop ping to pause CSI during scan */
if (s_ping_handle) {
esp_ping_stop(s_ping_handle);
esp_ping_delete_session(s_ping_handle);
s_ping_handle = NULL;
}
/* Hop through channels 1-13 */
for (int ch = 1; ch <= CHANSCAN_CHANNELS; ch++) {
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
vTaskDelay(pdMS_TO_TICKS(CHANSCAN_DWELL_MS));
}
/* Return to AP channel */
esp_wifi_set_channel(home_channel, WIFI_SECOND_CHAN_NONE);
/* Restart ping */
wifi_ping_router_start();
s_chanscan_last = esp_timer_get_time();
s_chanscan_active = false;
/* Emit completion event */
char event[128];
int len = snprintf(event, sizeof(event),
"EVENT,%s,chanscan=done channels=%d",
s_hostname, CHANSCAN_CHANNELS);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, event, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
}
ESP_LOGI(TAG, "CHANSCAN: complete, returned to channel %d", home_channel);
}
/* --- OTA --- */
static void ota_task(void *arg)
@@ -1155,12 +1257,13 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
char probe[192];
int len = snprintf(probe, sizeof(probe),
"PROBE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s\n",
"PROBE_DATA,%s,%02x:%02x:%02x:%02x:%02x:%02x,%d,%s,%d\n",
s_hostname,
hdr->addr2[0], hdr->addr2[1], hdr->addr2[2],
hdr->addr2[3], hdr->addr2[4], hdr->addr2[5],
pkt->rx_ctrl.rssi,
ssid);
ssid,
pkt->rx_ctrl.channel);
if (s_udp_socket >= 0) {
sendto(s_udp_socket, probe, len, 0,
@@ -1422,12 +1525,21 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
const char *csi_mode_str = (s_csi_mode == CSI_MODE_COMPACT) ? "compact" :
(s_csi_mode == CSI_MODE_HYBRID) ? "hybrid" : "raw";
/* NVS stats */
nvs_stats_t nvs_stats = {0};
nvs_get_stats("nvs", &nvs_stats);
/* Partition info */
const esp_partition_t *running = esp_ota_get_running_partition();
uint32_t part_size = running ? running->size : 0;
snprintf(reply, reply_size,
"OK STATUS uptime=%s uptime_s=%lld heap=%lu rssi=%d channel=%d tx_power=%d rate=%d csi_rate=%d"
" hostname=%s version=%s adaptive=%s motion=%d ble=%s target=%s:%d"
" temp=%.1f csi_count=%lu boots=%lu rssi_min=%d rssi_max=%d"
" csi_mode=%s hybrid_n=%d auth=%s flood_thresh=%d/%d powersave=%s"
" presence=%s pr_score=%.4f",
" presence=%s pr_score=%.4f chanscan=%s"
" nvs_used=%lu nvs_free=%lu nvs_total=%lu part_size=%lu",
uptime_str, (long long)up, (unsigned long)heap, rssi, channel, (int)s_tx_power_dbm,
s_send_frequency, actual_rate,
s_hostname, app_desc->version,
@@ -1439,7 +1551,12 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
s_auth_secret[0] ? "on" : "off",
s_flood_thresh, s_flood_window_s,
s_powersave ? "on" : "off",
s_presence_enabled ? "on" : "off", s_pr_last_score);
s_presence_enabled ? "on" : "off", s_pr_last_score,
s_chanscan_enabled ? "on" : "off",
(unsigned long)nvs_stats.used_entries,
(unsigned long)nvs_stats.free_entries,
(unsigned long)nvs_stats.total_entries,
(unsigned long)part_size);
return strlen(reply);
}
@@ -1750,8 +1867,8 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
/* OTA <url> */
if (strncmp(cmd, "OTA ", 4) == 0) {
const char *url = cmd + 4;
if (strncmp(url, "http://", 7) != 0) {
snprintf(reply, reply_size, "ERR OTA url must start with http://");
if (strncmp(url, "http://", 7) != 0 && strncmp(url, "https://", 8) != 0) {
snprintf(reply, reply_size, "ERR OTA url must start with http:// or https://");
return strlen(reply);
}
if (s_ota_in_progress) {
@@ -1912,6 +2029,46 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return strlen(reply);
}
/* CHANSCAN [ON|OFF|NOW|INTERVAL <60-3600>] */
if (strcmp(cmd, "CHANSCAN") == 0) {
snprintf(reply, reply_size, "OK CHANSCAN %s interval=%ds active=%s",
s_chanscan_enabled ? "on" : "off",
s_chanscan_interval_s,
s_chanscan_active ? "yes" : "no");
return strlen(reply);
}
if (strncmp(cmd, "CHANSCAN ", 9) == 0) {
const char *arg = cmd + 9;
if (strncmp(arg, "ON", 2) == 0) {
s_chanscan_enabled = true;
config_save_i8("chanscan", 1);
snprintf(reply, reply_size, "OK CHANSCAN on interval=%ds", s_chanscan_interval_s);
} else if (strncmp(arg, "OFF", 3) == 0) {
s_chanscan_enabled = false;
config_save_i8("chanscan", 0);
snprintf(reply, reply_size, "OK CHANSCAN off");
} else if (strncmp(arg, "NOW", 3) == 0) {
if (s_chanscan_active) {
snprintf(reply, reply_size, "ERR CHANSCAN already in progress");
} else {
channel_scan_run();
snprintf(reply, reply_size, "OK CHANSCAN triggered");
}
} else if (strncmp(arg, "INTERVAL ", 9) == 0) {
int val = atoi(arg + 9);
if (val < 60 || val > 3600) {
snprintf(reply, reply_size, "ERR CHANSCAN INTERVAL range 60-3600 seconds");
return strlen(reply);
}
s_chanscan_interval_s = val;
config_save_i32("chanscan_int", (int32_t)val);
snprintf(reply, reply_size, "OK CHANSCAN INTERVAL %ds (saved)", val);
} else {
snprintf(reply, reply_size, "ERR CHANSCAN ON|OFF|NOW|INTERVAL <60-3600>");
}
return strlen(reply);
}
snprintf(reply, reply_size, "ERR UNKNOWN");
return strlen(reply);
}