Compare commits

..

12 Commits

Author SHA1 Message Date
ruv e9bb4faf53 feat: ADR-039 edge intelligence — on-device CSI processing pipeline
Implements a dual-core edge processing system for ESP32-S3:
- Lock-free SPSC ring buffer (Core 0 produces, Core 1 consumes)
- Tier 1: phase unwrap, Welford stats, top-K subcarrier selection, delta compression
- Tier 2: presence/motion detection, vital signs (breathing/heart rate via biquad IIR), fall detection
- Vitals UDP packet (magic 0xC5110002, 32 bytes, sent at 1 Hz)
- NVS/Kconfig configurable (edge_tier, thresholds, intervals)
- Backward compatible: tier=0 (default) is a no-op
- GitHub Actions firmware CI: build, binary size gate, credential scan, flash image verification
- Binary: 777 KB (24% free in 1 MB partition)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 19:55:35 -05:00
ruv 14902e6b4e docs: ADR-039 ESP32-S3 edge intelligence — on-device signal processing
3-tier edge pipeline: smart filtering (60-80% bandwidth reduction),
on-device vital signs (breathing, heart rate, fall detection), and
lightweight feature extraction. Maps capabilities from ADR-014, 016,
021, 027, 029, 030, 031, 037 to ESP32-S3 hardware budget.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 19:37:49 -05:00
ruv 086b0e690f docs: update README with v0.2.0-esp32 release link and provision.py path
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 19:25:43 -05:00
ruv e0fe10b3dc feat: add provision.py to repo, fix user guide paths
- Move provision.py from release-only asset into firmware/esp32-csi-node/
- Fix user guide references from scripts/provision.py to correct path
- Update release link to v0.2.0-esp32

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 19:07:32 -05:00
rUv 915943cef4 feat: ESP32 CSI MAC address filtering with NVS/Kconfig support (#101)
* feat: add MAC address filter for ESP32 CSI collection

In multi-AP environments, CSI frames from different access points get
mixed together, corrupting the sensing signal. Add transmitter MAC
filtering so only frames from a specified AP are processed.

Implementation:
- csi_collector: filter in wifi_csi_callback by comparing info->mac
  against configured MAC; log transmitter MAC in periodic debug output
- csi_collector_set_filter_mac(): runtime API to enable/disable filter
- Kconfig: CSI_FILTER_MAC option (format "AA:BB:CC:DD:EE:FF")
- NVS: "filter_mac" 6-byte blob overrides Kconfig at runtime
- nvs_config: parse Kconfig MAC string at boot, load NVS override
- main: apply filter from config after csi_collector_init()

When no filter is configured (default), behavior is unchanged —
all transmitter MACs are accepted for backward compatibility.

Fixes #98

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: add CLAUDE.local.md to .gitignore

Local machine configuration (ESP-IDF paths, COM port, build
instructions) should not be committed to the repository.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 17:08:27 -05:00
ruv 66392cb4e2 docs: ADR-038 Sublinear Goal-Oriented Action Planning (GOAP)
GOAP-based planning system for dynamically prioritizing which ADRs to
implement next based on current project state, available hardware, user
goals, and resource constraints.

Key design decisions:
- 25 boolean feature flags + 5 hardware flags + 6 quality metrics
- ~80 actions mapped to ADR implementation phases
- Sublinear search via backward relevance pruning, hierarchical tier
  decomposition, incremental replanning, and admissible heuristics
- PageRank-based priority when no specific goal is given
- Integration with claude-flow swarm for dispatching to agents

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 14:39:15 -05:00
ruv 9f1fca5513 fix: update broken dataset download links in user guide
Replace dead URLs for MM-Fi and Wi-Pose datasets with working links:
- MM-Fi: https://ntu-aiot-lab.github.io/mm-fi + GitHub repo with download links
- Wi-Pose: https://github.com/NjtechCVLab/Wi-PoseDataset with Google Drive links

Also corrects Wi-Pose source attribution (Entropy 2023, 12 subjects).

Fixes #84

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 13:58:40 -05:00
ruv 36b0d27474 docs: ADR-037 multi-person pose detection from single ESP32 CSI stream
Four-phase approach: eigenvalue-based person count estimation, NMF signal
decomposition, multi-skeleton generation with Kalman tracking, and neural
multi-person model training via RVF pipeline.

Ref: #97

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 13:49:38 -05:00
rUv 113011e704 fix: WebSocket race condition, data source indicators, auto-start pose detection (#96)
* feat: RVF training pipeline & UI integration (ADR-036)

Implement full model training, management, and inference pipeline:

Backend (Rust):
- recording.rs: CSI recording API (start/stop/list/download/delete)
- model_manager.rs: RVF model loading, LoRA profile switching, model library
- training_api.rs: Training API with WebSocket progress streaming, simulated
  training mode with realistic loss curves, auto-RVF export on completion
- main.rs: Wire new modules, recording hooks in all CSI paths, data dirs

UI (new components):
- ModelPanel.js: Dark-mode model library with load/unload, LoRA dropdown
- TrainingPanel.js: Recording controls, training config, live Canvas charts
- model.service.js: Model REST API client with events
- training.service.js: Training + recording API client with WebSocket progress

UI (enhancements):
- LiveDemoTab: Model selector, LoRA profile switcher, A/B split view toggle,
  training quick-panel with 60s recording shortcut
- SettingsPanel: Full dark mode conversion (issue #92), model configuration
  (device, threads, auto-load), training configuration (epochs, LR, patience)
- PoseDetectionCanvas: 10-frame pose trail with ghost keypoints and motion
  trajectory lines, cyan trail toggle button
- pose.service.js: Model-inference confidence thresholds

UI (plumbing):
- index.html: Training tab (8th tab)
- app.js: Panel initialization and tab routing
- style.css: ~250 lines of training/model panel dark-mode styles

191 Rust tests pass, 0 failures. Closes #92.

Refs: ADR-036, #93

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: real RuVector training pipeline + UI service fixes

Training pipeline (training_api.rs):
- Replace simulated training with real signal-based training loop
- Load actual CSI data from .csi.jsonl recordings or live frame history
- Extract 180 features per frame: subcarrier amplitudes, temporal variance,
  Goertzel frequency analysis (9 bands), motion gradients, global stats
- Train calibrated linear CSI-to-pose mapping via mini-batch gradient descent
  with L2 regularization (ridge regression), Xavier init, cosine LR decay
- Self-supervised: teacher targets from derive_pose_from_sensing() heuristics
- Real validation metrics: MSE and PCK@0.2 on 80/20 train/val split
- Export trained .rvf with real weights, feature normalization stats, witness
- Add infer_pose_from_model() for live inference from trained model
- 16 new tests covering features, training, inference, serialization

UI fixes:
- Fix double-URL bug in model.service.js and training.service.js
  (buildApiUrl was called twice — once in service, once in apiService)
- Fix route paths to match Rust backend (/api/v1/train/*, /api/v1/recording/*)
- Fix request body formats (session_name, nested config object)
- Fix top-level await in LiveDemoTab.js blocking module graph
- Dynamic imports for ModelPanel/TrainingPanel in app.js
- Center nav tabs with flex-wrap for 8-tab layout

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: WebSocket onOpen race condition, data source indicators, auto-start pose detection

- Fix WebSocket onOpen race condition in websocket.service.js where
  setupEventHandlers replaced onopen after socket was already open,
  preventing pose service from receiving connection signal
- Add 4-state data source indicator (LIVE/SIMULATED/RECONNECTING/OFFLINE)
  across Dashboard, Sensing, and Live Demo tabs via sensing.service.js
- Add hot-plug ESP32 auto-detection in sensing server (auto mode runs
  both UDP listener and simulation, switches on ESP32_TIMEOUT)
- Auto-start pose detection when backend is reachable
- Hide duplicate PoseDetectionCanvas controls when enableControls=false
- Add standalone Demo button in LiveDemoTab for offline animated demo
- Add data source banner and status styling

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-02 13:47:49 -05:00
rUv c193cd4299 Merge pull request #88 from Harshit10j2004/harshit_1001
Update the dockerfile.python 1 by disabling Python bytecode generation
2026-03-02 11:27:17 -05:00
rUv 7e8568a8e5 Merge pull request #91 from ruvnet/fix/issue-86-live-demo-real-data
fix: live demo accuracy, data source transparency, dark mode UI (issue #86)
2026-03-02 11:15:34 -05:00
harshit 8a46fff6b0 Update the dockerfile.python 1 by disabling Python bytecode generation with PYTHONDONTWRITEBYTECODE=1 to make runtime faster 2026-03-02 20:53:15 +05:30
38 changed files with 9098 additions and 107 deletions
+305
View File
@@ -0,0 +1,305 @@
name: Firmware CI/CD
on:
push:
branches: [ main, develop, 'feature/*', 'feat/*', 'hotfix/*' ]
paths:
- 'firmware/**'
- '.github/workflows/firmware-ci.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'firmware/**'
- '.github/workflows/firmware-ci.yml'
workflow_dispatch:
env:
IDF_VERSION: v5.2
IDF_TARGET: esp32s3
FIRMWARE_DIR: firmware/esp32-csi-node
BINARY_PATH: firmware/esp32-csi-node/build/esp32-csi-node.bin
# 900 KB in bytes = 921600
BINARY_SIZE_LIMIT: 921600
jobs:
# ── Build ────────────────────────────────────────────────────────────────────
build:
name: Build Firmware (ESP-IDF ${{ env.IDF_VERSION }})
runs-on: ubuntu-latest
container:
image: espressif/idf:v5.2
options: --user root
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build firmware
working-directory: ${{ env.FIRMWARE_DIR }}
shell: bash
run: |
. /opt/esp/idf/export.sh
idf.py set-target ${{ env.IDF_TARGET }}
idf.py build
- name: Capture build size summary
working-directory: ${{ env.FIRMWARE_DIR }}
shell: bash
run: |
. /opt/esp/idf/export.sh
idf.py size 2>&1 | tee build-size.txt
- name: Upload firmware artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-${{ github.sha }}
retention-days: 30
path: |
${{ env.FIRMWARE_DIR }}/build/esp32-csi-node.bin
${{ env.FIRMWARE_DIR }}/build/bootloader/bootloader.bin
${{ env.FIRMWARE_DIR }}/build/partition_table/partition-table.bin
${{ env.FIRMWARE_DIR }}/build/flasher_args.json
${{ env.FIRMWARE_DIR }}/build/flash_args
${{ env.FIRMWARE_DIR }}/build-size.txt
# ── Binary size gate ─────────────────────────────────────────────────────────
binary-size-check:
name: Binary Size Check (<= 900 KB)
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Download firmware artifacts
uses: actions/download-artifact@v4
with:
name: firmware-${{ github.sha }}
path: artifacts
- name: Check binary size
run: |
BINARY="artifacts/firmware/esp32-csi-node/build/esp32-csi-node.bin"
# Fallback: search for the binary if the path differs
if [ ! -f "$BINARY" ]; then
BINARY=$(find artifacts -name 'esp32-csi-node.bin' | head -n 1)
fi
if [ ! -f "$BINARY" ]; then
echo "ERROR: esp32-csi-node.bin not found in artifacts"
exit 1
fi
SIZE=$(stat -c%s "$BINARY")
LIMIT=${{ env.BINARY_SIZE_LIMIT }}
echo "Binary: $BINARY"
echo "Size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
echo "Limit: $LIMIT bytes ($(( LIMIT / 1024 )) KB, 90% of 1 MB partition)"
if [ "$SIZE" -gt "$LIMIT" ]; then
echo "FAIL: binary exceeds 900 KB limit by $(( SIZE - LIMIT )) bytes"
exit 1
fi
PCT=$(( SIZE * 100 / LIMIT ))
echo "PASS: binary is ${PCT}% of the 900 KB budget"
# ── Credential leak scan ─────────────────────────────────────────────────────
credential-scan:
name: Credential Leak Check
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Download firmware artifacts
uses: actions/download-artifact@v4
with:
name: firmware-${{ github.sha }}
path: artifacts
- name: Scan binary for credential patterns
run: |
BINARY=$(find artifacts -name 'esp32-csi-node.bin' | head -n 1)
if [ ! -f "$BINARY" ]; then
echo "ERROR: esp32-csi-node.bin not found in artifacts"
exit 1
fi
echo "Scanning $BINARY for credential patterns..."
# Patterns to search for (case-insensitive strings embedded in the binary)
PATTERNS=(
"password"
"passwd"
"secret"
"api_key"
"apikey"
"private_key"
"access_token"
"auth_token"
"credentials"
"BEGIN RSA PRIVATE"
"BEGIN EC PRIVATE"
"BEGIN OPENSSH PRIVATE"
"AKIA"
)
FOUND=0
for PATTERN in "${PATTERNS[@]}"; do
# Use strings to extract printable text from the binary, then grep
MATCHES=$(strings "$BINARY" | grep -i "$PATTERN" | grep -v "^nvs_config\|^csi_cfg\|override: password=\*\*\*\|NVS override" || true)
if [ -n "$MATCHES" ]; then
echo "WARNING: pattern '$PATTERN' found in binary:"
echo "$MATCHES"
FOUND=$(( FOUND + 1 ))
fi
done
if [ "$FOUND" -gt 0 ]; then
echo ""
echo "FAIL: $FOUND credential pattern(s) detected in firmware binary."
echo "Review the matches above. Legitimate log-format strings (e.g."
echo "'NVS override: password=***') are excluded automatically."
exit 1
fi
echo "PASS: no credential patterns detected in firmware binary"
# ── QEMU smoke test ──────────────────────────────────────────────────────────
# NOTE: QEMU in espressif/idf:v5.2 only supports -machine esp32 (LX6),
# not esp32s3 (LX7). This test verifies the flash image can be created
# and QEMU can be invoked, but boot verification is best-effort.
qemu-smoke-test:
name: QEMU Smoke Test (flash image creation)
runs-on: ubuntu-latest
needs: [build]
container:
image: espressif/idf:v5.2
options: --user root
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download firmware artifacts
uses: actions/download-artifact@v4
with:
name: firmware-${{ github.sha }}
path: artifacts
- name: Locate firmware binaries
id: locate
run: |
APP=$(find artifacts -name 'esp32-csi-node.bin' | head -n 1)
BOOT=$(find artifacts -name 'bootloader.bin' | head -n 1)
PART=$(find artifacts -name 'partition-table.bin' | head -n 1)
echo "app=$APP" >> "$GITHUB_OUTPUT"
echo "boot=$BOOT" >> "$GITHUB_OUTPUT"
echo "part=$PART" >> "$GITHUB_OUTPUT"
echo "Application: $APP"
echo "Bootloader: $BOOT"
echo "Partitions: $PART"
for f in "$APP" "$BOOT" "$PART"; do
if [ ! -f "$f" ]; then
echo "ERROR: missing binary: $f"
exit 1
fi
done
- name: Create merged flash image
run: |
. /opt/esp/idf/export.sh
APP="${{ steps.locate.outputs.app }}"
BOOT="${{ steps.locate.outputs.boot }}"
PART="${{ steps.locate.outputs.part }}"
# Merge bootloader + partition table + app into a single 4 MB flash image
esptool.py --chip esp32s3 merge_bin \
--fill-flash-size 4MB \
-o /tmp/flash_image.bin \
0x0000 "$BOOT" \
0x8000 "$PART" \
0x10000 "$APP"
ls -lh /tmp/flash_image.bin
echo "PASS: flash image created successfully (ready for esptool.py write_flash)"
- name: Verify flash image structure
run: |
# Verify the merged image has the expected components at correct offsets
FLASH=/tmp/flash_image.bin
SIZE=$(stat -c%s "$FLASH")
echo "Flash image size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
# Check for ESP32-S3 bootloader magic at offset 0
MAGIC=$(xxd -p -l 1 -s 0 "$FLASH")
echo "Bootloader first byte: 0x$MAGIC"
# Check for partition table magic at offset 0x8000
PT_MAGIC=$(xxd -p -l 2 -s 0x8000 "$FLASH")
echo "Partition table magic: 0x$PT_MAGIC"
# Check for app binary at offset 0x10000 (ESP image magic = 0xE9)
APP_MAGIC=$(xxd -p -l 1 -s 0x10000 "$FLASH")
echo "App image magic: 0x$APP_MAGIC"
if [ "$APP_MAGIC" = "e9" ]; then
echo "PASS: ESP application image detected at 0x10000"
else
echo "WARN: unexpected app magic byte (expected 0xe9, got 0x$APP_MAGIC)"
fi
# ── Release artifact ─────────────────────────────────────────────────────────
release-artifacts:
name: Attach Firmware to Release
runs-on: ubuntu-latest
needs: [binary-size-check, credential-scan, qemu-smoke-test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Download firmware artifacts
uses: actions/download-artifact@v4
with:
name: firmware-${{ github.sha }}
path: artifacts
- name: Bundle release assets
run: |
mkdir -p release
find artifacts -name '*.bin' -exec cp {} release/ \;
find artifacts -name 'flasher_args.json' -exec cp {} release/ \;
find artifacts -name 'flash_args' -exec cp {} release/ \;
ls -lh release/
- name: Upload release assets
uses: actions/upload-artifact@v4
with:
name: firmware-release-${{ github.run_number }}
retention-days: 90
path: release/
- name: Create GitHub Release (on tag)
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
name: Firmware ${{ github.ref_name }}
body: |
ESP32-S3 CSI Node firmware — built from ${{ github.sha }}
**Build details:**
- ESP-IDF version: ${{ env.IDF_VERSION }}
- Target: ${{ env.IDF_TARGET }}
- Commit: ${{ github.sha }}
**Flashing:**
```
esptool.py --chip esp32s3 --baud 460800 write_flash @flash_args
```
files: release/*
draft: false
prerelease: false
+3
View File
@@ -1,3 +1,6 @@
# Local machine configuration (not shared)
CLAUDE.local.md
# ESP32 firmware build artifacts and local config (contains WiFi credentials)
firmware/esp32-csi-node/build/
firmware/esp32-csi-node/sdkconfig
+3 -3
View File
@@ -831,16 +831,16 @@ ESP32-S3 (STA + promiscuous) UDP/5005 Rust aggregator
```bash
# Pre-built binaries — no toolchain required
# https://github.com/ruvnet/wifi-densepose/releases/tag/v0.1.0-esp32
# https://github.com/ruvnet/wifi-densepose/releases/tag/v0.2.0-esp32
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write-flash --flash-mode dio --flash-size 4MB \
0x0 bootloader.bin 0x8000 partition-table.bin 0x10000 esp32-csi-node.bin
python scripts/provision.py --port COM7 \
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
cargo run -p wifi-densepose-hardware --bin aggregator -- --bind 0.0.0.0:5005 --verbose
cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source esp32
```
See [firmware/esp32-csi-node/README.md](firmware/esp32-csi-node/README.md) and [Tutorial #34](https://github.com/ruvnet/wifi-densepose/issues/34).
+5
View File
@@ -26,4 +26,9 @@ EXPOSE 8080
ENV PYTHONUNBUFFERED=1
#Prevent Python from writing .pyc files and __pycache__ folders to disk
#Make the runtime faster
ENV PYTHONDONTWRITEBYTECODE=1
CMD ["python", "-m", "v1.src.sensing.ws_server"]
@@ -0,0 +1,228 @@
# ADR-036: RVF Model Training Pipeline & UI Integration
## Status
Proposed
## Date
2026-03-02
## Context
The wifi-densepose system currently operates in **signal-derived** mode — `derive_pose_from_sensing()` maps aggregate CSI features (motion power, breathing rate, variance) to keypoint positions using deterministic math. This gives whole-body presence and gross motion but cannot track individual limbs.
The infrastructure for **model inference** mode exists but is disconnected:
1. **RVF container format** (`rvf_container.rs`, 1,102 lines) — a 64-byte-aligned binary format supporting model weights (`SEG_VEC`), metadata (`SEG_MANIFEST`), quantization (`SEG_QUANT`), LoRA profiles (`SEG_LORA`), contrastive embeddings (`SEG_EMBED`), and witness audit trails (`SEG_WITNESS`). Builder and reader are fully implemented with CRC32 integrity checks.
2. **Training crate** (`wifi-densepose-train`) — AdamW optimizer, PCK@0.2/OKS metrics, LR scheduling with warmup, early stopping, CSV logging, and checkpoint export. Supports `CsiDataset` trait with planned MM-Fi (114→56 subcarrier interpolation) and Wi-Pose (30→56 zero-pad) loaders per ADR-015.
3. **NN inference crate** (`wifi-densepose-nn`) — ONNX Runtime backend with CPU/GPU support, dynamic tensor shapes, thread-safe `OnnxBackend` wrapper, model info inspection, and warmup.
4. **Sensing server CLI** (`--model <path>`, `--train`, `--pretrain`, `--embed`) — flags exist for model loading, training mode, and embedding extraction, but the end-to-end path from raw CSI → trained `.rvf` → live inference is not wired together.
5. **UI gaps** — No model management, training progress visualization, LoRA profile switching, or embedding inspection. The Settings panel lacks model configuration. The Live Demo has no way to load a trained model or compare signal-derived vs model-inference output side-by-side.
### What users need
- A way to **collect labeled CSI data** from their own environment (self-supervised or teacher-student from camera).
- A way to **train an .rvf model** from collected data without leaving the UI.
- A way to **load and switch models** in the live demo, seeing the quality improvement.
- Visibility into **training progress** (loss curves, validation PCK, early stopping).
- **Environment adaptation** via LoRA profiles (office → home → warehouse) without full retraining.
## Decision
### Phase 1: Data Collection & Self-Supervised Pretraining
#### 1.1 CSI Recording API
Add REST endpoints to the sensing server:
```
POST /api/v1/recording/start { duration_secs, label?, session_name }
POST /api/v1/recording/stop
GET /api/v1/recording/list
GET /api/v1/recording/download/:id
DELETE /api/v1/recording/:id
```
- Records raw CSI frames + extracted features to `.csi.jsonl` files.
- Optional camera-based label overlay via teacher model (Detectron2/MediaPipe on client).
- Each recording session tagged with environment metadata (room dimensions, node positions, AP count).
#### 1.2 Contrastive Pretraining (ADR-024 Phase 1)
- Self-supervised NT-Xent loss learns a 128-dim CSI embedding without pose labels.
- Positive pairs: adjacent frames from same person; negatives: different sessions/rooms.
- VICReg regularization prevents embedding collapse.
- Output: `.rvf` container with `SEG_EMBED` + `SEG_VEC` segments.
- Training triggered via `POST /api/v1/train/pretrain { dataset_ids[], epochs, lr }`.
### Phase 2: Supervised Training Pipeline
#### 2.1 Dataset Integration
- **MM-Fi loader**: Parse HDF5 files, 114→56 subcarrier interpolation via `ruvector-solver` sparse least-squares.
- **Wi-Pose loader**: Parse .mat files, 30→56 zero-padding with Hann window smoothing.
- **Self-collected**: `.csi.jsonl` from Phase 1 recording + camera-generated labels.
- All datasets implement `CsiDataset` trait and produce `(amplitude[B,T*links,56], phase[B,T*links,56], keypoints[B,17,2], visibility[B,17])`.
#### 2.2 Training API
```
POST /api/v1/train/start {
dataset_ids: string[],
config: {
epochs: 100,
batch_size: 32,
learning_rate: 3e-4,
weight_decay: 1e-4,
early_stopping_patience: 15,
warmup_epochs: 5,
pretrained_rvf?: string, // Base model for fine-tuning
lora_profile?: string, // Environment-specific LoRA
}
}
POST /api/v1/train/stop
GET /api/v1/train/status // { epoch, train_loss, val_pck, val_oks, lr, eta_secs }
WS /ws/train/progress // Real-time streaming of training metrics
```
#### 2.3 RVF Export
On training completion:
- Best checkpoint exported as `.rvf` with `SEG_VEC` (weights), `SEG_MANIFEST` (metadata), `SEG_WITNESS` (training hash + final metrics), and optional `SEG_QUANT` (INT8 quantization).
- Stored in `data/models/` directory, indexed by model ID.
- `GET /api/v1/models` lists available models; `POST /api/v1/models/load { model_id }` hot-loads into inference.
### Phase 3: LoRA Environment Adaptation
#### 3.1 LoRA Fine-Tuning
- Given a base `.rvf` model, fine-tune only LoRA adapter weights (rank 4-16) on environment-specific recordings.
- 5-10 minutes of labeled data from new environment suffices.
- New LoRA profile appended to existing `.rvf` via `SEG_LORA` segment.
- `POST /api/v1/train/lora { base_model_id, dataset_ids[], profile_name, rank: 8, epochs: 20 }`.
#### 3.2 Profile Switching
- `POST /api/v1/models/lora/activate { model_id, profile_name }` — hot-swap LoRA weights without reloading base model.
- UI dropdown lists available profiles per loaded model.
### Phase 4: UI Integration
#### 4.1 Model Management Panel (new: `ui/components/ModelPanel.js`)
- **Model Library**: List loaded and available `.rvf` models with metadata (version, dataset, PCK score, size, created date).
- **Model Inspector**: Show RVF segment breakdown — weight count, quantization type, LoRA profiles, embedding config, witness hash.
- **Load/Unload**: One-click model loading with progress bar.
- **Compare**: Side-by-side signal-derived vs model-inference toggle in Live Demo.
#### 4.2 Training Dashboard (new: `ui/components/TrainingPanel.js`)
- **Recording Controls**: Start/stop CSI recording, session list with duration and frame counts.
- **Training Progress**: Real-time loss curve (train loss, val loss) and metric charts (PCK@0.2, OKS) via WebSocket streaming.
- **Epoch Table**: Scrollable table of per-epoch metrics with best-epoch highlighting.
- **Early Stopping Indicator**: Visual countdown of patience remaining.
- **Export Button**: Download trained `.rvf` from browser.
#### 4.3 Live Demo Enhancements
- **Model Selector**: Dropdown in toolbar to switch between signal-derived and loaded `.rvf` models.
- **LoRA Profile Selector**: Sub-dropdown showing environment profiles for the active model.
- **Confidence Heatmap Overlay**: Per-keypoint confidence visualization when model is loaded (toggle in render mode dropdown).
- **Pose Trail**: Ghosted keypoint history showing last N frames of motion trajectory.
- **A/B Split View**: Left half signal-derived, right half model-inference for quality comparison.
#### 4.4 Settings Panel Extensions
- **Model section**: Default model path, auto-load on startup, GPU/CPU toggle, inference threads.
- **Training section**: Default hyperparameters, checkpoint directory, auto-export on completion.
- **Recording section**: Default recording directory, max duration, auto-label with camera.
#### 4.5 Dark Mode
All new panels follow the dark mode established in ADR-035 (`#0d1117` backgrounds, `#e0e0e0` text, translucent dark panels with colored accents).
### Phase 5: Inference Pipeline Wiring
#### 5.1 Model-Inference Pose Path
When a `.rvf` model is loaded:
1. CSI frame arrives (UDP or simulated).
2. Extract amplitude + phase tensors from subcarrier data.
3. Feed through ONNX session: `input[1, T*links, 56]``output[1, 17, 4]` (x, y, z, conf).
4. Apply Kalman smoothing from `pose_tracker.rs`.
5. Broadcast via WebSocket with `pose_source: "model_inference"`.
6. UI Estimation Mode badge switches from green "SIGNAL-DERIVED" to blue "MODEL INFERENCE".
#### 5.2 Progressive Loading (ADR-031 Layer A/B/C)
- **Layer A** (instant): Signal-derived pose starts immediately.
- **Layer B** (5-10s): Contrastive embeddings loaded, HNSW index warm.
- **Layer C** (30-60s): Full pose model loaded, inference active.
- Transitions seamlessly; UI badge updates automatically.
## Consequences
### Positive
- Users can train a model on **their own environment** without external tools or Python dependencies.
- LoRA profiles mean a single base model adapts to multiple rooms in minutes, not hours.
- Training progress is visible in real-time — no black-box waiting.
- A/B comparison lets users see the quality jump from signal-derived to model-inference.
- RVF container bundles everything (weights, metadata, LoRA, witness) in one portable file.
- Self-supervised pretraining requires no labels — just leave ESP32s running.
- Progressive loading means the UI is never "loading..." — signal-derived kicks in immediately.
### Negative
- Training requires significant compute: GPU recommended for supervised training (CPU possible but 10-50x slower).
- MM-Fi and Wi-Pose datasets must be downloaded separately (10-50 GB each) — cannot be bundled.
- LoRA rank must be tuned per environment; too low loses expressiveness, too high overfits.
- ONNX Runtime adds ~50 MB to the binary size when GPU support is enabled.
- Real-time inference at 10 FPS requires ~10ms per frame — tight budget on CPU.
- Teacher-student labeling (camera → pose labels → CSI training) requires camera access, which may conflict with the privacy-first premise.
### Mitigations
- Provide pre-trained base `.rvf` model downloadable from releases (trained on MM-Fi + Wi-Pose).
- INT8 quantization (`SEG_QUANT`) reduces model size 4x and speeds inference ~2x on CPU.
- Camera-based labeling is **optional** — self-supervised pretraining works without camera.
- Training API validates VRAM availability before starting GPU training; falls back to CPU with warning.
## Implementation Order
| Phase | Effort | Dependencies | Priority |
|-------|--------|-------------|----------|
| 1.1 CSI Recording API | 2-3 days | sensing server | High |
| 1.2 Contrastive Pretraining | 3-5 days | ADR-024, recording API | High |
| 2.1 Dataset Integration | 3-5 days | ADR-015, CsiDataset trait | High |
| 2.2 Training API | 2-3 days | training crate, dataset loaders | High |
| 2.3 RVF Export | 1-2 days | RvfBuilder | Medium |
| 3.1 LoRA Fine-Tuning | 3-5 days | base trained model | Medium |
| 3.2 Profile Switching | 1 day | LoRA in RVF | Medium |
| 4.1 Model Panel UI | 2-3 days | models API | High |
| 4.2 Training Dashboard UI | 3-4 days | training API + WS | High |
| 4.3 Live Demo Enhancements | 2-3 days | model loading | Medium |
| 4.4 Settings Extensions | 1 day | model/training APIs | Low |
| 4.5 Dark Mode | 0.5 days | new panels | Low |
| 5.1 Inference Wiring | 3-5 days | ONNX backend, pose tracker | High |
| 5.2 Progressive Loading | 2-3 days | ADR-031 | Medium |
**Total estimate: 4-6 weeks** (phases can overlap; 1+2 parallel with 4).
## Files to Create/Modify
### New Files
- `ui/components/ModelPanel.js` — Model library, inspector, load/unload controls
- `ui/components/TrainingPanel.js` — Recording controls, training progress, metric charts
- `rust-port/.../sensing-server/src/recording.rs` — CSI recording API handlers
- `rust-port/.../sensing-server/src/training_api.rs` — Training API handlers + WS progress stream
- `rust-port/.../sensing-server/src/model_manager.rs` — Model loading, hot-swap, 32LoRA activation
- `data/models/` — Default model storage directory
### Modified Files
- `rust-port/.../sensing-server/src/main.rs` — Wire recording, training, and model APIs
- `rust-port/.../train/src/trainer.rs` — Add WebSocket progress callback, LoRA training mode
- `rust-port/.../train/src/dataset.rs` — MM-Fi and Wi-Pose dataset loaders
- `rust-port/.../nn/src/onnx.rs` — LoRA weight injection, INT8 quantization support
- `ui/components/LiveDemoTab.js` — Model selector, LoRA dropdown, A/B spsplit view
- `ui/components/SettingsPanel.js` — Model and training configuration sections
- `ui/components/PoseDetectionCanvas.js` — Pose trail rendering, confidence heatmap overlay
- `ui/services/pose.service.js` — Model-inference keypoint processing
- `ui/index.html` — Add Training tabhee
- `ui/style.css` — Styles for new panels
## References
- ADR-015: MM-Fi + Wi-Pose training datasets
- ADR-016: RuVector training pipeline integration
- ADR-024: Project AETHER — contrastive CSI embedding model
- ADR-029: RuvSense multistatic sensing mode
- ADR-031: RuView sensing-first RF mode (progressive loading)
- ADR-035: Live sensing UI accuracy & data source transparency
- Issue: https://github.com/ruvnet/wifi-densepose/issues/92
- RVF format: `crates/wifi-densepose-sensing-server/src/rvf_container.rs`
- Training crate: `crates/wifi-densepose-train/src/trainer.rs`
- NN inference: `crates/wifi-densepose-nn/src/onnx.rs`
@@ -0,0 +1,121 @@
# ADR-037: Multi-Person Pose Detection from Single ESP32 CSI Stream
- **Status**: Proposed
- **Date**: 2026-03-02
- **Issue**: [#97](https://github.com/ruvnet/wifi-densepose/issues/97)
- **Deciders**: @ruvnet
- **Supersedes**: None
- **Related**: ADR-014 (SOTA signal processing), ADR-024 (AETHER re-ID), ADR-029 (multistatic sensing), ADR-036 (RVF training pipeline)
## Context
The current signal-derived pose estimation pipeline (`derive_pose_from_sensing()` in the sensing server) generates at most one skeleton per frame from aggregate CSI features. When multiple people are present, only a single blended skeleton is produced. Live testing with ESP32 hardware confirmed: 2 people in the room yields 1 detected person.
A single ESP32 node provides 1 TX × 1 RX × 56 subcarriers of CSI data per frame. While this is limited spatial resolution compared to camera-based systems, the signal contains composite reflections from all scatterers in the environment. The challenge is decomposing these composite signals into per-person contributions.
## Decision
Implement multi-person pose detection in four phases, progressively improving accuracy from heuristic to neural approaches.
### Phase 1: Person Count Estimation
Estimate occupancy count from CSI signal statistics without decomposition.
**Approach**: Eigenvalue analysis of the CSI covariance matrix across subcarriers.
- Compute the 56×56 covariance matrix of CSI amplitudes over a sliding window (e.g., 50 frames / 5 seconds)
- Count eigenvalues above a noise threshold — each significant eigenvalue corresponds to an independent scatterer (person or static object)
- Subtract the static environment baseline (estimated during calibration or from the field model's SVD eigenstructure)
- The residual significant eigenvalue count estimates person count
**Accuracy target**: > 80% for 0-3 people with single ESP32 node.
**Integration point**: `signal/src/ruvsense/field_model.rs` already computes SVD eigenstructure. Extend with a `estimate_occupancy()` method.
### Phase 2: Signal Decomposition
Separate per-person signal contributions using blind source separation.
**Approach**: Non-negative Matrix Factorization (NMF) on the CSI spectrogram.
- Construct a time-frequency matrix from CSI amplitudes: rows = subcarriers (56), columns = time frames
- Apply NMF with k components (k = estimated person count from Phase 1)
- Each component's frequency profile maps to a person's motion pattern
- NMF is preferred over ICA because CSI amplitudes are non-negative
**Alternative**: Independent Component Analysis (ICA) on complex CSI (amplitude + phase). More powerful but requires phase calibration (see `ruvsense/phase_align.rs`).
**Integration point**: New module `signal/src/ruvsense/separation.rs`.
### Phase 3: Multi-Skeleton Generation
Generate distinct pose skeletons per decomposed component.
**Approach**: Per-component feature extraction → per-person skeleton synthesis.
- Extract motion features (dominant frequency, energy, spectral centroid) per NMF component
- Map each component to a spatial position using subcarrier phase gradient (Fresnel zone model)
- Generate 17-keypoint COCO skeleton per person with position offset
- Assign person IDs using the existing Kalman tracker (`ruvsense/pose_tracker.rs`) with AETHER re-ID embeddings (ADR-024)
**Integration point**: Modify `derive_pose_from_sensing()` in `sensing-server/src/main.rs` to return `Vec<Person>` with length > 1.
### Phase 4: Neural Multi-Person Model
Train a dedicated multi-person model using the RVF pipeline (ADR-036).
- Use MM-Fi dataset (ADR-015) multi-person scenarios for training data
- Architecture: shared CSI encoder → person count head + per-person pose heads
- LoRA fine-tuning profile for multi-person specialization
- Inference via the model manager in the sensing server
**Accuracy target**: PCK@0.2 > 60% for 2-person scenarios.
## Consequences
### Positive
- Enables room occupancy counting (Phase 1 alone is useful)
- Distinct pose tracking per person enables activity recognition per individual
- Progressive approach — each phase delivers incremental value
- Reuses existing infrastructure (field model SVD, Kalman tracker, AETHER, RVF pipeline)
### Negative
- Single ESP32 node has fundamental spatial resolution limits — separating 2 people standing close together (< 0.5m) will be unreliable
- NMF decomposition adds ~5-10ms latency per frame
- Person count estimation will have false positives from large moving objects (pets, fans)
- Phase 4 neural model requires multi-person training data collection
### Neutral
- Multi-node multistatic mesh (ADR-029) dramatically improves multi-person separation but is a separate effort
- UI already supports multi-person rendering — no frontend changes needed for the `persons[]` array
## Affected Components
| Component | Phase | Change |
|-----------|-------|--------|
| `signal/src/ruvsense/field_model.rs` | 1 | Add `estimate_occupancy()` |
| `signal/src/ruvsense/separation.rs` | 2 | New module: NMF decomposition |
| `sensing-server/src/main.rs` | 3 | `derive_pose_from_sensing()` multi-person output |
| `signal/src/ruvsense/pose_tracker.rs` | 3 | Multi-target tracking |
| `nn/` | 4 | Multi-person inference head |
| `train/` | 4 | Multi-person training pipeline |
## Performance Budget
| Operation | Budget | Phase |
|-----------|--------|-------|
| Person count estimation | < 2ms | 1 |
| NMF decomposition (k=3) | < 10ms | 2 |
| Multi-skeleton synthesis | < 3ms | 3 |
| Neural inference (multi-person) | < 50ms | 4 |
| **Total pipeline** | **< 65ms** (15 FPS) | All |
## Alternatives Considered
1. **Camera fusion**: Use a camera for person detection and WiFi for pose — rejected because the project goal is camera-free sensing.
2. **Multiple single-person models**: Run N independent pose estimators — rejected because they would produce correlated outputs from the same CSI data.
3. **Spatial filtering (beamforming)**: Use antenna array beamforming to isolate directions — rejected because single ESP32 has only 1 antenna; viable with multistatic mesh (ADR-029).
4. **Skip signal-derived, go straight to neural**: Train an end-to-end multi-person model — rejected because signal-derived provides faster iteration and interpretability for the early phases.
@@ -0,0 +1,546 @@
# ADR-038: Sublinear Goal-Oriented Action Planning (GOAP) for Project Roadmap Optimization
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-02 |
| **Deciders** | ruv |
| **Relates to** | All 37 prior ADRs; ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Integration), ADR-024 (AETHER Embeddings), ADR-027 (MERIDIAN Generalization), ADR-029 (RuvSense Multistatic), ADR-037 (Multi-Person Detection) |
---
## 1. Context
### 1.1 The Planning Problem
WiFi-DensePose has 37 Architecture Decision Records. Of these, 14 are Accepted/Complete, 4 are Partially Implemented, 19 are Proposed, and 1 is Superseded. The proposed ADRs span diverse capabilities: vital sign detection (ADR-021), multi-BSSID scanning (ADR-022), contrastive embeddings (ADR-024), cross-environment generalization (ADR-027), multistatic mesh sensing (ADR-029), persistent field models (ADR-030), multi-person pose detection (ADR-037), and more.
A single developer (or a small team aided by AI agents) must decide **what to build next** given:
- **Dense dependency graph**: ADR-037 (multi-person) depends on ADR-014 (signal processing), ADR-024 (AETHER), and ADR-029 (multistatic). ADR-029 depends on ADR-012 (ESP32 mesh), ADR-014, ADR-016, and ADR-018. Many ADRs share prerequisites.
- **Hardware variability**: Some ADRs require ESP32 hardware (ADR-021 vital signs, ADR-029 multistatic mesh), while others are software-only (ADR-024 AETHER, ADR-027 MERIDIAN). The available hardware changes session to session.
- **Shifting goals**: One session the user wants accuracy improvement; the next session they want multi-person support; the next they want WebAssembly deployment.
- **Resource constraints**: Limited compute budget, single-developer throughput, CI pipeline capacity.
Manually navigating this decision space is error-prone. The developer must hold the full dependency graph in working memory, re-evaluate priorities when goals shift, and avoid dead-end plans that block on unavailable hardware.
### 1.2 Why GOAP
Goal-Oriented Action Planning (GOAP), originally developed for game AI by Jeff Orkin (2003), models the world as a set of boolean/numeric state properties and defines actions with typed preconditions and effects. A planner searches from the current world state to a goal state, producing an optimal action sequence. GOAP is a natural fit for this problem because:
1. **ADR implementations are actions** with clear preconditions (which other ADRs/hardware must exist) and effects (which capabilities are unlocked).
2. **The world state is observable** -- we can query cargo test results, check hardware connections, read crate manifests, and measure accuracy metrics.
3. **Goals are declarative** -- "I want multi-person tracking at 20 Hz" translates to `{multi_person_tracking: true, update_rate_hz: 20}`.
4. **Replanning is cheap** -- when hardware becomes available or a user changes goals, the planner re-runs in milliseconds.
### 1.3 Why Sublinear
The naive GOAP planner uses A* search over the full action-state graph. With 37 ADRs, each potentially having multiple phases (ADR-037 has 4 phases, ADR-029 has 9 actions), the raw action count exceeds 80. The full state space is `2^N` for N boolean properties. Exhaustive search is wasteful because:
- Most actions are irrelevant to any given goal (the user asking for vital signs does not need WebAssembly deployment actions in the search).
- The dependency graph is sparse -- most actions depend on 1-3 prerequisites, not all other actions.
- Many state properties are independent (vital sign detection does not interact with WebAssembly compilation).
A sublinear approach avoids exploring the full state space by exploiting this sparsity.
---
## 2. Decision
Implement a GOAP planning system as a coordinator module within the claude-flow swarm framework. The planner takes a user goal, the current project state, and available hardware as input, and produces an ordered action plan that is dispatched to specialized agents for execution.
### 2.1 World State Model
The world state is a flat map of typed properties representing the current project capabilities.
#### 2.1.1 Feature Implementation Flags (Boolean)
| Property | Source of Truth | Description |
|----------|----------------|-------------|
| `sota_signal_processing` | `cargo test -p wifi-densepose-signal` passes | ADR-014 SOTA algorithms implemented |
| `ruvector_training_integrated` | `train/` crate builds with ruvector deps | ADR-016 RuVector training pipeline |
| `ruvector_signal_integrated` | `signal/src/ruvsense/` module exists | ADR-017 RuVector signal integration |
| `esp32_firmware_base` | `firmware/esp32-csi-node/` compiles | ADR-018 ESP32 base firmware |
| `esp32_channel_hopping` | Firmware supports multi-channel | ADR-029 Phase 1 |
| `multi_band_fusion` | `ruvsense/multiband.rs` passes tests | ADR-029 Phase 2 |
| `multistatic_mesh` | Multi-node fusion operational | ADR-029 Phase 3 |
| `coherence_gating` | `ruvsense/coherence_gate.rs` passes tests | ADR-029 Phase 6-7 |
| `pose_tracker_17kp` | `ruvsense/pose_tracker.rs` passes tests | ADR-029 Phase 4 |
| `vital_signs_extraction` | `vitals/` crate passes tests | ADR-021 |
| `vital_signs_esp32_validated` | ESP32 breathing detection verified | ADR-021 Phase 2 |
| `multi_bssid_scan` | `wifiscan/` crate passes tests | ADR-022 Phase 1 |
| `multi_bssid_concurrent` | Concurrent BSSID scanning | ADR-022 Phase 2 |
| `aether_embeddings` | Contrastive CSI encoder trained | ADR-024 |
| `aether_reid` | Person re-identification via embeddings | ADR-024 Phase 3 |
| `meridian_generalization` | Cross-environment transfer working | ADR-027 |
| `persistent_field_model` | Field model serializes/deserializes | ADR-030 |
| `person_count_estimation` | Eigenvalue occupancy estimator | ADR-037 Phase 1 |
| `signal_decomposition` | NMF per-person separation | ADR-037 Phase 2 |
| `multi_skeleton_generation` | Multiple skeletons per frame | ADR-037 Phase 3 |
| `multi_person_neural` | Neural multi-person model | ADR-037 Phase 4 |
| `wasm_deployment` | WebAssembly build functional | ADR-025 |
| `mat_survivor_detection` | MAT disaster detection operational | ADR-011/ADR-026 |
| `ruview_sensing_ui` | Sensing-first RF UI mode | ADR-031 |
| `mesh_security_hardened` | Multistatic mesh security layer | ADR-032 |
#### 2.1.2 Hardware Availability Flags (Boolean)
| Property | Detection Method | Description |
|----------|-----------------|-------------|
| `esp32_connected` | USB serial probe (`/dev/ttyUSB*` or `COM*`) | At least one ESP32 on USB |
| `esp32_count` | Count USB serial devices with ESP32 VID/PID | Number of ESP32 nodes |
| `esp32_multistatic_ready` | `esp32_count >= 2` | Sufficient for multistatic |
| `gpu_available` | `nvidia-smi` or CUDA probe | GPU for neural training |
| `wifi_adapter_present` | OS WiFi interface enumeration | Host WiFi for multi-BSSID |
#### 2.1.3 Quality Metrics (Numeric)
| Property | Source | Description |
|----------|--------|-------------|
| `pose_accuracy_pck02` | Benchmark suite output | PCK@0.2 accuracy (0.0-1.0) |
| `update_rate_hz` | Pipeline timing measurement | Effective output frame rate |
| `max_persons_tracked` | Multi-person test result | Maximum simultaneous persons |
| `breathing_snr_db` | Vital signs test output | Breathing detection SNR |
| `torso_jitter_mm` | Tracking benchmark | RMS torso keypoint jitter |
| `rust_test_count` | `cargo test --workspace` output | Total passing Rust tests |
### 2.2 Action Definitions
Each action maps to an ADR implementation phase. Actions are defined as structs with preconditions, effects, cost, and metadata.
```rust
pub struct GoapAction {
/// Unique identifier (e.g., "adr029_phase1_channel_hopping")
pub id: String,
/// Human-readable name
pub name: String,
/// ADR reference (e.g., "ADR-029")
pub adr: String,
/// Phase within the ADR (e.g., "Phase 1")
pub phase: Option<String>,
/// Preconditions: state properties that must be true/meet threshold
pub preconditions: Vec<Condition>,
/// Effects: state properties set after successful execution
pub effects: Vec<Effect>,
/// Estimated effort in developer-days
pub cost_days: f32,
/// Whether this action requires hardware
pub requires_hardware: Vec<String>,
/// Agent types needed to execute this action
pub agent_types: Vec<String>,
/// Affected crates/files
pub affected_components: Vec<String>,
}
pub enum Condition {
BoolTrue(String), // property must be true
BoolFalse(String), // property must be false
NumericGte(String, f64), // property >= threshold
NumericLte(String, f64), // property <= threshold
}
pub enum Effect {
SetBool(String, bool), // set boolean property
SetNumeric(String, f64), // set numeric property
IncrementNumeric(String, f64), // add to numeric property
}
```
#### 2.2.1 Action Catalog (Key ADR Actions)
| Action ID | ADR | Cost (days) | Preconditions | Effects | Hardware |
|-----------|-----|-------------|---------------|---------|----------|
| `adr037_p1_person_count` | 037 | 3 | `sota_signal_processing` | `person_count_estimation = true` | None |
| `adr037_p2_nmf_decomp` | 037 | 5 | `person_count_estimation` | `signal_decomposition = true` | None |
| `adr037_p3_multi_skel` | 037 | 4 | `signal_decomposition`, `pose_tracker_17kp` | `multi_skeleton_generation = true`, `max_persons_tracked += 2` | None |
| `adr037_p4_neural_multi` | 037 | 10 | `signal_decomposition`, `aether_embeddings`, `gpu_available` | `multi_person_neural = true`, `pose_accuracy_pck02 = 0.6` | GPU |
| `adr021_vital_core` | 021 | 3 | `sota_signal_processing` | `vital_signs_extraction = true` | None |
| `adr021_vital_esp32` | 021 | 5 | `vital_signs_extraction`, `esp32_connected` | `vital_signs_esp32_validated = true`, `breathing_snr_db = 10.0` | ESP32 |
| `adr030_persist_field` | 030 | 2 | `ruvector_signal_integrated` | `persistent_field_model = true` | None |
| `adr022_p2_concurrent` | 022 | 4 | `multi_bssid_scan`, `wifi_adapter_present` | `multi_bssid_concurrent = true` | WiFi adapter |
| `adr029_p1_ch_hop` | 029 | 5 | `esp32_firmware_base`, `esp32_connected` | `esp32_channel_hopping = true` | ESP32 |
| `adr029_p2_multiband` | 029 | 5 | `esp32_channel_hopping` | `multi_band_fusion = true` | ESP32 |
| `adr029_p3_multistatic` | 029 | 5 | `multi_band_fusion`, `esp32_multistatic_ready` | `multistatic_mesh = true` | 2+ ESP32 |
| `adr029_p67_coherence` | 029 | 3 | `multi_band_fusion` | `coherence_gating = true` | None |
| `adr029_p4_tracker` | 029 | 3 | `multistatic_mesh`, `coherence_gating` | `pose_tracker_17kp = true`, `torso_jitter_mm = 30.0` | None |
| `adr024_aether_train` | 024 | 8 | `sota_signal_processing`, `gpu_available` | `aether_embeddings = true` | GPU |
| `adr024_aether_reid` | 024 | 4 | `aether_embeddings`, `pose_tracker_17kp` | `aether_reid = true` | None |
| `adr027_meridian` | 027 | 10 | `aether_embeddings`, `gpu_available` | `meridian_generalization = true` | GPU |
| `adr025_wasm` | 025 | 5 | `sota_signal_processing` | `wasm_deployment = true` | None |
| `adr011_mat` | 011 | 8 | `vital_signs_extraction`, `person_count_estimation` | `mat_survivor_detection = true` | None |
| `adr031_ruview` | 031 | 4 | `persistent_field_model`, `coherence_gating` | `ruview_sensing_ui = true` | None |
| `adr032_mesh_security` | 032 | 5 | `multistatic_mesh` | `mesh_security_hardened = true` | None |
### 2.3 Goal Specification
Goals are expressed as partial world states -- a set of conditions that must be satisfied.
```rust
pub struct Goal {
/// Human-readable description
pub description: String,
/// Conditions that define success
pub conditions: Vec<Condition>,
/// Priority weight (higher = more important when competing)
pub priority: f32,
}
```
**Predefined goal templates:**
| Goal | Conditions | Typical Plan Length |
|------|-----------|---------------------|
| Multi-person tracking | `multi_skeleton_generation = true`, `max_persons_tracked >= 3` | 4-6 actions |
| Vital sign monitoring | `vital_signs_esp32_validated = true`, `breathing_snr_db >= 10` | 2-3 actions |
| Production accuracy | `pose_accuracy_pck02 >= 0.6`, `torso_jitter_mm <= 30` | 5-8 actions |
| Browser deployment | `wasm_deployment = true` | 1-2 actions |
| Disaster response (MAT) | `mat_survivor_detection = true`, `multi_skeleton_generation = true` | 5-7 actions |
| Full multistatic mesh | `multistatic_mesh = true`, `coherence_gating = true`, `pose_tracker_17kp = true` | 5-7 actions |
| Cross-environment robustness | `meridian_generalization = true` | 3-5 actions |
### 2.4 Sublinear Planning Algorithm
The planner avoids exhaustive A* search over the full state space using three techniques.
#### 2.4.1 Backward Relevance Pruning
Before search begins, identify which actions are **relevant** to the goal using backward chaining:
```
function relevantActions(goal, allActions):
relevant = {}
frontier = {conditions in goal that are not satisfied}
while frontier is not empty:
pick condition C from frontier
for each action A in allActions:
if A.effects satisfies C:
relevant.add(A)
for each precondition P of A:
if P is not satisfied in current state:
frontier.add(P)
return relevant
```
This typically reduces the action set from ~80 to 5-15 for a specific goal. The search then operates only on relevant actions.
**Complexity**: O(G * A) where G is the number of unsatisfied goal/precondition properties and A is the total action count. Since G << 2^N and A is fixed at ~80, this is constant-time relative to the state space.
#### 2.4.2 Hierarchical Decomposition
Actions are organized into three tiers based on the ADR dependency structure:
```
Tier 0 (Foundation): ADR-014, ADR-016, ADR-018
No internal prerequisites. Always satisfiable.
Tier 1 (Infrastructure): ADR-017, ADR-021-core, ADR-022-p1, ADR-029-p1, ADR-030
Depend only on Tier 0.
Tier 2 (Capability): ADR-024, ADR-029-p2/p3, ADR-037-p1/p2, ADR-021-esp32
Depend on Tier 0-1.
Tier 3 (Integration): ADR-027, ADR-037-p3/p4, ADR-029-p4, ADR-011, ADR-031
Depend on Tier 0-2.
```
The planner first resolves Tier 0 preconditions (usually already satisfied), then plans Tier 1 actions, then Tier 2, then Tier 3. Within each tier, actions are independent and can be planned in parallel. This reduces the effective search depth from ~15 (worst case linear chain) to ~4 (tier depth).
#### 2.4.3 Incremental Replanning
When the world state changes (a test passes, hardware is plugged in, the user shifts goals), the planner does not replan from scratch. Instead:
1. **Invalidation**: Mark actions in the current plan whose preconditions are no longer satisfied or whose effects are already achieved.
2. **Patch**: Remove invalidated actions and re-run backward relevance pruning only for the remaining unsatisfied goal conditions.
3. **Merge**: Insert new actions into the existing plan at the correct dependency-ordered position.
This is sublinear in the total action count because only the delta is re-examined.
#### 2.4.4 Heuristic Cost Function
The A* heuristic estimates remaining cost as the sum of minimum-cost actions needed to satisfy each unsatisfied goal condition, divided by the maximum parallelism available (number of idle agents). This is admissible (never overestimates) because actions can satisfy multiple conditions.
```
h(state, goal) = sum(min_cost_to_satisfy(c) for c in unsatisfied(state, goal)) / max_parallelism
```
#### 2.4.5 Complexity Analysis
| Component | Naive GOAP | Sublinear GOAP |
|-----------|-----------|----------------|
| State space | 2^N (N=25 booleans) = 33M | Pruned to relevant subset |
| Actions evaluated | All ~80 per expansion | 5-15 (backward pruning) |
| Search depth | Up to 15 | Up to 4 (tier decomposition) |
| Replan cost | Full re-search | Delta patch only |
| Typical plan time | ~100ms | <5ms |
### 2.5 State Observation
The planner queries the real project state before planning. Each property has a defined observation method.
| Property | Observation Command | Cache TTL |
|----------|-------------------|-----------|
| `sota_signal_processing` | `cargo test -p wifi-densepose-signal --no-default-features 2>&1 \| grep "test result"` | 10 min |
| `esp32_connected` | Platform-specific USB serial probe | 30 sec |
| `esp32_count` | Count ESP32 VID/PID USB devices | 30 sec |
| `gpu_available` | `nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null` | 5 min |
| `rust_test_count` | Parse `cargo test --workspace --no-default-features` output | 10 min |
| `wifi_adapter_present` | OS-specific WiFi interface enumeration | 5 min |
| Module existence flags | `test -f <path>` for key source files | 1 min |
Observations are cached with TTL to avoid re-running expensive commands (cargo test) on every plan request. Cache invalidation occurs on file change events or explicit user request.
### 2.6 Plan Execution via Swarm
Once the planner produces an ordered action list, execution is dispatched through the claude-flow swarm system.
#### 2.6.1 GOAP Coordinator Agent
The planner runs as a `goap-coordinator` agent within a hierarchical swarm topology:
```
goap-coordinator (planner + dispatcher)
|
+-- researcher (dependency analysis, API review)
+-- coder (implementation)
+-- tester (validation, state observation)
+-- reviewer (code review, security check)
```
The coordinator:
1. Observes current world state
2. Accepts a goal from the user
3. Runs the sublinear planner to produce an action sequence
4. Dispatches each action to appropriate agent types (from the action's `agent_types` field)
5. Monitors action completion via the memory system
6. Updates the world state after each action completes
7. Re-plans if the world state diverges from expectations
#### 2.6.2 State Persistence via Memory
World state is stored in the claude-flow memory system under the `goap` namespace:
```bash
# Store observed state
npx @claude-flow/cli@latest memory store \
--namespace goap \
--key "world-state" \
--value '{"sota_signal_processing": true, "esp32_connected": false, ...}'
# Store current plan
npx @claude-flow/cli@latest memory store \
--namespace goap \
--key "current-plan" \
--value '{"goal": "multi-person tracking", "actions": ["adr037_p1", "adr037_p2", ...], "progress": 1}'
# Search for past successful plans
npx @claude-flow/cli@latest memory search \
--namespace goap \
--query "multi-person tracking plan"
```
#### 2.6.3 Action-to-Agent Routing
Each action declares which agent types are needed. The coordinator maps these to swarm agents:
| Agent Type | Role in GOAP Action | Example Actions |
|-----------|---------------------|-----------------|
| `researcher` | Analyze dependencies, review papers, check API compatibility | Pre-action analysis for any ADR |
| `coder` | Write implementation code | All implementation actions |
| `tester` | Run tests, observe state, validate effects | Post-action verification |
| `reviewer` | Code review, security audit | ADR-032 mesh security, any PR |
| `performance-engineer` | Benchmark, optimize latency | ADR-029 pipeline timing |
| `security-architect` | Threat model, audit | ADR-032 security hardening |
#### 2.6.4 Execution Protocol
For each action in the plan:
```
1. PRE-CHECK: Observe preconditions. If any unsatisfied, re-plan.
2. DISPATCH: Spawn required agents with action context.
3. EXECUTE: Agents implement the action (write code, run tests).
4. VERIFY: Tester agent observes the world state.
5. UPDATE: If effects achieved, mark action complete, update state.
6. REPLAN: If effects not achieved, flag failure, re-plan with updated state.
```
### 2.7 Dependency Graph Visualization
The planner can emit its action graph in DOT format for visualization:
```
digraph goap {
rankdir=LR;
node [shape=box, style=rounded];
// Tier 0 (green = complete)
adr014 [label="ADR-014\nSOTA Signal", color=green];
adr016 [label="ADR-016\nRuVector Train", color=green];
adr018 [label="ADR-018\nESP32 Base", color=green];
// Tier 1 (blue = in progress)
adr017 [label="ADR-017\nRuVector Signal", color=blue];
adr030 [label="ADR-030\nField Model", color=orange];
// Tier 2 (orange = planned)
adr037_p1 [label="ADR-037 P1\nPerson Count", color=orange];
adr037_p2 [label="ADR-037 P2\nNMF Decomp", color=orange];
adr024 [label="ADR-024\nAETHER", color=orange];
// Tier 3 (gray = future)
adr037_p3 [label="ADR-037 P3\nMulti-Skeleton", color=gray];
adr027 [label="ADR-027\nMERIDIAN", color=gray];
// Edges
adr014 -> adr037_p1;
adr037_p1 -> adr037_p2;
adr037_p2 -> adr037_p3;
adr014 -> adr024;
adr024 -> adr037_p3;
adr024 -> adr027;
adr014 -> adr017;
adr017 -> adr030;
}
```
### 2.8 PageRank-Based Prioritization
When the user has not specified a single goal but asks "what should I work on next?", the planner uses PageRank on the action dependency graph to identify the highest-leverage actions:
1. Construct the adjacency matrix where `A[i][j] = 1` if action j depends on action i (i.e., completing i unblocks j).
2. Run PageRank with damping factor 0.85.
3. Actions with the highest PageRank scores are the most "load-bearing" -- they unblock the most downstream work.
4. Filter to actions whose preconditions are currently satisfiable.
5. Return the top-K actions ranked by `PageRank_score * (1 / cost_days)` (value per effort).
This naturally surfaces foundation actions (ADR-014, ADR-016) over leaf actions (ADR-032 security), matching the intuition that infrastructure work has the highest leverage.
---
## 3. Implementation
### 3.1 Module Structure
The GOAP planner is implemented as a TypeScript module within the claude-flow coordination layer (not in the Rust workspace, since it orchestrates Rust development rather than being part of the Rust product).
```
.claude-flow/goap/
state.ts -- World state model and observation
actions.ts -- Action catalog (all ~80 actions)
planner.ts -- Sublinear A* planner with backward pruning
goals.ts -- Goal templates and user goal parser
executor.ts -- Swarm dispatch and action lifecycle
pagerank.ts -- Dependency graph prioritization
visualize.ts -- DOT graph export
```
### 3.2 CLI Integration
```bash
# Plan: produce an action sequence for a goal
npx @claude-flow/cli@latest goap plan --goal "multi-person tracking"
# Observe: snapshot current world state
npx @claude-flow/cli@latest goap observe
# Prioritize: PageRank-based "what next?" recommendation
npx @claude-flow/cli@latest goap prioritize --top-k 5
# Execute: run the plan via swarm
npx @claude-flow/cli@latest goap execute --goal "vital sign monitoring"
# Visualize: emit DOT dependency graph
npx @claude-flow/cli@latest goap graph --format dot > goap.dot
```
### 3.3 Integration Points
| System | Integration | Purpose |
|--------|------------|---------|
| claude-flow memory | `goap` namespace | Persist world state, plans, execution history |
| claude-flow swarm | Hierarchical coordinator | Dispatch actions to agent teams |
| claude-flow hooks | `pre-task` / `post-task` | Trigger state observation before/after work |
| cargo test | State observation | Detect which crates/modules pass tests |
| USB device enumeration | Hardware observation | Detect ESP32 availability |
| Git status | Implementation detection | Check if files/modules exist |
---
## 4. Consequences
### 4.1 Positive
- **Eliminates manual priority analysis**: The developer states a goal; the planner produces a concrete, dependency-ordered action list.
- **Hardware-aware planning**: Actions requiring ESP32 or GPU are automatically excluded when hardware is unavailable, preventing dead-end plans.
- **Sublinear plan time**: Backward pruning + tier decomposition keeps planning under 5ms for typical goals, enabling interactive replanning.
- **Incremental replanning**: When state changes (a test starts passing, hardware is plugged in), only the delta is re-evaluated.
- **Swarm integration**: Actions are dispatched to specialized agents, enabling parallel execution of independent actions within the same tier.
- **Cross-session continuity**: World state and plan progress persist in the memory system, so the planner resumes where it left off.
- **PageRank prioritization**: When no specific goal is given, the planner identifies the highest-leverage next action based on the dependency graph structure.
- **Transparent reasoning**: The dependency graph can be visualized in DOT format, making the planner's reasoning inspectable.
### 4.2 Negative
- **Action catalog maintenance**: Every new ADR or ADR phase must be added to the action catalog with correct preconditions and effects. Stale actions produce incorrect plans.
- **State observation overhead**: Some state checks (running `cargo test`) are expensive. Caching with TTL mitigates this but introduces staleness risk.
- **Approximate cost model**: Action costs in developer-days are estimates. Actual effort varies with developer experience and codebase familiarity.
- **Boolean state simplification**: Some capabilities are continuous (accuracy improves gradually) but are modeled as boolean thresholds, losing nuance.
### 4.3 Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Action catalog diverges from reality | Medium | Plans reference nonexistent or completed actions | Validate catalog against ADR directory at plan time |
| State observation produces false positives | Low | Planner skips needed actions | Cross-validate with multiple observation methods |
| User goals conflict (accuracy vs latency) | Medium | Planner produces suboptimal compromise | Support multi-objective goals with explicit weights |
| Swarm agents fail during action execution | Medium | Plan stalls | Timeout + automatic replan with failure noted in state |
---
## 5. Affected Components
| Component | Change | Description |
|-----------|--------|-------------|
| `.claude-flow/goap/` | New | GOAP planner module (TypeScript) |
| claude-flow memory (`goap` namespace) | New | World state and plan persistence |
| claude-flow swarm coordinator | Extended | GOAP coordinator agent type |
| claude-flow CLI | Extended | `goap` subcommand (plan, observe, prioritize, execute, graph) |
---
## 6. Performance Budget
| Operation | Budget | Method |
|-----------|--------|--------|
| World state observation (cached) | < 100ms | Read from memory cache |
| World state observation (fresh) | < 30s | Run cargo test + hardware probes |
| Plan generation (sublinear) | < 5ms | Backward pruning + tier A* |
| PageRank prioritization | < 2ms | Sparse matrix iteration |
| Incremental replan | < 1ms | Delta patch on existing plan |
| DOT graph generation | < 1ms | Traverse action catalog |
---
## 7. Alternatives Considered
1. **Manual priority spreadsheet**: Maintain a spreadsheet of ADR priorities and dependencies. Rejected because it requires manual updates, does not adapt to hardware availability, and cannot be queried programmatically by agents.
2. **Full A* over raw state space**: Standard GOAP without sublinear optimizations. Rejected because 2^25 boolean states is unnecessarily large when most actions are irrelevant to any given goal.
3. **Hierarchical Task Network (HTN)**: HTN decomposes tasks into subtasks using predefined methods. More powerful than GOAP but requires hand-authored decomposition methods for every task. GOAP's flat action model with automatic planning is simpler to maintain as ADRs evolve.
4. **Reinforcement learning planner**: Train an RL agent to select actions. Rejected because the action space changes as ADRs are added, the reward signal is sparse (project completion), and the sample complexity is too high for a planning problem with known structure.
5. **Simple topological sort**: Sort actions by dependency order and execute top-down. Rejected because it does not consider goals (executes everything), does not handle hardware constraints, and does not support replanning.
---
## 8. References
1. Orkin, J. (2003). "Applying Goal-Oriented Action Planning to Games." AI Game Programming Wisdom 2.
2. Orkin, J. (2006). "Three States and a Plan: The A.I. of F.E.A.R." Game Developers Conference.
3. Page, L., Brin, S., Motwani, R., Winograd, T. (1999). "The PageRank Citation Ranking: Bringing Order to the Web." Stanford InfoLab.
4. Ghallab, M., Nau, D., Traverso, P. (2004). "Automated Planning: Theory and Practice." Morgan Kaufmann.
5. Russell, S., Norvig, P. (2020). "Artificial Intelligence: A Modern Approach." 4th ed., Chapter 11: Automated Planning.
+299
View File
@@ -0,0 +1,299 @@
# ADR-039: ESP32-S3 Edge Intelligence — On-Device Signal Processing and RuVector Integration
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-03 |
| **Depends on** | ADR-018 (binary frame format), ADR-014 (SOTA signal processing), ADR-021 (vital sign extraction), ADR-029 (multistatic sensing), ADR-030 (persistent field model), ADR-031 (RuView sensing-first RF) |
| **Supersedes** | None |
## Context
The current ESP32-S3 firmware (1,018 lines, 7 files) is a "dumb sensor" — it captures raw CSI frames and streams them unprocessed over UDP at ~20 Hz. All signal processing, feature extraction, presence detection, vital sign estimation, and pose inference happen server-side in the Rust crates.
This creates several limitations:
1. **Bandwidth waste** — raw CSI frames are 128-384 bytes each at 20 Hz = ~60 KB/s per node. Most of this is noise.
2. **Latency** — round-trip to server adds 5-50ms depending on network.
3. **Server dependency** — nodes are useless without an active aggregator.
4. **Scalability ceiling** — 6-node mesh at 20 Hz = 120 frames/s = server bottleneck.
5. **No local alerting** — fall detection, breathing anomaly, or intrusion must wait for server roundtrip.
The ESP32-S3 has significant untapped compute:
- **Dual-core Xtensa LX7** at 240 MHz
- **512 KB SRAM** + optional 8 MB PSRAM (our board has 8 MB flash)
- **Vector/DSP instructions** (PIE — Processor Instruction Extensions)
- **FPU** — hardware single-precision floating point
- **~80% idle CPU** — current firmware uses <20% (WiFi + CSI callback + UDP send)
## Decision
Implement a **3-tier edge intelligence pipeline** on the ESP32-S3 firmware, progressively offloading signal processing from the server to the device. Each tier is independently toggleable via NVS configuration.
### Tier 1: Smart Filtering & Compression (Firmware C)
Lightweight processing in the CSI callback path. Zero additional latency.
| Feature | Source ADR | Algorithm | Memory | CPU |
|---------|-----------|-----------|--------|-----|
| **Phase sanitization** | ADR-014 | Linear phase unwrap + conjugate multiply | 256 B | <1% |
| **Amplitude normalization** | ADR-014 | Per-subcarrier running mean/std (Welford) | 512 B | <1% |
| **Subcarrier selection** | ADR-016 (ruvector-mincut) | Top-K variance subcarriers | 128 B | <1% |
| **Static environment suppression** | ADR-030 | Exponential moving average subtraction | 512 B | <1% |
| **Adaptive frame decimation** | New | Skip frames when CSI variance < threshold | 8 B | <1% |
| **Delta compression** | New | XOR + RLE vs. previous frame | 512 B | <2% |
**Bandwidth reduction**: 60-80% (send only changed, high-variance subcarriers).
**ADR-018 v2 frame extension** (backward-compatible):
```
Existing 20-byte header unchanged.
New optional trailer (if magic bit set):
[N*2] Compressed I/Q (delta-coded, only selected subcarriers)
[2] Subcarrier bitmap (which of 64 subcarriers included)
[1] Frame flags: bit0=compressed, bit1=phase-sanitized, bit2=amplitude-normed
[1] Motion score (0-255)
[1] Presence confidence (0-255)
[1] Reserved
```
### Tier 2: On-Device Vital Signs & Presence (Firmware C + fixed-point DSP)
Runs as a FreeRTOS task on Core 1 (CSI collection on Core 0), processing a sliding window of CSI frames.
| Feature | Source ADR | Algorithm | Memory | CPU (Core 1) |
|---------|-----------|-----------|--------|--------------|
| **Presence detection** | ADR-029 | Variance threshold on amplitude envelope | 2 KB | 5% |
| **Motion scoring** | ADR-014 | Subcarrier correlation coefficient | 1 KB | 3% |
| **Breathing rate** | ADR-021 | Bandpass 0.1-0.5 Hz + peak detection on CSI phase | 8 KB | 10% |
| **Heart rate** | ADR-021 | Bandpass 0.8-2.0 Hz + autocorrelation on CSI phase | 8 KB | 15% |
| **Fall detection** | ADR-029 | Sudden variance spike + sustained stillness | 1 KB | 2% |
| **Room occupancy count** | ADR-037 | CSI rank estimation (eigenvalue spread) | 4 KB | 8% |
| **Coherence gate** | ADR-029 (ruvsense) | Z-score coherence, accept/reject/recalibrate | 1 KB | 2% |
**Total memory**: ~25 KB (fits in SRAM, no PSRAM needed).
**Total CPU**: ~45% of Core 1.
**Output**: Compact vital-signs UDP packet (32 bytes) at 1 Hz:
```
Offset Size Field
0 4 Magic: 0xC5110002 (vitals packet)
4 1 Node ID
5 1 Packet type (0x02 = vitals)
6 2 Sequence (LE u16)
8 1 Presence (0=empty, 1=present, 2=moving)
9 1 Motion score (0-255)
10 1 Occupancy estimate (0-8 persons)
11 1 Coherence gate (0=reject, 1=predict, 2=accept, 3=recalibrate)
12 2 Breathing rate (BPM * 100, LE u16) — 0 if not detected
14 2 Heart rate (BPM * 100, LE u16) — 0 if not detected
16 2 Breathing confidence (0-10000, LE u16)
18 2 Heart rate confidence (0-10000, LE u16)
20 1 Fall detected (0/1)
21 1 Anomaly flags (bitfield)
22 2 Ambient RSSI mean (LE i16)
24 4 CSI frame count since last report (LE u32)
28 4 Uptime seconds (LE u32)
```
### Tier 3: Lightweight Feature Extraction (Firmware C + optional PSRAM)
Pre-compute features that the server-side neural network needs, reducing server CPU by 60-80%.
| Feature | Source ADR | Algorithm | Memory | CPU |
|---------|-----------|-----------|--------|-----|
| **Phase difference matrix** | ADR-014 | Adjacent subcarrier phase diff | 4 KB | 5% |
| **Amplitude spectrogram** | ADR-014 | 64-bin FFT on 1s window per subcarrier | 32 KB | 15% |
| **Doppler-time map** | ADR-029 | 2D FFT across subcarriers × time | 16 KB | 10% |
| **Fresnel zone crossing** | ADR-014 | First Fresnel radius + fade count | 1 KB | 2% |
| **Cross-link correlation** | ADR-029 | Pearson correlation between TX-RX pairs | 2 KB | 5% |
| **Environment fingerprint** | ADR-027 (MERIDIAN) | PCA-compressed 16-dim CSI signature | 4 KB | 5% |
| **Gesture template match** | ADR-029 (ruvsense) | DTW on 8-dim feature vector | 8 KB | 10% |
**Total memory**: ~67 KB (SRAM) or up to 256 KB with PSRAM.
**Total CPU**: ~52% of Core 1.
**Output**: Feature vector UDP packet (variable size, ~200-500 bytes) at 4 Hz:
```
Offset Size Field
0 4 Magic: 0xC5110003 (feature packet)
4 1 Node ID
5 1 Packet type (0x03 = features)
6 2 Feature bitmap (which features included)
8 4 Timestamp ms (LE u32)
12 N Feature payloads (concatenated, lengths determined by bitmap)
```
## NVS Configuration
All tiers controllable via NVS without reflashing:
| NVS Key | Type | Default | Description |
|---------|------|---------|-------------|
| `edge_tier` | u8 | 0 | 0=raw only, 1=smart filter, 2=+vitals, 3=+features |
| `decim_thresh` | u16 | 100 | Adaptive decimation variance threshold |
| `subk_count` | u8 | 32 | Top-K subcarriers to keep (Tier 1) |
| `vital_window` | u16 | 300 | Vital sign window frames (15s at 20 Hz) |
| `vital_interval` | u16 | 1000 | Vital report interval ms |
| `feature_hz` | u8 | 4 | Feature extraction rate |
| `fall_thresh` | u16 | 500 | Fall detection variance spike threshold |
| `presence_thresh` | u16 | 50 | Presence detection threshold |
Provisioning:
```bash
python firmware/esp32-csi-node/provision.py --port COM7 \
--edge-tier 2 --vital-window 300 --presence-thresh 50
```
## Implementation Plan
### Phase 1: Infrastructure (1 week)
1. **Dual-core task architecture**
- Core 0: WiFi + CSI callback (existing)
- Core 1: Edge processing task (new FreeRTOS task)
- Lock-free ring buffer between cores (producer-consumer)
2. **Ring buffer design**
```c
#define RING_BUF_FRAMES 64 // ~3.2s at 20 Hz
typedef struct {
wifi_csi_info_t info;
int8_t iq_data[384]; // Max I/Q payload
uint32_t timestamp_ms;
uint8_t tx_mac[6];
} csi_ring_entry_t;
```
3. **NVS config extension** — add `edge_tier` and tier-specific params
4. **ADR-018 v2 header** — backward-compatible extension bit
### Phase 2: Tier 1 — Smart Filtering (1 week)
1. **Phase unwrap** — O(N) linear scan, in-place
2. **Welford running stats** — per-subcarrier mean/variance, O(1) update
3. **Top-K subcarrier selection** — partial sort, O(N) with selection algorithm
4. **Delta compression** — XOR vs previous frame, RLE encode
5. **Adaptive decimation** — skip frame if total variance < threshold
### Phase 3: Tier 2 — Vital Signs (2 weeks)
1. **Presence detector** — amplitude variance over 1s window
2. **Motion scorer** — correlation coefficient between consecutive frames
3. **Breathing extractor** — port from `wifi-densepose-vitals::BreathingExtractor::esp32_default()`
- Bandpass via biquad IIR filter (0.1-0.5 Hz)
- Peak detection with parabolic interpolation
- Fixed-point arithmetic (Q15.16) for efficiency
4. **Heart rate extractor** — port from `wifi-densepose-vitals::HeartRateExtractor::esp32_default()`
- Bandpass via biquad IIR (0.8-2.0 Hz)
- Autocorrelation peak search
5. **Fall detection** — variance spike (>5σ) followed by sustained stillness (>3s)
6. **Coherence gate** — port from `ruvsense::coherence_gate` (Z-score threshold)
### Phase 4: Tier 3 — Feature Extraction (2 weeks)
1. **FFT engine** — fixed-point 64-point FFT (radix-2 DIT, no library needed)
2. **Amplitude spectrogram** — 1s sliding window FFT per subcarrier
3. **Doppler-time map** — 2D FFT across subcarrier × time dimensions
4. **Phase difference matrix** — adjacent subcarrier Δφ
5. **Environment fingerprint** — online PCA (incremental SVD, 16 components)
6. **Gesture DTW** — 8 stored templates, dynamic time warping on 8-dim feature
### Phase 5: CI/CD + Testing (1 week)
1. **GitHub Actions firmware build** — Docker `espressif/idf:v5.2` on every PR
2. **Host-side unit tests** — compile edge processing functions on x86 with mock CSI data
3. **Credential leak check** — binary string scan in CI
4. **Binary size tracking** — fail CI if firmware exceeds 90% of partition
5. **QEMU smoke test** — boot verification, NVS load, task creation
## ESP32-S3 Resource Budget
| Resource | Available | Tier 1 | Tier 2 | Tier 3 | Remaining |
|----------|-----------|--------|--------|--------|-----------|
| **SRAM** | 512 KB | 2 KB | 25 KB | 67 KB | 418 KB |
| **Core 0 CPU** | 100% | 5% | 0% | 0% | 75% (WiFi uses ~20%) |
| **Core 1 CPU** | 100% | 0% | 45% | 52% | 3% (Tier 2+3 exclusive) |
| **Flash** | 1 MB partition | 4 KB code | 12 KB code | 20 KB code | 964 KB |
Note: Tier 2 and Tier 3 run on Core 1 but are time-multiplexed — vitals at 1 Hz, features at 4 Hz. Combined peak load is ~60% of Core 1.
## Mapping to Existing ADRs
| Existing ADR | Capability | Edge Tier | Implementation |
|-------------|------------|-----------|----------------|
| **ADR-014** (SOTA signal) | Phase sanitization | 1 | Linear unwrap in CSI callback |
| **ADR-014** | Amplitude normalization | 1 | Welford running stats |
| **ADR-014** | Feature extraction | 3 | FFT spectrogram + phase diff matrix |
| **ADR-014** | Fresnel zone detection | 3 | Fade counting + first Fresnel radius |
| **ADR-016** (RuVector) | Subcarrier selection | 1 | Top-K variance (simplified mincut) |
| **ADR-021** (Vitals) | Breathing rate | 2 | Biquad IIR + peak detect |
| **ADR-021** | Heart rate | 2 | Biquad IIR + autocorrelation |
| **ADR-021** | Anomaly detection | 2 | Z-score on vital readings |
| **ADR-027** (MERIDIAN) | Environment fingerprint | 3 | Online PCA, 16-dim signature |
| **ADR-029** (RuvSense) | Coherence gate | 2 | Z-score coherence scoring |
| **ADR-029** | Multistatic correlation | 3 | Pearson cross-link correlation |
| **ADR-029** | Gesture recognition | 3 | DTW template matching |
| **ADR-030** (Field model) | Static suppression | 1 | EMA background subtraction |
| **ADR-031** (RuView) | Sensing-first NDP | Existing | Already in firmware (stub) |
| **ADR-037** (Multi-person) | Occupancy counting | 2 | CSI rank estimation |
## Server-Side Changes
The Rust aggregator (`wifi-densepose-hardware`) needs to handle the new packet types:
```rust
match magic {
0xC5110001 => parse_raw_csi_frame(buf), // Existing
0xC5110002 => parse_vitals_packet(buf), // New: Tier 2
0xC5110003 => parse_feature_packet(buf), // New: Tier 3
_ => Err(ParseError::UnknownMagic(magic)),
}
```
When edge tier ≥ 1, the server can skip its own phase sanitization and amplitude normalization. When edge tier = 3, the server skips feature extraction entirely and feeds pre-computed features directly to the neural network.
## Testing Strategy
| Test Type | Tool | What |
|-----------|------|------|
| **Host unit tests** | gcc + Unity + mock CSI data | Phase unwrap, Welford stats, IIR filter, peak detect, DTW |
| **QEMU smoke test** | Docker QEMU | Boot, NVS load, task creation, ring buffer |
| **Hardware regression** | ESP32-S3 + serial log | Full pipeline: CSI → edge processing → UDP → server |
| **Accuracy validation** | Python reference impl | Compare edge vitals vs. server vitals on same CSI data |
| **Stress test** | 6-node mesh | Tier 3 at 20 Hz sustained, no frame drops |
## Alternatives Considered
1. **Rust on ESP32 (esp-rs)** — More type-safe, could share code with server crates. Rejected: larger binary, longer compile times, less mature ESP-IDF support for CSI APIs.
2. **MicroPython on ESP32** — Easier prototyping. Rejected: too slow for 20 Hz real-time processing, no fixed-point DSP.
3. **External co-processor (FPGA/DSP)** — Maximum throughput. Rejected: cost ($50+ per node), defeats the $8 ESP32 value proposition.
4. **Server-only processing** — Keep firmware dumb. Rejected: doesn't solve bandwidth, latency, or standalone operation requirements.
## Risks
| Risk | Mitigation |
|------|------------|
| Core 1 processing exceeds real-time budget | Adaptive quality: reduce feature_hz or fall back to lower tier |
| Fixed-point arithmetic introduces accuracy drift | Validate against Rust f64 reference on same CSI data; track error bounds |
| NVS config complexity overwhelms users | Sensible defaults; provision.py presets: `--preset home`, `--preset medical`, `--preset security` |
| ADR-018 v2 header breaks old aggregators | Backward-compatible: old magic = old format. New bit in flags field signals extension |
| Memory fragmentation from ring buffer | Static allocation only; no malloc in edge processing path |
## Success Criteria
- [ ] Tier 1 reduces bandwidth by ≥60% with <1 dB SNR loss
- [ ] Tier 2 breathing rate within ±1 BPM of server-side estimate
- [ ] Tier 2 heart rate within ±3 BPM of server-side estimate
- [ ] Tier 2 fall detection latency <500ms (vs. ~2s server roundtrip)
- [ ] Tier 2 presence detection accuracy ≥95%
- [ ] Tier 3 feature extraction matches server output within 5% RMSE
- [ ] All tiers: zero frame drops at 20 Hz sustained on single node
- [ ] Firmware binary stays under 90% of 1 MB app partition
- [ ] SRAM usage stays under 400 KB (leave headroom for WiFi stack)
- [ ] CI pipeline: build + host unit tests + binary size check on every PR
+12 -12
View File
@@ -484,12 +484,12 @@ The training pipeline is implemented in pure Rust (7,832 lines, zero external ML
The system supports two public WiFi CSI datasets:
| Dataset | Source | Format | Subjects | Environments |
|---------|--------|--------|----------|-------------|
| [MM-Fi](https://mmfi.github.io/) | NeurIPS 2023 | `.npy` | 40 | 4 rooms |
| [Wi-Pose](https://github.com/aiot-lab/Wi-Pose) | AAAI 2024 | `.mat` | 8 | 3 rooms |
| Dataset | Source | Format | Subjects | Environments | Download |
|---------|--------|--------|----------|-------------|----------|
| [MM-Fi](https://ntu-aiot-lab.github.io/mm-fi) | NeurIPS 2023 | `.npy` | 40 | 4 rooms | [GitHub repo](https://github.com/ybhbingo/MMFi_dataset) (Google Drive / Baidu links inside) |
| [Wi-Pose](https://github.com/NjtechCVLab/Wi-PoseDataset) | Entropy 2023 | `.mat` | 12 | 1 room | [GitHub repo](https://github.com/NjtechCVLab/Wi-PoseDataset) (Google Drive / Baidu links inside) |
Download and place in a `data/` directory.
Download the dataset files and place them in a `data/` directory.
### Step 2: Train
@@ -612,7 +612,7 @@ A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-no
**Flashing firmware:**
Pre-built binaries are available at [Releases](https://github.com/ruvnet/wifi-densepose/releases/tag/v0.1.0-esp32).
Pre-built binaries are available at [Releases](https://github.com/ruvnet/wifi-densepose/releases/tag/v0.2.0-esp32).
```bash
# Flash an ESP32-S3 (requires esptool: pip install esptool)
@@ -624,7 +624,7 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
**Provisioning:**
```bash
python scripts/provision.py --port COM7 \
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20
```
@@ -635,7 +635,7 @@ Replace `192.168.1.20` with the IP of the machine running the sensing server.
For multistatic mesh deployments with authenticated beacons (ADR-032), provision a shared mesh key:
```bash
python scripts/provision.py --port COM7 \
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20 \
--mesh-key "$(openssl rand -hex 32)"
```
@@ -648,13 +648,13 @@ Each node in a multistatic mesh needs a unique TDM slot ID (0-based):
```bash
# Node 0 (slot 0) — first transmitter
python scripts/provision.py --port COM7 --tdm-slot 0 --tdm-total 3
python firmware/esp32-csi-node/provision.py --port COM7 --tdm-slot 0 --tdm-total 3
# Node 1 (slot 1)
python scripts/provision.py --port COM8 --tdm-slot 1 --tdm-total 3
python firmware/esp32-csi-node/provision.py --port COM8 --tdm-slot 1 --tdm-total 3
# Node 2 (slot 2)
python scripts/provision.py --port COM9 --tdm-slot 2 --tdm-total 3
python firmware/esp32-csi-node/provision.py --port COM9 --tdm-slot 2 --tdm-total 3
```
**Start the aggregator:**
@@ -720,7 +720,7 @@ docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
### ESP32: No data arriving
1. Verify the ESP32 is connected to the same WiFi network
2. Check the target IP matches the sensing server machine: `python scripts/provision.py --port COM7 --target-ip <YOUR_IP>`
2. Check the target IP matches the sensing server machine: `python firmware/esp32-csi-node/provision.py --port COM7 --target-ip <YOUR_IP>`
3. Verify UDP port 5005 is not blocked by firewall
4. Test with: `nc -lu 5005` (Linux) or similar UDP listener
+1 -1
View File
@@ -1,4 +1,4 @@
idf_component_register(
SRCS "main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
SRCS "main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c" "edge_processing.c"
INCLUDE_DIRS "."
)
@@ -39,4 +39,93 @@ menu "CSI Node Configuration"
help
WiFi channel to listen on for CSI data.
config CSI_FILTER_MAC
string "CSI source MAC filter (AA:BB:CC:DD:EE:FF or empty)"
default ""
help
When set to a valid MAC address (e.g. "AA:BB:CC:DD:EE:FF"),
only CSI frames from that transmitter are processed. All
other frames are silently dropped. This prevents signal
mixing in multi-AP environments.
Leave empty to accept CSI from all transmitters.
Can be overridden at runtime via NVS key "filter_mac"
(6-byte blob) without reflashing.
endmenu
menu "Edge Intelligence (ADR-039)"
config EDGE_TIER
int "Edge processing tier (0=off, 1=phase/stats, 2=vitals)"
default 0
range 0 3
help
Controls the level of on-device CSI processing:
0 = Disabled. Raw CSI frames are streamed unchanged (default).
This preserves full backward compatibility.
1 = Phase sanitization + Welford statistics + top-K subcarrier
selection + delta compression. Runs on Core 1.
2 = All of Tier 1, plus presence detection, vital signs
extraction (breathing/heart rate), motion scoring,
and fall detection. Sends vitals packets over UDP.
3 = Reserved for future ML inference tier.
config EDGE_PRESENCE_THRESH
int "Presence detection threshold (0-65535)"
default 50
range 0 65535
depends on EDGE_TIER > 0
help
Amplitude variance threshold for presence detection.
Higher = less sensitive. Values below threshold/2 indicate
empty room; values above threshold indicate motion.
config EDGE_FALL_THRESH
int "Fall detection threshold (0-65535)"
default 500
range 0 65535
depends on EDGE_TIER > 0
help
Minimum variance spike (scaled by 100) required for fall
detection. The actual threshold is also gated by 5-sigma
above the running mean, whichever is higher.
config EDGE_VITAL_WINDOW
int "Vital signs window (frames, 60-600)"
default 300
range 60 600
depends on EDGE_TIER > 0
help
Number of phase history samples used for vital signs
estimation. At 20 Hz CSI rate, 300 frames = 15 seconds.
Larger windows give more stable estimates but respond
more slowly to changes.
config EDGE_VITAL_INTERVAL
int "Vitals packet send interval (ms)"
default 1000
range 100 60000
depends on EDGE_TIER > 0
help
How often to send a vitals summary packet over UDP.
1000 ms (1 Hz) is recommended for real-time dashboards.
Increase to reduce network bandwidth.
config EDGE_SUBK_COUNT
int "Top-K subcarrier count (1-192)"
default 32
range 1 192
depends on EDGE_TIER > 0
help
Number of highest-variance subcarriers to select for
downstream processing (vital signs, delta compression).
32 is a good default for HT20 (64 subcarriers).
Increase for HT40 (128 subcarriers).
endmenu
+52 -4
View File
@@ -13,6 +13,7 @@
#include "csi_collector.h"
#include "stream_sender.h"
#include "edge_processing.h"
#include <string.h>
#include "esp_log.h"
@@ -26,6 +27,15 @@ static uint32_t s_sequence = 0;
static uint32_t s_cb_count = 0;
static uint32_t s_send_ok = 0;
static uint32_t s_send_fail = 0;
static uint32_t s_filtered = 0;
/* ---- MAC address filter (Issue #98) ---- */
/** When non-zero, only CSI from s_filter_mac is accepted. */
static uint8_t s_filter_enabled = 0;
/** The accepted transmitter MAC address (6 bytes). */
static uint8_t s_filter_mac[6] = {0};
/* ---- ADR-029: Channel-hop state ---- */
@@ -124,20 +134,58 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
return frame_size;
}
void csi_collector_set_filter_mac(const uint8_t *mac)
{
if (mac == NULL) {
s_filter_enabled = 0;
memset(s_filter_mac, 0, 6);
ESP_LOGI(TAG, "MAC filter disabled — accepting CSI from all transmitters");
} else {
memcpy(s_filter_mac, mac, 6);
s_filter_enabled = 1;
ESP_LOGI(TAG, "MAC filter enabled: only accepting %02X:%02X:%02X:%02X:%02X:%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
s_filtered = 0;
}
/**
* WiFi CSI callback invoked by ESP-IDF when CSI data is available.
*
* When a MAC filter is active, frames from non-matching transmitters are
* silently dropped to prevent signal mixing in multi-AP environments.
*/
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
{
(void)ctx;
s_cb_count++;
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d",
(unsigned long)s_cb_count, info->len,
info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* ---- MAC address filter (Issue #98) ---- */
if (s_filter_enabled) {
if (memcmp(info->mac, s_filter_mac, 6) != 0) {
s_filtered++;
if (s_filtered <= 3 || (s_filtered % 500) == 0) {
ESP_LOGD(TAG, "Filtered CSI from %02X:%02X:%02X:%02X:%02X:%02X (dropped %lu)",
info->mac[0], info->mac[1], info->mac[2],
info->mac[3], info->mac[4], info->mac[5],
(unsigned long)s_filtered);
}
return;
}
}
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d mac=%02X:%02X:%02X:%02X:%02X:%02X",
(unsigned long)s_cb_count, info->len,
info->rx_ctrl.rssi, info->rx_ctrl.channel,
info->mac[0], info->mac[1], info->mac[2],
info->mac[3], info->mac[4], info->mac[5]);
}
/* ADR-039: Feed edge processing ring buffer (lock-free, O(1)).
* This is a no-op when edge_tier == 0. */
edge_push_csi(info);
uint8_t frame_buf[CSI_MAX_FRAME_SIZE];
size_t frame_len = csi_serialize_frame(info, frame_buf, sizeof(frame_buf));
@@ -22,12 +22,28 @@
/** Maximum number of channels in the hop table (ADR-029). */
#define CSI_HOP_CHANNELS_MAX 6
/** Length of a MAC address in bytes. */
#define CSI_MAC_LEN 6
/**
* Initialize CSI collection.
* Registers the WiFi CSI callback.
*/
void csi_collector_init(void);
/**
* Set a MAC address filter for CSI collection.
*
* When set, only CSI frames from the specified transmitter MAC are processed;
* all others are silently dropped. This prevents signal mixing in multi-AP
* environments.
*
* Pass NULL to disable filtering (accept CSI from all transmitters).
*
* @param mac 6-byte MAC address to accept, or NULL to disable filtering.
*/
void csi_collector_set_filter_mac(const uint8_t *mac);
/**
* Serialize CSI data into ADR-018 binary frame format.
*
@@ -0,0 +1,932 @@
/**
* @file edge_processing.c
* @brief ADR-039 Edge Intelligence on-device CSI processing.
*
* Implements a dual-core pipeline:
* Core 0 (ISR context): wifi_csi_callback -> edge_push_csi() -> SPSC ring
* Core 1 (edge_task): ring -> phase unwrap -> Welford -> top-K -> compress
* -> (Tier 2) presence / vitals / fall
*
* Memory budget (static):
* Ring buffer: 64 * ~400 B = ~25 KB
* Tier 1 state: ~4 KB
* Tier 2 state: ~2 KB
* Scratch: ~2 KB
* Total: ~33 KB on Core 1 stack + BSS
*
* All DSP uses the ESP32-S3 hardware single-precision FPU.
*/
#include "edge_processing.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "sdkconfig.h"
static const char *TAG = "edge_proc";
/* ================================================================== */
/* Configuration (loaded from nvs_config at init) */
/* ================================================================== */
static uint8_t s_tier = 0;
static uint8_t s_node_id = 0;
static uint16_t s_presence_thresh = 50;
static uint16_t s_fall_thresh = 500;
static uint16_t s_vital_window = 300;
static uint16_t s_vital_interval_ms = 1000;
static uint8_t s_subk_count = 32;
/* ================================================================== */
/* Lock-free SPSC ring buffer */
/* ================================================================== */
/**
* Lock-free single-producer single-consumer ring buffer.
*
* Producer (Core 0, ISR-safe): increments s_ring_write after writing.
* Consumer (Core 1, edge_task): increments s_ring_read after reading.
* Both indices are volatile to prevent compiler reordering.
* Ring capacity is EDGE_RING_SIZE - 1 to distinguish full from empty.
*/
static edge_csi_entry_t s_ring[EDGE_RING_SIZE];
static volatile uint32_t s_ring_write = 0; /**< Next write position (producer). */
static volatile uint32_t s_ring_read = 0; /**< Next read position (consumer). */
/** Notification semaphore: producer gives, consumer takes. */
static SemaphoreHandle_t s_ring_sem = NULL;
/** Number of entries in the ring (lock-free). */
static inline uint32_t ring_count(void)
{
uint32_t w = s_ring_write;
uint32_t r = s_ring_read;
return (w - r) & (EDGE_RING_SIZE - 1);
}
/** Check if ring is full. */
static inline bool ring_full(void)
{
return ring_count() >= (EDGE_RING_SIZE - 1);
}
/* ================================================================== */
/* Processing state (Core 1 only — no synchronization needed) */
/* ================================================================== */
static edge_tier1_state_t s_t1;
static edge_tier2_state_t s_t2;
/** Scratch buffers for DSP on Core 1. */
static float s_phase_buf[EDGE_MAX_SUBCARRIERS];
static float s_amp_buf[EDGE_MAX_SUBCARRIERS];
static float s_var_buf[EDGE_MAX_SUBCARRIERS];
static uint8_t s_topk_idx[EDGE_MAX_SUBCARRIERS]; /* worst case k == n */
/** Compressed output buffer. */
static uint8_t s_compress_buf[EDGE_MAX_IQ_LEN * 2];
/** Running RSSI accumulator (for vitals packet). */
static float s_rssi_sum = 0.0f;
static uint32_t s_rssi_count = 0;
/** Total frames processed and vitals sequence counter. */
static uint32_t s_frame_count = 0;
static uint16_t s_vitals_seq = 0;
/** Vitals packet send timer. */
static esp_timer_handle_t s_vitals_timer = NULL;
static volatile bool s_vitals_due = false;
/* ================================================================== */
/* Biquad IIR filter for vital signs (Tier 2) */
/* ================================================================== */
/**
* Second-order IIR (biquad) filter coefficients.
* Direct Form II Transposed.
*/
typedef struct {
float b0, b1, b2;
float a1, a2;
float z1, z2; /**< State variables. */
} biquad_t;
/**
* Pre-computed biquad coefficients for 20 Hz sample rate.
* These are bandpass filters designed with the bilinear transform.
*
* Breathing band: 0.1 - 0.5 Hz (6 - 30 BPM)
* Heart rate band: 0.8 - 2.0 Hz (48 - 120 BPM)
*
* Coefficients were computed offline using scipy.signal.iirfilter
* with Butterworth type, order=2, fs=20.
*/
/** Breathing bandpass: 0.1-0.5 Hz at 20 Hz sample rate, 2nd order Butterworth. */
static biquad_t s_bq_breath = {
.b0 = 0.02008337f,
.b1 = 0.0f,
.b2 = -0.02008337f,
.a1 = -1.93803473f,
.a2 = 0.95983326f,
.z1 = 0.0f, .z2 = 0.0f,
};
/** Heart rate bandpass: 0.8-2.0 Hz at 20 Hz sample rate, 2nd order Butterworth. */
static biquad_t s_bq_heart = {
.b0 = 0.09853117f,
.b1 = 0.0f,
.b2 = -0.09853117f,
.a1 = -1.53073372f,
.a2 = 0.80293766f,
.z1 = 0.0f, .z2 = 0.0f,
};
/** Apply biquad filter to a single sample (Direct Form II Transposed). */
static inline float biquad_process(biquad_t *bq, float x)
{
float y = bq->b0 * x + bq->z1;
bq->z1 = bq->b1 * x - bq->a1 * y + bq->z2;
bq->z2 = bq->b2 * x - bq->a2 * y;
return y;
}
/* ================================================================== */
/* Tier 1: Phase unwrap */
/* ================================================================== */
void edge_phase_unwrap(const int8_t *iq, uint16_t n_sc,
float *phase_out, float *phase_prev)
{
if (iq == NULL || phase_out == NULL || phase_prev == NULL || n_sc == 0) {
return;
}
for (uint16_t i = 0; i < n_sc; i++) {
float ii = (float)iq[2 * i];
float qq = (float)iq[2 * i + 1];
/* atan2 gives phase in [-pi, pi]. ESP32-S3 FPU handles this. */
float phase = atan2f(qq, ii);
/* Unwrap: correct jumps > pi relative to previous phase. */
float diff = phase - phase_prev[i];
if (diff > (float)M_PI) {
phase -= 2.0f * (float)M_PI;
} else if (diff < -(float)M_PI) {
phase += 2.0f * (float)M_PI;
}
phase_out[i] = phase;
phase_prev[i] = phase;
}
}
/* ================================================================== */
/* Tier 1: Welford online statistics */
/* ================================================================== */
void edge_welford_update(float value, float *mean, float *m2, uint32_t *count)
{
(*count)++;
float delta = value - *mean;
*mean += delta / (float)(*count);
float delta2 = value - *mean;
*m2 += delta * delta2;
}
float edge_welford_variance(float m2, uint32_t count)
{
if (count < 2) {
return 0.0f;
}
return m2 / (float)count;
}
/* ================================================================== */
/* Tier 1: Top-K subcarrier selection (partial sort) */
/* ================================================================== */
uint16_t edge_select_top_k(const float *variances, uint16_t n,
uint8_t k, uint8_t *selected)
{
if (variances == NULL || selected == NULL || n == 0 || k == 0) {
return 0;
}
/* Clamp k to available subcarriers and uint8_t max (255). */
uint16_t actual_k = (k < n) ? k : n;
if (actual_k > 255) {
actual_k = 255;
}
/*
* Simple O(n*k) selection good enough for n <= 192, k <= 64.
* A full partial-sort (quickselect) is overkill at these sizes.
*
* We maintain a sorted (descending) list of the top-k seen so far.
*/
float top_val[255];
uint8_t top_idx_local[255];
/* Initialize with -infinity. */
for (uint16_t i = 0; i < actual_k; i++) {
top_val[i] = -1.0e30f;
top_idx_local[i] = 0;
}
for (uint16_t i = 0; i < n; i++) {
float v = variances[i];
/* Check if v belongs in the top-k list. */
if (v > top_val[actual_k - 1]) {
/* Find insertion point (linear scan of small array). */
uint16_t pos = actual_k - 1;
while (pos > 0 && v > top_val[pos - 1]) {
top_val[pos] = top_val[pos - 1];
top_idx_local[pos] = top_idx_local[pos - 1];
pos--;
}
top_val[pos] = v;
top_idx_local[pos] = (uint8_t)i;
}
}
for (uint16_t i = 0; i < actual_k; i++) {
selected[i] = top_idx_local[i];
}
return (uint16_t)actual_k;
}
/* ================================================================== */
/* Tier 1: Delta compression (XOR + RLE) */
/* ================================================================== */
uint16_t edge_delta_compress(const int8_t *cur, const int8_t *prev,
uint16_t len, uint8_t *out, uint16_t out_len)
{
if (cur == NULL || prev == NULL || out == NULL || len == 0 || out_len < 2) {
return 0;
}
/*
* Algorithm:
* 1. XOR current with previous frame (delta).
* 2. RLE encode the delta: (count, value) pairs.
* - count is stored as uint8_t (max 255 consecutive same-value bytes).
* - This works well because CSI delta is often near-zero.
*/
uint16_t out_pos = 0;
uint16_t i = 0;
while (i < len) {
uint8_t delta_val = (uint8_t)(cur[i] ^ prev[i]);
uint8_t run_len = 1;
/* Count consecutive identical delta values. */
while (i + run_len < len && run_len < 255) {
uint8_t next_delta = (uint8_t)(cur[i + run_len] ^ prev[i + run_len]);
if (next_delta != delta_val) {
break;
}
run_len++;
}
/* Write (count, value) pair. */
if (out_pos + 2 > out_len) {
/* Output buffer full — compression failed to save space. */
return 0;
}
out[out_pos++] = run_len;
out[out_pos++] = delta_val;
i += run_len;
}
return out_pos;
}
/* ================================================================== */
/* Tier 2: Presence detection */
/* ================================================================== */
void edge_update_presence(edge_tier2_state_t *state,
const float *amplitudes, uint16_t n)
{
if (state == NULL || amplitudes == NULL || n == 0) {
return;
}
/*
* Compute total amplitude variance across all subcarriers.
* High variance = motion. Low but nonzero = static presence.
* Near-zero = empty room.
*/
float sum = 0.0f;
float sum_sq = 0.0f;
for (uint16_t i = 0; i < n; i++) {
sum += amplitudes[i];
sum_sq += amplitudes[i] * amplitudes[i];
}
float mean = sum / (float)n;
float var = (sum_sq / (float)n) - (mean * mean);
if (var < 0.0f) {
var = 0.0f;
}
/* Convert variance to an integer score. */
float var_scaled = var * 10.0f;
uint16_t var_int = (var_scaled > 65535.0f) ? 65535 : (uint16_t)var_scaled;
if (var_int < s_presence_thresh / 2) {
state->presence = 0; /* Empty */
state->motion_score = 0;
} else if (var_int < s_presence_thresh) {
state->presence = 1; /* Present (static) */
state->motion_score = (uint8_t)(var_int * 128 / s_presence_thresh);
} else {
state->presence = 2; /* Moving */
uint32_t score = (uint32_t)var_int * 255 / (s_presence_thresh * 10);
state->motion_score = (score > 255) ? 255 : (uint8_t)score;
}
/* Simple occupancy estimate: if motion on many subcarriers, likely > 1 person.
* Count subcarriers with amplitude > 2 * mean as "active". */
uint16_t active_count = 0;
float thresh = mean * 2.0f;
for (uint16_t i = 0; i < n; i++) {
if (amplitudes[i] > thresh) {
active_count++;
}
}
/* Heuristic: every ~24 active subcarriers roughly corresponds to 1 person
* in a typical 64-subcarrier environment. */
uint8_t occ = (uint8_t)(active_count / 24);
if (occ > 8) occ = 8;
if (state->presence == 0) occ = 0;
state->occupancy = occ;
/* Fall detection via variance spike. */
state->fall_detected = edge_detect_fall(state, var) ? 1 : 0;
}
/* ================================================================== */
/* Tier 2: Vital signs extraction */
/* ================================================================== */
void edge_update_vitals(edge_tier2_state_t *state,
const float *phases, uint16_t n)
{
if (state == NULL || phases == NULL || n == 0) {
return;
}
/*
* Use the first subcarrier's phase (caller should pass the best
* subcarrier selected by top-K). Push into circular buffer.
*/
float phase_val = phases[0];
state->phase_history[state->history_idx] = phase_val;
state->history_idx = (state->history_idx + 1) % EDGE_PHASE_HISTORY_LEN;
if (state->history_len < EDGE_PHASE_HISTORY_LEN) {
state->history_len++;
}
/*
* Only estimate vitals when we have at least 3 seconds of data (60 samples at 20 Hz).
* Full confidence requires the full window.
*/
if (state->history_len < 60) {
state->breathing_bpm = 0.0f;
state->heartrate_bpm = 0.0f;
state->breathing_confidence = 0.0f;
state->heartrate_confidence = 0.0f;
return;
}
/*
* Process the most recent samples through biquad bandpass filters.
* We filter the latest sample and count zero-crossings over the buffer.
*
* For real-time use we filter each incoming sample and count peaks
* over a sliding window.
*/
float breath_val = biquad_process(&s_bq_breath, phase_val);
float heart_val = biquad_process(&s_bq_heart, phase_val);
/*
* Peak counting: count positive zero-crossings over the history.
* We re-scan the last 'window' samples each time for simplicity.
* On ESP32-S3 at 20 Hz, scanning 300 floats is trivial (<0.1 ms).
*/
uint16_t window = state->history_len;
if (window > s_vital_window) {
window = s_vital_window;
}
/* Apply bandpass to the entire window and count peaks.
* We use temporary biquads for the full-window scan so as not to
* disturb the streaming filter state. */
biquad_t bq_br_tmp = s_bq_breath;
biquad_t bq_hr_tmp = s_bq_heart;
/* Reset temporary filter state. */
bq_br_tmp.z1 = 0.0f; bq_br_tmp.z2 = 0.0f;
bq_hr_tmp.z1 = 0.0f; bq_hr_tmp.z2 = 0.0f;
uint16_t breath_crossings = 0;
uint16_t heart_crossings = 0;
float prev_br = 0.0f;
float prev_hr = 0.0f;
/* Walk the circular buffer from oldest to newest. */
uint16_t start_idx;
if (state->history_len < EDGE_PHASE_HISTORY_LEN) {
start_idx = 0;
} else {
start_idx = state->history_idx; /* Oldest entry. */
}
for (uint16_t j = 0; j < window; j++) {
uint16_t idx = (start_idx + j) % EDGE_PHASE_HISTORY_LEN;
float sample = state->phase_history[idx];
float br = biquad_process(&bq_br_tmp, sample);
float hr = biquad_process(&bq_hr_tmp, sample);
/* Positive zero crossing. */
if (j > 0) {
if (prev_br <= 0.0f && br > 0.0f) {
breath_crossings++;
}
if (prev_hr <= 0.0f && hr > 0.0f) {
heart_crossings++;
}
}
prev_br = br;
prev_hr = hr;
}
/* Convert crossings to BPM.
* Each positive zero crossing corresponds to one cycle.
* window samples at 20 Hz = window/20 seconds. */
float duration_s = (float)window / 20.0f;
if (duration_s > 0.0f) {
state->breathing_bpm = (float)breath_crossings * 60.0f / duration_s;
state->heartrate_bpm = (float)heart_crossings * 60.0f / duration_s;
}
/* Clamp to physiological ranges. */
if (state->breathing_bpm < 4.0f) state->breathing_bpm = 0.0f;
if (state->breathing_bpm > 40.0f) state->breathing_bpm = 0.0f;
if (state->heartrate_bpm < 40.0f) state->heartrate_bpm = 0.0f;
if (state->heartrate_bpm > 150.0f) state->heartrate_bpm = 0.0f;
/* Confidence: based on signal amplitude relative to noise floor.
* Higher filtered amplitude = more confident. */
float br_amp = fabsf(breath_val);
float hr_amp = fabsf(heart_val);
state->breathing_confidence = (br_amp > 0.5f) ? 1.0f : br_amp * 2.0f;
state->heartrate_confidence = (hr_amp > 0.3f) ? 1.0f : hr_amp * 3.33f;
if (state->breathing_confidence > 1.0f) state->breathing_confidence = 1.0f;
if (state->heartrate_confidence > 1.0f) state->heartrate_confidence = 1.0f;
/* If no presence detected, zero out vitals. */
if (state->presence == 0) {
state->breathing_bpm = 0.0f;
state->heartrate_bpm = 0.0f;
state->breathing_confidence = 0.0f;
state->heartrate_confidence = 0.0f;
}
(void)breath_val;
(void)heart_val;
}
/* ================================================================== */
/* Tier 2: Fall detection */
/* ================================================================== */
bool edge_detect_fall(edge_tier2_state_t *state, float current_variance)
{
if (state == NULL) {
return false;
}
/* Store current variance in history ring. */
state->variance_history[state->var_idx] = current_variance;
state->var_idx = (state->var_idx + 1) % EDGE_VAR_HISTORY_LEN;
/*
* Fall detection heuristic:
* 1. Compute mean and stdev of variance history.
* 2. If current variance > mean + 5*stdev, that is a "spike".
* 3. If the last 3 entries after the spike show low variance
* (< mean), declare a fall (spike + stillness).
*
* At 20 Hz and 20-entry history, this covers the last 1 second.
* We check the last ~3 seconds by requiring the spike to have
* happened recently and stillness to follow.
*/
float sum = 0.0f;
float sum_sq = 0.0f;
uint8_t valid = 0;
for (uint8_t i = 0; i < EDGE_VAR_HISTORY_LEN; i++) {
float v = state->variance_history[i];
if (v >= 0.0f) {
sum += v;
sum_sq += v * v;
valid++;
}
}
if (valid < 10) {
return false; /* Not enough history yet. */
}
float mean = sum / (float)valid;
float var_of_var = (sum_sq / (float)valid) - (mean * mean);
if (var_of_var < 0.0f) var_of_var = 0.0f;
float stdev = sqrtf(var_of_var);
float spike_thresh = mean + 5.0f * stdev;
if (spike_thresh < (float)s_fall_thresh / 100.0f) {
spike_thresh = (float)s_fall_thresh / 100.0f;
}
/* Check if there was a recent spike (within last 10 entries)
* followed by low values (last 3 entries). */
bool saw_spike = false;
for (uint8_t i = 0; i < 10; i++) {
uint8_t idx = (state->var_idx + EDGE_VAR_HISTORY_LEN - 1 - i) % EDGE_VAR_HISTORY_LEN;
if (state->variance_history[idx] > spike_thresh) {
saw_spike = true;
break;
}
}
if (!saw_spike) {
return false;
}
/* Check if the last 3 entries show stillness. */
uint8_t still_count = 0;
for (uint8_t i = 0; i < 3; i++) {
uint8_t idx = (state->var_idx + EDGE_VAR_HISTORY_LEN - 1 - i) % EDGE_VAR_HISTORY_LEN;
if (state->variance_history[idx] < mean * 0.5f) {
still_count++;
}
}
return (still_count >= 2);
}
/* ================================================================== */
/* Vitals packet construction and send */
/* ================================================================== */
static void send_vitals_packet(void)
{
edge_vitals_packet_t pkt;
memset(&pkt, 0, sizeof(pkt));
pkt.magic = EDGE_VITALS_MAGIC;
pkt.node_id = s_node_id;
pkt.pkt_type = EDGE_PKT_TYPE_VITALS;
pkt.sequence = s_vitals_seq++;
pkt.presence = s_t2.presence;
pkt.motion_score = s_t2.motion_score;
pkt.occupancy = s_t2.occupancy;
pkt.coherence_gate = 0; /* Reserved. */
pkt.breathing_bpm_x100 = (uint16_t)(s_t2.breathing_bpm * 100.0f);
pkt.heartrate_bpm_x100 = (uint16_t)(s_t2.heartrate_bpm * 100.0f);
pkt.breathing_conf = (uint16_t)(s_t2.breathing_confidence * 10000.0f);
pkt.heartrate_conf = (uint16_t)(s_t2.heartrate_confidence * 10000.0f);
pkt.fall_detected = s_t2.fall_detected;
pkt.anomaly_flags = 0;
if (s_rssi_count > 0) {
pkt.rssi_mean = (int16_t)(s_rssi_sum / (float)s_rssi_count);
} else {
pkt.rssi_mean = 0;
}
pkt.csi_count = s_frame_count;
pkt.uptime_s = (uint32_t)(esp_timer_get_time() / 1000000ULL);
/* Send via existing UDP sender. */
stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
ESP_LOGD(TAG, "Vitals pkt #%u: presence=%u motion=%u br=%.1f hr=%.1f",
pkt.sequence, pkt.presence, pkt.motion_score,
s_t2.breathing_bpm, s_t2.heartrate_bpm);
}
/**
* Timer callback for periodic vitals packet transmission.
* Sets a flag that the edge task checks avoids doing work in timer context.
*/
static void vitals_timer_cb(void *arg)
{
(void)arg;
s_vitals_due = true;
}
/* ================================================================== */
/* Edge processing task (pinned to Core 1) */
/* ================================================================== */
/**
* Process a single CSI frame through the Tier 1 pipeline.
*/
static void process_tier1(const edge_csi_entry_t *entry)
{
uint16_t n_sc = entry->iq_len / 2;
if (n_sc == 0 || n_sc > EDGE_MAX_SUBCARRIERS) {
return;
}
/* Phase unwrap. */
edge_phase_unwrap(entry->iq_data, n_sc, s_phase_buf, s_t1.phase_prev);
/* Compute amplitudes and update Welford stats. */
for (uint16_t i = 0; i < n_sc; i++) {
float ii = (float)entry->iq_data[2 * i];
float qq = (float)entry->iq_data[2 * i + 1];
s_amp_buf[i] = sqrtf(ii * ii + qq * qq);
edge_welford_update(s_amp_buf[i],
&s_t1.amp_mean[i],
&s_t1.amp_m2[i],
&s_t1.amp_count);
}
/* Note: amp_count is shared across subcarriers (they all advance together).
* This is correct because we call Welford once per subcarrier per frame,
* and all subcarriers receive the same frame count. The count represents
* the number of frames seen, not per-subcarrier counts. */
/* Compute per-subcarrier variance for top-K selection. */
for (uint16_t i = 0; i < n_sc; i++) {
s_var_buf[i] = edge_welford_variance(s_t1.amp_m2[i], s_t1.amp_count);
}
/* Select top-K highest-variance subcarriers. */
uint8_t k = s_subk_count;
if (k > n_sc) k = (uint8_t)n_sc;
uint16_t selected = edge_select_top_k(s_var_buf, n_sc, k, s_topk_idx);
(void)selected; /* Available for downstream use. */
/* Delta compress if we have a previous frame. */
if (s_t1.has_prev) {
uint16_t compressed_len = edge_delta_compress(
entry->iq_data, s_t1.prev_iq,
entry->iq_len, s_compress_buf, sizeof(s_compress_buf));
(void)compressed_len; /* Will be used for Tier 3 compressed streaming. */
}
/* Store current frame as previous for next delta. */
memcpy(s_t1.prev_iq, entry->iq_data, entry->iq_len);
s_t1.has_prev = true;
/* Accumulate RSSI for vitals packet. */
s_rssi_sum += (float)entry->rssi;
s_rssi_count++;
}
/**
* Process a single CSI frame through the Tier 2 pipeline.
* Requires Tier 1 to have run first (uses s_phase_buf, s_amp_buf).
*/
static void process_tier2(const edge_csi_entry_t *entry)
{
uint16_t n_sc = entry->iq_len / 2;
if (n_sc == 0 || n_sc > EDGE_MAX_SUBCARRIERS) {
return;
}
/* Presence and motion detection from amplitudes. */
edge_update_presence(&s_t2, s_amp_buf, n_sc);
/* Vital signs from the best subcarrier's phase.
* Use the first entry in the top-K list (highest variance). */
if (s_subk_count > 0 && n_sc > 0) {
uint8_t best_sc = s_topk_idx[0];
if (best_sc < n_sc) {
float best_phase = s_phase_buf[best_sc];
edge_update_vitals(&s_t2, &best_phase, 1);
}
}
}
/**
* Main edge processing task runs on Core 1.
*
* Blocks on the ring buffer semaphore, then drains all available entries.
*/
static void edge_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "Edge task started on core %d (tier=%u)",
xPortGetCoreID(), (unsigned)s_tier);
while (1) {
/* Block until producer signals new data (or timeout for vitals). */
xSemaphoreTake(s_ring_sem, pdMS_TO_TICKS(100));
/* Drain all available ring entries. */
while (s_ring_read != s_ring_write) {
uint32_t idx = s_ring_read & (EDGE_RING_SIZE - 1);
const edge_csi_entry_t *entry = &s_ring[idx];
/* Tier 1: always run if tier >= 1. */
process_tier1(entry);
s_frame_count++;
/* Tier 2: run if tier >= 2. */
if (s_tier >= 2) {
process_tier2(entry);
}
/* Advance read pointer (memory barrier via volatile). */
s_ring_read++;
}
/* Send vitals packet at configured interval (Tier 2). */
if (s_tier >= 2 && s_vitals_due) {
s_vitals_due = false;
send_vitals_packet();
/* Reset RSSI accumulator. */
s_rssi_sum = 0.0f;
s_rssi_count = 0;
}
}
}
/* ================================================================== */
/* Public API */
/* ================================================================== */
void edge_push_csi(const wifi_csi_info_t *info)
{
if (s_tier == 0 || info == NULL || info->buf == NULL) {
return;
}
/* Check ring space. */
if (ring_full()) {
/* Drop frame — producer must never block in ISR context. */
static uint32_t s_drop_count = 0;
s_drop_count++;
if (s_drop_count <= 3 || (s_drop_count % 1000) == 0) {
ESP_LOGW(TAG, "Ring full, frame dropped (total=%lu)",
(unsigned long)s_drop_count);
}
return;
}
/* Write entry at current write position. */
uint32_t idx = s_ring_write & (EDGE_RING_SIZE - 1);
edge_csi_entry_t *entry = &s_ring[idx];
uint16_t iq_len = (uint16_t)info->len;
if (iq_len > EDGE_MAX_IQ_LEN) {
iq_len = EDGE_MAX_IQ_LEN;
}
memcpy(entry->iq_data, info->buf, iq_len);
entry->iq_len = iq_len;
entry->rssi = (int8_t)info->rx_ctrl.rssi;
entry->noise_floor = (int8_t)info->rx_ctrl.noise_floor;
entry->channel = (uint8_t)info->rx_ctrl.channel;
memcpy(entry->tx_mac, info->mac, 6);
entry->timestamp_ms = (uint32_t)(esp_timer_get_time() / 1000ULL);
/* Advance write pointer (volatile write acts as release fence). */
s_ring_write++;
/* Wake the consumer task. */
if (s_ring_sem != NULL) {
xSemaphoreGiveFromISR(s_ring_sem, NULL);
}
}
uint8_t edge_get_tier(void)
{
return s_tier;
}
void edge_processing_init(uint8_t tier)
{
s_tier = tier;
if (tier == 0) {
ESP_LOGI(TAG, "Edge processing disabled (tier=0)");
return;
}
ESP_LOGI(TAG, "Initializing edge processing tier=%u", (unsigned)tier);
/* Read configuration from the extern nvs_config (already loaded in main). */
/* These are set via the Kconfig / NVS defaults applied in nvs_config_load. */
extern nvs_config_t s_cfg; /* Defined in main.c */
s_node_id = s_cfg.node_id;
s_presence_thresh = s_cfg.presence_thresh;
s_fall_thresh = s_cfg.fall_thresh;
s_vital_window = s_cfg.vital_window;
s_vital_interval_ms = s_cfg.vital_interval_ms;
s_subk_count = s_cfg.subk_count;
ESP_LOGI(TAG, " presence_thresh=%u fall_thresh=%u vital_window=%u interval=%ums subk=%u",
s_presence_thresh, s_fall_thresh, s_vital_window,
s_vital_interval_ms, s_subk_count);
/* Initialize state. */
memset(&s_t1, 0, sizeof(s_t1));
memset(&s_t2, 0, sizeof(s_t2));
s_ring_write = 0;
s_ring_read = 0;
s_frame_count = 0;
s_vitals_seq = 0;
s_rssi_sum = 0.0f;
s_rssi_count = 0;
s_vitals_due = false;
/* Reset biquad filter state. */
s_bq_breath.z1 = 0.0f; s_bq_breath.z2 = 0.0f;
s_bq_heart.z1 = 0.0f; s_bq_heart.z2 = 0.0f;
/* Create notification semaphore (binary). */
s_ring_sem = xSemaphoreCreateBinary();
if (s_ring_sem == NULL) {
ESP_LOGE(TAG, "Failed to create ring semaphore");
return;
}
/* Create edge processing task pinned to Core 1.
* Stack size: 8 KB is sufficient for our static-alloc pipeline. */
BaseType_t ret = xTaskCreatePinnedToCore(
edge_task,
"edge_task",
8192, /* Stack size in bytes. */
NULL,
5, /* Priority (above idle, below WiFi). */
NULL,
1 /* Core 1. */
);
if (ret != pdPASS) {
ESP_LOGE(TAG, "Failed to create edge task");
return;
}
/* For Tier 2: start the periodic vitals packet timer. */
if (tier >= 2 && s_vital_interval_ms > 0) {
esp_timer_create_args_t timer_args = {
.callback = vitals_timer_cb,
.arg = NULL,
.name = "vitals_tx",
};
esp_err_t err = esp_timer_create(&timer_args, &s_vitals_timer);
if (err == ESP_OK) {
err = esp_timer_start_periodic(s_vitals_timer,
(uint64_t)s_vital_interval_ms * 1000);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start vitals timer: %s",
esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "Vitals timer started: interval=%u ms",
s_vital_interval_ms);
}
} else {
ESP_LOGE(TAG, "Failed to create vitals timer: %s",
esp_err_to_name(err));
}
}
ESP_LOGI(TAG, "Edge processing initialized (tier=%u, ring=%u slots)",
(unsigned)tier, (unsigned)EDGE_RING_SIZE);
}
@@ -0,0 +1,242 @@
/**
* @file edge_processing.h
* @brief ADR-039 Edge Intelligence on-device CSI processing.
*
* Phase 1 + Tier 1: Phase sanitization, Welford running statistics,
* subcarrier selection, and delta compression on the ESP32-S3.
*
* Tier 2 (optional): Presence detection, vital signs extraction,
* motion scoring, and fall detection.
*
* Design:
* - Lock-free SPSC ring buffer (Core 0 produces, Core 1 consumes).
* - FreeRTOS task pinned to Core 1 for DSP.
* - All static allocation, no malloc in hot path.
* - edge_tier=0 disables edge processing (existing behavior preserved).
*/
#ifndef EDGE_PROCESSING_H
#define EDGE_PROCESSING_H
#include <stdint.h>
#include <stdbool.h>
#include "esp_wifi_types.h"
/* ------------------------------------------------------------------ */
/* Ring buffer configuration */
/* ------------------------------------------------------------------ */
/** Ring buffer capacity (must be power of 2). */
#define EDGE_RING_SIZE 64
/** Maximum I/Q data length per CSI frame (4 antennas * 256 subcarriers * 2). */
#define EDGE_MAX_IQ_LEN 384
/** Ring buffer entry — copied from the CSI callback on Core 0. */
typedef struct {
int8_t iq_data[EDGE_MAX_IQ_LEN];
uint16_t iq_len;
int8_t rssi;
int8_t noise_floor;
uint8_t channel;
uint8_t tx_mac[6];
uint32_t timestamp_ms;
} edge_csi_entry_t;
/* ------------------------------------------------------------------ */
/* Tier 1: Phase sanitization and subcarrier selection */
/* ------------------------------------------------------------------ */
/** Maximum subcarriers we track (HT40 = 128 subcarriers, with margin). */
#define EDGE_MAX_SUBCARRIERS 192
/** Per-subcarrier running statistics for phase unwrap and Welford. */
typedef struct {
float phase_prev[EDGE_MAX_SUBCARRIERS]; /**< Previous phase for unwrap. */
float amp_mean[EDGE_MAX_SUBCARRIERS]; /**< Welford running mean of amplitude. */
float amp_m2[EDGE_MAX_SUBCARRIERS]; /**< Welford M2 accumulator. */
uint32_t amp_count; /**< Total sample count. */
int8_t prev_iq[EDGE_MAX_IQ_LEN]; /**< Previous I/Q frame for delta compression. */
bool has_prev; /**< True after first frame received. */
} edge_tier1_state_t;
/* ------------------------------------------------------------------ */
/* Tier 2: Vital signs and presence detection */
/* ------------------------------------------------------------------ */
/** Phase history depth: 15 seconds at 20 Hz. */
#define EDGE_PHASE_HISTORY_LEN 300
/** Variance history depth for fall detection. */
#define EDGE_VAR_HISTORY_LEN 20
typedef struct {
float phase_history[EDGE_PHASE_HISTORY_LEN]; /**< Ring buffer of phases for vital signs. */
uint16_t history_len; /**< Number of valid entries. */
uint16_t history_idx; /**< Current write index. */
float breathing_bpm; /**< Estimated breathing rate (BPM). */
float heartrate_bpm; /**< Estimated heart rate (BPM). */
float breathing_confidence; /**< Confidence [0..1]. */
float heartrate_confidence; /**< Confidence [0..1]. */
uint8_t presence; /**< 0=empty, 1=present, 2=moving. */
uint8_t motion_score; /**< 0-255 motion intensity. */
uint8_t occupancy; /**< Estimated occupant count (0-8). */
uint8_t fall_detected; /**< 1 if fall detected in current window. */
float variance_history[EDGE_VAR_HISTORY_LEN]; /**< Recent variance for fall detection. */
uint8_t var_idx; /**< Write index into variance_history. */
} edge_tier2_state_t;
/* ------------------------------------------------------------------ */
/* Vitals UDP packet (Tier 2, Magic 0xC5110002) */
/* ------------------------------------------------------------------ */
/** ADR-039 vitals packet magic number. */
#define EDGE_VITALS_MAGIC 0xC5110002
/** Vitals packet type identifier. */
#define EDGE_PKT_TYPE_VITALS 0x02
/**
* Vitals packet 32 bytes, sent at 1 Hz over UDP.
* Compatible with the ADR-018 aggregator (different magic discriminates).
*/
typedef struct __attribute__((packed)) {
uint32_t magic; /**< 0xC5110002 */
uint8_t node_id;
uint8_t pkt_type; /**< EDGE_PKT_TYPE_VITALS */
uint16_t sequence;
uint8_t presence; /**< 0=empty, 1=present, 2=moving */
uint8_t motion_score; /**< 0-255 */
uint8_t occupancy; /**< 0-8 */
uint8_t coherence_gate; /**< Reserved for future use */
uint16_t breathing_bpm_x100; /**< BPM * 100 */
uint16_t heartrate_bpm_x100; /**< BPM * 100 */
uint16_t breathing_conf; /**< Confidence * 10000 */
uint16_t heartrate_conf; /**< Confidence * 10000 */
uint8_t fall_detected;
uint8_t anomaly_flags; /**< Reserved */
int16_t rssi_mean; /**< Averaged RSSI */
uint32_t csi_count; /**< Total frames processed */
uint32_t uptime_s; /**< Seconds since boot */
} edge_vitals_packet_t;
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
/**
* Initialize edge processing.
*
* @param tier Processing tier (0=disabled, 1=phase/stats/compress, 2=vitals).
* Tier 0 is a no-op for backward compatibility.
*/
void edge_processing_init(uint8_t tier);
/**
* Push a CSI frame into the edge processing ring buffer.
* Called from the CSI callback on Core 0. Lock-free, O(1).
*
* @param info WiFi CSI info from the ESP-IDF callback.
*/
void edge_push_csi(const wifi_csi_info_t *info);
/**
* Get the currently configured edge processing tier.
*
* @return Tier (0-3).
*/
uint8_t edge_get_tier(void);
/* ------------------------------------------------------------------ */
/* Tier 1 pure functions (suitable for unit testing) */
/* ------------------------------------------------------------------ */
/**
* Phase unwrap: extract phase from I/Q data with 2pi correction.
*
* @param iq Raw I/Q pairs (I0, Q0, I1, Q1, ...).
* @param n_sc Number of subcarriers.
* @param phase_out Output phases in radians (size >= n_sc).
* @param phase_prev Previous phases for unwrap (updated in place).
*/
void edge_phase_unwrap(const int8_t *iq, uint16_t n_sc,
float *phase_out, float *phase_prev);
/**
* Welford online algorithm update running mean and M2.
*
* @param value New sample value.
* @param mean Running mean (updated in place).
* @param m2 Running M2 (updated in place).
* @param count Sample count (updated in place).
*/
void edge_welford_update(float value, float *mean, float *m2, uint32_t *count);
/**
* Compute variance from Welford M2 accumulator.
*
* @param m2 M2 value.
* @param count Sample count (must be >= 2).
* @return Population variance, or 0 if count < 2.
*/
float edge_welford_variance(float m2, uint32_t count);
/**
* Select top-K subcarriers by variance (partial sort).
*
* @param variances Variance array (size n).
* @param n Total subcarrier count.
* @param k Number to select.
* @param selected Output array of selected indices (size >= k).
* @return Actual number selected (min(k, n)).
*/
uint16_t edge_select_top_k(const float *variances, uint16_t n,
uint8_t k, uint8_t *selected);
/**
* Delta compress I/Q data: XOR with previous frame, then simple RLE.
*
* @param cur Current I/Q data.
* @param prev Previous I/Q data.
* @param len Length of I/Q data in bytes.
* @param out Output buffer for compressed data.
* @param out_len Size of output buffer.
* @return Number of bytes written to out, or 0 if compression failed.
*/
uint16_t edge_delta_compress(const int8_t *cur, const int8_t *prev,
uint16_t len, uint8_t *out, uint16_t out_len);
/* ------------------------------------------------------------------ */
/* Tier 2 functions */
/* ------------------------------------------------------------------ */
/**
* Update presence / motion detection from amplitude data.
*
* @param state Tier 2 state (updated in place).
* @param amplitudes Amplitude array for current frame.
* @param n Number of subcarriers.
*/
void edge_update_presence(edge_tier2_state_t *state,
const float *amplitudes, uint16_t n);
/**
* Update vital signs estimation from phase data.
*
* @param state Tier 2 state (updated in place).
* @param phases Phase array for current frame.
* @param n Number of subcarriers.
*/
void edge_update_vitals(edge_tier2_state_t *state,
const float *phases, uint16_t n);
/**
* Check for fall event: variance spike >5 sigma followed by stillness.
*
* @param state Tier 2 state (updated in place).
* @param current_variance Current frame variance.
* @return true if a fall is detected.
*/
bool edge_detect_fall(edge_tier2_state_t *state, float current_variance);
#endif /* EDGE_PROCESSING_H */
+16 -4
View File
@@ -21,11 +21,13 @@
#include "csi_collector.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "edge_processing.h"
static const char *TAG = "main";
/* Runtime configuration (loaded from NVS or Kconfig defaults). */
static nvs_config_t s_cfg;
/* Runtime configuration (loaded from NVS or Kconfig defaults).
* Non-static so edge_processing.c can access it via extern. */
nvs_config_t s_cfg;
/* Event group bits */
#define WIFI_CONNECTED_BIT BIT0
@@ -134,8 +136,18 @@ void app_main(void)
/* Initialize CSI collection */
csi_collector_init();
ESP_LOGI(TAG, "CSI streaming active → %s:%d",
s_cfg.target_ip, s_cfg.target_port);
/* Apply MAC address filter if configured (Issue #98) */
if (s_cfg.filter_mac_enabled) {
csi_collector_set_filter_mac(s_cfg.filter_mac);
} else {
ESP_LOGI(TAG, "No MAC filter — accepting CSI from all transmitters");
}
/* ADR-039: Initialize edge processing (tier 0 = no-op for backward compat) */
edge_processing_init(s_cfg.edge_tier);
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u)",
s_cfg.target_ip, s_cfg.target_port, (unsigned)s_cfg.edge_tier);
/* Main loop — keep alive */
while (1) {
+139
View File
@@ -9,6 +9,7 @@
#include "nvs_config.h"
#include <string.h>
#include <stdio.h>
#include "esp_log.h"
#include "nvs_flash.h"
#include "nvs.h"
@@ -51,6 +52,66 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->tdm_slot_index = 0;
cfg->tdm_node_count = 1;
/* MAC filter: default disabled (all zeros) */
memset(cfg->filter_mac, 0, 6);
cfg->filter_mac_enabled = 0;
/* ADR-039: Edge processing defaults */
#ifdef CONFIG_EDGE_TIER
cfg->edge_tier = (uint8_t)CONFIG_EDGE_TIER;
#else
cfg->edge_tier = 0;
#endif
#ifdef CONFIG_EDGE_PRESENCE_THRESH
cfg->presence_thresh = (uint16_t)CONFIG_EDGE_PRESENCE_THRESH;
#else
cfg->presence_thresh = 50;
#endif
#ifdef CONFIG_EDGE_FALL_THRESH
cfg->fall_thresh = (uint16_t)CONFIG_EDGE_FALL_THRESH;
#else
cfg->fall_thresh = 500;
#endif
#ifdef CONFIG_EDGE_VITAL_WINDOW
cfg->vital_window = (uint16_t)CONFIG_EDGE_VITAL_WINDOW;
#else
cfg->vital_window = 300;
#endif
#ifdef CONFIG_EDGE_VITAL_INTERVAL
cfg->vital_interval_ms = (uint16_t)CONFIG_EDGE_VITAL_INTERVAL;
#else
cfg->vital_interval_ms = 1000;
#endif
#ifdef CONFIG_EDGE_SUBK_COUNT
cfg->subk_count = (uint8_t)CONFIG_EDGE_SUBK_COUNT;
#else
cfg->subk_count = 32;
#endif
/* Parse compile-time Kconfig MAC filter if set (format: "AA:BB:CC:DD:EE:FF") */
#ifdef CONFIG_CSI_FILTER_MAC
{
const char *mac_str = CONFIG_CSI_FILTER_MAC;
unsigned int m[6];
if (mac_str[0] != '\0' &&
sscanf(mac_str, "%x:%x:%x:%x:%x:%x",
&m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6) {
for (int i = 0; i < 6; i++) {
cfg->filter_mac[i] = (uint8_t)m[i];
}
cfg->filter_mac_enabled = 1;
ESP_LOGI(TAG, "Kconfig MAC filter: %02X:%02X:%02X:%02X:%02X:%02X",
cfg->filter_mac[0], cfg->filter_mac[1], cfg->filter_mac[2],
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
}
}
#endif
/* Try to override from NVS */
nvs_handle_t handle;
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
@@ -152,6 +213,27 @@ void nvs_config_load(nvs_config_t *cfg)
}
}
/* MAC filter (stored as a 6-byte blob in NVS key "filter_mac") */
uint8_t mac_blob[6];
size_t mac_len = 6;
if (nvs_get_blob(handle, "filter_mac", mac_blob, &mac_len) == ESP_OK && mac_len == 6) {
/* Check it's not all zeros (which would mean "no filter") */
uint8_t is_zero = 1;
for (int i = 0; i < 6; i++) {
if (mac_blob[i] != 0) { is_zero = 0; break; }
}
if (!is_zero) {
memcpy(cfg->filter_mac, mac_blob, 6);
cfg->filter_mac_enabled = 1;
ESP_LOGI(TAG, "NVS override: filter_mac=%02X:%02X:%02X:%02X:%02X:%02X",
mac_blob[0], mac_blob[1], mac_blob[2],
mac_blob[3], mac_blob[4], mac_blob[5]);
} else {
cfg->filter_mac_enabled = 0;
ESP_LOGI(TAG, "NVS override: filter_mac disabled (all zeros)");
}
}
/* Validate tdm_slot_index < tdm_node_count */
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",
@@ -159,5 +241,62 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->tdm_slot_index = 0;
}
/* ADR-039: Edge processing overrides */
uint8_t edge_tier_val;
if (nvs_get_u8(handle, "edge_tier", &edge_tier_val) == ESP_OK) {
if (edge_tier_val <= 3) {
cfg->edge_tier = edge_tier_val;
ESP_LOGI(TAG, "NVS override: edge_tier=%u", (unsigned)cfg->edge_tier);
} else {
ESP_LOGW(TAG, "NVS edge_tier=%u out of range [0..3], ignored",
(unsigned)edge_tier_val);
}
}
uint16_t presence_val;
if (nvs_get_u16(handle, "pres_thresh", &presence_val) == ESP_OK) {
cfg->presence_thresh = presence_val;
ESP_LOGI(TAG, "NVS override: presence_thresh=%u", cfg->presence_thresh);
}
uint16_t fall_val;
if (nvs_get_u16(handle, "fall_thresh", &fall_val) == ESP_OK) {
cfg->fall_thresh = fall_val;
ESP_LOGI(TAG, "NVS override: fall_thresh=%u", cfg->fall_thresh);
}
uint16_t vital_win_val;
if (nvs_get_u16(handle, "vital_win", &vital_win_val) == ESP_OK) {
if (vital_win_val >= 60 && vital_win_val <= 600) {
cfg->vital_window = vital_win_val;
ESP_LOGI(TAG, "NVS override: vital_window=%u", cfg->vital_window);
} else {
ESP_LOGW(TAG, "NVS vital_win=%u out of range [60..600], ignored",
(unsigned)vital_win_val);
}
}
uint16_t vital_int_val;
if (nvs_get_u16(handle, "vital_int", &vital_int_val) == ESP_OK) {
if (vital_int_val >= 100) {
cfg->vital_interval_ms = vital_int_val;
ESP_LOGI(TAG, "NVS override: vital_interval_ms=%u", cfg->vital_interval_ms);
} else {
ESP_LOGW(TAG, "NVS vital_int=%u too small, ignored",
(unsigned)vital_int_val);
}
}
uint8_t subk_val;
if (nvs_get_u8(handle, "subk_count", &subk_val) == ESP_OK) {
if (subk_val >= 1 && subk_val <= 192) {
cfg->subk_count = subk_val;
ESP_LOGI(TAG, "NVS override: subk_count=%u", (unsigned)cfg->subk_count);
} else {
ESP_LOGW(TAG, "NVS subk_count=%u out of range [1..192], ignored",
(unsigned)subk_val);
}
}
nvs_close(handle);
}
+12
View File
@@ -35,6 +35,18 @@ typedef struct {
uint32_t dwell_ms; /**< Dwell time per channel in ms. */
uint8_t tdm_slot_index; /**< This node's TDM slot index (0-based). */
uint8_t tdm_node_count; /**< Total nodes in the TDM schedule. */
/* MAC address filter for CSI source selection (Issue #98) */
uint8_t filter_mac[6]; /**< Transmitter MAC to accept (all zeros = no filter). */
uint8_t filter_mac_enabled; /**< 1 = filter active, 0 = accept all. */
/* ADR-039: Edge intelligence configuration */
uint8_t edge_tier; /**< 0=disabled, 1=phase/stats, 2=vitals, 3=reserved. */
uint16_t presence_thresh; /**< Presence detection threshold (default 50). */
uint16_t fall_thresh; /**< Fall detection threshold (default 500). */
uint16_t vital_window; /**< Vital signs window in frames (default 300). */
uint16_t vital_interval_ms; /**< Vitals packet send interval in ms (default 1000). */
uint8_t subk_count; /**< Top-K subcarrier count (default 32). */
} nvs_config_t;
/**
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
ESP32-S3 CSI Node Provisioning Script
Writes WiFi credentials and aggregator target to the ESP32's NVS partition
so users can configure a pre-built firmware binary without recompiling.
Usage:
python provision.py --port COM7 --ssid "MyWiFi" --password "secret" --target-ip 192.168.1.20
Requirements:
pip install esptool nvs-partition-gen
(or use the nvs_partition_gen.py bundled with ESP-IDF)
"""
import argparse
import csv
import io
import os
import struct
import subprocess
import sys
import tempfile
# NVS partition table offset — default for ESP-IDF 4MB flash with standard
# partition scheme. The "nvs" partition starts at 0x9000 (36864) and is
# 0x6000 (24576) bytes.
NVS_PARTITION_OFFSET = 0x9000
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
def build_nvs_csv(ssid, password, target_ip, target_port, node_id):
"""Build an NVS CSV string for the csi_cfg namespace."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["key", "type", "encoding", "value"])
writer.writerow(["csi_cfg", "namespace", "", ""])
if ssid:
writer.writerow(["ssid", "data", "string", ssid])
if password is not None:
writer.writerow(["password", "data", "string", password])
if target_ip:
writer.writerow(["target_ip", "data", "string", target_ip])
if target_port is not None:
writer.writerow(["target_port", "data", "u16", str(target_port)])
if node_id is not None:
writer.writerow(["node_id", "data", "u8", str(node_id)])
return buf.getvalue()
def generate_nvs_binary(csv_content, size):
"""Generate an NVS partition binary from CSV using nvs_partition_gen.py."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
f_csv.write(csv_content)
csv_path = f_csv.name
bin_path = csv_path.replace(".csv", ".bin")
try:
# Try the pip-installed version first
try:
import nvs_partition_gen
nvs_partition_gen.generate(csv_path, bin_path, size)
with open(bin_path, "rb") as f:
return f.read()
except ImportError:
pass
# Fall back to calling the ESP-IDF script directly
idf_path = os.environ.get("IDF_PATH", "")
gen_script = os.path.join(idf_path, "components", "nvs_flash",
"nvs_partition_generator", "nvs_partition_gen.py")
if os.path.isfile(gen_script):
subprocess.check_call([
sys.executable, gen_script, "generate",
csv_path, bin_path, hex(size)
])
with open(bin_path, "rb") as f:
return f.read()
# Last resort: try as a module
subprocess.check_call([
sys.executable, "-m", "nvs_partition_gen", "generate",
csv_path, bin_path, hex(size)
])
with open(bin_path, "rb") as f:
return f.read()
finally:
for p in (csv_path, bin_path):
if os.path.isfile(p):
os.unlink(p)
def flash_nvs(port, baud, nvs_bin):
"""Flash the NVS partition binary to the ESP32."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(nvs_bin)
bin_path = f.name
try:
cmd = [
sys.executable, "-m", "esptool",
"--chip", "esp32s3",
"--port", port,
"--baud", str(baud),
"write_flash",
hex(NVS_PARTITION_OFFSET), bin_path,
]
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port}...")
subprocess.check_call(cmd)
print("NVS provisioning complete!")
finally:
os.unlink(bin_path)
def main():
parser = argparse.ArgumentParser(
description="Provision ESP32-S3 CSI Node with WiFi and aggregator settings",
epilog="Example: python provision.py --port COM7 --ssid MyWiFi --password secret --target-ip 192.168.1.20",
)
parser.add_argument("--port", required=True, help="Serial port (e.g. COM7, /dev/ttyUSB0)")
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
parser.add_argument("--ssid", help="WiFi SSID")
parser.add_argument("--password", help="WiFi password")
parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)")
parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)")
parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)")
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
args = parser.parse_args()
if not any([args.ssid, args.password is not None, args.target_ip,
args.target_port, args.node_id is not None]):
parser.error("At least one config value must be specified "
"(--ssid, --password, --target-ip, --target-port, --node-id)")
print("Building NVS configuration:")
if args.ssid:
print(f" WiFi SSID: {args.ssid}")
if args.password is not None:
print(f" WiFi Password: {'*' * len(args.password)}")
if args.target_ip:
print(f" Target IP: {args.target_ip}")
if args.target_port:
print(f" Target Port: {args.target_port}")
if args.node_id is not None:
print(f" Node ID: {args.node_id}")
csv_content = build_nvs_csv(args.ssid, args.password, args.target_ip,
args.target_port, args.node_id)
try:
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)
except Exception as e:
print(f"\nError generating NVS binary: {e}", file=sys.stderr)
print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr)
fallback_path = "nvs_config.csv"
with open(fallback_path, "w") as f:
f.write(csv_content)
print(f"Saved NVS CSV to {fallback_path}", file=sys.stderr)
print(f"Flash with: python $IDF_PATH/components/nvs_flash/"
f"nvs_partition_generator/nvs_partition_gen.py generate "
f"{fallback_path} nvs.bin 0x6000", file=sys.stderr)
sys.exit(1)
if args.dry_run:
out = "nvs_provision.bin"
with open(out, "wb") as f:
f.write(nvs_bin)
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
print(f"Flash manually: python -m esptool --chip esp32s3 --port {args.port} "
f"write_flash 0x9000 {out}")
return
flash_nvs(args.port, args.baud, nvs_bin)
if __name__ == "__main__":
main()
@@ -11,6 +11,9 @@
mod rvf_container;
mod rvf_pipeline;
mod vital_signs;
mod recording;
mod model_manager;
mod training_api;
// Training pipeline modules (exposed via lib.rs)
use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding};
@@ -272,6 +275,9 @@ struct AppStateInner {
frame_history: VecDeque<Vec<f64>>,
tick: u64,
source: String,
/// Timestamp of the last ESP32 UDP frame received.
/// Used by the hybrid auto-detect task to switch between esp32 and simulation.
last_esp32_frame: Option<std::time::Instant>,
tx: broadcast::Sender<String>,
total_detections: u64,
start_time: std::time::Instant,
@@ -289,6 +295,14 @@ struct AppStateInner {
active_sona_profile: Option<String>,
/// Whether a trained model is loaded.
model_loaded: bool,
/// CSI frame recording state (ADR-036).
recording_state: recording::RecordingState,
/// Currently loaded model via model_manager API (ADR-036).
loaded_model: Option<model_manager::LoadedModelState>,
/// Training pipeline state (ADR-036).
training_state: training_api::TrainingState,
/// Broadcast channel for training progress WebSocket (ADR-036).
training_progress_tx: tokio::sync::broadcast::Sender<String>,
}
/// Number of frames retained in `frame_history` for temporal analysis.
@@ -889,6 +903,17 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
s.latest_vitals = vitals.clone();
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = multi_ap_frame.amplitudes.clone();
let rec_rssi = first_rssi;
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
let update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
@@ -921,7 +946,14 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
debug!(
"Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}",
@@ -998,6 +1030,16 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
s.latest_vitals = vitals.clone();
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = vec![signal_pct];
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
let update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
@@ -1030,7 +1072,14 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
state, &rec_amps, rssi_dbm, -90.0, &rec_features,
).await;
}
/// Probe if Windows WiFi is connected
@@ -1766,6 +1815,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
let mut s = state.write().await;
s.source = "esp32".to_string();
s.last_esp32_frame = Some(std::time::Instant::now());
// Append current amplitudes to history before extracting features so
// that temporal analysis includes the most recent frame.
@@ -1829,7 +1879,25 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.iter().take(56).cloned().collect::<Vec<_>>();
let rec_rssi = features.mean_rssi;
let rec_features = serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi,
frame.noise_floor as f64, &rec_features,
).await;
}
}
Err(e) => {
@@ -1842,6 +1910,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// ── Simulated data task ──────────────────────────────────────────────────────
/// Duration without ESP32 frames before falling back to simulation.
const ESP32_TIMEOUT: Duration = Duration::from_secs(3);
async fn simulated_data_task(state: SharedState, tick_ms: u64) {
let mut interval = tokio::time::interval(Duration::from_millis(tick_ms));
info!("Simulated data source active (tick={}ms)", tick_ms);
@@ -1849,7 +1920,23 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
loop {
interval.tick().await;
// If ESP32 sent a frame recently, skip simulation — real data is flowing.
{
let s = state.read().await;
if let Some(last) = s.last_esp32_frame {
if last.elapsed() < ESP32_TIMEOUT {
continue; // ESP32 is active, don't emit simulated frames
}
}
}
let mut s = state.write().await;
// If we just transitioned from esp32 → simulated, log once.
if s.source == "esp32" {
info!("ESP32 silent for {}s — switching to simulation", ESP32_TIMEOUT.as_secs());
}
s.source = "simulated".to_string();
s.tick += 1;
let tick = s.tick;
@@ -1928,7 +2015,24 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.clone();
let rec_rssi = features.mean_rssi;
let rec_features = serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
}
}
@@ -2396,6 +2500,7 @@ async fn main() {
info!(" Source: {}", args.source);
// Auto-detect data source
let is_auto_mode = args.source == "auto";
let source = match args.source.as_str() {
"auto" => {
info!("Auto-detecting data source...");
@@ -2406,7 +2511,7 @@ async fn main() {
info!(" Windows WiFi detected");
"wifi"
} else {
info!(" No hardware detected, using simulation");
info!(" No hardware detected, starting with simulation (hot-plug enabled)");
"simulate"
}
}
@@ -2488,12 +2593,14 @@ async fn main() {
}
let (tx, _) = broadcast::channel::<String>(256);
let (training_progress_tx, _) = broadcast::channel::<String>(512);
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
latest_update: None,
rssi_history: VecDeque::new(),
frame_history: VecDeque::new(),
tick: 0,
source: source.into(),
last_esp32_frame: if source == "esp32" { Some(std::time::Instant::now()) } else { None },
tx,
total_detections: 0,
start_time: std::time::Instant::now(),
@@ -2504,19 +2611,39 @@ async fn main() {
progressive_loader,
active_sona_profile: None,
model_loaded,
recording_state: recording::RecordingState::default(),
loaded_model: None,
training_state: training_api::TrainingState::default(),
training_progress_tx,
}));
// Start background tasks based on source
match source {
"esp32" => {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
// Ensure data directories exist (ADR-036).
for dir in &[recording::RECORDINGS_DIR, model_manager::MODELS_DIR] {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create directory {dir}: {e}");
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
// Start background tasks based on source.
// In auto mode we always start BOTH the UDP listener (for ESP32 hot-plug)
// and the simulation task (which self-pauses when ESP32 packets arrive).
if is_auto_mode {
info!("Auto mode: UDP listener + simulation fallback both active (hot-plug enabled)");
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
} else {
match source {
"esp32" => {
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
}
}
@@ -2571,6 +2698,10 @@ async fn main() {
.route("/api/v1/stream/pose", get(ws_pose_handler))
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
.route("/ws/sensing", get(ws_sensing_handler))
// ADR-036: Recording, model management, and training APIs
.merge(recording::routes())
.merge(model_manager::routes())
.merge(training_api::routes())
// Static UI files
.nest_service("/ui", ServeDir::new(&ui_path))
.layer(SetResponseHeaderLayer::overriding(
@@ -0,0 +1,482 @@
//! Model loading and lifecycle management API.
//!
//! Provides REST endpoints for listing, loading, and unloading `.rvf` models.
//! Models are stored in `data/models/` and inspected using `RvfReader`.
//!
//! Endpoints:
//! - `GET /api/v1/models` — list all available models
//! - `GET /api/v1/models/:id` — detailed info for a specific model
//! - `POST /api/v1/models/load` — load a model for inference
//! - `POST /api/v1/models/unload` — unload the active model
//! - `GET /api/v1/models/active` — get active model info
//! - `POST /api/v1/models/lora/activate` — activate a LoRA profile
//! - `GET /api/v1/models/lora/profiles` — list LoRA profiles for active model
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use axum::{
extract::{Path as AxumPath, State},
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info};
use crate::rvf_container::RvfReader;
// ── Models data directory ────────────────────────────────────────────────────
/// Base directory for RVF model files.
pub const MODELS_DIR: &str = "data/models";
// ── Types ────────────────────────────────────────────────────────────────────
/// Summary information for a model discovered on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub filename: String,
pub version: String,
pub description: String,
pub size_bytes: u64,
pub created_at: String,
pub pck_score: Option<f64>,
pub has_quantization: bool,
pub lora_profiles: Vec<String>,
pub segment_count: usize,
}
/// Information about the currently loaded model, including runtime stats.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveModelInfo {
pub model_id: String,
pub filename: String,
pub version: String,
pub description: String,
pub avg_inference_ms: f64,
pub frames_processed: u64,
pub pose_source: String,
pub lora_profiles: Vec<String>,
pub active_lora_profile: Option<String>,
}
/// Runtime state for the loaded model.
///
/// Stored inside `AppStateInner` and read by the inference path.
pub struct LoadedModelState {
/// Model identifier (derived from filename).
pub model_id: String,
/// Original filename.
pub filename: String,
/// Version string from the RVF manifest.
pub version: String,
/// Description from the RVF manifest.
pub description: String,
/// LoRA profiles available in this model.
pub lora_profiles: Vec<String>,
/// Currently active LoRA profile (if any).
pub active_lora_profile: Option<String>,
/// Model weights (f32 parameters).
pub weights: Vec<f32>,
/// Number of frames processed since load.
pub frames_processed: u64,
/// Cumulative inference time for avg calculation.
pub total_inference_ms: f64,
/// When the model was loaded.
pub loaded_at: Instant,
}
/// Request body for `POST /api/v1/models/load`.
#[derive(Debug, Deserialize)]
pub struct LoadModelRequest {
pub model_id: String,
}
/// Request body for `POST /api/v1/models/lora/activate`.
#[derive(Debug, Deserialize)]
pub struct ActivateLoraRequest {
pub model_id: String,
pub profile_name: String,
}
/// Shared application state type.
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// ── Internal helpers ─────────────────────────────────────────────────────────
/// Scan the models directory and build `ModelInfo` for each `.rvf` file.
async fn scan_models() -> Vec<ModelInfo> {
let dir = PathBuf::from(MODELS_DIR);
let mut models = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return models,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rvf") {
continue;
}
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let id = filename.trim_end_matches(".rvf").to_string();
let size_bytes = tokio::fs::metadata(&path)
.await
.map(|m| m.len())
.unwrap_or(0);
// Read the RVF to extract manifest info.
// This is a blocking I/O operation so we use spawn_blocking.
let path_clone = path.clone();
let info = tokio::task::spawn_blocking(move || {
RvfReader::from_file(&path_clone).ok()
})
.await
.unwrap_or(None);
let (version, description, pck_score, has_quant, lora_profiles, segment_count, created_at) =
if let Some(reader) = &info {
let manifest = reader.manifest().unwrap_or_default();
let metadata = reader.metadata().unwrap_or_default();
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let created_at = manifest
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let pck = metadata
.get("training")
.and_then(|t| t.get("best_pck"))
.and_then(|v| v.as_f64());
let has_quant = reader.quant_info().is_some();
let lora = reader.lora_profiles();
let seg_count = reader.segment_count();
(version, description, pck, has_quant, lora, seg_count, created_at)
} else {
(
"unknown".to_string(),
String::new(),
None,
false,
Vec::new(),
0,
String::new(),
)
};
models.push(ModelInfo {
id,
filename,
version,
description,
size_bytes,
created_at,
pck_score,
has_quantization: has_quant,
lora_profiles,
segment_count,
});
}
models.sort_by(|a, b| a.id.cmp(&b.id));
models
}
/// Load a model from disk by ID and return its `LoadedModelState`.
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
let file_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
let reader = RvfReader::from_file(&file_path)?;
let manifest = reader.manifest().unwrap_or_default();
let version = manifest
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let description = manifest
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let filename = format!("{model_id}.rvf");
let lora_profiles = reader.lora_profiles();
let weights = reader.weights().unwrap_or_default();
Ok(LoadedModelState {
model_id: model_id.to_string(),
filename,
version,
description,
lora_profiles,
active_lora_profile: None,
weights,
frames_processed: 0,
total_inference_ms: 0.0,
loaded_at: Instant::now(),
})
}
// ── Axum handlers ────────────────────────────────────────────────────────────
async fn list_models(State(_state): State<AppState>) -> Json<serde_json::Value> {
let models = scan_models().await;
Json(serde_json::json!({
"models": models,
"count": models.len(),
}))
}
async fn get_model(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let models = scan_models().await;
match models.into_iter().find(|m| m.id == id) {
Some(model) => Json(serde_json::to_value(&model).unwrap_or_default()),
None => Json(serde_json::json!({
"status": "error",
"message": format!("Model '{id}' not found"),
})),
}
}
async fn load_model(
State(state): State<AppState>,
Json(body): Json<LoadModelRequest>,
) -> Json<serde_json::Value> {
let model_id = body.model_id.clone();
// Perform blocking file I/O on spawn_blocking.
let load_result = tokio::task::spawn_blocking(move || load_model_from_disk(&model_id))
.await
.map_err(|e| format!("spawn_blocking panicked: {e}"));
let loaded = match load_result {
Ok(Ok(loaded)) => loaded,
Ok(Err(e)) => {
error!("Failed to load model '{}': {e}", body.model_id);
return Json(serde_json::json!({
"status": "error",
"message": format!("Failed to load model: {e}"),
}));
}
Err(e) => {
error!("Internal error loading model: {e}");
return Json(serde_json::json!({
"status": "error",
"message": format!("Internal error: {e}"),
}));
}
};
let model_id = loaded.model_id.clone();
let weight_count = loaded.weights.len();
{
let mut s = state.write().await;
s.loaded_model = Some(loaded);
s.model_loaded = true;
}
info!("Model loaded: {model_id} ({weight_count} params)");
Json(serde_json::json!({
"status": "loaded",
"model_id": model_id,
"weight_count": weight_count,
}))
}
async fn unload_model(State(state): State<AppState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
if s.loaded_model.is_none() {
return Json(serde_json::json!({
"status": "error",
"message": "No model is currently loaded.",
}));
}
let model_id = s
.loaded_model
.as_ref()
.map(|m| m.model_id.clone())
.unwrap_or_default();
s.loaded_model = None;
s.model_loaded = false;
info!("Model unloaded: {model_id}");
Json(serde_json::json!({
"status": "unloaded",
"model_id": model_id,
}))
}
async fn active_model(State(state): State<AppState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.loaded_model {
Some(model) => {
let avg_ms = if model.frames_processed > 0 {
model.total_inference_ms / model.frames_processed as f64
} else {
0.0
};
let info = ActiveModelInfo {
model_id: model.model_id.clone(),
filename: model.filename.clone(),
version: model.version.clone(),
description: model.description.clone(),
avg_inference_ms: avg_ms,
frames_processed: model.frames_processed,
pose_source: "model_inference".to_string(),
lora_profiles: model.lora_profiles.clone(),
active_lora_profile: model.active_lora_profile.clone(),
};
Json(serde_json::to_value(&info).unwrap_or_default())
}
None => Json(serde_json::json!({
"status": "no_model",
"message": "No model is currently loaded.",
})),
}
}
async fn activate_lora(
State(state): State<AppState>,
Json(body): Json<ActivateLoraRequest>,
) -> Json<serde_json::Value> {
let mut s = state.write().await;
let model = match s.loaded_model.as_mut() {
Some(m) => m,
None => {
return Json(serde_json::json!({
"status": "error",
"message": "No model is loaded. Load a model first.",
}));
}
};
if model.model_id != body.model_id {
return Json(serde_json::json!({
"status": "error",
"message": format!(
"Model '{}' is not loaded. Active model: '{}'",
body.model_id, model.model_id
),
}));
}
if !model.lora_profiles.contains(&body.profile_name) {
return Json(serde_json::json!({
"status": "error",
"message": format!(
"LoRA profile '{}' not found. Available: {:?}",
body.profile_name, model.lora_profiles
),
}));
}
model.active_lora_profile = Some(body.profile_name.clone());
info!(
"LoRA profile activated: {} on model {}",
body.profile_name, body.model_id
);
Json(serde_json::json!({
"status": "activated",
"model_id": body.model_id,
"profile_name": body.profile_name,
}))
}
async fn list_lora_profiles(State(state): State<AppState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.loaded_model {
Some(model) => Json(serde_json::json!({
"model_id": model.model_id,
"profiles": model.lora_profiles,
"active": model.active_lora_profile,
})),
None => Json(serde_json::json!({
"profiles": serde_json::Value::Array(vec![]),
"message": "No model is loaded.",
})),
}
}
// ── Router factory ───────────────────────────────────────────────────────────
/// Build the model management sub-router.
///
/// All routes are prefixed with `/api/v1/models`.
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/models", get(list_models))
.route("/api/v1/models/active", get(active_model))
.route("/api/v1/models/load", post(load_model))
.route("/api/v1/models/unload", post(unload_model))
.route("/api/v1/models/lora/activate", post(activate_lora))
.route("/api/v1/models/lora/profiles", get(list_lora_profiles))
.route("/api/v1/models/{id}", get(get_model))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_info_serializes() {
let info = ModelInfo {
id: "test-model".to_string(),
filename: "test-model.rvf".to_string(),
version: "1.0.0".to_string(),
description: "A test model".to_string(),
size_bytes: 1024,
created_at: "2024-01-01T00:00:00Z".to_string(),
pck_score: Some(0.85),
has_quantization: false,
lora_profiles: vec!["default".to_string()],
segment_count: 5,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("test-model"));
assert!(json.contains("0.85"));
}
#[test]
fn active_model_info_serializes() {
let info = ActiveModelInfo {
model_id: "demo".to_string(),
filename: "demo.rvf".to_string(),
version: "0.1.0".to_string(),
description: String::new(),
avg_inference_ms: 2.5,
frames_processed: 100,
pose_source: "model_inference".to_string(),
lora_profiles: vec![],
active_lora_profile: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("model_inference"));
}
}
@@ -0,0 +1,486 @@
//! CSI frame recording API.
//!
//! Provides REST endpoints for recording CSI frames to `.csi.jsonl` files.
//! When recording is active, each processed CSI frame is appended as a JSON
//! line to the current session file stored under `data/recordings/`.
//!
//! Endpoints:
//! - `POST /api/v1/recording/start` — start a new recording session
//! - `POST /api/v1/recording/stop` — stop the active recording
//! - `GET /api/v1/recording/list` — list all recording sessions
//! - `GET /api/v1/recording/download/:id` — download a recording file
//! - `DELETE /api/v1/recording/:id` — delete a recording
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use axum::{
extract::{Path as AxumPath, State},
response::{IntoResponse, Json},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info, warn};
// ── Recording data directory ─────────────────────────────────────────────────
/// Base directory for recording files.
pub const RECORDINGS_DIR: &str = "data/recordings";
// ── Types ────────────────────────────────────────────────────────────────────
/// Request body for `POST /api/v1/recording/start`.
#[derive(Debug, Deserialize)]
pub struct StartRecordingRequest {
pub session_name: String,
pub label: Option<String>,
pub duration_secs: Option<u64>,
}
/// Metadata for a completed or active recording session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingSession {
pub id: String,
pub name: String,
pub label: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub frame_count: u64,
pub file_size_bytes: u64,
pub file_path: String,
}
/// A single recorded CSI frame line (JSONL format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedFrame {
pub timestamp: f64,
pub subcarriers: Vec<f64>,
pub rssi: f64,
pub noise_floor: f64,
pub features: serde_json::Value,
}
/// Runtime state for the active recording session.
///
/// Stored inside `AppStateInner` and checked on each CSI frame tick.
pub struct RecordingState {
/// Whether a recording is currently active.
pub active: bool,
/// Session ID of the active recording.
pub session_id: String,
/// Session display name.
pub session_name: String,
/// Optional label / activity tag.
pub label: Option<String>,
/// Path to the JSONL file being written.
pub file_path: PathBuf,
/// Number of frames written so far.
pub frame_count: u64,
/// When the recording started.
pub start_time: Instant,
/// ISO-8601 start timestamp for metadata.
pub started_at: String,
/// Optional auto-stop duration.
pub duration_secs: Option<u64>,
}
impl Default for RecordingState {
fn default() -> Self {
Self {
active: false,
session_id: String::new(),
session_name: String::new(),
label: None,
file_path: PathBuf::new(),
frame_count: 0,
start_time: Instant::now(),
started_at: String::new(),
duration_secs: None,
}
}
}
/// Shared application state type used across all handlers.
pub type AppState = Arc<RwLock<super::AppStateInner>>;
// ── Public helpers (called from the CSI processing loop in main.rs) ──────────
/// Append a single frame to the active recording file.
///
/// This is designed to be called from the main CSI processing tick.
/// If recording is not active, it returns immediately.
pub async fn maybe_record_frame(
state: &AppState,
subcarriers: &[f64],
rssi: f64,
noise_floor: f64,
features: &serde_json::Value,
) {
let should_write;
let file_path;
let auto_stop;
{
let s = state.read().await;
let rec = &s.recording_state;
if !rec.active {
return;
}
should_write = true;
file_path = rec.file_path.clone();
auto_stop = rec.duration_secs.map(|d| rec.start_time.elapsed().as_secs() >= d).unwrap_or(false);
}
if auto_stop {
// Duration exceeded — stop recording.
stop_recording_inner(state).await;
return;
}
if !should_write {
return;
}
let frame = RecordedFrame {
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
subcarriers: subcarriers.to_vec(),
rssi,
noise_floor,
features: features.clone(),
};
let line = match serde_json::to_string(&frame) {
Ok(l) => l,
Err(e) => {
warn!("Failed to serialize recording frame: {e}");
return;
}
};
// Append line to file (async).
if let Err(e) = append_line(&file_path, &line).await {
warn!("Failed to write recording frame: {e}");
return;
}
// Increment frame counter.
{
let mut s = state.write().await;
s.recording_state.frame_count += 1;
}
}
async fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
Ok(())
}
// ── Internal helpers ─────────────────────────────────────────────────────────
/// Stop the active recording and write session metadata.
async fn stop_recording_inner(state: &AppState) {
let mut s = state.write().await;
if !s.recording_state.active {
return;
}
s.recording_state.active = false;
let ended_at = chrono::Utc::now().to_rfc3339();
let session = RecordingSession {
id: s.recording_state.session_id.clone(),
name: s.recording_state.session_name.clone(),
label: s.recording_state.label.clone(),
started_at: s.recording_state.started_at.clone(),
ended_at: Some(ended_at),
frame_count: s.recording_state.frame_count,
file_size_bytes: std::fs::metadata(&s.recording_state.file_path)
.map(|m| m.len())
.unwrap_or(0),
file_path: s.recording_state.file_path.to_string_lossy().to_string(),
};
// Write a companion .meta.json alongside the JSONL file.
let meta_path = s.recording_state.file_path.with_extension("meta.json");
if let Ok(json) = serde_json::to_string_pretty(&session) {
if let Err(e) = tokio::fs::write(&meta_path, json).await {
warn!("Failed to write recording metadata: {e}");
}
}
info!(
"Recording stopped: {} ({} frames)",
session.id, session.frame_count
);
}
/// Scan the recordings directory and return all sessions with metadata.
async fn list_sessions() -> Vec<RecordingSession> {
let dir = PathBuf::from(RECORDINGS_DIR);
let mut sessions = Vec::new();
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(_) => return sessions,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json")
&& path.to_string_lossy().contains(".meta.")
{
if let Ok(data) = tokio::fs::read_to_string(&path).await {
if let Ok(session) = serde_json::from_str::<RecordingSession>(&data) {
sessions.push(session);
}
}
}
}
// Sort by started_at descending (newest first).
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
// ── Axum handlers ────────────────────────────────────────────────────────────
async fn start_recording(
State(state): State<AppState>,
Json(body): Json<StartRecordingRequest>,
) -> Json<serde_json::Value> {
// Ensure recordings directory exists.
if let Err(e) = tokio::fs::create_dir_all(RECORDINGS_DIR).await {
error!("Failed to create recordings directory: {e}");
return Json(serde_json::json!({
"status": "error",
"message": format!("Cannot create recordings directory: {e}"),
}));
}
let mut s = state.write().await;
if s.recording_state.active {
return Json(serde_json::json!({
"status": "error",
"message": "A recording is already active. Stop it first.",
"active_session": s.recording_state.session_id,
}));
}
let session_id = format!(
"{}-{}",
body.session_name.replace(' ', "_"),
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
let file_name = format!("{session_id}.csi.jsonl");
let file_path = PathBuf::from(RECORDINGS_DIR).join(&file_name);
let started_at = chrono::Utc::now().to_rfc3339();
s.recording_state = RecordingState {
active: true,
session_id: session_id.clone(),
session_name: body.session_name.clone(),
label: body.label.clone(),
file_path: file_path.clone(),
frame_count: 0,
start_time: Instant::now(),
started_at: started_at.clone(),
duration_secs: body.duration_secs,
};
info!(
"Recording started: {session_id} (label={:?}, duration={:?}s)",
body.label, body.duration_secs
);
Json(serde_json::json!({
"status": "recording",
"session_id": session_id,
"session_name": body.session_name,
"label": body.label,
"started_at": started_at,
"file_path": file_path.to_string_lossy(),
"duration_secs": body.duration_secs,
}))
}
async fn stop_recording(State(state): State<AppState>) -> Json<serde_json::Value> {
{
let s = state.read().await;
if !s.recording_state.active {
return Json(serde_json::json!({
"status": "error",
"message": "No active recording to stop.",
}));
}
}
stop_recording_inner(&state).await;
let s = state.read().await;
Json(serde_json::json!({
"status": "stopped",
"session_id": s.recording_state.session_id,
"frame_count": s.recording_state.frame_count,
}))
}
async fn list_recordings(
State(_state): State<AppState>,
) -> Json<serde_json::Value> {
let sessions = list_sessions().await;
Json(serde_json::json!({
"recordings": sessions,
"count": sessions.len(),
}))
}
async fn download_recording(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
let dir = PathBuf::from(RECORDINGS_DIR);
// Find the JSONL file matching the ID.
let file_path = dir.join(format!("{id}.csi.jsonl"));
if !file_path.exists() {
return (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({
"status": "error",
"message": format!("Recording '{id}' not found"),
})),
)
.into_response();
}
match tokio::fs::read(&file_path).await {
Ok(data) => {
let headers = [
(
axum::http::header::CONTENT_TYPE,
"application/x-ndjson".to_string(),
),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{id}.csi.jsonl\""),
),
];
(headers, data).into_response()
}
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"status": "error",
"message": format!("Failed to read recording: {e}"),
})),
)
.into_response(),
}
}
async fn delete_recording(
State(_state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
let dir = PathBuf::from(RECORDINGS_DIR);
let jsonl_path = dir.join(format!("{id}.csi.jsonl"));
let meta_path = dir.join(format!("{id}.csi.meta.json"));
if !jsonl_path.exists() && !meta_path.exists() {
return Json(serde_json::json!({
"status": "error",
"message": format!("Recording '{id}' not found"),
}));
}
let mut deleted = Vec::new();
if jsonl_path.exists() {
if let Err(e) = tokio::fs::remove_file(&jsonl_path).await {
warn!("Failed to delete {}: {e}", jsonl_path.display());
} else {
deleted.push(jsonl_path.to_string_lossy().to_string());
}
}
if meta_path.exists() {
if let Err(e) = tokio::fs::remove_file(&meta_path).await {
warn!("Failed to delete {}: {e}", meta_path.display());
} else {
deleted.push(meta_path.to_string_lossy().to_string());
}
}
Json(serde_json::json!({
"status": "deleted",
"id": id,
"deleted_files": deleted,
}))
}
// ── Router factory ───────────────────────────────────────────────────────────
/// Build the recording sub-router.
///
/// Mount this at the top level; all routes are prefixed with `/api/v1/recording`.
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/recording/start", post(start_recording))
.route("/api/v1/recording/stop", post(stop_recording))
.route("/api/v1/recording/list", get(list_recordings))
.route(
"/api/v1/recording/download/{id}",
get(download_recording),
)
.route("/api/v1/recording/{id}", delete(delete_recording))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_recording_state_is_inactive() {
let rs = RecordingState::default();
assert!(!rs.active);
assert_eq!(rs.frame_count, 0);
}
#[test]
fn recorded_frame_serializes_to_json() {
let frame = RecordedFrame {
timestamp: 1700000000.0,
subcarriers: vec![1.0, 2.0, 3.0],
rssi: -45.0,
noise_floor: -90.0,
features: serde_json::json!({"motion": 0.5}),
};
let json = serde_json::to_string(&frame).unwrap();
assert!(json.contains("\"timestamp\""));
assert!(json.contains("\"subcarriers\""));
}
#[test]
fn recording_session_deserializes() {
let json = r#"{
"id": "test-20240101_120000",
"name": "test",
"label": "walking",
"started_at": "2024-01-01T12:00:00Z",
"ended_at": "2024-01-01T12:05:00Z",
"frame_count": 3000,
"file_size_bytes": 1500000,
"file_path": "data/recordings/test-20240101_120000.csi.jsonl"
}"#;
let session: RecordingSession = serde_json::from_str(json).unwrap();
assert_eq!(session.id, "test-20240101_120000");
assert_eq!(session.frame_count, 3000);
assert_eq!(session.label, Some("walking".to_string()));
}
}
+35
View File
@@ -130,6 +130,9 @@ class WiFiDensePoseApp {
this.components.sensing = new SensingTab(sensingContainer);
}
// Training tab - lazy load to avoid breaking other tabs if import fails
this.initTrainingTab();
// Architecture tab - static content, no component needed
// Performance tab - static content, no component needed
@@ -137,6 +140,28 @@ class WiFiDensePoseApp {
// Applications tab - static content, no component needed
}
// Lazy-load Training tab panels (dynamic import so failures don't break other tabs)
async initTrainingTab() {
try {
const [{ default: TrainingPanel }, { default: ModelPanel }] = await Promise.all([
import('./components/TrainingPanel.js'),
import('./components/ModelPanel.js')
]);
const trainingContainer = document.getElementById('training-panel-container');
if (trainingContainer) {
this.components.trainingPanel = new TrainingPanel(trainingContainer);
}
const modelContainer = document.getElementById('model-panel-container');
if (modelContainer) {
this.components.modelPanel = new ModelPanel(modelContainer);
}
} catch (error) {
console.error('Failed to load Training tab components:', error);
}
}
// Handle tab changes
handleTabChange(newTab, oldTab) {
console.log(`Tab changed from ${oldTab} to ${newTab}`);
@@ -168,6 +193,16 @@ class WiFiDensePoseApp {
});
}
break;
case 'training':
// Refresh panels when training tab becomes visible
if (this.components.trainingPanel && typeof this.components.trainingPanel.refresh === 'function') {
this.components.trainingPanel.refresh();
}
if (this.components.modelPanel && typeof this.components.modelPanel.refresh === 'function') {
this.components.modelPanel.refresh();
}
break;
}
}
+35 -2
View File
@@ -2,6 +2,7 @@
import { healthService } from '../services/health.service.js';
import { poseService } from '../services/pose.service.js';
import { sensingService } from '../services/sensing.service.js';
export class DashboardTab {
constructor(containerElement) {
@@ -63,6 +64,17 @@ export class DashboardTab {
this.updateHealthStatus(health);
});
// Subscribe to sensing service state changes for data source indicator
this._sensingUnsub = sensingService.onStateChange(() => {
this.updateDataSourceIndicator();
});
// Also update on data — catches source changes mid-stream
this._sensingDataUnsub = sensingService.onData(() => {
this.updateDataSourceIndicator();
});
// Initial update
this.updateDataSourceIndicator();
// Start periodic stats updates
this.statsInterval = setInterval(() => {
this.updateLiveStats();
@@ -72,6 +84,25 @@ export class DashboardTab {
healthService.startHealthMonitoring(30000);
}
// Update the data source indicator on the dashboard
updateDataSourceIndicator() {
const el = this.container.querySelector('#dashboard-datasource');
if (!el) return;
const ds = sensingService.dataSource;
const statusText = el.querySelector('.status-text');
const statusMsg = el.querySelector('.status-message');
const config = {
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
};
const cfg = config[ds] || config['reconnecting'];
el.className = `component-status status-${cfg.status}`;
if (statusText) statusText.textContent = cfg.text;
if (statusMsg) statusMsg.textContent = cfg.msg;
}
// Update API info display
updateApiInfo(info) {
// Update version
@@ -394,11 +425,13 @@ export class DashboardTab {
if (this.healthSubscription) {
this.healthSubscription();
}
if (this._sensingUnsub) this._sensingUnsub();
if (this._sensingDataUnsub) this._sensingDataUnsub();
if (this.statsInterval) {
clearInterval(this.statsInterval);
}
healthService.stopHealthMonitoring();
}
}
+799 -10
View File
@@ -4,6 +4,11 @@ import { PoseDetectionCanvas } from './PoseDetectionCanvas.js';
import { poseService } from '../services/pose.service.js';
import { streamService } from '../services/stream.service.js';
import { wsService } from '../services/websocket.service.js';
import { sensingService } from '../services/sensing.service.js';
// Optional services - loaded lazily in init() to avoid blocking module graph
let modelService = null;
let trainingService = null;
export class LiveDemoTab {
constructor(containerElement) {
@@ -32,6 +37,27 @@ export class LiveDemoTab {
connectionAttempts: 0
};
// Model control state
this.modelState = {
models: [],
activeModelId: null,
activeModelInfo: null,
loraProfiles: [],
selectedLoraProfile: null,
loading: false
};
// Training state
this.trainingState = {
status: 'idle', // 'idle' | 'training' | 'recording'
epoch: 0,
totalEpochs: 0,
showTrainingPanel: false
};
// A/B split view state
this.splitViewActive = false;
this.subscriptions = [];
this.logger = this.createLogger();
@@ -58,7 +84,17 @@ export class LiveDemoTab {
async init() {
try {
this.logger.info('Initializing LiveDemoTab component');
// Load optional services (non-blocking)
try {
const mod = await import('../services/model.service.js');
modelService = mod.modelService;
} catch (e) { /* model features disabled */ }
try {
const mod = await import('../services/training.service.js');
trainingService = mod.trainingService;
} catch (e) { /* training features disabled */ }
// Create enhanced DOM structure
this.createEnhancedStructure();
@@ -71,9 +107,31 @@ export class LiveDemoTab {
// Set up monitoring and health checks
this.setupMonitoring();
// Fetch available models on init
this.fetchModels();
// Set up model/training event listeners
this.setupServiceListeners();
// Initialize state
this.updateUI();
// Auto-start pose detection when a backend is reachable.
// Check after a brief delay (sensing WS may still be connecting).
this._autoStartOnce = false;
const tryAutoStart = () => {
if (this._autoStartOnce || this.state.isActive) return;
const ds = sensingService.dataSource;
if (ds === 'live' || ds === 'server-simulated') {
this._autoStartOnce = true;
this.logger.info('Auto-starting pose detection (data source: ' + ds + ')');
this.startDemo();
}
};
setTimeout(tryAutoStart, 2000);
// Also listen for sensing state changes in case server connects later
this._autoStartUnsub = sensingService.onStateChange(tryAutoStart);
this.logger.info('LiveDemoTab component initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize LiveDemoTab', { error: error.message });
@@ -88,6 +146,11 @@ export class LiveDemoTab {
// Create enhanced structure if it doesn't exist
const enhancedHTML = `
<div class="live-demo-enhanced">
<!-- Data source banner prominent indicator for live vs simulated -->
<div id="demo-source-banner" class="demo-source-banner demo-source-unknown" role="status" aria-live="polite">
Detecting data source...
</div>
<div class="demo-header">
<div class="demo-title">
<h2>Live Human Pose Detection</h2>
@@ -99,6 +162,7 @@ export class LiveDemoTab {
<div class="demo-controls">
<button class="btn btn--primary" id="start-enhanced-demo">Start Detection</button>
<button class="btn btn--secondary" id="stop-enhanced-demo" disabled>Stop Detection</button>
<button class="btn btn--accent" id="run-offline-demo">Demo</button>
<button class="btn btn--primary" id="toggle-debug">Debug Mode</button>
<select class="zone-select" id="zone-selector">
<option value="zone_1">Zone 1</option>
@@ -148,6 +212,49 @@ export class LiveDemoTab {
</div>
</div>
<div class="model-control-panel" id="model-control-panel">
<h4>Model Control</h4>
<div class="setting-row-ld">
<label class="ld-label">Model:</label>
<select class="ld-select" id="model-selector">
<option value="">Signal-Derived (no model)</option>
</select>
</div>
<div class="model-info-row" id="model-active-info" style="display: none;">
<span class="ld-label" id="model-active-name"></span>
<span class="model-pck-badge" id="model-active-pck"></span>
</div>
<div class="setting-row-ld" id="lora-profile-row" style="display: none;">
<label class="ld-label">LoRA Profile:</label>
<select class="ld-select" id="lora-profile-selector">
<option value="">None</option>
</select>
</div>
<div class="model-actions">
<button class="btn-ld btn-ld-accent" id="load-model-btn">Load Model</button>
<button class="btn-ld btn-ld-muted" id="unload-model-btn" disabled>Unload</button>
</div>
<div class="model-status-text" id="model-status-text">No model loaded</div>
</div>
<div class="split-view-panel">
<div class="setting-row-ld">
<label class="ld-label">Compare: Signal vs Model</label>
<button class="btn-ld btn-ld-toggle" id="split-view-toggle" disabled>Off</button>
</div>
</div>
<div class="training-quick-panel" id="training-quick-panel">
<h4>Training</h4>
<div class="training-status-row">
<span class="training-status-badge" id="training-status-badge">Idle</span>
</div>
<div class="training-actions">
<button class="btn-ld btn-ld-accent" id="open-training-panel-btn">Open Training Panel</button>
<button class="btn-ld btn-ld-muted" id="quick-record-btn">Record 60s</button>
</div>
</div>
<div class="setup-guide-panel">
<h4>Setup Guide</h4>
<div class="setup-levels">
@@ -606,6 +713,270 @@ export class LiveDemoTab {
border-radius: 3px;
font-size: 10px;
}
/* Model Control Panel */
.model-control-panel,
.split-view-panel,
.training-quick-panel {
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 12px;
padding: 16px;
}
.model-control-panel h4,
.training-quick-panel h4 {
margin: 0 0 12px 0;
color: #e0e0e0;
font-size: 14px;
font-weight: 600;
}
.setting-row-ld {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 8px;
}
.ld-label {
color: #8899aa;
font-size: 11px;
flex-shrink: 0;
}
.ld-select {
flex: 1;
padding: 6px 10px;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 6px;
background: rgba(15, 20, 35, 0.8);
color: #b0b8c8;
font-size: 12px;
cursor: pointer;
min-width: 0;
}
.ld-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.ld-select option {
background: #1a2234;
color: #c8d0dc;
}
.model-info-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding: 6px 8px;
background: rgba(30, 40, 60, 0.6);
border-radius: 6px;
}
.model-pck-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 8px;
background: rgba(102, 126, 234, 0.15);
color: #8ea4f0;
}
.model-actions,
.training-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn-ld {
flex: 1;
padding: 7px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.btn-ld:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-ld-accent {
background: rgba(102, 126, 234, 0.15);
color: #8ea4f0;
border-color: rgba(102, 126, 234, 0.3);
}
.btn-ld-accent:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.25);
border-color: rgba(102, 126, 234, 0.5);
}
.btn-ld-muted {
background: rgba(30, 40, 60, 0.8);
color: #8899aa;
border-color: rgba(255, 255, 255, 0.08);
}
.btn-ld-muted:hover:not(:disabled) {
background: rgba(40, 50, 70, 0.9);
color: #b0b8c8;
}
.btn-ld-toggle {
min-width: 44px;
flex: 0;
padding: 4px 10px;
background: rgba(30, 40, 60, 0.8);
color: #8899aa;
border-color: rgba(255, 255, 255, 0.08);
border-radius: 12px;
font-size: 11px;
}
.btn-ld-toggle.active {
background: rgba(0, 212, 255, 0.15);
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.4);
}
.model-status-text {
margin-top: 8px;
font-size: 11px;
color: #6b7a8d;
}
.training-status-row {
margin-bottom: 8px;
}
.training-status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
background: rgba(108, 117, 125, 0.15);
color: #8899aa;
border: 1px solid rgba(108, 117, 125, 0.3);
}
.training-status-badge.training {
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.3);
}
.training-status-badge.recording {
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
animation: pulse 1.5s ease-in-out infinite;
}
/* A/B Split View Overlay */
.split-view-divider {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.4) 0px,
rgba(255, 255, 255, 0.4) 6px,
transparent 6px,
transparent 12px
);
z-index: 15;
pointer-events: none;
}
.split-view-label {
position: absolute;
top: 8px;
z-index: 16;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 4px;
pointer-events: none;
}
.split-view-label.left {
left: 8px;
background: rgba(0, 204, 136, 0.2);
color: #00cc88;
}
.split-view-label.right {
right: 8px;
background: rgba(102, 126, 234, 0.2);
color: #8ea4f0;
}
/* Training modal overlay */
.training-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.training-panel-modal {
background: #0d1117;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 12px;
padding: 24px;
min-width: 400px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
color: #e0e0e0;
}
.training-panel-modal h3 {
margin: 0 0 16px 0;
font-size: 18px;
color: #e0e0e0;
}
.training-panel-modal .close-btn {
float: right;
background: rgba(30, 40, 60, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #8899aa;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
}
.training-panel-modal .close-btn:hover {
background: rgba(50, 60, 80, 0.9);
color: #c8d0dc;
}
`;
if (!document.querySelector('#live-demo-enhanced-styles')) {
@@ -664,6 +1035,16 @@ export class LiveDemoTab {
stopBtn.addEventListener('click', () => this.stopDemo());
}
// Offline demo button — runs client-side animated demo (no server needed)
const offlineDemoBtn = this.container.querySelector('#run-offline-demo');
if (offlineDemoBtn) {
offlineDemoBtn.addEventListener('click', () => {
if (this.components.poseCanvas) {
this.components.poseCanvas.toggleDemo();
}
});
}
if (debugBtn) {
debugBtn.addEventListener('click', () => this.toggleDebugMode());
}
@@ -690,6 +1071,9 @@ export class LiveDemoTab {
exportLogsBtn.addEventListener('click', () => this.exportLogs());
}
// Model, training, and split-view controls
this.setupModelTrainingControls();
this.logger.debug('Enhanced controls set up');
}
@@ -706,6 +1090,23 @@ export class LiveDemoTab {
this.updateMetricsDisplay();
}, 1000);
// Subscribe to sensing service for data-source changes
this._sensingStateUnsub = sensingService.onStateChange(() => {
this.updateSourceBanner();
this.updateStatusIndicator();
});
// Throttle data-based banner updates (frames arrive at 10Hz)
let lastBannerUpdate = 0;
this._sensingDataUnsub = sensingService.onData(() => {
const now = Date.now();
if (now - lastBannerUpdate > 2000) {
lastBannerUpdate = now;
this.updateSourceBanner();
}
});
// Initial banner update
this.updateSourceBanner();
this.logger.debug('Monitoring set up');
}
@@ -901,17 +1302,40 @@ export class LiveDemoTab {
}
getStatusClass() {
if (this.state.isActive) {
return this.state.connectionState === 'connected' ? 'active' : 'connecting';
if (!this.state.isActive) {
return this.state.connectionState === 'error' ? 'error' : '';
}
return this.state.connectionState === 'error' ? 'error' : '';
const ds = sensingService.dataSource;
if (ds === 'live') return 'active';
if (ds === 'server-simulated') return 'sim';
return 'connecting';
}
getStatusText() {
if (this.state.isActive) {
return this.state.connectionState === 'connected' ? 'Active' : 'Connecting...';
if (!this.state.isActive) {
return this.state.connectionState === 'error' ? 'Error' : 'Ready';
}
return this.state.connectionState === 'error' ? 'Error' : 'Ready';
const ds = sensingService.dataSource;
if (ds === 'live') return 'Active \u2014 ESP32 Live';
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
return 'Connecting...';
}
/** Update the prominent data-source banner at the top of Live Demo. */
updateSourceBanner() {
const banner = this.container.querySelector('#demo-source-banner');
if (!banner) return;
const ds = sensingService.dataSource;
const config = {
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
};
const cfg = config[ds] || config['reconnecting'];
banner.textContent = cfg.text;
banner.className = 'demo-source-banner ' + cfg.cls;
}
updateControls() {
@@ -942,8 +1366,20 @@ export class LiveDemoTab {
};
if (elements.connectionStatus) {
elements.connectionStatus.textContent = this.state.connectionState;
elements.connectionStatus.className = `health-${this.getHealthClass(this.state.connectionState)}`;
const ds = sensingService.dataSource;
const dsLabels = {
'live': 'Connected \u2014 ESP32',
'server-simulated': 'Connected \u2014 Simulated',
'reconnecting': 'Reconnecting...',
'simulated': 'Offline \u2014 Simulated',
};
const label = dsLabels[ds] || this.state.connectionState;
elements.connectionStatus.textContent = label;
const cls = ds === 'live' ? 'good'
: ds === 'server-simulated' ? 'sim'
: ds === 'simulated' ? 'bad'
: this.getHealthClass(this.state.connectionState);
elements.connectionStatus.className = `health-${cls}`;
}
if (elements.frameCount) {
@@ -1061,6 +1497,356 @@ export class LiveDemoTab {
}
}
// --- Model Control Methods ---
async fetchModels() {
if (!modelService) return;
try {
const data = await modelService.listModels();
this.modelState.models = data?.models || [];
this.populateModelSelector();
// Check if a model is already active
const active = await modelService.getActiveModel();
if (active && active.model_id) {
this.modelState.activeModelId = active.model_id;
this.modelState.activeModelInfo = active;
this.updateModelUI();
}
} catch (error) {
this.logger.warn('Could not fetch models', { error: error.message });
}
}
populateModelSelector() {
const selector = this.container.querySelector('#model-selector');
if (!selector) return;
// Keep the first "Signal-Derived" option
selector.innerHTML = '<option value="">Signal-Derived (no model)</option>';
this.modelState.models.forEach(model => {
const opt = document.createElement('option');
opt.value = model.id || model.model_id || model.name;
opt.textContent = model.name || model.id || 'Unknown Model';
selector.appendChild(opt);
});
if (this.modelState.activeModelId) {
selector.value = this.modelState.activeModelId;
}
}
async handleLoadModel() {
if (!modelService) return;
const selector = this.container.querySelector('#model-selector');
const modelId = selector?.value;
if (!modelId) {
this.setModelStatus('Select a model first');
return;
}
try {
this.modelState.loading = true;
this.setModelStatus('Loading...');
const loadBtn = this.container.querySelector('#load-model-btn');
if (loadBtn) loadBtn.disabled = true;
await modelService.loadModel(modelId);
this.modelState.activeModelId = modelId;
// Try to fetch full info
try {
const info = await modelService.getModel(modelId);
this.modelState.activeModelInfo = info;
} catch (e) {
this.modelState.activeModelInfo = { model_id: modelId };
}
// Fetch LoRA profiles
try {
const profiles = await modelService.getLoraProfiles();
this.modelState.loraProfiles = profiles || [];
} catch (e) {
this.modelState.loraProfiles = [];
}
this.modelState.loading = false;
this.updateModelUI();
this.updateSplitViewAvailability();
// Update pose source badge to model inference
this.setState({ poseSource: 'model_inference' });
} catch (error) {
this.modelState.loading = false;
this.setModelStatus(`Error: ${error.message}`);
const loadBtn = this.container.querySelector('#load-model-btn');
if (loadBtn) loadBtn.disabled = false;
this.logger.error('Failed to load model', { error: error.message });
}
}
async handleUnloadModel() {
if (!modelService) return;
try {
await modelService.unloadModel();
this.modelState.activeModelId = null;
this.modelState.activeModelInfo = null;
this.modelState.loraProfiles = [];
this.modelState.selectedLoraProfile = null;
this.updateModelUI();
this.updateSplitViewAvailability();
this.disableSplitView();
this.setState({ poseSource: 'signal_derived' });
} catch (error) {
this.setModelStatus(`Error: ${error.message}`);
this.logger.error('Failed to unload model', { error: error.message });
}
}
async handleLoraProfileChange(profileName) {
if (!modelService || !this.modelState.activeModelId) return;
if (!profileName) return;
try {
await modelService.activateLoraProfile(this.modelState.activeModelId, profileName);
this.modelState.selectedLoraProfile = profileName;
this.setModelStatus(`LoRA: ${profileName} active`);
} catch (error) {
this.setModelStatus(`LoRA error: ${error.message}`);
}
}
updateModelUI() {
const loadBtn = this.container.querySelector('#load-model-btn');
const unloadBtn = this.container.querySelector('#unload-model-btn');
const infoRow = this.container.querySelector('#model-active-info');
const nameEl = this.container.querySelector('#model-active-name');
const pckEl = this.container.querySelector('#model-active-pck');
const loraRow = this.container.querySelector('#lora-profile-row');
const loraSel = this.container.querySelector('#lora-profile-selector');
const isLoaded = !!this.modelState.activeModelId;
if (loadBtn) loadBtn.disabled = isLoaded;
if (unloadBtn) unloadBtn.disabled = !isLoaded;
if (infoRow) {
infoRow.style.display = isLoaded ? 'flex' : 'none';
}
if (isLoaded && this.modelState.activeModelInfo) {
const info = this.modelState.activeModelInfo;
const name = info.name || info.model_id || this.modelState.activeModelId;
const version = info.version ? ` v${info.version}` : '';
const pck = info.pck_score != null ? info.pck_score.toFixed(2) : '--';
if (nameEl) nameEl.textContent = `${name}${version}`;
if (pckEl) pckEl.textContent = `PCK: ${pck}`;
this.setModelStatus(`Model: ${name} (PCK: ${pck})`);
} else if (!isLoaded) {
this.setModelStatus('No model loaded');
}
// LoRA profiles
if (loraRow && loraSel) {
if (isLoaded && this.modelState.loraProfiles.length > 0) {
loraRow.style.display = 'flex';
loraSel.innerHTML = '<option value="">None</option>';
this.modelState.loraProfiles.forEach(profile => {
const opt = document.createElement('option');
opt.value = profile.name || profile;
opt.textContent = profile.name || profile;
loraSel.appendChild(opt);
});
} else {
loraRow.style.display = 'none';
}
}
}
setModelStatus(text) {
const el = this.container.querySelector('#model-status-text');
if (el) el.textContent = text;
}
// --- A/B Split View Methods ---
updateSplitViewAvailability() {
const toggle = this.container.querySelector('#split-view-toggle');
if (toggle) {
toggle.disabled = !this.modelState.activeModelId;
}
}
toggleSplitView() {
if (!this.modelState.activeModelId) return;
this.splitViewActive = !this.splitViewActive;
const toggle = this.container.querySelector('#split-view-toggle');
if (toggle) {
toggle.textContent = this.splitViewActive ? 'On' : 'Off';
toggle.classList.toggle('active', this.splitViewActive);
}
this.updateSplitViewOverlay();
}
disableSplitView() {
this.splitViewActive = false;
const toggle = this.container.querySelector('#split-view-toggle');
if (toggle) {
toggle.textContent = 'Off';
toggle.classList.remove('active');
}
this.updateSplitViewOverlay();
}
updateSplitViewOverlay() {
const mainContainer = this.container.querySelector('.pose-detection-container');
if (!mainContainer) return;
// Remove existing overlays
mainContainer.querySelectorAll('.split-view-divider, .split-view-label').forEach(el => el.remove());
if (this.splitViewActive) {
const divider = document.createElement('div');
divider.className = 'split-view-divider';
mainContainer.appendChild(divider);
const leftLabel = document.createElement('div');
leftLabel.className = 'split-view-label left';
leftLabel.textContent = 'Signal-Derived';
mainContainer.appendChild(leftLabel);
const rightLabel = document.createElement('div');
rightLabel.className = 'split-view-label right';
rightLabel.textContent = 'Model Inference';
mainContainer.appendChild(rightLabel);
}
}
// --- Training Quick-Panel Methods ---
updateTrainingStatus() {
const badge = this.container.querySelector('#training-status-badge');
if (!badge) return;
const state = this.trainingState.status;
badge.classList.remove('training', 'recording');
if (state === 'training') {
badge.classList.add('training');
badge.textContent = `Training epoch ${this.trainingState.epoch}/${this.trainingState.totalEpochs}`;
} else if (state === 'recording') {
badge.classList.add('recording');
badge.textContent = 'Recording...';
} else {
badge.textContent = 'Idle';
}
}
async handleQuickRecord() {
if (!trainingService) {
this.logger.warn('Training service not available');
return;
}
try {
await trainingService.startRecording({ session_name: `quick_${Date.now()}`, duration_secs: 60 });
this.trainingState.status = 'recording';
this.updateTrainingStatus();
// Auto-reset after ~65 seconds
setTimeout(() => {
if (this.trainingState.status === 'recording') {
this.trainingState.status = 'idle';
this.updateTrainingStatus();
}
}, 65000);
} catch (error) {
this.logger.error('Quick record failed', { error: error.message });
}
}
showTrainingPanel() {
// Create a simple modal overlay for the training panel
const existing = document.querySelector('.training-panel-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'training-panel-overlay';
overlay.innerHTML = `
<div class="training-panel-modal">
<button class="close-btn" id="close-training-modal">Close</button>
<h3>Training Panel</h3>
<p style="color: #8899aa; font-size: 13px; margin-bottom: 16px;">
Configure and start model training from here. Connect to the backend training API to manage epochs, datasets, and checkpoints.
</p>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div class="setting-row-ld">
<label class="ld-label" style="flex: 1;">Status:</label>
<span style="color: #c8d0dc; font-size: 12px;">${this.trainingState.status}</span>
</div>
<div class="setting-row-ld">
<label class="ld-label" style="flex: 1;">Training service:</label>
<span style="color: ${trainingService ? '#00cc88' : '#ef4444'}; font-size: 12px;">${trainingService ? 'Connected' : 'Not available'}</span>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Close handler
overlay.querySelector('#close-training-modal').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
}
// --- Service Event Listeners ---
setupServiceListeners() {
if (modelService) {
const unsub1 = modelService.on('model-loaded', (data) => {
this.logger.info('Model loaded event', data);
});
const unsub2 = modelService.on('model-unloaded', () => {
this.modelState.activeModelId = null;
this.modelState.activeModelInfo = null;
this.updateModelUI();
this.disableSplitView();
});
this.subscriptions.push(unsub1, unsub2);
}
if (trainingService) {
const unsub3 = trainingService.on('progress', (data) => {
if (data && data.epoch != null) {
this.trainingState.epoch = data.epoch;
this.trainingState.totalEpochs = data.total_epochs || data.totalEpochs || this.trainingState.totalEpochs;
this.trainingState.status = 'training';
this.updateTrainingStatus();
}
});
const unsub4 = trainingService.on('training-stopped', () => {
this.trainingState.status = 'idle';
this.updateTrainingStatus();
});
this.subscriptions.push(unsub3, unsub4);
}
}
// --- Enhanced Controls Setup ---
setupModelTrainingControls() {
// Model control buttons
const loadBtn = this.container.querySelector('#load-model-btn');
const unloadBtn = this.container.querySelector('#unload-model-btn');
const loraSel = this.container.querySelector('#lora-profile-selector');
const splitToggle = this.container.querySelector('#split-view-toggle');
const openTrainingBtn = this.container.querySelector('#open-training-panel-btn');
const quickRecordBtn = this.container.querySelector('#quick-record-btn');
if (loadBtn) loadBtn.addEventListener('click', () => this.handleLoadModel());
if (unloadBtn) unloadBtn.addEventListener('click', () => this.handleUnloadModel());
if (loraSel) loraSel.addEventListener('change', (e) => this.handleLoraProfileChange(e.target.value));
if (splitToggle) splitToggle.addEventListener('click', () => this.toggleSplitView());
if (openTrainingBtn) openTrainingBtn.addEventListener('click', () => this.showTrainingPanel());
if (quickRecordBtn) quickRecordBtn.addEventListener('click', () => this.handleQuickRecord());
}
// Clean up
dispose() {
try {
@@ -1088,6 +1874,9 @@ export class LiveDemoTab {
// Unsubscribe from services
this.subscriptions.forEach(unsubscribe => unsubscribe());
this.subscriptions = [];
if (this._sensingStateUnsub) this._sensingStateUnsub();
if (this._sensingDataUnsub) this._sensingDataUnsub();
if (this._autoStartUnsub) this._autoStartUnsub();
this.logger.info('LiveDemoTab component disposed successfully');
} catch (error) {
+230
View File
@@ -0,0 +1,230 @@
// ModelPanel Component for WiFi-DensePose UI
// Dark-mode panel for model management: listing, loading, LoRA profiles.
import { modelService } from '../services/model.service.js';
const MP_STYLES = `
.mp-panel{background:rgba(17,24,39,.9);border:1px solid rgba(56,68,89,.6);border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;overflow:hidden}
.mp-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;background:rgba(13,17,23,.95);border-bottom:1px solid rgba(56,68,89,.6)}
.mp-title{font-size:14px;font-weight:600;color:#e0e0e0}
.mp-badge{background:rgba(102,126,234,.2);color:#8ea4f0;font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;border:1px solid rgba(102,126,234,.3)}
.mp-error{background:rgba(220,53,69,.15);color:#f5a0a8;border:1px solid rgba(220,53,69,.3);border-radius:4px;padding:8px 12px;margin:10px 12px 0;font-size:12px}
.mp-active-card{margin:12px;padding:12px;background:rgba(13,17,23,.8);border:1px solid rgba(56,68,89,.6);border-left:3px solid #28a745;border-radius:6px}
.mp-active-name{font-size:14px;font-weight:600;color:#c8d0dc;margin-bottom:6px}
.mp-active-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.mp-active-stats{font-size:12px;color:#8899aa;margin-bottom:10px}
.mp-stat-label{color:#8899aa}.mp-stat-value{color:#c8d0dc;font-weight:500}.mp-stat-sep{color:rgba(56,68,89,.8);margin:0 6px}
.mp-lora-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.mp-lora-label{font-size:12px;color:#8899aa}
.mp-lora-select{flex:1;padding:4px 8px;background:rgba(30,40,60,.8);border:1px solid rgba(56,68,89,.6);border-radius:4px;color:#c8d0dc;font-size:12px}
.mp-list-section{padding:0 12px 12px}
.mp-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:#8899aa;padding:10px 0 8px}
.mp-model-card{padding:10px;margin-bottom:8px;background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.4);border-radius:6px;transition:border-color .2s}
.mp-model-card:hover{border-color:rgba(102,126,234,.4)}
.mp-card-name{font-size:13px;font-weight:500;color:#c8d0dc;margin-bottom:4px}
.mp-card-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.mp-meta-tag{background:rgba(30,40,60,.8);color:#8899aa;font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid rgba(56,68,89,.4)}
.mp-card-actions{display:flex;gap:6px}
.mp-empty{color:#6b7a8d;font-size:12px;padding:16px 0;text-align:center;line-height:1.5}
.mp-footer{padding:10px 12px;border-top:1px solid rgba(56,68,89,.4);display:flex;justify-content:flex-end}
.mp-btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
.mp-btn:disabled{opacity:.5;cursor:not-allowed}
.mp-btn-success{background:rgba(40,167,69,.2);color:#51cf66;border-color:rgba(40,167,69,.3)}
.mp-btn-success:hover:not(:disabled){background:rgba(40,167,69,.35)}
.mp-btn-danger{background:rgba(220,53,69,.2);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
.mp-btn-danger:hover:not(:disabled){background:rgba(220,53,69,.35)}
.mp-btn-secondary{background:rgba(30,40,60,.8);color:#b0b8c8;border-color:rgba(56,68,89,.6)}
.mp-btn-secondary:hover:not(:disabled){background:rgba(40,50,75,.9)}
.mp-btn-muted{background:transparent;color:#6b7a8d;border-color:rgba(56,68,89,.4);font-size:11px;padding:4px 8px}
.mp-btn-muted:hover:not(:disabled){color:#ff6b6b;border-color:rgba(220,53,69,.3)}
`;
export default class ModelPanel {
constructor(container) {
this.container = typeof container === 'string'
? document.getElementById(container) : container;
if (!this.container) throw new Error('ModelPanel: container element not found');
this.state = { models: [], activeModel: null, loraProfiles: [], loading: false, error: null };
this.unsubs = [];
this._injectStyles();
this.render();
this.refresh();
this.unsubs.push(
modelService.on('model-loaded', () => this.refresh()),
modelService.on('model-unloaded', () => this.refresh()),
modelService.on('lora-activated', () => this.refresh())
);
}
// --- Data ---
async refresh() {
this._set({ loading: true, error: null });
try {
const [listRes, active] = await Promise.all([
modelService.listModels().catch(() => ({ models: [] })),
modelService.getActiveModel().catch(() => null)
]);
let lora = [];
if (active) lora = await modelService.getLoraProfiles().catch(() => []);
this._set({ models: listRes?.models ?? [], activeModel: active, loraProfiles: lora, loading: false });
} catch (e) { this._set({ loading: false, error: e.message }); }
}
// --- Actions ---
async _load(id) {
this._set({ loading: true, error: null });
try { await modelService.loadModel(id); await this.refresh(); }
catch (e) { this._set({ loading: false, error: `Load failed: ${e.message}` }); }
}
async _unload() {
this._set({ loading: true, error: null });
try { await modelService.unloadModel(); await this.refresh(); }
catch (e) { this._set({ loading: false, error: `Unload failed: ${e.message}` }); }
}
async _delete(id) {
this._set({ loading: true, error: null });
try { await modelService.deleteModel(id); await this.refresh(); }
catch (e) { this._set({ loading: false, error: `Delete failed: ${e.message}` }); }
}
async _loraChange(modelId, profile) {
if (!profile) return;
this._set({ loading: true, error: null });
try { await modelService.activateLoraProfile(modelId, profile); await this.refresh(); }
catch (e) { this._set({ loading: false, error: `LoRA failed: ${e.message}` }); }
}
_set(p) { Object.assign(this.state, p); this.render(); }
// --- Render ---
render() {
const el = this.container;
el.innerHTML = '';
const panel = this._el('div', 'mp-panel');
// Header
const hdr = this._el('div', 'mp-header');
hdr.appendChild(this._el('span', 'mp-title', 'Model Library'));
hdr.appendChild(this._el('span', 'mp-badge', String(this.state.models.length)));
panel.appendChild(hdr);
if (this.state.error) panel.appendChild(this._el('div', 'mp-error', this.state.error));
// Active model
if (this.state.activeModel) panel.appendChild(this._renderActive());
// List
const ls = this._el('div', 'mp-list-section');
ls.appendChild(this._el('div', 'mp-section-title', 'Available Models'));
const models = this.state.models.filter(
m => !(this.state.activeModel && this.state.activeModel.model_id === m.id)
);
if (models.length === 0 && !this.state.loading) {
ls.appendChild(this._el('div', 'mp-empty', 'No .rvf models found. Train a model or place .rvf files in data/models/'));
} else {
models.forEach(m => ls.appendChild(this._renderCard(m)));
}
panel.appendChild(ls);
// Footer
const ft = this._el('div', 'mp-footer');
const rb = this._btn('Refresh', 'mp-btn mp-btn-secondary', () => this.refresh());
rb.disabled = this.state.loading;
ft.appendChild(rb);
panel.appendChild(ft);
el.appendChild(panel);
}
_renderActive() {
const am = this.state.activeModel;
const card = this._el('div', 'mp-active-card');
card.appendChild(this._el('div', 'mp-active-name', am.model_id || 'Active Model'));
const full = this.state.models.find(m => m.id === am.model_id);
if (full) {
const meta = this._el('div', 'mp-active-meta');
if (full.version) meta.appendChild(this._tag('v' + full.version));
if (full.pck_score != null) meta.appendChild(this._tag('PCK ' + (full.pck_score * 100).toFixed(1) + '%'));
card.appendChild(meta);
}
if (am.avg_inference_ms != null) {
const st = this._el('div', 'mp-active-stats');
st.innerHTML = `<span class="mp-stat-label">Inference:</span> <span class="mp-stat-value">${am.avg_inference_ms.toFixed(1)} ms</span><span class="mp-stat-sep">|</span><span class="mp-stat-label">Frames:</span> <span class="mp-stat-value">${am.frames_processed ?? 0}</span>`;
card.appendChild(st);
}
if (this.state.loraProfiles.length > 0) {
const row = this._el('div', 'mp-lora-row');
row.appendChild(this._el('span', 'mp-lora-label', 'LoRA Profile:'));
const sel = document.createElement('select');
sel.className = 'mp-lora-select';
const def = document.createElement('option');
def.value = ''; def.textContent = '-- none --'; sel.appendChild(def);
this.state.loraProfiles.forEach(p => {
const o = document.createElement('option');
o.value = p; o.textContent = p; sel.appendChild(o);
});
sel.addEventListener('change', () => this._loraChange(am.model_id, sel.value));
row.appendChild(sel);
card.appendChild(row);
}
const ub = this._btn('Unload', 'mp-btn mp-btn-danger', () => this._unload());
ub.disabled = this.state.loading;
card.appendChild(ub);
return card;
}
_renderCard(model) {
const card = this._el('div', 'mp-model-card');
card.appendChild(this._el('div', 'mp-card-name', model.filename || model.id));
const meta = this._el('div', 'mp-card-meta');
if (model.version) meta.appendChild(this._tag('v' + model.version));
if (model.size_bytes != null) meta.appendChild(this._tag(this._fmtB(model.size_bytes)));
if (model.pck_score != null) meta.appendChild(this._tag('PCK ' + (model.pck_score * 100).toFixed(1) + '%'));
if (model.lora_profiles && model.lora_profiles.length > 0) meta.appendChild(this._tag(model.lora_profiles.length + ' LoRA'));
card.appendChild(meta);
const acts = this._el('div', 'mp-card-actions');
const lb = this._btn('Load', 'mp-btn mp-btn-success', () => this._load(model.id));
lb.disabled = this.state.loading;
const db = this._btn('Delete', 'mp-btn mp-btn-muted', () => this._delete(model.id));
db.disabled = this.state.loading;
acts.appendChild(lb); acts.appendChild(db);
card.appendChild(acts);
return card;
}
// --- Helpers ---
_el(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
_btn(txt, cls, fn) { const b = document.createElement('button'); b.className = cls; b.textContent = txt; b.addEventListener('click', fn); return b; }
_tag(txt) { return this._el('span', 'mp-meta-tag', txt); }
_fmtB(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
_injectStyles() {
if (document.getElementById('model-panel-styles')) return;
const s = document.createElement('style');
s.id = 'model-panel-styles';
s.textContent = MP_STYLES;
document.head.appendChild(s);
}
destroy() {
this.unsubs.forEach(fn => fn());
this.unsubs = [];
if (this.container) this.container.innerHTML = '';
}
dispose() {
this.destroy();
}
}
+159 -5
View File
@@ -45,7 +45,12 @@ export class PoseDetectionCanvas {
// Initialize settings panel
this.settingsPanel = null;
// Pose trail state
this.poseTrail = [];
this.showTrail = false;
this.maxTrailLength = 10;
// Initialize component
this.initializeComponent();
}
@@ -88,7 +93,7 @@ export class PoseDetectionCanvas {
<span class="status-text" id="status-text-${this.containerId}">Disconnected</span>
</div>
</div>
<div class="pose-canvas-controls" id="controls-${this.containerId}">
<div class="pose-canvas-controls" id="controls-${this.containerId}" ${!this.config.enableControls ? 'style="display:none"' : ''}>
<button class="btn btn-start" id="start-btn-${this.containerId}">&#9654; Start</button>
<button class="btn btn-stop" id="stop-btn-${this.containerId}" disabled>&#9632; Stop</button>
<button class="btn btn-reconnect" id="reconnect-btn-${this.containerId}" disabled>&#8635; Reconnect</button>
@@ -99,6 +104,7 @@ export class PoseDetectionCanvas {
<option value="heatmap">Heatmap</option>
<option value="dense">Dense</option>
</select>
<button class="btn btn-trail" id="trail-btn-${this.containerId}">&#9676; Trail</button>
<button class="btn btn-settings" id="settings-btn-${this.containerId}">&#9881; Settings</button>
</div>
</div>
@@ -285,6 +291,25 @@ export class PoseDetectionCanvas {
border-color: rgba(100, 116, 139, 0.5);
}
.btn-trail {
background: rgba(0, 212, 255, 0.1);
color: #5ec4d4;
border-color: rgba(0, 212, 255, 0.25);
}
.btn-trail:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.2);
border-color: rgba(0, 212, 255, 0.45);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.15);
}
.btn-trail.active {
background: rgba(0, 212, 255, 0.2);
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.5);
box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
}
.mode-select {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -416,6 +441,10 @@ export class PoseDetectionCanvas {
const demoBtn = document.getElementById(`demo-btn-${this.containerId}`);
demoBtn.addEventListener('click', () => this.toggleDemo());
// Trail toggle button
const trailBtn = document.getElementById(`trail-btn-${this.containerId}`);
trailBtn.addEventListener('click', () => this.toggleTrail());
// Settings button
const settingsBtn = document.getElementById(`settings-btn-${this.containerId}`);
settingsBtn.addEventListener('click', () => this.showSettings());
@@ -445,6 +474,7 @@ export class PoseDetectionCanvas {
case 'pose_update':
this.state.lastPoseData = update.data;
this.state.frameCount++;
this.updateTrail(update.data);
this.renderPoseData(update.data);
this.updateStats();
this.notifyCallback('onPoseUpdate', update.data);
@@ -487,14 +517,40 @@ export class PoseDetectionCanvas {
return;
}
try {
// Render trail before the current frame if enabled
if (this.showTrail && this.poseTrail.length > 1) {
// The renderer.render() clears the canvas, so we render trail
// by hooking into the renderer's canvas context after clear.
// We override the render flow: clear, trail, then current.
this.renderer.clearCanvas();
this.renderTrail(this.renderer.ctx);
// Now render current frame without clearing again
this.renderCurrentFrameNoClean(poseData);
} else {
this.renderer.render(poseData, {
frameCount: this.state.frameCount,
connectionState: this.state.connectionState
});
}
} catch (error) {
this.logger.error('Render error', { error: error.message });
this.showError(`Render error: ${error.message}`);
}
}
renderCurrentFrameNoClean(poseData) {
// Call the renderer's render logic without clearing the canvas.
// We temporarily stub clearCanvas, render, then restore.
const origClear = this.renderer.clearCanvas.bind(this.renderer);
this.renderer.clearCanvas = () => {}; // no-op
try {
this.renderer.render(poseData, {
frameCount: this.state.frameCount,
connectionState: this.state.connectionState
});
} catch (error) {
this.logger.error('Render error', { error: error.message });
this.showError(`Render error: ${error.message}`);
} finally {
this.renderer.clearCanvas = origClear;
}
}
@@ -650,6 +706,104 @@ export class PoseDetectionCanvas {
}
}
// --- Pose Trail Methods ---
toggleTrail() {
this.showTrail = !this.showTrail;
const trailBtn = document.getElementById(`trail-btn-${this.containerId}`);
if (trailBtn) {
trailBtn.classList.toggle('active', this.showTrail);
trailBtn.textContent = this.showTrail ? '\u25CB Trail On' : '\u25CB Trail';
}
if (!this.showTrail) {
this.poseTrail = [];
}
this.logger.info('Trail toggled', { showTrail: this.showTrail });
}
updateTrail(poseData) {
if (!this.showTrail) return;
if (!poseData || !poseData.persons || poseData.persons.length === 0) return;
// Deep clone the keypoints from all persons for this frame
const frameKeypoints = poseData.persons.map(person => {
if (!person.keypoints) return null;
return person.keypoints.map(kp => ({
x: kp.x,
y: kp.y,
confidence: kp.confidence
}));
}).filter(Boolean);
if (frameKeypoints.length > 0) {
this.poseTrail.push(frameKeypoints);
if (this.poseTrail.length > this.maxTrailLength) {
this.poseTrail.shift();
}
}
}
renderTrail(ctx) {
if (!this.poseTrail || this.poseTrail.length < 2) return;
const totalFrames = this.poseTrail.length;
// Keypoint color palette (same as renderer's body part colors)
const kpColors = [
'#ff0000', '#ff4500', '#ffa500', '#ffff00', '#adff2f',
'#00ff00', '#00ff7f', '#00ffff', '#0080ff', '#0000ff',
'#4000ff', '#8000ff', '#ff00ff', '#ff0080', '#ff0040',
'#ff8080', '#ffb380'
];
// Render ghosted keypoints and trajectory lines for each frame in the trail
// (skip the last frame since it's the current one rendered by the normal pipeline)
for (let frameIdx = 0; frameIdx < totalFrames - 1; frameIdx++) {
const alpha = 0.1 + (frameIdx / totalFrames) * 0.7;
const framePersons = this.poseTrail[frameIdx];
const nextFramePersons = this.poseTrail[frameIdx + 1];
framePersons.forEach((personKeypoints, personIdx) => {
if (!personKeypoints) return;
personKeypoints.forEach((kp, kpIdx) => {
if (kp.confidence <= 0.1) return;
const x = this.renderer.scaleX(kp.x);
const y = this.renderer.scaleY(kp.y);
const color = kpColors[kpIdx % kpColors.length];
// Draw ghosted keypoint dot
ctx.globalAlpha = alpha * 0.6;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fill();
// Draw trajectory line to same keypoint in next frame
if (nextFramePersons && nextFramePersons[personIdx]) {
const nextKp = nextFramePersons[personIdx][kpIdx];
if (nextKp && nextKp.confidence > 0.1) {
const nx = this.renderer.scaleX(nextKp.x);
const ny = this.renderer.scaleY(nextKp.y);
ctx.globalAlpha = alpha * 0.4;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
}
}
});
});
}
// Reset alpha
ctx.globalAlpha = 1.0;
}
// Toggle demo mode
toggleDemo() {
if (this.demoState && this.demoState.isRunning) {
+6 -4
View File
@@ -216,9 +216,10 @@ export class SensingTab {
// Map the service's dataSource to banner text and CSS modifier class.
const dataSource = sensingService.dataSource;
const bannerConfig = {
live: { text: 'LIVE - ESP32', cls: 'sensing-source-live' },
reconnecting: { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
simulated: { text: 'SIMULATED DATA', cls: 'sensing-source-simulated' },
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
};
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
banner.textContent = cfg.text;
@@ -256,7 +257,8 @@ export class SensingTab {
// Details
this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz');
this._setText('valChangePoints', String(f.change_points || 0));
this._setText('valSampleRate', data.source === 'simulated' ? 'sim' : 'live');
const srcLabel = (data.source === 'simulated' || data.source === 'simulate') ? 'sim' : data.source || 'live';
this._setText('valSampleRate', srcLabel);
// Sparkline
this._drawSparkline();
+196 -38
View File
@@ -55,7 +55,23 @@ export class SettingsPanel {
// Advanced settings
heartbeatInterval: 30000,
maxReconnectAttempts: 10,
enableSmoothing: true
enableSmoothing: true,
// Model settings
defaultModelPath: 'data/models/',
autoLoadModel: false,
inferenceDevice: 'CPU',
inferenceThreads: 4,
progressiveLoading: true,
// Training settings
defaultEpochs: 100,
defaultBatchSize: 32,
defaultLearningRate: 0.0003,
earlyStoppingPatience: 15,
checkpointDirectory: 'data/models/',
autoExportOnCompletion: true,
recordingDirectory: 'data/recordings/'
};
this.callbacks = {
@@ -245,6 +261,67 @@ export class SettingsPanel {
</div>
</div>
<!-- Model Settings -->
<div class="settings-section">
<h4>Model Configuration</h4>
<div class="setting-row">
<label for="default-model-path-${this.containerId}">Default Model Path:</label>
<input type="text" id="default-model-path-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/models/">
</div>
<div class="setting-row">
<label for="auto-load-model-${this.containerId}">Auto-load Model on Startup:</label>
<input type="checkbox" id="auto-load-model-${this.containerId}" class="setting-checkbox">
</div>
<div class="setting-row">
<label for="inference-device-${this.containerId}">Inference Device:</label>
<select id="inference-device-${this.containerId}" class="setting-select">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
</select>
</div>
<div class="setting-row">
<label for="inference-threads-${this.containerId}">Inference Threads:</label>
<input type="number" id="inference-threads-${this.containerId}" class="setting-input" min="1" max="16">
</div>
<div class="setting-row">
<label for="progressive-loading-${this.containerId}">Progressive Loading:</label>
<input type="checkbox" id="progressive-loading-${this.containerId}" class="setting-checkbox">
</div>
</div>
<!-- Training Settings -->
<div class="settings-section">
<h4>Training Configuration</h4>
<div class="setting-row">
<label for="default-epochs-${this.containerId}">Default Epochs:</label>
<input type="number" id="default-epochs-${this.containerId}" class="setting-input" min="1" max="10000">
</div>
<div class="setting-row">
<label for="default-batch-size-${this.containerId}">Default Batch Size:</label>
<input type="number" id="default-batch-size-${this.containerId}" class="setting-input" min="1" max="512">
</div>
<div class="setting-row">
<label for="default-learning-rate-${this.containerId}">Default Learning Rate:</label>
<input type="number" id="default-learning-rate-${this.containerId}" class="setting-input" min="0.000001" max="1" step="0.0001">
</div>
<div class="setting-row">
<label for="early-stopping-patience-${this.containerId}">Early Stopping Patience:</label>
<input type="number" id="early-stopping-patience-${this.containerId}" class="setting-input" min="1" max="100">
</div>
<div class="setting-row">
<label for="checkpoint-directory-${this.containerId}">Checkpoint Directory:</label>
<input type="text" id="checkpoint-directory-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/models/">
</div>
<div class="setting-row">
<label for="auto-export-on-completion-${this.containerId}">Auto-export on Completion:</label>
<input type="checkbox" id="auto-export-on-completion-${this.containerId}" class="setting-checkbox">
</div>
<div class="setting-row">
<label for="recording-directory-${this.containerId}">Recording Directory:</label>
<input type="text" id="recording-directory-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/recordings/">
</div>
</div>
<div class="settings-toggle">
<button class="btn btn-sm" id="toggle-advanced-${this.containerId}">Show Advanced</button>
</div>
@@ -267,11 +344,12 @@ export class SettingsPanel {
const style = document.createElement('style');
style.textContent = `
.settings-panel {
background: #fff;
border: 1px solid #ddd;
background: #0d1117;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 8px;
font-family: Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
color: #e0e0e0;
}
.settings-header {
@@ -279,13 +357,13 @@ export class SettingsPanel {
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-bottom: 1px solid #ddd;
background: rgba(15, 20, 35, 0.95);
border-bottom: 1px solid rgba(56, 68, 89, 0.6);
}
.settings-header h3 {
margin: 0;
color: #333;
color: #e0e0e0;
font-size: 16px;
font-weight: 600;
}
@@ -297,26 +375,43 @@ export class SettingsPanel {
.settings-content {
padding: 20px;
max-height: 400px;
max-height: 500px;
overflow-y: auto;
}
.settings-content::-webkit-scrollbar {
width: 6px;
}
.settings-content::-webkit-scrollbar-track {
background: rgba(15, 20, 35, 0.5);
}
.settings-content::-webkit-scrollbar-thumb {
background: rgba(56, 68, 89, 0.8);
border-radius: 3px;
}
.settings-content::-webkit-scrollbar-thumb:hover {
background: rgba(80, 96, 120, 0.9);
}
.settings-section {
margin-bottom: 25px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
padding: 16px;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(56, 68, 89, 0.4);
border-radius: 8px;
}
.settings-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.settings-section h4 {
margin: 0 0 15px 0;
color: #555;
font-size: 14px;
color: #8899aa;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -332,7 +427,7 @@ export class SettingsPanel {
.setting-row label {
flex: 1;
color: #666;
color: #8899aa;
font-size: 13px;
font-weight: 500;
}
@@ -340,9 +435,26 @@ export class SettingsPanel {
.setting-input, .setting-select {
flex: 0 0 120px;
padding: 6px 8px;
border: 1px solid #ddd;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 4px;
font-size: 13px;
background: rgba(15, 20, 35, 0.8);
color: #e0e0e0;
}
.setting-input:focus, .setting-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.setting-input-wide {
flex: 0 0 160px;
}
.setting-select option {
background: #1a2234;
color: #c8d0dc;
}
.setting-range {
@@ -353,41 +465,45 @@ export class SettingsPanel {
.setting-value {
flex: 0 0 40px;
font-size: 12px;
color: #666;
color: #b0b8c8;
text-align: center;
background: #f8f9fa;
background: rgba(15, 20, 35, 0.8);
padding: 2px 6px;
border-radius: 3px;
border: 1px solid #ddd;
border: 1px solid rgba(56, 68, 89, 0.6);
}
.setting-checkbox {
flex: 0 0 auto;
width: 18px;
height: 18px;
accent-color: #667eea;
}
.setting-color {
flex: 0 0 50px;
height: 30px;
border: 1px solid #ddd;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 4px;
cursor: pointer;
background: rgba(15, 20, 35, 0.8);
}
.btn {
padding: 6px 12px;
border: 1px solid #ddd;
border: 1px solid rgba(56, 68, 89, 0.6);
border-radius: 4px;
background: #fff;
background: rgba(30, 40, 60, 0.8);
color: #b0b8c8;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.btn:hover {
background: #f8f9fa;
border-color: #adb5bd;
background: rgba(40, 55, 80, 0.9);
border-color: rgba(80, 96, 120, 0.8);
color: #e0e0e0;
}
.btn-sm {
@@ -398,32 +514,32 @@ export class SettingsPanel {
.settings-toggle {
text-align: center;
padding-top: 15px;
border-top: 1px solid #eee;
border-top: 1px solid rgba(56, 68, 89, 0.4);
}
.settings-footer {
padding: 10px 20px;
background: #f8f9fa;
border-top: 1px solid #ddd;
background: rgba(15, 20, 35, 0.95);
border-top: 1px solid rgba(56, 68, 89, 0.6);
text-align: center;
}
.settings-status {
font-size: 12px;
color: #666;
color: #6b7a8d;
}
.advanced-section {
background: #f9f9f9;
background: rgba(20, 28, 45, 0.9);
margin: 0 -20px 25px -20px;
padding: 20px;
border: none;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
border-top: 1px solid rgba(56, 68, 89, 0.4);
border-bottom: 1px solid rgba(56, 68, 89, 0.4);
}
.advanced-section h4 {
color: #dc3545;
color: #ef4444;
}
`;
@@ -492,7 +608,9 @@ export class SettingsPanel {
const checkboxes = [
'auto-reconnect', 'show-keypoints', 'show-skeleton', 'show-bounding-box',
'show-confidence', 'show-zones', 'show-debug-info', 'enable-validation',
'enable-performance-tracking', 'enable-debug-logging', 'enable-smoothing'
'enable-performance-tracking', 'enable-debug-logging', 'enable-smoothing',
'auto-load-model', 'progressive-loading',
'auto-export-on-completion'
];
checkboxes.forEach(id => {
@@ -503,12 +621,14 @@ export class SettingsPanel {
});
});
// Number inputs
// Number inputs (integers)
const numberInputs = [
'connection-timeout', 'max-persons', 'max-fps',
'heartbeat-interval', 'max-reconnect-attempts'
'connection-timeout', 'max-persons', 'max-fps',
'heartbeat-interval', 'max-reconnect-attempts',
'inference-threads', 'default-epochs', 'default-batch-size',
'early-stopping-patience'
];
numberInputs.forEach(id => {
const input = document.getElementById(`${id}-${this.containerId}`);
input?.addEventListener('change', (e) => {
@@ -517,6 +637,32 @@ export class SettingsPanel {
});
});
// Float number inputs
const floatInputs = ['default-learning-rate'];
floatInputs.forEach(id => {
const input = document.getElementById(`${id}-${this.containerId}`);
input?.addEventListener('change', (e) => {
const settingKey = this.camelCase(id);
this.updateSetting(settingKey, parseFloat(e.target.value));
});
});
// Text inputs
const textInputs = ['default-model-path', 'checkpoint-directory', 'recording-directory'];
textInputs.forEach(id => {
const input = document.getElementById(`${id}-${this.containerId}`);
input?.addEventListener('change', (e) => {
const settingKey = this.camelCase(id);
this.updateSetting(settingKey, e.target.value);
});
});
// Inference device select
const inferenceDeviceSelect = document.getElementById(`inference-device-${this.containerId}`);
inferenceDeviceSelect?.addEventListener('change', (e) => {
this.updateSetting('inferenceDevice', e.target.value);
});
// Color inputs
const colorInputs = ['skeleton-color', 'keypoint-color', 'bounding-box-color'];
colorInputs.forEach(id => {
@@ -696,7 +842,19 @@ export class SettingsPanel {
enableDebugLogging: false,
heartbeatInterval: 30000,
maxReconnectAttempts: 10,
enableSmoothing: true
enableSmoothing: true,
defaultModelPath: 'data/models/',
autoLoadModel: false,
inferenceDevice: 'CPU',
inferenceThreads: 4,
progressiveLoading: true,
defaultEpochs: 100,
defaultBatchSize: 32,
defaultLearningRate: 0.0003,
earlyStoppingPatience: 15,
checkpointDirectory: 'data/models/',
autoExportOnCompletion: true,
recordingDirectory: 'data/recordings/'
};
}
+419
View File
@@ -0,0 +1,419 @@
// TrainingPanel Component for WiFi-DensePose UI
// Dark-mode panel for training management, CSI recordings, and progress charts.
import { trainingService } from '../services/training.service.js';
const TP_STYLES = `
.tp-panel{background:rgba(17,24,39,.9);border:1px solid rgba(56,68,89,.6);border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;overflow:hidden}
.tp-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;background:rgba(13,17,23,.95);border-bottom:1px solid rgba(56,68,89,.6)}
.tp-title{font-size:14px;font-weight:600;color:#e0e0e0}
.tp-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px}
.tp-badge-idle{background:rgba(108,117,125,.2);color:#8899aa;border:1px solid rgba(108,117,125,.3)}
.tp-badge-active{background:rgba(40,167,69,.2);color:#51cf66;border:1px solid rgba(40,167,69,.3);animation:tp-pulse 1.5s ease-in-out infinite}
.tp-badge-done{background:rgba(102,126,234,.2);color:#8ea4f0;border:1px solid rgba(102,126,234,.3)}
@keyframes tp-pulse{0%,100%{opacity:1}50%{opacity:.6}}
.tp-error{background:rgba(220,53,69,.15);color:#f5a0a8;border:1px solid rgba(220,53,69,.3);border-radius:4px;padding:8px 12px;margin:10px 12px 0;font-size:12px}
.tp-section{padding:12px;border-bottom:1px solid rgba(56,68,89,.3)}
.tp-section:last-child{border-bottom:none}
.tp-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:#8899aa;margin-bottom:8px}
.tp-empty{color:#6b7a8d;font-size:12px;padding:12px 0;text-align:center}
.tp-rec-row{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;margin-bottom:4px;background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.3);border-radius:4px}
.tp-rec-info{display:flex;flex-direction:column;gap:2px}
.tp-rec-name{font-size:12px;color:#c8d0dc;font-weight:500}
.tp-rec-meta{font-size:10px;color:#6b7a8d}
.tp-rec-actions{margin-top:8px}
.tp-config-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.tp-config-form{display:flex;flex-direction:column;gap:6px}
.tp-label{font-size:12px;color:#8899aa;display:block;margin-bottom:2px}
.tp-input-row{display:flex;justify-content:space-between;align-items:center;gap:8px}
.tp-input-row .tp-label{flex:1;margin-bottom:0}
.tp-input{width:110px;padding:4px 8px;background:rgba(30,40,60,.8);border:1px solid rgba(56,68,89,.6);border-radius:4px;color:#c8d0dc;font-size:12px}
.tp-input:focus{outline:none;border-color:#667eea}
.tp-ds-container{display:flex;flex-direction:column;gap:4px;margin-bottom:4px;max-height:100px;overflow-y:auto}
.tp-ds-item{display:flex;align-items:center;gap:6px;font-size:12px;color:#c8d0dc;cursor:pointer}
.tp-ds-item input{width:14px;height:14px}
.tp-train-actions{display:flex;gap:6px;margin-top:10px}
.tp-progress-bar{height:6px;background:rgba(30,40,60,.8);border-radius:3px;overflow:hidden;margin-bottom:4px}
.tp-progress-fill{height:100%;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:3px;transition:width .3s}
.tp-progress-label{font-size:11px;color:#8899aa;text-align:center;margin-bottom:10px}
.tp-chart-row{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}
.tp-chart-row canvas{border:1px solid rgba(56,68,89,.4);border-radius:4px;flex:1;min-width:120px}
.tp-metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.tp-metric-cell{background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.3);border-radius:4px;padding:6px 8px}
.tp-metric-label{font-size:10px;color:#6b7a8d;text-transform:uppercase;letter-spacing:.3px}
.tp-metric-value{font-size:13px;color:#c8d0dc;font-weight:500;margin-top:2px}
.tp-btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
.tp-btn:disabled{opacity:.5;cursor:not-allowed}
.tp-btn-success{background:rgba(40,167,69,.2);color:#51cf66;border-color:rgba(40,167,69,.3)}
.tp-btn-success:hover:not(:disabled){background:rgba(40,167,69,.35)}
.tp-btn-danger{background:rgba(220,53,69,.2);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
.tp-btn-danger:hover:not(:disabled){background:rgba(220,53,69,.35)}
.tp-btn-secondary{background:rgba(30,40,60,.8);color:#b0b8c8;border-color:rgba(56,68,89,.6)}
.tp-btn-secondary:hover:not(:disabled){background:rgba(40,50,75,.9)}
.tp-btn-rec{background:rgba(220,53,69,.15);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
.tp-btn-rec:hover:not(:disabled){background:rgba(220,53,69,.3)}
.tp-btn-muted{background:transparent;color:#6b7a8d;border-color:rgba(56,68,89,.4);font-size:11px;padding:3px 8px}
.tp-btn-muted:hover:not(:disabled){color:#b0b8c8;border-color:rgba(56,68,89,.8)}
`;
export default class TrainingPanel {
constructor(container) {
this.container = typeof container === 'string'
? document.getElementById(container) : container;
if (!this.container) throw new Error('TrainingPanel: container element not found');
this.state = {
recordings: [], trainingStatus: null, isRecording: false,
configOpen: true, loading: false, error: null
};
this.config = {
epochs: 100, batch_size: 32, learning_rate: 3e-4, patience: 15,
selectedRecordings: [], base_model: '', lora_profile_name: ''
};
this.progressData = { losses: [], pcks: [] };
this.unsubscribers = [];
this._injectStyles();
this.render();
this.refresh();
this._bindEvents();
}
_bindEvents() {
this.unsubscribers.push(
trainingService.on('progress', (d) => this._onProgress(d)),
trainingService.on('training-started', () => this.refresh()),
trainingService.on('training-stopped', () => {
trainingService.disconnectProgressStream();
this.refresh();
})
);
}
_onProgress(data) {
if (data.train_loss != null) this.progressData.losses.push(data.train_loss);
if (data.val_pck != null) this.progressData.pcks.push(data.val_pck);
this._set({ trainingStatus: { ...this.state.trainingStatus, ...data } });
}
// --- Data ---
async refresh() {
this._set({ loading: true, error: null });
try {
const [recordings, status] = await Promise.all([
trainingService.listRecordings().catch(() => []),
trainingService.getTrainingStatus().catch(() => null)
]);
if (status && !status.active) this.progressData = { losses: [], pcks: [] };
this._set({ recordings, trainingStatus: status, loading: false });
} catch (e) { this._set({ loading: false, error: e.message }); }
}
// --- Actions ---
async _startRec() {
this._set({ loading: true, error: null });
try {
await trainingService.startRecording({ session_name: `rec_${Date.now()}`, label: 'pose' });
this._set({ isRecording: true, loading: false });
await this.refresh();
} catch (e) { this._set({ loading: false, error: `Recording failed: ${e.message}` }); }
}
async _stopRec() {
this._set({ loading: true, error: null });
try {
await trainingService.stopRecording();
this._set({ isRecording: false, loading: false });
await this.refresh();
} catch (e) { this._set({ loading: false, error: `Stop recording failed: ${e.message}` }); }
}
async _delRec(id) {
this._set({ loading: true, error: null });
try {
await trainingService.deleteRecording(id);
this.config.selectedRecordings = this.config.selectedRecordings.filter(r => r !== id);
await this.refresh();
} catch (e) { this._set({ loading: false, error: `Delete failed: ${e.message}` }); }
}
async _launchTraining(method, extraCfg = {}) {
this._set({ loading: true, error: null });
this.progressData = { losses: [], pcks: [] };
try {
trainingService.connectProgressStream();
const payload = {
dataset_ids: this.config.selectedRecordings,
config: {
epochs: this.config.epochs,
batch_size: this.config.batch_size,
learning_rate: this.config.learning_rate,
...extraCfg
}
};
await trainingService[method](payload);
await this.refresh();
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
}
async _stopTraining() {
this._set({ loading: true, error: null });
try { await trainingService.stopTraining(); await this.refresh(); }
catch (e) { this._set({ loading: false, error: `Stop failed: ${e.message}` }); }
}
_set(p) { Object.assign(this.state, p); this.render(); }
// --- Render ---
render() {
const el = this.container;
el.innerHTML = '';
const panel = this._el('div', 'tp-panel');
panel.appendChild(this._renderHeader());
if (this.state.error) panel.appendChild(this._el('div', 'tp-error', this.state.error));
panel.appendChild(this._renderRecordings());
const ts = this.state.trainingStatus;
const active = ts && ts.active;
if (active) panel.appendChild(this._renderProgress());
else if (ts && !ts.active && this.progressData.losses.length > 0) panel.appendChild(this._renderComplete());
else panel.appendChild(this._renderConfig());
el.appendChild(panel);
if (active) requestAnimationFrame(() => this._drawCharts());
}
_renderHeader() {
const h = this._el('div', 'tp-header');
h.appendChild(this._el('span', 'tp-title', 'Training'));
const ts = this.state.trainingStatus;
let cls = 'tp-badge tp-badge-idle', txt = 'Idle';
if (ts && ts.active) { cls = 'tp-badge tp-badge-active'; txt = 'Training'; }
else if (ts && !ts.active && this.progressData.losses.length > 0) { cls = 'tp-badge tp-badge-done'; txt = 'Completed'; }
h.appendChild(this._el('span', cls, txt));
return h;
}
_renderRecordings() {
const s = this._el('div', 'tp-section');
s.appendChild(this._el('div', 'tp-section-title', 'CSI Recordings'));
if (this.state.recordings.length === 0 && !this.state.loading) {
s.appendChild(this._el('div', 'tp-empty', 'Start recording CSI data to train a model'));
} else {
this.state.recordings.forEach(rec => {
const row = this._el('div', 'tp-rec-row');
const info = this._el('div', 'tp-rec-info');
info.appendChild(this._el('span', 'tp-rec-name', rec.name || rec.id));
const parts = [];
if (rec.frame_count != null) parts.push(rec.frame_count + ' frames');
if (rec.file_size_bytes != null) parts.push(this._fmtB(rec.file_size_bytes));
if (rec.started_at && rec.ended_at) parts.push(Math.round((new Date(rec.ended_at) - new Date(rec.started_at)) / 1000) + 's');
info.appendChild(this._el('span', 'tp-rec-meta', parts.join(' / ')));
row.appendChild(info);
const del = this._btn('Delete', 'tp-btn tp-btn-muted', () => this._delRec(rec.id));
del.disabled = this.state.loading;
row.appendChild(del);
s.appendChild(row);
});
}
const acts = this._el('div', 'tp-rec-actions');
if (this.state.isRecording) {
const b = this._btn('Stop Recording', 'tp-btn tp-btn-danger', () => this._stopRec());
b.disabled = this.state.loading; acts.appendChild(b);
} else {
const b = this._btn('Start Recording', 'tp-btn tp-btn-rec', () => this._startRec());
b.disabled = this.state.loading; acts.appendChild(b);
}
s.appendChild(acts);
return s;
}
_renderConfig() {
const s = this._el('div', 'tp-section');
const hdr = this._el('div', 'tp-config-header');
hdr.appendChild(this._el('span', 'tp-section-title', 'Training Configuration'));
hdr.appendChild(this._btn(this.state.configOpen ? 'Collapse' : 'Expand', 'tp-btn tp-btn-muted',
() => { this.state.configOpen = !this.state.configOpen; this.render(); }));
s.appendChild(hdr);
if (!this.state.configOpen) return s;
const form = this._el('div', 'tp-config-form');
if (this.state.recordings.length > 0) {
form.appendChild(this._el('label', 'tp-label', 'Datasets'));
const dc = this._el('div', 'tp-ds-container');
this.state.recordings.forEach(rec => {
const lb = this._el('label', 'tp-ds-item');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = this.config.selectedRecordings.includes(rec.id);
cb.addEventListener('change', () => {
if (cb.checked) { if (!this.config.selectedRecordings.includes(rec.id)) this.config.selectedRecordings.push(rec.id); }
else { this.config.selectedRecordings = this.config.selectedRecordings.filter(r => r !== rec.id); }
});
lb.appendChild(cb);
lb.appendChild(this._el('span', null, rec.name || rec.id));
dc.appendChild(lb);
});
form.appendChild(dc);
}
const ir = (l, t, v, fn) => {
const r = this._el('div', 'tp-input-row');
r.appendChild(this._el('label', 'tp-label', l));
const inp = document.createElement('input');
inp.type = t; inp.className = 'tp-input'; inp.value = v;
inp.addEventListener('change', () => fn(inp.value));
r.appendChild(inp); return r;
};
form.appendChild(ir('Epochs', 'number', this.config.epochs, v => { this.config.epochs = parseInt(v) || 100; }));
form.appendChild(ir('Batch Size', 'number', this.config.batch_size, v => { this.config.batch_size = parseInt(v) || 32; }));
form.appendChild(ir('Learning Rate', 'text', this.config.learning_rate, v => { this.config.learning_rate = parseFloat(v) || 3e-4; }));
form.appendChild(ir('Early Stop Patience', 'number', this.config.patience, v => { this.config.patience = parseInt(v) || 15; }));
form.appendChild(ir('Base Model (opt.)', 'text', this.config.base_model, v => { this.config.base_model = v; }));
form.appendChild(ir('LoRA Profile (opt.)', 'text', this.config.lora_profile_name, v => { this.config.lora_profile_name = v; }));
s.appendChild(form);
const acts = this._el('div', 'tp-train-actions');
const btns = [
this._btn('Start Training', 'tp-btn tp-btn-success', () => this._launchTraining('startTraining', { patience: this.config.patience, base_model: this.config.base_model || undefined })),
this._btn('Pretrain', 'tp-btn tp-btn-secondary', () => this._launchTraining('startPretraining')),
this._btn('LoRA', 'tp-btn tp-btn-secondary', () => this._launchTraining('startLoraTraining', { base_model: this.config.base_model || undefined, profile_name: this.config.lora_profile_name || 'default' }))
];
btns.forEach(b => { b.disabled = this.state.loading; acts.appendChild(b); });
s.appendChild(acts);
return s;
}
_renderProgress() {
const ts = this.state.trainingStatus || {};
const s = this._el('div', 'tp-section');
s.appendChild(this._el('div', 'tp-section-title', 'Training Progress'));
const pct = ts.total_epochs ? Math.round((ts.epoch / ts.total_epochs) * 100) : 0;
const bar = this._el('div', 'tp-progress-bar');
const fill = this._el('div', 'tp-progress-fill');
fill.style.width = pct + '%';
bar.appendChild(fill); s.appendChild(bar);
s.appendChild(this._el('div', 'tp-progress-label', `Epoch ${ts.epoch ?? 0} / ${ts.total_epochs ?? '?'} (${pct}%)`));
const cr = this._el('div', 'tp-chart-row');
const lc = document.createElement('canvas'); lc.id = 'tp-loss-chart'; lc.width = 260; lc.height = 140;
const pc = document.createElement('canvas'); pc.id = 'tp-pck-chart'; pc.width = 260; pc.height = 140;
cr.appendChild(lc); cr.appendChild(pc); s.appendChild(cr);
const g = this._el('div', 'tp-metrics-grid');
const mc = (l, v) => { const c = this._el('div', 'tp-metric-cell'); c.appendChild(this._el('div', 'tp-metric-label', l)); c.appendChild(this._el('div', 'tp-metric-value', v)); return c; };
g.appendChild(mc('Loss', ts.train_loss != null ? ts.train_loss.toFixed(4) : '--'));
g.appendChild(mc('PCK', ts.val_pck != null ? (ts.val_pck * 100).toFixed(1) + '%' : '--'));
g.appendChild(mc('OKS', ts.val_oks != null ? ts.val_oks.toFixed(3) : '--'));
g.appendChild(mc('LR', ts.lr != null ? ts.lr.toExponential(1) : '--'));
g.appendChild(mc('Best PCK', ts.best_pck != null ? (ts.best_pck * 100).toFixed(1) + '% (e' + (ts.best_epoch ?? '?') + ')' : '--'));
g.appendChild(mc('Patience', ts.patience_remaining != null ? String(ts.patience_remaining) : '--'));
g.appendChild(mc('ETA', ts.eta_secs != null ? this._fmtEta(ts.eta_secs) : '--'));
g.appendChild(mc('Phase', ts.phase || '--'));
s.appendChild(g);
const stop = this._btn('Stop Training', 'tp-btn tp-btn-danger', () => this._stopTraining());
stop.disabled = this.state.loading; stop.style.marginTop = '10px'; s.appendChild(stop);
return s;
}
_renderComplete() {
const ts = this.state.trainingStatus || {};
const s = this._el('div', 'tp-section');
s.appendChild(this._el('div', 'tp-section-title', 'Training Complete'));
const g = this._el('div', 'tp-metrics-grid');
const mc = (l, v) => { const c = this._el('div', 'tp-metric-cell'); c.appendChild(this._el('div', 'tp-metric-label', l)); c.appendChild(this._el('div', 'tp-metric-value', v)); return c; };
const losses = this.progressData.losses;
g.appendChild(mc('Final Loss', losses.length > 0 ? losses[losses.length - 1].toFixed(4) : '--'));
g.appendChild(mc('Best PCK', ts.best_pck != null ? (ts.best_pck * 100).toFixed(1) + '%' : '--'));
g.appendChild(mc('Best Epoch', ts.best_epoch != null ? String(ts.best_epoch) : '--'));
g.appendChild(mc('Total Epochs', String(losses.length)));
s.appendChild(g);
const acts = this._el('div', 'tp-train-actions');
acts.appendChild(this._btn('New Training', 'tp-btn tp-btn-secondary', () => {
this.progressData = { losses: [], pcks: [] }; this._set({ trainingStatus: null });
}));
s.appendChild(acts);
return s;
}
// --- Chart drawing ---
_drawCharts() {
this._drawChart('tp-loss-chart', this.progressData.losses, { color: '#ff6b6b', label: 'Loss', yMin: 0, yMax: null });
this._drawChart('tp-pck-chart', this.progressData.pcks, { color: '#51cf66', label: 'PCK', yMin: 0, yMax: 1 });
}
_drawChart(id, data, opts) {
const cv = document.getElementById(id);
if (!cv) return;
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height;
const p = { t: 20, r: 10, b: 24, l: 44 };
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#8899aa'; ctx.font = '11px -apple-system,sans-serif'; ctx.fillText(opts.label, p.l, 14);
if (!data.length) { ctx.fillStyle = '#6b7a8d'; ctx.fillText('No data', w / 2 - 20, h / 2); return; }
const pw = w - p.l - p.r, ph = h - p.t - p.b;
let yMin = opts.yMin ?? Math.min(...data), yMax = opts.yMax ?? Math.max(...data);
if (yMax === yMin) yMax = yMin + 1;
ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = p.t + (ph / 4) * i;
ctx.beginPath(); ctx.moveTo(p.l, y); ctx.lineTo(w - p.r, y); ctx.stroke();
const v = yMax - ((yMax - yMin) / 4) * i;
ctx.fillStyle = '#6b7a8d'; ctx.font = '9px sans-serif'; ctx.fillText(v.toFixed(v >= 1 ? 2 : 3), 2, y + 3);
}
const xl = Math.min(data.length, 5);
for (let i = 0; i < xl; i++) {
const idx = Math.round((data.length - 1) * (i / (xl - 1 || 1)));
ctx.fillStyle = '#6b7a8d'; ctx.fillText(String(idx + 1), p.l + (pw * idx) / (data.length - 1 || 1) - 4, h - 4);
}
ctx.strokeStyle = opts.color; ctx.lineWidth = 1.5; ctx.beginPath();
data.forEach((v, i) => {
const x = p.l + (pw * i) / (data.length - 1 || 1);
const y = p.t + ph - ((v - yMin) / (yMax - yMin)) * ph;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
if (data.length > 0) {
const ly = p.t + ph - ((data[data.length - 1] - yMin) / (yMax - yMin)) * ph;
ctx.fillStyle = opts.color; ctx.beginPath(); ctx.arc(p.l + pw, ly, 3, 0, Math.PI * 2); ctx.fill();
}
}
// --- Helpers ---
_el(tag, cls, txt) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (txt != null) e.textContent = txt;
return e;
}
_btn(txt, cls, fn) {
const b = document.createElement('button');
b.className = cls; b.textContent = txt;
b.addEventListener('click', fn); return b;
}
_fmtB(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
_fmtEta(s) { return s < 60 ? Math.round(s) + 's' : s < 3600 ? Math.round(s / 60) + 'm' : (s / 3600).toFixed(1) + 'h'; }
_injectStyles() {
if (document.getElementById('training-panel-styles')) return;
const s = document.createElement('style');
s.id = 'training-panel-styles';
s.textContent = TP_STYLES;
document.head.appendChild(s);
}
destroy() {
this.unsubscribers.forEach(fn => fn());
this.unsubscribers = [];
trainingService.disconnectProgressStream();
if (this.container) this.container.innerHTML = '';
}
dispose() {
this.destroy();
}
}
+18
View File
@@ -28,6 +28,7 @@
<button class="nav-tab" data-tab="performance">Performance</button>
<button class="nav-tab" data-tab="applications">Applications</button>
<button class="nav-tab" data-tab="sensing">Sensing</button>
<button class="nav-tab" data-tab="training">Training</button>
</nav>
<!-- Dashboard Tab -->
@@ -67,6 +68,11 @@
<span class="status-text">-</span>
<span class="status-message"></span>
</div>
<div class="component-status" data-component="datasource" id="dashboard-datasource">
<span class="component-name">Data Source</span>
<span class="status-text">-</span>
<span class="status-message"></span>
</div>
</div>
</div>
@@ -482,6 +488,18 @@
<!-- Sensing Tab -->
<section id="sensing" class="tab-content"></section>
<!-- Training Tab -->
<section id="training" class="tab-content">
<div class="tab-header">
<h2>Model Training</h2>
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
</div>
<div id="training-container" style="display: flex; gap: 20px; flex-wrap: wrap;">
<div id="training-panel-container" style="flex: 1; min-width: 400px;"></div>
<div id="model-panel-container" style="flex: 1; min-width: 350px; max-width: 450px;"></div>
</div>
</section>
</div>
<!-- Error Toast -->
+152
View File
@@ -0,0 +1,152 @@
// Model Service for WiFi-DensePose UI
// Manages model loading, listing, LoRA profiles, and lifecycle events.
import { apiService } from './api.service.js';
export class ModelService {
constructor() {
this.activeModel = null;
this.listeners = {};
this.logger = this.createLogger();
}
createLogger() {
return {
debug: (...args) => console.debug('[MODEL-DEBUG]', new Date().toISOString(), ...args),
info: (...args) => console.info('[MODEL-INFO]', new Date().toISOString(), ...args),
warn: (...args) => console.warn('[MODEL-WARN]', new Date().toISOString(), ...args),
error: (...args) => console.error('[MODEL-ERROR]', new Date().toISOString(), ...args)
};
}
// --- Event emitter helpers ---
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (!this.listeners[event]) return;
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
}
emit(event, data) {
if (!this.listeners[event]) return;
this.listeners[event].forEach(cb => {
try { cb(data); } catch (err) { this.logger.error('Listener error', { event, err }); }
});
}
// --- API methods ---
async listModels() {
try {
const data = await apiService.get('/api/v1/models');
this.logger.info('Listed models', { count: data?.models?.length ?? 0 });
return data;
} catch (error) {
this.logger.error('Failed to list models', { error: error.message });
throw error;
}
}
async getModel(id) {
try {
const data = await apiService.get(`/api/v1/models/${encodeURIComponent(id)}`);
return data;
} catch (error) {
this.logger.error('Failed to get model', { id, error: error.message });
throw error;
}
}
async loadModel(modelId) {
try {
this.logger.info('Loading model', { modelId });
const data = await apiService.post('/api/v1/models/load', { model_id: modelId });
this.activeModel = { model_id: modelId };
this.emit('model-loaded', { model_id: modelId });
return data;
} catch (error) {
this.logger.error('Failed to load model', { modelId, error: error.message });
throw error;
}
}
async unloadModel() {
try {
this.logger.info('Unloading model');
const data = await apiService.post('/api/v1/models/unload', {});
this.activeModel = null;
this.emit('model-unloaded', {});
return data;
} catch (error) {
this.logger.error('Failed to unload model', { error: error.message });
throw error;
}
}
async getActiveModel() {
try {
const data = await apiService.get('/api/v1/models/active');
this.activeModel = data || null;
return this.activeModel;
} catch (error) {
if (error.status === 404) {
this.activeModel = null;
return null;
}
this.logger.error('Failed to get active model', { error: error.message });
throw error;
}
}
async activateLoraProfile(modelId, profileName) {
try {
this.logger.info('Activating LoRA profile', { modelId, profileName });
const data = await apiService.post(
'/api/v1/models/lora/activate',
{ model_id: modelId, profile_name: profileName }
);
this.emit('lora-activated', { model_id: modelId, profile: profileName });
return data;
} catch (error) {
this.logger.error('Failed to activate LoRA', { modelId, profileName, error: error.message });
throw error;
}
}
async getLoraProfiles() {
try {
const data = await apiService.get('/api/v1/models/lora/profiles');
return data?.profiles ?? [];
} catch (error) {
this.logger.error('Failed to get LoRA profiles', { error: error.message });
throw error;
}
}
async deleteModel(id) {
try {
this.logger.info('Deleting model', { id });
const data = await apiService.delete(`/api/v1/models/${encodeURIComponent(id)}`);
return data;
} catch (error) {
this.logger.error('Failed to delete model', { id, error: error.message });
throw error;
}
}
dispose() {
this.listeners = {};
this.activeModel = null;
this.logger.info('ModelService disposed');
}
}
// Create singleton instance
export const modelService = new ModelService();
+32 -6
View File
@@ -21,13 +21,17 @@ export class PoseService {
};
this.validationErrors = [];
this.logger = this.createLogger();
// Model inference mode tracking
this.modelActive = false;
// Configuration
this.config = {
enableValidation: true,
enablePerformanceTracking: true,
maxValidationErrors: 10,
confidenceThreshold: 0.3,
confidenceThresholdModelInference: 0.15,
maxPersons: 10,
timeoutMs: 5000
};
@@ -127,9 +131,14 @@ export class PoseService {
throw new Error(`Invalid stream options: ${validationResult.errors.join(', ')}`);
}
// Use a lower confidence threshold when model inference is active
const defaultThreshold = this.modelActive
? this.config.confidenceThresholdModelInference
: this.config.confidenceThreshold;
const params = {
zone_ids: options.zoneIds?.join(','),
min_confidence: options.minConfidence || this.config.confidenceThreshold,
min_confidence: options.minConfidence || defaultThreshold,
max_fps: options.maxFps || 30,
token: options.token || apiService.authToken
};
@@ -494,9 +503,18 @@ export class PoseService {
};
}
// Extract persons from zone data
const persons = zoneData.pose.persons || [];
console.log('👥 Extracted persons:', persons);
// Determine the pose source for this message
const poseSource = originalMessage.pose_source || zoneData.pose_source || null;
// Choose confidence threshold based on pose source
const threshold = (poseSource === 'model_inference' || this.modelActive)
? this.config.confidenceThresholdModelInference
: this.config.confidenceThreshold;
// Extract persons from zone data, applying source-aware filtering
const rawPersons = zoneData.pose.persons || [];
const persons = rawPersons.filter(p => p.confidence === undefined || p.confidence >= threshold);
console.log('Extracted persons:', persons.length, '/', rawPersons.length, '(threshold:', threshold, ')');
// Create zone summary
const zoneSummary = {};
@@ -511,7 +529,7 @@ export class PoseService {
persons: persons,
zone_summary: zoneSummary,
processing_time_ms: zoneData.metadata?.processing_time_ms || 0,
pose_source: originalMessage.pose_source || zoneData.pose_source || null,
pose_source: poseSource,
metadata: {
mock_data: false,
source: 'websocket',
@@ -653,6 +671,14 @@ export class PoseService {
this.logger.info('Configuration updated', { config: this.config });
}
// Enable or disable model inference mode.
// When active, confidence thresholds are lowered because model inference
// produces more reliable detections than raw signal-derived heuristics.
setModelMode(active) {
this.modelActive = !!active;
this.logger.info('Model mode updated', { modelActive: this.modelActive });
}
// Health check
async healthCheck() {
try {
+61 -3
View File
@@ -32,8 +32,14 @@ class SensingService {
this._simTimer = null;
// Connection state: disconnected | connecting | connected | reconnecting | simulated
this._state = 'disconnected';
// Data-source label exposed to the UI: "live" | "reconnecting" | "simulated"
// Data-source label exposed to the UI:
// "live" — real ESP32 hardware connected
// "server-simulated" — server is running but using synthetic data (no hardware)
// "reconnecting" — WebSocket disconnected, retrying
// "simulated" — client-side fallback simulation (server unreachable)
this._dataSource = 'reconnecting';
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
this._serverSource = null;
this._lastMessage = null;
// Ring buffer of recent RSSI values for sparkline
@@ -113,7 +119,9 @@ class SensingService {
this._reconnectAttempt = 0;
this._stopSimulation();
this._setState('connected');
this._setDataSource('live');
// Don't assume "live" yet — wait for first frame's source field.
// Fetch server status to determine actual data source immediately.
this._detectServerSource();
};
this._ws.onmessage = (evt) => {
@@ -256,11 +264,61 @@ class SensingService {
};
}
// ---- Server source detection -------------------------------------------
/**
* Fetch `/api/v1/status` to find out if the server is using real
* hardware or simulation. Called once on WebSocket open.
*/
async _detectServerSource() {
try {
const resp = await fetch('/api/v1/status');
if (resp.ok) {
const json = await resp.json();
this._applyServerSource(json.source);
} else {
// Can't reach status endpoint — assume live until first frame tells us
this._setDataSource('live');
}
} catch {
this._setDataSource('live');
}
}
/**
* Map a raw server source string to the UI data-source label.
*/
_applyServerSource(rawSource) {
this._serverSource = rawSource;
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
this._setDataSource('live');
} else if (rawSource === 'simulated' || rawSource === 'simulate') {
this._setDataSource('server-simulated');
} else {
// Unknown source — show as server-simulated to be safe
this._setDataSource('server-simulated');
}
}
/** @return {string|null} Raw server source (e.g. "esp32", "simulated") */
get serverSource() {
return this._serverSource;
}
// ---- Data handling -----------------------------------------------------
_handleData(data) {
this._lastMessage = data;
// Track the server's source field from each frame so the UI
// can react if the server switches between esp32 ↔ simulated at runtime.
if (data.source && this._state === 'connected') {
const raw = data.source;
if (raw !== this._serverSource) {
this._applyServerSource(raw);
}
}
// Update RSSI history for sparkline
if (data.features && data.features.mean_rssi != null) {
this._rssiHistory.push(data.features.mean_rssi);
@@ -292,7 +350,7 @@ class SensingService {
/**
* Update the dataSource label and notify state listeners so the UI can
* react without needing a separate subscription.
* @param {'live'|'reconnecting'|'simulated'} source
* @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source
*/
_setDataSource(source) {
if (source === this._dataSource) return;
+211
View File
@@ -0,0 +1,211 @@
// Training Service for WiFi-DensePose UI
// Manages training lifecycle, progress streaming, and CSI recordings.
import { buildWsUrl } from '../config/api.config.js';
import { apiService } from './api.service.js';
export class TrainingService {
constructor() {
this.progressSocket = null;
this.listeners = {};
this.logger = this.createLogger();
}
createLogger() {
return {
debug: (...args) => console.debug('[TRAIN-DEBUG]', new Date().toISOString(), ...args),
info: (...args) => console.info('[TRAIN-INFO]', new Date().toISOString(), ...args),
warn: (...args) => console.warn('[TRAIN-WARN]', new Date().toISOString(), ...args),
error: (...args) => console.error('[TRAIN-ERROR]', new Date().toISOString(), ...args)
};
}
// --- Event emitter helpers ---
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (!this.listeners[event]) return;
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
}
emit(event, data) {
if (!this.listeners[event]) return;
this.listeners[event].forEach(cb => {
try { cb(data); } catch (err) { this.logger.error('Listener error', { event, err }); }
});
}
// --- Training API methods ---
async startTraining(config) {
try {
this.logger.info('Starting training', { config });
const data = await apiService.post('/api/v1/train/start', config);
this.emit('training-started', data);
return data;
} catch (error) {
this.logger.error('Failed to start training', { error: error.message });
throw error;
}
}
async stopTraining() {
try {
this.logger.info('Stopping training');
const data = await apiService.post('/api/v1/train/stop', {});
this.emit('training-stopped', data);
return data;
} catch (error) {
this.logger.error('Failed to stop training', { error: error.message });
throw error;
}
}
async getTrainingStatus() {
try {
const data = await apiService.get('/api/v1/train/status');
return data;
} catch (error) {
this.logger.error('Failed to get training status', { error: error.message });
throw error;
}
}
async startPretraining(config) {
try {
this.logger.info('Starting pretraining', { config });
const data = await apiService.post('/api/v1/train/pretrain', config);
this.emit('training-started', data);
return data;
} catch (error) {
this.logger.error('Failed to start pretraining', { error: error.message });
throw error;
}
}
async startLoraTraining(config) {
try {
this.logger.info('Starting LoRA training', { config });
const data = await apiService.post('/api/v1/train/lora', config);
this.emit('training-started', data);
return data;
} catch (error) {
this.logger.error('Failed to start LoRA training', { error: error.message });
throw error;
}
}
// --- Recording API methods ---
async listRecordings() {
try {
const data = await apiService.get('/api/v1/recording/list');
return data?.recordings ?? [];
} catch (error) {
this.logger.error('Failed to list recordings', { error: error.message });
throw error;
}
}
async startRecording(config) {
try {
this.logger.info('Starting recording', { config });
const data = await apiService.post('/api/v1/recording/start', config);
this.emit('recording-started', data);
return data;
} catch (error) {
this.logger.error('Failed to start recording', { error: error.message });
throw error;
}
}
async stopRecording() {
try {
this.logger.info('Stopping recording');
const data = await apiService.post('/api/v1/recording/stop', {});
this.emit('recording-stopped', data);
return data;
} catch (error) {
this.logger.error('Failed to stop recording', { error: error.message });
throw error;
}
}
async deleteRecording(id) {
try {
this.logger.info('Deleting recording', { id });
const data = await apiService.delete(
`/api/v1/recording/${encodeURIComponent(id)}`
);
return data;
} catch (error) {
this.logger.error('Failed to delete recording', { id, error: error.message });
throw error;
}
}
// --- WebSocket progress stream ---
connectProgressStream() {
if (this.progressSocket) {
this.logger.warn('Progress stream already connected');
return this.progressSocket;
}
const url = buildWsUrl('/ws/train/progress');
this.logger.info('Connecting progress stream', { url });
const ws = new WebSocket(url);
ws.onopen = () => {
this.logger.info('Progress stream connected');
this.emit('progress-connected', {});
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.emit('progress', data);
} catch (err) {
this.logger.warn('Failed to parse progress message', { error: err.message });
}
};
ws.onerror = (error) => {
this.logger.error('Progress stream error', { error });
this.emit('progress-error', { error });
};
ws.onclose = () => {
this.logger.info('Progress stream disconnected');
this.progressSocket = null;
this.emit('progress-disconnected', {});
};
this.progressSocket = ws;
return ws;
}
disconnectProgressStream() {
if (this.progressSocket) {
this.progressSocket.close();
this.progressSocket = null;
}
}
dispose() {
this.disconnectProgressStream();
this.listeners = {};
this.logger.info('TrainingService disposed');
}
}
// Create singleton instance
export const trainingService = new TrainingService();
+16 -1
View File
@@ -83,9 +83,24 @@ export class WebSocketService {
const ws = await this.createWebSocketWithTimeout(url);
connectionData.ws = ws;
// Set up event handlers
// Set up event handlers (replaces onopen/onmessage/etc.)
this.setupEventHandlers(url, ws, handlers);
// The WebSocket is already open at this point (createWebSocketWithTimeout
// resolved on the original onopen). setupEventHandlers replaced onopen, so
// the new handler never fires. Manually trigger the connected path now.
if (ws.readyState === WebSocket.OPEN) {
connectionData.status = 'connected';
connectionData.lastActivity = Date.now();
this.reconnectAttempts.set(url, 0);
this.notifyConnectionState(url, 'connected');
if (handlers.onOpen) {
try { handlers.onOpen(new Event('open')); } catch (e) {
this.logger.error('Error in onOpen handler', { url, error: e.message });
}
}
}
// Start heartbeat
this.startHeartbeat(url);
+471 -3
View File
@@ -355,6 +355,21 @@ pre code {
background: var(--color-secondary-active);
}
.btn--accent {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
border-color: rgba(139, 92, 246, 0.3);
}
.btn--accent:hover {
background: rgba(139, 92, 246, 0.3);
border-color: rgba(139, 92, 246, 0.5);
}
.btn--accent:active {
background: rgba(139, 92, 246, 0.15);
}
.btn--outline {
background: transparent;
border: 1px solid var(--color-border);
@@ -683,7 +698,9 @@ body {
/* Navigation tabs */
.nav-tabs {
display: flex;
overflow-x: auto;
justify-content: center;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-24);
scrollbar-width: none;
@@ -695,11 +712,11 @@ body {
}
.nav-tab {
padding: var(--space-12) var(--space-20);
padding: var(--space-12) var(--space-16);
background: none;
border: none;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--duration-normal) var(--ease-standard);
@@ -1033,9 +1050,87 @@ body {
}
.demo-status {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* Status indicator dot */
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #555;
}
.status-indicator.active {
background: #00cc88;
box-shadow: 0 0 6px #00cc88;
}
.status-indicator.sim {
background: #ffa500;
box-shadow: 0 0 6px #ffa500;
animation: pulse 1.5s infinite;
}
.status-indicator.connecting {
background: #f0ad4e;
animation: pulse 1s infinite;
}
.status-indicator.error {
background: #ff3c3c;
}
/* Live Demo data-source banner */
.demo-source-banner {
display: block;
width: 100%;
padding: 10px 16px;
margin-bottom: 12px;
border-radius: 6px;
text-align: center;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
box-sizing: border-box;
}
.demo-source-live {
background: rgba(0, 204, 136, 0.15);
border: 1px solid #00cc88;
color: #00cc88;
}
.demo-source-sim {
background: rgba(255, 165, 0, 0.15);
border: 1px solid #ffa500;
color: #ffa500;
}
.demo-source-reconnecting {
background: rgba(255, 180, 0, 0.12);
border: 1px solid #f0ad4e;
color: #f0ad4e;
animation: pulse 1.5s infinite;
}
.demo-source-offline {
background: rgba(255, 60, 60, 0.12);
border: 1px solid #ff3c3c;
color: #ff3c3c;
}
.demo-source-unknown {
background: rgba(128, 128, 128, 0.12);
border: 1px solid #888;
color: #888;
}
.demo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1388,6 +1483,15 @@ canvas {
background: rgba(var(--color-warning-rgb), 0.05);
}
.component-status.status-warning {
border-color: #ffa500;
background: rgba(255, 165, 0, 0.08);
}
.component-status.status-warning .status-text {
color: #ffa500;
}
.component-status.status-unhealthy {
border-color: var(--color-error);
background: rgba(var(--color-error-rgb), 0.05);
@@ -1806,12 +1910,24 @@ canvas {
animation: pulse 1.5s infinite;
}
.sensing-source-server-sim {
background: rgba(255, 165, 0, 0.15);
border: 1px solid #ffa500;
color: #ffa500;
}
.sensing-source-simulated {
background: rgba(255, 60, 60, 0.12);
border: 1px solid var(--color-error);
color: var(--color-error);
}
/* Health indicator for server-simulated data */
.health-sim {
color: #ffa500;
font-weight: 600;
}
/* Big RSSI value */
.sensing-big-value {
font-size: var(--font-size-3xl);
@@ -1956,3 +2072,355 @@ canvas {
font-family: var(--font-family-mono);
font-weight: var(--font-weight-medium);
}
/* ===== Training Tab Styles ===== */
#training .tab-header {
margin-bottom: 20px;
}
#training .tab-header h2 {
color: var(--color-text);
margin: 0 0 8px 0;
}
#training .tab-header p {
color: var(--color-text-secondary);
margin: 0;
font-size: var(--font-size-sm);
}
/* Training Panel */
.training-panel {
background: var(--color-surface);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
padding: var(--space-16);
}
.training-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-16);
padding-bottom: var(--space-12);
border-bottom: 1px solid var(--color-card-border-inner);
}
.training-panel-header h3 {
color: var(--color-text);
margin: 0;
font-size: var(--font-size-base);
}
.training-status-badge {
padding: var(--space-2) 10px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
.training-status-idle {
background: var(--color-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.training-status-active {
background: rgba(var(--color-error-rgb), 0.15);
color: var(--color-error);
border: 1px solid rgba(var(--color-error-rgb), var(--status-border-opacity));
animation: pulse-training 2s infinite;
}
.training-status-completed {
background: rgba(var(--color-success-rgb), 0.15);
color: var(--color-success);
border: 1px solid rgba(var(--color-success-rgb), var(--status-border-opacity));
}
@keyframes pulse-training {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Recording list */
.recording-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px var(--space-12);
background: var(--color-secondary);
border: 1px solid var(--color-card-border-inner);
border-radius: var(--radius-base);
margin-bottom: var(--space-8);
}
.recording-item-info {
flex: 1;
}
.recording-item-name {
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.recording-item-meta {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
margin-top: var(--space-2);
}
/* Model cards */
.model-card {
padding: var(--space-12);
background: var(--color-secondary);
border: 1px solid var(--color-card-border-inner);
border-radius: var(--radius-base);
margin-bottom: var(--space-8);
transition: border-color 0.2s;
}
.model-card:hover {
border-color: var(--color-border);
}
.model-card-active {
border-left: 3px solid var(--color-success);
}
.model-card-name {
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.model-card-meta {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
margin-top: var(--space-4);
}
.model-card-stats {
display: flex;
gap: var(--space-12);
margin-top: var(--space-8);
}
.model-card-stat {
font-size: var(--font-size-xs);
}
.model-card-stat-label {
color: var(--color-text-secondary);
}
.model-card-stat-value {
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
/* Training chart */
.training-chart-container {
background: var(--color-secondary);
border: 1px solid var(--color-card-border-inner);
border-radius: var(--radius-base);
padding: var(--space-12);
margin: var(--space-12) 0;
}
.training-chart-label {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-8);
}
/* Training config form */
.training-config-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-12);
}
.training-form-group {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.training-form-label {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.training-form-input {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
color: var(--color-text);
padding: var(--space-8) 10px;
font-size: var(--font-size-sm);
font-family: inherit;
}
.training-form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.training-form-select {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
color: var(--color-text);
padding: var(--space-8) 10px;
font-size: var(--font-size-sm);
}
/* Training buttons */
.training-btn {
padding: var(--space-8) var(--space-16);
border-radius: var(--radius-base);
border: 1px solid transparent;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all 0.2s;
}
.training-btn-primary {
background: rgba(var(--color-success-rgb), 0.15);
color: var(--color-success);
border-color: rgba(var(--color-success-rgb), var(--status-border-opacity));
}
.training-btn-primary:hover {
background: rgba(var(--color-success-rgb), 0.25);
}
.training-btn-danger {
background: rgba(var(--color-error-rgb), 0.15);
color: var(--color-error);
border-color: rgba(var(--color-error-rgb), var(--status-border-opacity));
}
.training-btn-danger:hover {
background: rgba(var(--color-error-rgb), 0.25);
}
.training-btn-secondary {
background: rgba(var(--color-primary-rgb), 0.15);
color: var(--color-primary);
border-color: rgba(var(--color-primary-rgb), var(--status-border-opacity));
}
.training-btn-secondary:hover {
background: rgba(var(--color-primary-rgb), 0.25);
}
.training-btn-muted {
background: var(--color-secondary);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
.training-btn-muted:hover {
background: var(--color-secondary-hover);
}
/* Progress bar */
.training-progress-bar {
width: 100%;
height: 6px;
background: var(--color-secondary);
border-radius: var(--radius-full);
overflow: hidden;
margin: var(--space-8) 0;
}
.training-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--color-success));
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
/* Metrics grid */
.training-metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-8);
margin: var(--space-12) 0;
}
.training-metric {
text-align: center;
padding: var(--space-8);
background: var(--color-secondary);
border-radius: var(--radius-base);
}
.training-metric-value {
color: var(--color-text);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
font-family: var(--font-family-mono);
}
.training-metric-label {
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-2);
}
/* Collapsible section */
.training-collapsible-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
cursor: pointer;
color: var(--color-text);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
border-bottom: 1px solid var(--color-card-border-inner);
}
.training-collapsible-header:hover {
color: var(--color-primary);
}
.training-collapsible-content {
padding: var(--space-12) 0;
}
/* Pose trail toggle in toolbar */
.pose-trail-btn {
padding: var(--space-6) 14px;
border-radius: var(--radius-base);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all 0.2s;
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
border: 1px solid rgba(var(--color-primary-rgb), 0.3);
}
.pose-trail-btn.active {
background: rgba(var(--color-primary-rgb), 0.25);
border-color: rgba(var(--color-primary-rgb), 0.6);
}
.pose-trail-btn:hover {
background: rgba(var(--color-primary-rgb), 0.2);
}