mirror of
https://github.com/ruvnet/RuView
synced 2026-06-18 11:43:19 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c58145a4 | |||
| b41681e079 | |||
| 0f13a55f52 | |||
| 71f9597f58 | |||
| bfe5cbc83a | |||
| 1e0af686a0 | |||
| 21ec163941 | |||
| a8f5276d9b | |||
| e574cbe129 | |||
| 1dbea4e9fb | |||
| fb2d1afb0c | |||
| ffeaa46bc6 | |||
| a467dfed9f | |||
| d793c1f49f | |||
| 3457610c9f | |||
| e9d5ea3ad3 | |||
| 9cefb32815 | |||
| a7c74e0c57 | |||
| 98a2b0462c | |||
| e5e3d42ca2 | |||
| 7c1351fd5d |
@@ -0,0 +1,355 @@
|
||||
name: Firmware QEMU Tests (ADR-061)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
|
||||
env:
|
||||
IDF_VERSION: "v5.4"
|
||||
QEMU_REPO: "https://github.com/espressif/qemu.git"
|
||||
QEMU_BRANCH: "esp-develop"
|
||||
|
||||
jobs:
|
||||
build-qemu:
|
||||
name: Build Espressif QEMU
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cache QEMU build
|
||||
id: cache-qemu
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/qemu-esp32
|
||||
# Include date component so cache refreshes monthly when branch updates
|
||||
key: qemu-esp32s3-${{ env.QEMU_BRANCH }}-v4
|
||||
restore-keys: |
|
||||
qemu-esp32s3-${{ env.QEMU_BRANCH }}-
|
||||
|
||||
- name: Install QEMU build dependencies
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
git build-essential ninja-build pkg-config \
|
||||
libglib2.0-dev libpixman-1-dev libslirp-dev \
|
||||
python3 python3-venv
|
||||
|
||||
- name: Clone and build Espressif QEMU
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 -b "$QEMU_BRANCH" "$QEMU_REPO" /tmp/qemu-esp
|
||||
cd /tmp/qemu-esp
|
||||
mkdir build && cd build
|
||||
../configure \
|
||||
--target-list=xtensa-softmmu \
|
||||
--prefix=/opt/qemu-esp32 \
|
||||
--enable-slirp \
|
||||
--disable-werror
|
||||
ninja -j$(nproc)
|
||||
ninja install
|
||||
|
||||
- name: Verify QEMU binary
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
/opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
echo "QEMU binary size: $(file_size /opt/qemu-esp32/bin/qemu-system-xtensa) bytes"
|
||||
|
||||
- name: Upload QEMU artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32/
|
||||
retention-days: 7
|
||||
|
||||
qemu-test:
|
||||
name: QEMU Test (${{ matrix.nvs_config }})
|
||||
needs: build-qemu
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
nvs_config:
|
||||
- default
|
||||
- full-adr060
|
||||
- edge-tier0
|
||||
- edge-tier1
|
||||
- tdm-3node
|
||||
- boundary-max
|
||||
- boundary-min
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
|
||||
- name: Verify QEMU works
|
||||
run: /opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Set target ESP32-S3
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
|
||||
- name: Build firmware (mock CSI mode)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py \
|
||||
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \
|
||||
build
|
||||
|
||||
- name: Generate NVS matrix
|
||||
run: |
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir firmware/esp32-csi-node/build/nvs_matrix \
|
||||
--only ${{ matrix.nvs_config }}
|
||||
|
||||
- name: Create merged flash image
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
|
||||
# Determine merge_bin arguments
|
||||
OTA_ARGS=""
|
||||
if [ -f build/ota_data_initial.bin ]; then
|
||||
OTA_ARGS="0xf000 build/ota_data_initial.bin"
|
||||
fi
|
||||
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
$OTA_ARGS \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Flash image size: $(file_size build/qemu_flash.bin) bytes"
|
||||
|
||||
- name: Inject NVS partition
|
||||
if: matrix.nvs_config != 'default'
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
NVS_BIN="build/nvs_matrix/nvs_${{ matrix.nvs_config }}.bin"
|
||||
if [ -f "$NVS_BIN" ]; then
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Injecting NVS: $NVS_BIN ($(file_size "$NVS_BIN") bytes)"
|
||||
dd if="$NVS_BIN" of=build/qemu_flash.bin \
|
||||
bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null
|
||||
else
|
||||
echo "WARNING: NVS binary not found: $NVS_BIN"
|
||||
fi
|
||||
|
||||
- name: Run QEMU smoke test
|
||||
env:
|
||||
QEMU_PATH: /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
QEMU_TIMEOUT: "90"
|
||||
run: |
|
||||
echo "Starting QEMU (timeout: ${QEMU_TIMEOUT}s)..."
|
||||
|
||||
timeout "$QEMU_TIMEOUT" "$QEMU_PATH" \
|
||||
-machine esp32s3 \
|
||||
-nographic \
|
||||
-drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-serial mon:stdio \
|
||||
-nic user,model=open_eth,net=10.0.2.0/24 \
|
||||
-no-reboot \
|
||||
2>&1 | tee firmware/esp32-csi-node/build/qemu_output.log || true
|
||||
|
||||
echo "QEMU finished. Log size: $(wc -l < firmware/esp32-csi-node/build/qemu_output.log) lines"
|
||||
|
||||
- name: Validate QEMU output
|
||||
run: |
|
||||
python3 scripts/validate_qemu_output.py \
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-logs-${{ matrix.nvs_config }}
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
firmware/esp32-csi-node/build/nvs_matrix/
|
||||
retention-days: 14
|
||||
|
||||
fuzz-test:
|
||||
name: Fuzz Testing (ADR-061 Layer 6)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install clang
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang
|
||||
|
||||
- name: Build fuzz targets
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make all CC=clang
|
||||
|
||||
- name: Run serialize fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_serialize FUZZ_DURATION=60 || echo "FUZZER_CRASH=serialize" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run edge enqueue fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_edge FUZZ_DURATION=60 || echo "FUZZER_CRASH=edge" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run NVS config fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_nvs FUZZ_DURATION=60 || echo "FUZZER_CRASH=nvs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check for crashes
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
CRASHES=$(find . -type f \( -name "crash-*" -o -name "oom-*" -o -name "timeout-*" \) 2>/dev/null | wc -l)
|
||||
echo "Crash artifacts found: $CRASHES"
|
||||
if [ "$CRASHES" -gt 0 ] || [ -n "${FUZZER_CRASH:-}" ]; then
|
||||
echo "::error::Fuzzer found $CRASHES crash/oom/timeout artifacts. FUZZER_CRASH=${FUZZER_CRASH:-none}"
|
||||
ls -la crash-* oom-* timeout-* 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload fuzz artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-crashes
|
||||
path: |
|
||||
firmware/esp32-csi-node/test/crash-*
|
||||
firmware/esp32-csi-node/test/oom-*
|
||||
firmware/esp32-csi-node/test/timeout-*
|
||||
retention-days: 30
|
||||
|
||||
nvs-matrix-validate:
|
||||
name: NVS Matrix Generation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NVS generator
|
||||
run: pip install esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Generate all 14 NVS configs
|
||||
run: |
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir build/nvs_matrix
|
||||
|
||||
- name: Verify all binaries generated
|
||||
run: |
|
||||
EXPECTED=14
|
||||
ACTUAL=$(find build/nvs_matrix -type f -name "nvs_*.bin" 2>/dev/null | wc -l)
|
||||
echo "Generated $ACTUAL / $EXPECTED NVS binaries"
|
||||
ls -la build/nvs_matrix/
|
||||
|
||||
if [ "$ACTUAL" -lt "$EXPECTED" ]; then
|
||||
echo "::error::Only $ACTUAL of $EXPECTED NVS binaries generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify binary sizes
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
for f in build/nvs_matrix/nvs_*.bin; do
|
||||
SIZE=$(file_size "$f")
|
||||
if [ "$SIZE" -ne 24576 ]; then
|
||||
echo "::error::$f has unexpected size $SIZE (expected 24576)"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: $(basename $f) ($SIZE bytes)"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-062: QEMU Swarm Configurator Test
|
||||
#
|
||||
# Runs a lightweight 3-node swarm (ci_matrix preset) under QEMU to validate
|
||||
# multi-node orchestration, TDM slot coordination, and swarm-level health
|
||||
# assertions. Uses the pre-built QEMU binary from the build-qemu job and the
|
||||
# firmware built by qemu-test.
|
||||
#
|
||||
# The CI runner is non-root, so TAP bridge networking is unavailable.
|
||||
# The orchestrator (qemu_swarm.py) detects this and falls back to SLIRP
|
||||
# user-mode networking, which is sufficient for the ci_matrix preset.
|
||||
# ---------------------------------------------------------------------------
|
||||
swarm-test:
|
||||
name: Swarm Test (ADR-062)
|
||||
needs: [build-qemu]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: ${{ github.workspace }}/qemu-build
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x ${{ github.workspace }}/qemu-build/bin/qemu-system-xtensa
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install pyyaml esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Build firmware for swarm
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
- name: Run swarm smoke test
|
||||
run: |
|
||||
python3 scripts/qemu_swarm.py --preset ci_matrix \
|
||||
--qemu-path ${{ github.workspace }}/qemu-build/bin/qemu-system-xtensa \
|
||||
--output-dir build/swarm-results
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Upload swarm results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: swarm-results
|
||||
path: |
|
||||
build/swarm-results/
|
||||
retention-days: 14
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug (attach)",
|
||||
"type": "cppdbg",
|
||||
"request": "attach",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **QEMU ESP32-S3 testing platform (ADR-061)** — 9-layer firmware testing without hardware
|
||||
- Mock CSI generator with 10 physics-based scenarios (empty room, walking, fall, multi-person, etc.)
|
||||
- Single-node QEMU runner with 16-check UART validation
|
||||
- Multi-node TDM mesh simulation (TAP networking, 2-6 nodes)
|
||||
- GDB remote debugging with VS Code integration
|
||||
- Code coverage via gcov/lcov + apptrace
|
||||
- Fuzz testing (3 libFuzzer targets + ASAN/UBSAN)
|
||||
- NVS provisioning matrix (14 configs)
|
||||
- Snapshot-based regression testing (sub-second VM restore)
|
||||
- Chaos testing with fault injection + health monitoring
|
||||
- **QEMU Swarm Configurator (ADR-062)** — YAML-driven multi-ESP32 test orchestration
|
||||
- 4 topologies: star, mesh, line, ring
|
||||
- 3 node roles: sensor, coordinator, gateway
|
||||
- 9 swarm-level assertions (boot, crashes, TDM, frame rate, fall detection, etc.)
|
||||
- 7 presets: smoke (2n/15s), standard (3n/60s), ci-matrix, large-mesh, line-relay, ring-fault, heterogeneous
|
||||
- Health oracle with cross-node validation
|
||||
- **QEMU installer** (`install-qemu.sh`) — auto-detects OS, installs deps, builds Espressif QEMU fork
|
||||
- **Unified QEMU CLI** (`qemu-cli.sh`) — single entry point for all 11 QEMU test commands
|
||||
- CI: `firmware-qemu.yml` workflow with QEMU test matrix, fuzz testing, NVS validation, and swarm test jobs
|
||||
- User guide: QEMU testing and swarm configurator section with plain-language walkthrough
|
||||
|
||||
### Fixed
|
||||
- Firmware now boots in QEMU: WiFi/UDP/OTA/display guards for mock CSI mode
|
||||
- 9 bugs in mock_csi.c (LFSR bias, MAC filter init, scenario loop, overflow burst timing)
|
||||
- 23 bugs from ADR-061 deep review (inject_fault.py writes, CI cache, snapshot log corruption, etc.)
|
||||
- 16 bugs from ADR-062 deep review (log filename mismatch, SLIRP port collision, heap false positives, etc.)
|
||||
- All scripts: `--help` flags, prerequisite checks with install hints, standardized exit codes
|
||||
|
||||
- **Sensing server UI API completion (ADR-043)** — 14 fully-functional REST endpoints for model management, CSI recording, and training control
|
||||
- Model CRUD: `GET /api/v1/models`, `GET /api/v1/models/active`, `POST /api/v1/models/load`, `POST /api/v1/models/unload`, `DELETE /api/v1/models/:id`, `GET /api/v1/models/lora/profiles`, `POST /api/v1/models/lora/activate`
|
||||
- CSI recording: `GET /api/v1/recording/list`, `POST /api/v1/recording/start`, `POST /api/v1/recording/stop`, `DELETE /api/v1/recording/:id`
|
||||
|
||||
@@ -75,7 +75,7 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
|----------|-------------|
|
||||
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
|
||||
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 48 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 62 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 7 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [Desktop App](rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
|
||||
@@ -87,10 +87,14 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
</a>
|
||||
<br>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables</em>
|
||||
<br>
|
||||
<br><br>
|
||||
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
|
||||
|
|
||||
<a href="https://ruvnet.github.io/RuView/pose-fusion.html"><strong>▶ Dual-Modal Pose Fusion Demo</strong></a>
|
||||
|
||||
> The [server](#-quick-start) is optional for visualization and aggregation — the ESP32 [runs independently](#esp32-s3-hardware-pipeline) for presence detection, vital signs, and fall alerts.
|
||||
>
|
||||
> **Live ESP32 pipeline**: Connect an ESP32-S3 node → run the [sensing server](#sensing-server) → open the [pose fusion demo](https://ruvnet.github.io/RuView/pose-fusion.html) for real-time dual-modal pose estimation (webcam + WiFi CSI). See [ADR-059](docs/adr/ADR-059-live-esp32-csi-pipeline.md).
|
||||
|
||||
|
||||
## 🚀 Key Features
|
||||
@@ -1692,6 +1696,82 @@ WebSocket: `ws://localhost:3001/ws/sensing` (real-time sensing + vital signs)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>QEMU Firmware Testing (ADR-061) — 9-Layer Platform</strong></summary>
|
||||
|
||||
Test ESP32-S3 firmware without physical hardware using Espressif's QEMU fork. The platform provides 9 layers of testing capability:
|
||||
|
||||
| Layer | Capability | Script / Config |
|
||||
|-------|-----------|-----------------|
|
||||
| 1 | Mock CSI generator (10 physics-based scenarios) | `firmware/esp32-csi-node/main/mock_csi.c` |
|
||||
| 2 | Single-node QEMU runner + UART validation (16 checks) | `scripts/qemu-esp32s3-test.sh`, `scripts/validate_qemu_output.py` |
|
||||
| 3 | Multi-node TDM mesh simulation (TAP networking) | `scripts/qemu-mesh-test.sh`, `scripts/validate_mesh_test.py` |
|
||||
| 4 | GDB remote debugging (VS Code integration) | `.vscode/launch.json` |
|
||||
| 5 | Code coverage (gcov/lcov via apptrace) | `firmware/esp32-csi-node/sdkconfig.coverage` |
|
||||
| 6 | Fuzz testing (libFuzzer + ASAN/UBSAN) | `firmware/esp32-csi-node/test/fuzz_*.c` |
|
||||
| 7 | NVS provisioning matrix (14 configs) | `scripts/generate_nvs_matrix.py` |
|
||||
| 8 | Snapshot regression (sub-second VM restore) | `scripts/qemu-snapshot-test.sh` |
|
||||
| 9 | Chaos testing (fault injection + health monitoring) | `scripts/qemu-chaos-test.sh`, `scripts/inject_fault.py`, `scripts/check_health.py` |
|
||||
|
||||
```bash
|
||||
# Quick start: build + run + validate
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
|
||||
# Single-node test (builds, merges flash, runs QEMU, validates output)
|
||||
bash scripts/qemu-esp32s3-test.sh
|
||||
|
||||
# Multi-node mesh test (3 QEMU instances with TDM)
|
||||
sudo bash scripts/qemu-mesh-test.sh 3
|
||||
|
||||
# Fuzz testing (60 seconds per target)
|
||||
cd firmware/esp32-csi-node/test && make all CC=clang && make run_serialize FUZZ_DURATION=60
|
||||
|
||||
# Chaos testing (fault injection resilience)
|
||||
bash scripts/qemu-chaos-test.sh --faults all --duration 120
|
||||
```
|
||||
|
||||
**10 test scenarios**: empty room, static person, walking, fall, multi-person, channel sweep, MAC filter, ring overflow, boundary RSSI, zero-length frames.
|
||||
|
||||
**14 NVS configs**: default, WiFi-only, full ADR-060, edge tiers 0/1/2, TDM mesh, WASM signed/unsigned, 5GHz, boundary max/min, power-save, empty-strings.
|
||||
|
||||
**CI**: GitHub Actions workflow runs 7 NVS matrix configs, 3 fuzz targets, and NVS binary validation on every push to `firmware/`.
|
||||
|
||||
See [ADR-061](docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) for the full architecture.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>QEMU Swarm Configurator (ADR-062)</strong></summary>
|
||||
|
||||
Test multiple ESP32-S3 nodes simultaneously using a YAML-driven orchestrator. Define node roles, network topologies, and validation assertions in a config file.
|
||||
|
||||
```bash
|
||||
# Quick smoke test (2 nodes, 15 seconds)
|
||||
python3 scripts/qemu_swarm.py --preset smoke
|
||||
|
||||
# Standard 3-node test (coordinator + 2 sensors)
|
||||
python3 scripts/qemu_swarm.py --preset standard
|
||||
|
||||
# See all presets
|
||||
python3 scripts/qemu_swarm.py --list-presets
|
||||
|
||||
# Preview without running
|
||||
python3 scripts/qemu_swarm.py --preset standard --dry-run
|
||||
```
|
||||
|
||||
**Topologies**: star (sensors → coordinator), mesh (fully connected), line (relay chain), ring (circular).
|
||||
|
||||
**Node roles**: sensor (generates CSI), coordinator (aggregates), gateway (bridges to host).
|
||||
|
||||
**7 presets**: smoke, standard, ci-matrix, large-mesh, line-relay, ring-fault, heterogeneous.
|
||||
|
||||
**9 swarm assertions**: boot check, crash detection, TDM collision, frame production, coordinator reception, fall detection, frame rate, boot time, heap health.
|
||||
|
||||
See [ADR-062](docs/adr/ADR-062-qemu-swarm-configurator.md) and the [User Guide](docs/user-guide.md#testing-firmware-without-hardware-qemu) for step-by-step instructions.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Python Legacy CLI</strong> — v1 API server commands</summary>
|
||||
|
||||
@@ -1711,7 +1791,9 @@ wifi-densepose tasks list # List background tasks
|
||||
<details>
|
||||
<summary><strong>Documentation Links</strong></summary>
|
||||
|
||||
- [User Guide](docs/user-guide.md) — installation, first run, API, hardware setup, QEMU testing
|
||||
- [WiFi-Mat User Guide](docs/wifi-mat-user-guide.md) | [Domain Model](docs/ddd/wifi-mat-domain-model.md)
|
||||
- [ADR-061](docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) QEMU platform | [ADR-062](docs/adr/ADR-062-qemu-swarm-configurator.md) Swarm configurator
|
||||
- [ADR-021](docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md) | [ADR-022](docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) | [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
# ADR-058: Dual-Modal WASM Browser Pose Estimation — Live Video + WiFi CSI Fusion
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-03-12
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: wasm, browser, cnn, pose-estimation, ruvector, video, multimodal, fusion
|
||||
|
||||
## Context
|
||||
|
||||
WiFi-DensePose estimates human poses from WiFi CSI (Channel State Information).
|
||||
The `ruvector-cnn` crate provides a pure Rust CNN (MobileNet-V3) with WASM bindings.
|
||||
Both modalities exist independently — what's missing is **fusing live webcam video
|
||||
with WiFi CSI** in a single browser demo to achieve robust pose estimation that
|
||||
works even when one modality degrades (occlusion, signal noise, poor lighting).
|
||||
|
||||
Existing assets:
|
||||
|
||||
1. **`wifi-densepose-wasm`** — CSI signal processing compiled to WASM
|
||||
2. **`wifi-densepose-sensing-server`** — Axum server streaming live CSI via WebSocket
|
||||
3. **`ruvector-cnn`** — Pure Rust CNN with MobileNet-V3 backbones, SIMD, contrastive learning
|
||||
4. **`ruvector-cnn-wasm`** — wasm-bindgen bindings: `WasmCnnEmbedder`, `SimdOps`, `LayerOps`, contrastive losses
|
||||
5. **`vendor/ruvector/examples/wasm-vanilla/`** — Reference vanilla JS WASM example
|
||||
|
||||
Research shows multi-modal fusion (camera + WiFi) significantly outperforms either alone:
|
||||
- Camera fails under occlusion, poor lighting, privacy constraints
|
||||
- WiFi CSI fails with signal noise, multipath, low spatial resolution
|
||||
- Fusion compensates: WiFi provides through-wall coverage, camera provides fine-grained detail
|
||||
|
||||
## Decision
|
||||
|
||||
Build a **dual-modal browser demo** at `examples/wasm-browser-pose/` that:
|
||||
|
||||
1. Captures **live webcam video** via `getUserMedia` API
|
||||
2. Receives **live WiFi CSI** via WebSocket from the sensing server
|
||||
3. Processes **both streams** through separate CNN pipelines in `ruvector-cnn-wasm`
|
||||
4. **Fuses embeddings** with learned attention weights for combined pose estimation
|
||||
5. Renders **video overlay** with skeleton + WiFi confidence heatmap on Canvas
|
||||
6. Runs entirely in the browser — all inference client-side via WASM
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────────┐ ┌───────────────────┐ │
|
||||
│ │ getUserMedia│───▶│ Video Frame │───▶│ CNN WASM │ │
|
||||
│ │ (Webcam) │ │ Capture │ │ (Visual Embedder) │ │
|
||||
│ └────────────┘ │ 224×224 RGB │ │ → 512-dim │ │
|
||||
│ └────────────────┘ └────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ visual_embedding │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ ┌────────────┐ ┌────────────────┐ │ │ │
|
||||
│ │ WebSocket │───▶│ CSI WASM │ │ Attention │ │
|
||||
│ │ Client │ │ (densepose- │ │ Fusion │ │
|
||||
│ │ │ │ wasm) │ │ Module │ │
|
||||
│ └────────────┘ └───────┬────────┘ │ │ │
|
||||
│ │ └──────┬──────┘ │
|
||||
│ ┌───────▼────────┐ │ │
|
||||
│ │ CNN WASM │ fused_embedding │
|
||||
│ │ (CSI Embedder) │ │ │
|
||||
│ │ → 512-dim │ ┌──────▼──────┐ │
|
||||
│ └───────┬────────┘ │ Pose │ │
|
||||
│ │ │ Decoder │ │
|
||||
│ csi_embedding │ → 17 kpts │ │
|
||||
│ │ └──────┬──────┘ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────┐ ┌─────▼──────┐ │
|
||||
│ │ Video Canvas │◀────────│ Overlay │ │
|
||||
│ │ + Skeleton │ │ Renderer │ │
|
||||
│ │ + Heatmap │ └────────────┘ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ getUserMedia │ WebSocket
|
||||
│ (camera) │ (ws://host:3030/ws/csi)
|
||||
│ │
|
||||
┌────┴────┐ ┌───────┴─────────┐
|
||||
│ Webcam │ │ Sensing Server │
|
||||
└─────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Dual Pipeline Design
|
||||
|
||||
Two parallel CNN pipelines run on each frame tick (~30 FPS):
|
||||
|
||||
| Pipeline | Input | Preprocessing | CNN Config | Output |
|
||||
|----------|-------|---------------|------------|--------|
|
||||
| **Visual** | Webcam frame (640×480) | Resize to 224×224 RGB, ImageNet normalize | MobileNet-V3 Small, 512-dim | Visual embedding |
|
||||
| **CSI** | CSI frame (ADR-018 binary) | Amplitude/phase/delta → 224×224 pseudo-RGB | MobileNet-V3 Small, 512-dim | CSI embedding |
|
||||
|
||||
Both use the same `WasmCnnEmbedder` but with separate instances and weight sets.
|
||||
|
||||
### Fusion Strategy
|
||||
|
||||
**Learned attention-weighted fusion** combines the two 512-dim embeddings:
|
||||
|
||||
```javascript
|
||||
// Attention fusion: learn which modality to trust per-dimension
|
||||
// α ∈ [0,1]^512 — attention weights (shipped as JSON, trained offline)
|
||||
// visual_emb, csi_emb ∈ R^512
|
||||
|
||||
function fuseEmbeddings(visual_emb, csi_emb, attention_weights) {
|
||||
const fused = new Float32Array(512);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const α = attention_weights[i];
|
||||
fused[i] = α * visual_emb[i] + (1 - α) * csi_emb[i];
|
||||
}
|
||||
return fused;
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic confidence gating** adjusts fusion based on signal quality:
|
||||
|
||||
| Condition | Behavior |
|
||||
|-----------|----------|
|
||||
| Good video + good CSI | Balanced fusion (α ≈ 0.5) |
|
||||
| Poor lighting / occlusion | CSI-dominant (α → 0, WiFi takes over) |
|
||||
| CSI noise / no ESP32 | Video-dominant (α → 1, camera only) |
|
||||
| Video-only mode (no WiFi) | α = 1.0, pure visual CNN pose estimation |
|
||||
| CSI-only mode (no camera) | α = 0.0, pure WiFi pose estimation |
|
||||
|
||||
Quality detection:
|
||||
- **Video quality**: Frame brightness variance (dark = low quality), motion blur score
|
||||
- **CSI quality**: Signal-to-noise ratio from `wifi-densepose-wasm`, coherence gate output
|
||||
|
||||
### CSI-to-Image Encoding
|
||||
|
||||
CSI data encoded as 3-channel pseudo-image for the CSI CNN pipeline:
|
||||
|
||||
| Channel | Data | Normalization |
|
||||
|---------|------|---------------|
|
||||
| R | CSI amplitude (subcarrier × time window) | Min-max to [0, 255] |
|
||||
| G | CSI phase (unwrapped, subcarrier × time window) | Min-max to [0, 255] |
|
||||
| B | Temporal difference (frame-to-frame Δ amplitude) | Abs, min-max to [0, 255] |
|
||||
|
||||
### Video Processing
|
||||
|
||||
Webcam frames processed through standard ImageNet pipeline:
|
||||
|
||||
```javascript
|
||||
// Capture frame from video element
|
||||
const frame = captureVideoFrame(videoElement, 224, 224); // Returns Uint8Array RGB
|
||||
|
||||
// ImageNet normalization happens inside WasmCnnEmbedder.extract()
|
||||
const visual_embedding = visual_embedder.extract(frame, 224, 224);
|
||||
```
|
||||
|
||||
### Pose Keypoint Mapping
|
||||
|
||||
17 COCO-format keypoints decoded from the fused 512-dim embedding:
|
||||
|
||||
```
|
||||
0: nose 1: left_eye 2: right_eye
|
||||
3: left_ear 4: right_ear 5: left_shoulder
|
||||
6: right_shoulder 7: left_elbow 8: right_elbow
|
||||
9: left_wrist 10: right_wrist 11: left_hip
|
||||
12: right_hip 13: left_knee 14: right_knee
|
||||
15: left_ankle 16: right_ankle
|
||||
```
|
||||
|
||||
Each keypoint decoded as (x, y, confidence) = 51 values from the 512-dim embedding
|
||||
via a learned linear projection.
|
||||
|
||||
### Operating Modes
|
||||
|
||||
The demo supports three modes, selectable in the UI:
|
||||
|
||||
| Mode | Video | CSI | Fusion | Use Case |
|
||||
|------|-------|-----|--------|----------|
|
||||
| **Dual (default)** | ✅ | ✅ | Attention-weighted | Best accuracy, full demo |
|
||||
| **Video Only** | ✅ | ❌ | α = 1.0 | No ESP32 available, quick demo |
|
||||
| **CSI Only** | ❌ | ✅ | α = 0.0 | Privacy mode, through-wall sensing |
|
||||
|
||||
**Video Only mode works without any hardware** — just a webcam — making the demo
|
||||
instantly accessible for anyone wanting to try it.
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
examples/wasm-browser-pose/
|
||||
├── index.html # Single-page app (vanilla JS, no bundler)
|
||||
├── js/
|
||||
│ ├── app.js # Main entry, mode selection, orchestration
|
||||
│ ├── video-capture.js # getUserMedia, frame extraction, quality detection
|
||||
│ ├── csi-processor.js # WebSocket CSI client, frame parsing, pseudo-image encoding
|
||||
│ ├── fusion.js # Attention-weighted embedding fusion, confidence gating
|
||||
│ ├── pose-decoder.js # Fused embedding → 17 keypoints
|
||||
│ └── canvas-renderer.js # Video overlay, skeleton, CSI heatmap, confidence bars
|
||||
├── data/
|
||||
│ ├── visual-weights.json # Visual CNN → embedding projection (placeholder until trained)
|
||||
│ ├── csi-weights.json # CSI CNN → embedding projection (placeholder until trained)
|
||||
│ ├── fusion-weights.json # Attention fusion α weights (512 values)
|
||||
│ └── pose-weights.json # Fused embedding → keypoint projection
|
||||
├── css/
|
||||
│ └── style.css # Dark theme UI styling
|
||||
├── pkg/ # Built WASM packages (gitignored, built by script)
|
||||
│ ├── wifi_densepose_wasm/
|
||||
│ └── ruvector_cnn_wasm/
|
||||
├── build.sh # wasm-pack build script for both packages
|
||||
└── README.md # Setup and usage instructions
|
||||
```
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# build.sh — builds both WASM packages into pkg/
|
||||
|
||||
set -e
|
||||
|
||||
# Build wifi-densepose-wasm (CSI processing)
|
||||
wasm-pack build ../../rust-port/wifi-densepose-rs/crates/wifi-densepose-wasm \
|
||||
--target web --out-dir "$(pwd)/pkg/wifi_densepose_wasm" --no-typescript
|
||||
|
||||
# Build ruvector-cnn-wasm (CNN inference for both video and CSI)
|
||||
wasm-pack build ../../vendor/ruvector/crates/ruvector-cnn-wasm \
|
||||
--target web --out-dir "$(pwd)/pkg/ruvector_cnn_wasm" --no-typescript
|
||||
|
||||
echo "Build complete. Serve with: python3 -m http.server 8080"
|
||||
```
|
||||
|
||||
### UI Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ WiFi-DensePose — Live Dual-Modal Pose Estimation │
|
||||
│ [Dual Mode ▼] [⚙ Settings] FPS: 28 ◉ Live │
|
||||
├───────────────────────────┬─────────────────────────────┤
|
||||
│ │ │
|
||||
│ ┌───────────────────┐ │ ┌───────────────────┐ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ Video + Skeleton │ │ │ CSI Heatmap │ │
|
||||
│ │ Overlay │ │ │ (amplitude × │ │
|
||||
│ │ (main canvas) │ │ │ subcarrier) │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ └───────────────────┘ │ └───────────────────┘ │
|
||||
│ │ │
|
||||
├───────────────────────────┴─────────────────────────────┤
|
||||
│ Fusion Confidence: ████████░░ 78% │
|
||||
│ Video: ██████████ 95% │ CSI: ██████░░░░ 61% │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Embedding Space (2D projection) │ │
|
||||
│ │ · · · │ │
|
||||
│ │ · · · · · · (color = pose cluster) │ │
|
||||
│ │ · · · · │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Latency: Video 12ms │ CSI 8ms │ Fusion 1ms │ Total 21ms│
|
||||
│ [▶ Record] [📷 Snapshot] [Confidence: ████ 0.6] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### WASM Module Structure
|
||||
|
||||
| Package | Source Crate | Provides | Size (est.) |
|
||||
|---------|-------------|----------|-------------|
|
||||
| `wifi_densepose_wasm` | `wifi-densepose-wasm` | CSI frame parsing, signal processing, feature extraction | ~200KB |
|
||||
| `ruvector_cnn_wasm` | `ruvector-cnn-wasm` | `WasmCnnEmbedder` (×2 instances), `SimdOps`, `LayerOps`, contrastive losses | ~150KB |
|
||||
|
||||
Two `WasmCnnEmbedder` instances are created — one for video frames, one for CSI pseudo-images.
|
||||
They share the same WASM module but have independent state.
|
||||
|
||||
### Browser API Requirements
|
||||
|
||||
| API | Purpose | Required | Fallback |
|
||||
|-----|---------|----------|----------|
|
||||
| `getUserMedia` | Webcam capture | For video mode | CSI-only mode |
|
||||
| WebAssembly | CNN inference | Yes | None (hard requirement) |
|
||||
| WASM SIMD128 | Accelerated inference | No | Scalar fallback (~2× slower) |
|
||||
| WebSocket | CSI data stream | For CSI mode | Video-only mode |
|
||||
| Canvas 2D | Rendering | Yes | None |
|
||||
| `requestAnimationFrame` | Render loop | Yes | `setTimeout` fallback |
|
||||
| ES Modules | Code organization | Yes | None |
|
||||
|
||||
Target: Chrome 89+, Firefox 89+, Safari 15+, Edge 89+
|
||||
|
||||
### Performance Budget
|
||||
|
||||
| Stage | Target Latency | Notes |
|
||||
|-------|---------------|-------|
|
||||
| Video frame capture + resize | <3ms | `drawImage` to offscreen canvas |
|
||||
| Video CNN embedding | <15ms | 224×224 RGB → 512-dim |
|
||||
| CSI receive + parse | <2ms | Binary WebSocket message |
|
||||
| CSI pseudo-image encoding | <3ms | Amplitude/phase/delta channels |
|
||||
| CSI CNN embedding | <15ms | 224×224 pseudo-RGB → 512-dim |
|
||||
| Attention fusion | <1ms | Element-wise weighted sum |
|
||||
| Pose decoding | <1ms | Linear projection |
|
||||
| Canvas overlay render | <3ms | Video + skeleton + heatmap |
|
||||
| **Total (dual mode)** | **<33ms** | **30 FPS capable** |
|
||||
| **Total (video only)** | **<22ms** | **45 FPS capable** |
|
||||
|
||||
Note: Video and CSI CNN pipelines can run in parallel using Web Workers,
|
||||
reducing dual-mode latency to ~max(15, 15) + 5 = ~20ms (50 FPS).
|
||||
|
||||
### Contrastive Learning Integration
|
||||
|
||||
The demo optionally shows real-time contrastive learning in the browser:
|
||||
|
||||
- **InfoNCE loss** (`WasmInfoNCELoss`): Compare video vs CSI embeddings for the same pose — trains cross-modal alignment
|
||||
- **Triplet loss** (`WasmTripletLoss`): Push apart different poses, pull together same pose across modalities
|
||||
- **SimdOps**: Accelerated dot products for real-time similarity computation
|
||||
- **Embedding space panel**: Live 2D projection shows video and CSI embeddings converging when viewing the same person
|
||||
|
||||
### Relationship to Existing Crates
|
||||
|
||||
| Existing Crate | Role in This Demo |
|
||||
|---------------|-------------------|
|
||||
| `ruvector-cnn-wasm` | CNN inference for **both** video frames and CSI pseudo-images |
|
||||
| `wifi-densepose-wasm` | CSI frame parsing and signal processing |
|
||||
| `wifi-densepose-sensing-server` | WebSocket CSI data source |
|
||||
| `wifi-densepose-core` | ADR-018 frame format definitions |
|
||||
| `ruvector-cnn` | Underlying MobileNet-V3, layers, contrastive learning |
|
||||
|
||||
No new Rust crates are needed. The example is pure HTML/JS consuming existing WASM packages.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Instant demo**: Video-only mode works with just a webcam — no ESP32 needed
|
||||
- **Multi-modal showcase**: Demonstrates camera + WiFi fusion, the core innovation of the project
|
||||
- **Graceful degradation**: Works with video-only, CSI-only, or both
|
||||
- **Through-wall capability**: CSI mode shows pose estimation where cameras cannot reach
|
||||
- **Zero-install**: Anyone with a browser can try it
|
||||
- **Training data collection**: Can record paired (video, CSI) data for offline model training
|
||||
- **Reusable**: JS modules embed directly in the Tauri desktop app's webview
|
||||
|
||||
### Negative
|
||||
|
||||
- **Model weights**: Requires offline-trained weights for visual CNN, CSI CNN, fusion, and pose decoder (~200KB total JSON)
|
||||
- **WASM size**: Two WASM modules total ~350KB (acceptable)
|
||||
- **No GPU**: CPU-only WASM inference; adequate at 224×224 but limits resolution scaling
|
||||
- **Camera privacy**: Video mode requires camera permission (mitigated: CSI-only mode available)
|
||||
- **Two CNN instances**: Memory footprint doubles vs single-modal (~10MB total, acceptable for desktop browsers)
|
||||
|
||||
### Risks
|
||||
|
||||
- **Cross-modal alignment**: Video and CSI embeddings must be trained jointly for fusion to work;
|
||||
without proper training, fusion may be worse than either modality alone
|
||||
- **Latency on mobile**: Dual CNN on mobile browsers may exceed 33ms; implement automatic quality reduction
|
||||
- **WebSocket drops**: Network jitter → CSI frame gaps; buffer last 3 frames, interpolate missing data
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. **Phase 1 — Scaffold**: File layout, build.sh, index.html shell, mode selector UI
|
||||
2. **Phase 2 — Video pipeline**: getUserMedia → frame capture → CNN embedding → basic pose display
|
||||
3. **Phase 3 — CSI pipeline**: WebSocket client → CSI parsing → pseudo-image → CNN embedding
|
||||
4. **Phase 4 — Fusion**: Attention-weighted combination, confidence gating, mode switching
|
||||
5. **Phase 5 — Pose decoder**: Linear projection with placeholder weights → 17 keypoints
|
||||
6. **Phase 6 — Overlay renderer**: Video canvas with skeleton overlay, CSI heatmap panel
|
||||
7. **Phase 7 — Training**: Use `wifi-densepose-train` to generate real weights for both CNNs + fusion + decoder
|
||||
8. **Phase 8 — Contrastive demo**: Embedding space visualization, cross-modal similarity display
|
||||
9. **Phase 9 — Web Workers**: Move CNN inference to workers for parallel video + CSI processing
|
||||
10. **Phase 10 — Polish**: Recording, snapshots, adaptive quality, mobile optimization
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. CSI-Only (No Video)
|
||||
Rejected: Misses the opportunity to show multi-modal fusion and makes the demo less
|
||||
accessible (requires ESP32 hardware). Video-only mode as a fallback is strictly better.
|
||||
|
||||
### 2. Server-Side Video Inference
|
||||
Rejected: Adds latency, requires webcam stream upload (privacy concern), and defeats
|
||||
the WASM-first architecture. All inference must be client-side.
|
||||
|
||||
### 3. TensorFlow.js for Video, ruvector-cnn-wasm for CSI
|
||||
Rejected: Would require two different ML frameworks. Using `ruvector-cnn-wasm` for both
|
||||
keeps a single WASM module, unified embedding space, and simpler fusion.
|
||||
|
||||
### 4. Pre-recorded Video Demo
|
||||
Rejected: Live webcam input is far more compelling for demonstrations.
|
||||
Pre-recorded mode can be added as a secondary option.
|
||||
|
||||
### 5. React/Vue Framework
|
||||
Rejected: Adds build tooling. Vanilla JS + ES modules keeps the demo self-contained.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-018: Binary CSI Frame Format](ADR-018-binary-csi-frame-format.md)
|
||||
- [ADR-024: Contrastive CSI Embedding / AETHER](ADR-024-contrastive-csi-embedding.md)
|
||||
- [ADR-055: Integrated Sensing Server](ADR-055-integrated-sensing-server.md)
|
||||
- `vendor/ruvector/crates/ruvector-cnn/src/lib.rs` — CNN embedder implementation
|
||||
- `vendor/ruvector/crates/ruvector-cnn-wasm/src/lib.rs` — WASM bindings
|
||||
- `vendor/ruvector/examples/wasm-vanilla/index.html` — Reference vanilla JS WASM pattern
|
||||
- Person-in-WiFi: Fine-grained Person Perception using WiFi (ICCV 2019) — camera+WiFi fusion precedent
|
||||
- WiPose: Multi-Person WiFi Pose Estimation (TMC 2022) — cross-modal embedding approach
|
||||
@@ -0,0 +1,83 @@
|
||||
# ADR-059: Live ESP32 CSI Pipeline Integration
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
|
||||
2026-03-12
|
||||
|
||||
## Context
|
||||
|
||||
ADR-058 established a dual-modal browser demo combining webcam video and WiFi CSI for pose estimation. However, it used simulated CSI data. To demonstrate real-world capability, we need an end-to-end pipeline from physical ESP32 hardware through to the browser visualization.
|
||||
|
||||
The ESP32-S3 firmware (`firmware/esp32-csi-node/`) already supports CSI collection and UDP streaming (ADR-018). The sensing server (`wifi-densepose-sensing-server`) already supports UDP ingestion and WebSocket bridging. The missing piece was connecting these components and enabling the browser demo to consume live data.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a complete live CSI pipeline:
|
||||
|
||||
```
|
||||
ESP32-S3 (CSI capture) → UDP:5005 → sensing-server (Rust/Axum) → WS:8765 → browser demo
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **ESP32 Firmware** — Rebuilt with native Windows ESP-IDF v5.4.0 toolchain (no Docker). Configured for target network and PC IP via `sdkconfig`. Helper scripts added:
|
||||
- `build_firmware.ps1` — Sets up IDF environment, cleans, builds, and flashes
|
||||
- `read_serial.ps1` — Serial monitor with DTR/RTS reset capability
|
||||
|
||||
2. **Sensing Server** — `wifi-densepose-sensing-server` started with:
|
||||
- `--source esp32` — Expect real ESP32 UDP frames
|
||||
- `--bind-addr 0.0.0.0` — Accept connections from any interface
|
||||
- `--ui-path <path>` — Serve the demo UI via HTTP
|
||||
|
||||
3. **Browser Demo** — `main.js` updated to auto-connect to `ws://localhost:8765/ws/sensing` on page load. Falls back to simulated CSI if the WebSocket is unavailable (GitHub Pages).
|
||||
|
||||
### Network Configuration
|
||||
|
||||
The ESP32 sends UDP packets to a configured target IP. If the PC's IP doesn't match the firmware's compiled target, a secondary IP alias can be added:
|
||||
|
||||
```powershell
|
||||
# PowerShell (Admin)
|
||||
New-NetIPAddress -IPAddress 192.168.1.100 -PrefixLength 24 -InterfaceAlias "Wi-Fi"
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
| Stage | Protocol | Format | Rate |
|
||||
|-------|----------|--------|------|
|
||||
| ESP32 → Server | UDP | ADR-018 binary frame (magic `0xC5110001`, I/Q pairs) | ~100 Hz |
|
||||
| Server → Browser | WebSocket | ADR-018 binary frame (forwarded) | ~10 Hz (tick-ms=100) |
|
||||
| Browser decode | JavaScript | Float32 amplitude/phase arrays | Per frame |
|
||||
|
||||
### Build Environment (Windows)
|
||||
|
||||
ESP-IDF v5.4.0 on Windows requires:
|
||||
- IDF_PATH pointing to the ESP-IDF framework
|
||||
- IDF_TOOLS_PATH pointing to toolchain binaries
|
||||
- MSYS/MinGW environment variables removed (ESP-IDF rejects them)
|
||||
- Python venv from ESP-IDF tools for `idf.py` execution
|
||||
|
||||
The `build_firmware.ps1` script handles all of this automatically.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- First end-to-end demonstration of real WiFi CSI → pose estimation in a browser
|
||||
- No Docker required for firmware builds on Windows
|
||||
- Demo gracefully degrades to simulated CSI when no server is available
|
||||
- Same demo works on GitHub Pages (simulated) and locally (live ESP32)
|
||||
|
||||
### Negative
|
||||
- ESP32 target IP is compiled into firmware; changing it requires a rebuild or NVS override
|
||||
- Windows firewall may block UDP:5005; user must allow it
|
||||
- Mixed content restrictions prevent HTTPS pages from connecting to ws:// (local only)
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR-018](ADR-018-esp32-dev-implementation.md) — ESP32 CSI frame format and UDP streaming
|
||||
- [ADR-058](ADR-058-ruvector-wasm-browser-pose-example.md) — Dual-modal WASM browser pose demo
|
||||
- [ADR-039](ADR-039-edge-intelligence-framework.md) — Edge intelligence on ESP32
|
||||
- Issue [#245](https://github.com/ruvnet/RuView/issues/245) — Tracking issue
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR-060: Provision Channel Override and MAC Address Filtering
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-03-12
|
||||
- **Issues:** [#247](https://github.com/ruvnet/RuView/issues/247), [#229](https://github.com/ruvnet/RuView/issues/229)
|
||||
|
||||
## Context
|
||||
|
||||
Two related provisioning gaps were reported by users:
|
||||
|
||||
1. **Channel mismatch (Issue #247):** The CSI collector initializes on the
|
||||
Kconfig default channel (typically 6), even when the ESP32 connects to an AP
|
||||
on a different channel (e.g. 11). On managed networks where the user cannot
|
||||
change the router channel, this makes nodes undiscoverable. The
|
||||
`provision.py` script has no `--channel` argument.
|
||||
|
||||
2. **Missing MAC filter (Issue #229):** The v0.2.0 release notes documented a
|
||||
`--filter-mac` argument for `provision.py`, but it was never implemented.
|
||||
The firmware's CSI callback accepts frames from all sources, causing signal
|
||||
mixing in multi-AP environments.
|
||||
|
||||
## Decision
|
||||
|
||||
### Channel configuration
|
||||
|
||||
- Add `--channel` argument to `provision.py` that writes a `csi_channel` key
|
||||
(u8) to NVS.
|
||||
- In `nvs_config.c`, read the `csi_channel` key and override
|
||||
`channel_list[0]` when present.
|
||||
- In `csi_collector_init()`, after WiFi connects, auto-detect the AP channel
|
||||
via `esp_wifi_sta_get_ap_info()` and use it as the default CSI channel when
|
||||
no NVS override is set. This ensures the CSI collector always matches the
|
||||
connected AP's channel without requiring manual provisioning.
|
||||
|
||||
### MAC address filtering
|
||||
|
||||
- Add `--filter-mac` argument to `provision.py` that writes a `filter_mac`
|
||||
key (6-byte blob) to NVS.
|
||||
- In `nvs_config.h`, add a `filter_mac[6]` field and `filter_mac_set` flag.
|
||||
- In `nvs_config.c`, read the `filter_mac` blob from NVS.
|
||||
- In the CSI callback (`wifi_csi_callback`), if `filter_mac_set` is true,
|
||||
compare the source MAC from the received frame against the configured MAC
|
||||
and drop non-matching frames.
|
||||
|
||||
### Provisioning flow
|
||||
|
||||
```
|
||||
python provision.py --port COM7 --channel 11
|
||||
python provision.py --port COM7 --filter-mac "AA:BB:CC:DD:EE:FF"
|
||||
python provision.py --port COM7 --channel 11 --filter-mac "AA:BB:CC:DD:EE:FF"
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users on managed networks can force the CSI channel to match their AP
|
||||
- Multi-AP environments can filter CSI to a single source
|
||||
- Auto-channel detection eliminates the most common misconfiguration
|
||||
- Backward compatible: existing provisioned nodes without these keys behave
|
||||
as before (use Kconfig default channel, accept all MACs)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
# ADR-062: QEMU ESP32-S3 Swarm Configurator
|
||||
|
||||
| Field | Value |
|
||||
|-------------|------------------------------------------------|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-03-14 |
|
||||
| **Authors** | RuView Team |
|
||||
| **Relates** | ADR-061 (QEMU testing platform), ADR-060 (channel/MAC filter), ADR-018 (binary frame), ADR-039 (edge intel) |
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| Swarm | A group of N QEMU ESP32-S3 instances running simultaneously |
|
||||
| Topology | How nodes are connected: star, mesh, line, ring |
|
||||
| Role | Node function: `sensor` (collects CSI), `coordinator` (aggregates + forwards), `gateway` (bridges to host) |
|
||||
| Scenario matrix | Cross-product of topology × node count × NVS config × mock scenario |
|
||||
| Health oracle | Python process that monitors all node UART logs and declares swarm health |
|
||||
|
||||
## Context
|
||||
|
||||
ADR-061 Layer 3 provides a basic multi-node mesh test: N identical nodes with sequential TDM slots connected via a Linux bridge. This is useful but limited:
|
||||
|
||||
1. **All nodes are identical** — real deployments have heterogeneous roles (sensor, coordinator, gateway)
|
||||
2. **Single topology** — only fully-connected bridge; no star, line, or ring topologies
|
||||
3. **No scenario variation per node** — all nodes run the same mock CSI scenario
|
||||
4. **Manual configuration** — each test requires hand-editing env vars and arguments
|
||||
5. **No swarm-level health monitoring** — validation checks individual nodes, not collective behavior
|
||||
6. **No cross-node timing validation** — TDM slot ordering and inter-frame gaps aren't verified
|
||||
|
||||
Real WiFi-DensePose deployments use 3-8 ESP32-S3 nodes in various topologies. A single coordinator aggregates CSI from multiple sensors. The firmware must handle TDM conflicts, missing nodes, role-based behavior differences, and network partitions — none of which ADR-061 Layer 3 tests.
|
||||
|
||||
## Decision
|
||||
|
||||
Build a **QEMU Swarm Configurator** — a YAML-driven tool that defines multi-node test scenarios declaratively and orchestrates them under QEMU with swarm-level validation.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ swarm_config.yaml │
|
||||
│ nodes: [{role: sensor, scenario: 2, channel: 6}] │
|
||||
│ topology: star │
|
||||
│ duration: 60s │
|
||||
│ assertions: [all_nodes_boot, tdm_no_collision, ...] │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────┐
|
||||
│ qemu_swarm.py │
|
||||
│ (orchestrator) │
|
||||
└───┬────┬────┬───┬──────┘
|
||||
│ │ │ │
|
||||
┌────▼┐ ┌▼──┐ ▼ ┌▼────┐
|
||||
│Node0│ │N1 │... │N(n-1)│ QEMU instances
|
||||
│sens │ │sen│ │coord │
|
||||
└──┬──┘ └─┬─┘ └──┬───┘
|
||||
│ │ │
|
||||
┌──▼──────▼─────────▼──┐
|
||||
│ Virtual Network │ TAP bridge / SLIRP
|
||||
│ (topology-shaped) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
┌──────────▼───────────┐
|
||||
│ Aggregator (Rust) │ Collects frames
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
┌──────────▼───────────┐
|
||||
│ Health Oracle │ Swarm-level assertions
|
||||
│ (swarm_health.py) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### YAML Configuration Schema
|
||||
|
||||
```yaml
|
||||
# swarm_config.yaml
|
||||
swarm:
|
||||
name: "3-sensor-star"
|
||||
duration_s: 60
|
||||
topology: star # star | mesh | line | ring
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0 # empty room (baseline)
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true # receives aggregated frames
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 2 # walking person
|
||||
channel: 6
|
||||
tdm_slot: 1 # TDM slot index (auto-assigned from node position if omitted)
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 3 # fall event
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- coordinator_receives_from_all
|
||||
- fall_detected_by_node_2
|
||||
- frame_rate_above: 15 # Hz minimum per node
|
||||
- max_boot_time_s: 10
|
||||
```
|
||||
|
||||
### Topologies
|
||||
|
||||
| Topology | Network | Description |
|
||||
|----------|---------|-------------|
|
||||
| `star` | All sensors connect to coordinator; coordinator has TAP to each sensor | Hub-and-spoke, most common |
|
||||
| `mesh` | All nodes on same bridge (existing Layer 3 behavior) | Every node sees every other |
|
||||
| `line` | Node 0 ↔ Node 1 ↔ Node 2 ↔ ... | Linear chain, tests multi-hop |
|
||||
| `ring` | Like line but last connects to first | Circular, tests routing |
|
||||
|
||||
### Node Roles
|
||||
|
||||
| Role | Behavior | NVS Keys |
|
||||
|------|----------|----------|
|
||||
| `sensor` | Runs mock CSI, sends frames to coordinator | `node_id`, `tdm_slot`, `target_ip` |
|
||||
| `coordinator` | Receives frames from sensors, runs edge aggregation | `node_id`, `tdm_slot=0`, `edge_tier=2` |
|
||||
| `gateway` | Like coordinator but also bridges to host UDP | `node_id`, `target_ip=host`, `is_gateway=1` |
|
||||
|
||||
### Assertions (Swarm-Level)
|
||||
|
||||
| Assertion | What It Checks |
|
||||
|-----------|---------------|
|
||||
| `all_nodes_boot` | Every node's UART log shows boot indicators within timeout |
|
||||
| `no_crashes` | No Guru Meditation, assert, panic in any log |
|
||||
| `tdm_no_collision` | No two nodes transmit in the same TDM slot |
|
||||
| `all_nodes_produce_frames` | Every sensor node's log contains CSI frame output |
|
||||
| `coordinator_receives_from_all` | Coordinator log shows frames from each sensor's node_id |
|
||||
| `fall_detected_by_node_N` | Node N's log reports a fall detection event |
|
||||
| `frame_rate_above` | Each node produces at least N frames/second |
|
||||
| `max_boot_time_s` | All nodes boot within N seconds |
|
||||
| `no_heap_errors` | No OOM or heap corruption in any log |
|
||||
| `network_partitioned_recovery` | After deliberate partition, nodes resume communication (future) |
|
||||
|
||||
### Preset Configurations
|
||||
|
||||
| Preset | Nodes | Topology | Purpose |
|
||||
|--------|-------|----------|---------|
|
||||
| `smoke` | 2 | star | Quick CI smoke test (15s) |
|
||||
| `standard` | 3 | star | Default 3-node (sensor + sensor + coordinator) |
|
||||
| `large-mesh` | 6 | mesh | Scale test with 6 fully-connected nodes |
|
||||
| `line-relay` | 4 | line | Multi-hop relay chain |
|
||||
| `ring-fault` | 4 | ring | Ring with fault injection mid-test |
|
||||
| `heterogeneous` | 5 | star | Mixed scenarios: walk, fall, static, channel-sweep, empty |
|
||||
| `ci-matrix` | 3 | star | CI-optimized preset (30s, minimal assertions) |
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── qemu_swarm.py # Main orchestrator (CLI entry point)
|
||||
├── swarm_health.py # Swarm-level health oracle
|
||||
└── swarm_presets/
|
||||
├── smoke.yaml
|
||||
├── standard.yaml
|
||||
├── large_mesh.yaml
|
||||
├── line_relay.yaml
|
||||
├── ring_fault.yaml
|
||||
├── heterogeneous.yaml
|
||||
└── ci_matrix.yaml
|
||||
|
||||
.github/workflows/
|
||||
└── firmware-qemu.yml # MODIFIED: add swarm test job
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Declarative testing** — define swarm topology in YAML, not shell scripts
|
||||
2. **Role-based nodes** — test coordinator/sensor/gateway interactions
|
||||
3. **Topology variety** — star/mesh/line/ring match real deployment patterns
|
||||
4. **Swarm-level assertions** — validate collective behavior, not just individual nodes
|
||||
5. **Preset library** — quick CI smoke tests and thorough manual validation
|
||||
6. **Reproducible** — YAML configs are version-controlled and shareable
|
||||
|
||||
### Limitations
|
||||
|
||||
1. **Still requires root** for TAP bridge topologies (star, line, ring); mesh can use SLIRP
|
||||
2. **QEMU resource usage** — 6+ QEMU instances use ~2GB RAM, may slow CI runners
|
||||
3. **No real RF** — inter-node communication is IP-based, not WiFi CSI multipath
|
||||
|
||||
## References
|
||||
|
||||
- ADR-061: QEMU ESP32-S3 firmware testing platform (Layers 1-9)
|
||||
- ADR-060: Channel override and MAC address filter provisioning
|
||||
- ADR-018: Binary CSI frame format (magic `0xC5110001`)
|
||||
- ADR-039: Edge intelligence pipeline (biquad, vitals, fall detection)
|
||||
+334
-2
@@ -38,8 +38,17 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
||||
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
||||
15. [Docker Compose (Multi-Service)](#docker-compose-multi-service)
|
||||
16. [Troubleshooting](#troubleshooting)
|
||||
17. [FAQ](#faq)
|
||||
16. [Testing Firmware Without Hardware (QEMU)](#testing-firmware-without-hardware-qemu)
|
||||
- [What You Need](#what-you-need)
|
||||
- [Your First Test Run](#your-first-test-run)
|
||||
- [Understanding the Test Output](#understanding-the-test-output)
|
||||
- [Testing Multiple Nodes at Once (Swarm)](#testing-multiple-nodes-at-once-swarm)
|
||||
- [Swarm Presets](#swarm-presets)
|
||||
- [Writing Your Own Swarm Config](#writing-your-own-swarm-config)
|
||||
- [Debugging Firmware in QEMU](#debugging-firmware-in-qemu)
|
||||
- [Running the Full Test Suite](#running-the-full-test-suite)
|
||||
17. [Troubleshooting](#troubleshooting)
|
||||
18. [FAQ](#faq)
|
||||
|
||||
---
|
||||
|
||||
@@ -936,6 +945,288 @@ This starts:
|
||||
|
||||
---
|
||||
|
||||
## Testing Firmware Without Hardware (QEMU)
|
||||
|
||||
You can test the ESP32-S3 firmware on your computer without any physical hardware. The project uses **QEMU** — an emulator that pretends to be an ESP32-S3 chip, running the real firmware code inside a virtual machine on your PC.
|
||||
|
||||
This is useful when:
|
||||
- You don't have an ESP32-S3 board yet
|
||||
- You want to test firmware changes before flashing to real hardware
|
||||
- You're running automated tests in CI/CD
|
||||
- You want to simulate multiple ESP32 nodes talking to each other
|
||||
|
||||
### What You Need
|
||||
|
||||
**Required:**
|
||||
- Python 3.8+ (you probably already have this)
|
||||
- QEMU with ESP32-S3 support (Espressif's fork)
|
||||
|
||||
**Install QEMU (one-time setup):**
|
||||
|
||||
```bash
|
||||
# Easiest: use the automated installer (installs QEMU + Python tools)
|
||||
bash scripts/install-qemu.sh
|
||||
|
||||
# Or check what's already installed:
|
||||
bash scripts/install-qemu.sh --check
|
||||
```
|
||||
|
||||
The installer detects your OS (Ubuntu, Fedora, macOS, etc.), installs build dependencies, clones Espressif's QEMU fork, builds it, and adds it to your PATH. It also installs the Python tools (`esptool`, `pyyaml`, `esp-idf-nvs-partition-gen`).
|
||||
|
||||
<details>
|
||||
<summary>Manual installation (if you prefer)</summary>
|
||||
|
||||
```bash
|
||||
# Build from source
|
||||
git clone https://github.com/espressif/qemu.git
|
||||
cd qemu
|
||||
./configure --target-list=xtensa-softmmu --enable-slirp
|
||||
make -j$(nproc)
|
||||
export QEMU_PATH=$(pwd)/build/qemu-system-xtensa
|
||||
|
||||
# Install Python tools
|
||||
pip install esptool pyyaml esp-idf-nvs-partition-gen
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**For multi-node testing (optional):**
|
||||
|
||||
```bash
|
||||
# Linux only — needed for virtual network bridges
|
||||
sudo apt install socat bridge-utils iproute2
|
||||
```
|
||||
|
||||
### The `qemu-cli.sh` Command
|
||||
|
||||
All QEMU testing is available through a single command:
|
||||
|
||||
```bash
|
||||
bash scripts/qemu-cli.sh <command>
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `install` | Install QEMU (runs the installer above) |
|
||||
| `test` | Run single-node firmware test |
|
||||
| `swarm --preset smoke` | Quick 2-node swarm test |
|
||||
| `swarm --preset standard` | Standard 3-node test |
|
||||
| `mesh 3` | Multi-node mesh test |
|
||||
| `chaos` | Fault injection resilience test |
|
||||
| `fuzz --duration 60` | Run fuzz testing |
|
||||
| `status` | Show what's installed and ready |
|
||||
| `help` | Show all commands |
|
||||
|
||||
### Your First Test Run
|
||||
|
||||
The simplest way to test the firmware:
|
||||
|
||||
```bash
|
||||
# Using the CLI:
|
||||
bash scripts/qemu-cli.sh test
|
||||
|
||||
# Or directly:
|
||||
bash scripts/qemu-esp32s3-test.sh
|
||||
```
|
||||
|
||||
**What happens behind the scenes:**
|
||||
1. The firmware is compiled with a "mock CSI" mode — instead of reading real WiFi signals, it generates synthetic test data that mimics real people walking, falling, or breathing
|
||||
2. The compiled firmware is loaded into QEMU, which boots it like a real ESP32-S3
|
||||
3. The emulator's serial output (what you'd see on a USB cable) is captured
|
||||
4. A validation script checks the output for expected behavior and errors
|
||||
|
||||
If you already built the firmware and want to skip rebuilding:
|
||||
|
||||
```bash
|
||||
SKIP_BUILD=1 bash scripts/qemu-esp32s3-test.sh
|
||||
```
|
||||
|
||||
To give it more time (useful on slower machines):
|
||||
|
||||
```bash
|
||||
QEMU_TIMEOUT=120 bash scripts/qemu-esp32s3-test.sh
|
||||
```
|
||||
|
||||
### Understanding the Test Output
|
||||
|
||||
The test runs 16 checks on the firmware's output. Here's what a successful run looks like:
|
||||
|
||||
```
|
||||
=== QEMU ESP32-S3 Firmware Test (ADR-061) ===
|
||||
|
||||
[PASS] Boot: Firmware booted successfully
|
||||
[PASS] NVS config: Configuration loaded from flash
|
||||
[PASS] Mock CSI: Synthetic WiFi data generator started
|
||||
[PASS] Edge processing: Signal analysis pipeline running
|
||||
[PASS] Frame serialization: Data packets formatted correctly
|
||||
[PASS] No crashes: No error conditions detected
|
||||
...
|
||||
|
||||
16/16 checks passed
|
||||
=== Test Complete (exit code: 0) ===
|
||||
```
|
||||
|
||||
**Exit codes explained:**
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|------|---------|-----------|
|
||||
| 0 | **PASS** — everything works | Nothing, you're good! |
|
||||
| 1 | **WARN** — minor issues | Review the output; usually safe to continue |
|
||||
| 2 | **FAIL** — something broke | Check the `[FAIL]` lines for what went wrong |
|
||||
| 3 | **FATAL** — can't even start | Usually a missing tool or build failure; check error messages |
|
||||
|
||||
### Testing Multiple Nodes at Once (Swarm)
|
||||
|
||||
Real deployments use 3-8 ESP32 nodes. The **swarm configurator** lets you simulate multiple nodes on your computer, each with a different role:
|
||||
|
||||
- **Sensor nodes** — generate WiFi signal data (like ESP32s placed around a room)
|
||||
- **Coordinator node** — collects data from all sensors and runs analysis
|
||||
- **Gateway node** — bridges data to your computer
|
||||
|
||||
```bash
|
||||
# Quick 2-node smoke test (15 seconds)
|
||||
python3 scripts/qemu_swarm.py --preset smoke
|
||||
|
||||
# Standard 3-node test: 2 sensors + 1 coordinator (60 seconds)
|
||||
python3 scripts/qemu_swarm.py --preset standard
|
||||
|
||||
# See what's available
|
||||
python3 scripts/qemu_swarm.py --list-presets
|
||||
|
||||
# Preview what would run (without actually running)
|
||||
python3 scripts/qemu_swarm.py --preset standard --dry-run
|
||||
```
|
||||
|
||||
**Note:** Multi-node testing with virtual bridges requires Linux and `sudo`. On other systems, nodes use a simpler networking mode where each node can reach the coordinator but not each other.
|
||||
|
||||
### Swarm Presets
|
||||
|
||||
| Preset | Nodes | Duration | Best for |
|
||||
|--------|-------|----------|----------|
|
||||
| `smoke` | 2 | 15s | Quick check that things work |
|
||||
| `standard` | 3 | 60s | Normal development testing |
|
||||
| `ci_matrix` | 3 | 30s | CI/CD pipelines |
|
||||
| `large_mesh` | 6 | 90s | Testing at scale |
|
||||
| `line_relay` | 4 | 60s | Multi-hop relay testing |
|
||||
| `ring_fault` | 4 | 75s | Fault tolerance testing |
|
||||
| `heterogeneous` | 5 | 90s | Mixed scenario testing |
|
||||
|
||||
### Writing Your Own Swarm Config
|
||||
|
||||
Create a YAML file describing your test scenario:
|
||||
|
||||
```yaml
|
||||
# my_test.yaml
|
||||
swarm:
|
||||
name: my-custom-test
|
||||
duration_s: 45
|
||||
topology: star # star, mesh, line, or ring
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0 # 0=empty room (baseline)
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 2 # 2=walking person
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 3 # 3=fall event
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot # Did every node start up?
|
||||
- no_crashes # Any error/panic?
|
||||
- all_nodes_produce_frames # Is each sensor generating data?
|
||||
- fall_detected_by_node_2 # Did node 2 detect the fall?
|
||||
```
|
||||
|
||||
**Available scenarios** (what kind of fake WiFi data to generate):
|
||||
|
||||
| # | Scenario | Description |
|
||||
|---|----------|-------------|
|
||||
| 0 | Empty room | Baseline with just noise |
|
||||
| 1 | Static person | Someone standing still |
|
||||
| 2 | Walking | Someone walking across the room |
|
||||
| 3 | Fall | Someone falling down |
|
||||
| 4 | Multiple people | Two people in the room |
|
||||
| 5 | Channel sweep | Cycling through WiFi channels |
|
||||
| 6 | MAC filter | Testing device filtering |
|
||||
| 7 | Ring overflow | Stress test with burst of data |
|
||||
| 8 | RSSI sweep | Signal strength from weak to strong |
|
||||
| 9 | Zero-length | Edge case: empty data packet |
|
||||
|
||||
**Topology options:**
|
||||
|
||||
| Topology | Shape | When to use |
|
||||
|----------|-------|-------------|
|
||||
| `star` | All sensors connect to one coordinator | Most common setup |
|
||||
| `mesh` | Every node can talk to every other | Testing fully connected networks |
|
||||
| `line` | Nodes in a chain (A → B → C → D) | Testing relay/forwarding |
|
||||
| `ring` | Chain with ends connected | Testing circular routing |
|
||||
|
||||
Run your custom config:
|
||||
|
||||
```bash
|
||||
python3 scripts/qemu_swarm.py --config my_test.yaml
|
||||
```
|
||||
|
||||
### Debugging Firmware in QEMU
|
||||
|
||||
If something goes wrong, you can attach a debugger to the emulated ESP32:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start QEMU with debug support (paused at boot)
|
||||
qemu-system-xtensa -machine esp32s3 -nographic \
|
||||
-drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-s -S
|
||||
|
||||
# Terminal 2: Connect the debugger
|
||||
xtensa-esp-elf-gdb firmware/esp32-csi-node/build/esp32-csi-node.elf \
|
||||
-ex "target remote :1234" \
|
||||
-ex "break app_main" \
|
||||
-ex "continue"
|
||||
```
|
||||
|
||||
Or use VS Code: open the project, press **F5**, and select **"QEMU ESP32-S3 Debug"**.
|
||||
|
||||
### Running the Full Test Suite
|
||||
|
||||
For thorough validation before submitting a pull request:
|
||||
|
||||
```bash
|
||||
# 1. Single-node test (2 minutes)
|
||||
bash scripts/qemu-esp32s3-test.sh
|
||||
|
||||
# 2. Multi-node swarm test (1 minute)
|
||||
python3 scripts/qemu_swarm.py --preset standard
|
||||
|
||||
# 3. Fuzz testing — finds edge-case crashes (1-5 minutes)
|
||||
cd firmware/esp32-csi-node/test
|
||||
make all CC=clang
|
||||
make run_serialize FUZZ_DURATION=60
|
||||
make run_edge FUZZ_DURATION=60
|
||||
make run_nvs FUZZ_DURATION=60
|
||||
|
||||
# 4. NVS configuration matrix — tests 14 config combinations
|
||||
python3 scripts/generate_nvs_matrix.py --output-dir build/nvs_matrix
|
||||
|
||||
# 5. Chaos testing — injects faults to test resilience (2 minutes)
|
||||
bash scripts/qemu-chaos-test.sh
|
||||
```
|
||||
|
||||
All of these also run automatically in CI when you push changes to `firmware/`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker: "no matching manifest for linux/arm64" on macOS
|
||||
@@ -1015,6 +1306,47 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still
|
||||
- Hard refresh with Ctrl+Shift+R to clear cached settings
|
||||
- The auto-detect probes `/health` on the same origin — cross-origin won't work
|
||||
|
||||
### QEMU: "qemu-system-xtensa: command not found"
|
||||
|
||||
QEMU for ESP32-S3 must be built from Espressif's fork — it is not in standard package managers:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/espressif/qemu.git
|
||||
cd qemu && ./configure --target-list=xtensa-softmmu && make -j$(nproc)
|
||||
export QEMU_PATH=$(pwd)/build/qemu-system-xtensa
|
||||
```
|
||||
|
||||
Or point to an existing build: `QEMU_PATH=/path/to/qemu-system-xtensa bash scripts/qemu-esp32s3-test.sh`
|
||||
|
||||
### QEMU: Test times out with no output
|
||||
|
||||
The emulator is slower than real hardware. Increase the timeout:
|
||||
|
||||
```bash
|
||||
QEMU_TIMEOUT=120 bash scripts/qemu-esp32s3-test.sh
|
||||
```
|
||||
|
||||
If there's truly no output at all, the firmware build may have failed. Rebuild without `SKIP_BUILD`:
|
||||
|
||||
```bash
|
||||
bash scripts/qemu-esp32s3-test.sh # without SKIP_BUILD
|
||||
```
|
||||
|
||||
### QEMU: "esptool not found"
|
||||
|
||||
Install it with pip: `pip install esptool`
|
||||
|
||||
### QEMU Swarm: "Must be run as root"
|
||||
|
||||
Multi-node swarm tests with virtual network bridges require root on Linux. Two options:
|
||||
|
||||
1. Run with sudo: `sudo python3 scripts/qemu_swarm.py --preset standard`
|
||||
2. Skip bridges (nodes use simpler networking): the tool automatically falls back on non-root systems, but nodes can't communicate with each other (only with the aggregator)
|
||||
|
||||
### QEMU Swarm: "yaml module not found"
|
||||
|
||||
Install PyYAML: `pip install pyyaml`
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -523,6 +523,231 @@ The firmware is continuously verified by [`.github/workflows/firmware-ci.yml`](.
|
||||
|
||||
---
|
||||
|
||||
## QEMU Testing (ADR-061)
|
||||
|
||||
Test the firmware without physical hardware using Espressif's QEMU fork. A compile-time mock CSI generator (`CONFIG_CSI_MOCK_ENABLED=y`) replaces the real WiFi CSI callback with a timer-driven synthetic frame injector that exercises the full edge processing pipeline -- biquad filtering, Welford stats, top-K selection, presence/fall detection, and vitals extraction.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **ESP-IDF v5.4** -- [installation guide](https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32s3/get-started/)
|
||||
- **Espressif QEMU fork** -- must be built from source (not in Ubuntu packages):
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/espressif/qemu.git /tmp/qemu
|
||||
cd /tmp/qemu
|
||||
./configure --target-list=xtensa-softmmu --enable-slirp
|
||||
make -j$(nproc)
|
||||
sudo cp build/qemu-system-xtensa /usr/local/bin/
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
Three commands to go from source to running firmware in QEMU:
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node
|
||||
|
||||
# 1. Build with mock CSI enabled (replaces real WiFi CSI with synthetic frames)
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
|
||||
# 2. Create merged flash image
|
||||
esptool.py --chip esp32s3 merge_bin -o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
# 3. Run in QEMU
|
||||
qemu-system-xtensa -machine esp32s3 -nographic \
|
||||
-drive file=build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-serial mon:stdio -no-reboot
|
||||
```
|
||||
|
||||
The firmware boots FreeRTOS, loads NVS config, starts the mock CSI generator at 20 Hz, and runs all edge processing. UART output shows log lines that can be validated automatically.
|
||||
|
||||
### Mock CSI Scenarios
|
||||
|
||||
The mock generator cycles through 10 scenarios that exercise every edge processing path:
|
||||
|
||||
| ID | Scenario | Duration | Expected Output |
|
||||
|----|----------|----------|-----------------|
|
||||
| 0 | Empty room | 10 s | `presence=0`, `motion_energy < thresh` |
|
||||
| 1 | Static person | 10 s | `presence=1`, `breathing_rate` in [10, 25], `fall=0` |
|
||||
| 2 | Walking person | 10 s | `presence=1`, `motion_energy > 0.5`, `fall=0` |
|
||||
| 3 | Fall event | 5 s | `fall=1` flag set, `motion_energy` spike |
|
||||
| 4 | Multi-person | 15 s | `n_persons=2`, independent breathing rates |
|
||||
| 5 | Channel sweep | 5 s | Frames on channels 1, 6, 11 in sequence |
|
||||
| 6 | MAC filter test | 5 s | Frames with wrong MAC dropped (counter check) |
|
||||
| 7 | Ring buffer overflow | 3 s | 1000 frames in 100 ms burst, graceful drop |
|
||||
| 8 | Boundary RSSI | 5 s | RSSI sweeps -127 to 0, no crash |
|
||||
| 9 | Zero-length frame | 2 s | `iq_len=0` frames, serialize returns 0 |
|
||||
|
||||
### NVS Provisioning Matrix
|
||||
|
||||
14 NVS configurations are tested in CI to ensure all config paths work correctly:
|
||||
|
||||
| Config | NVS Values | Validates |
|
||||
|--------|-----------|-----------|
|
||||
| `default` | (empty NVS) | Kconfig fallback paths |
|
||||
| `wifi-only` | ssid, password | Basic provisioning |
|
||||
| `full-adr060` | channel=6, filter_mac=AA:BB:CC:DD:EE:FF | Channel override + MAC filter |
|
||||
| `edge-tier0` | edge_tier=0 | Raw CSI passthrough (no DSP) |
|
||||
| `edge-tier1` | edge_tier=1, pres_thresh=100, fall_thresh=2000 | Stats-only mode |
|
||||
| `edge-tier2-custom` | edge_tier=2, vital_win=128, vital_int=500, subk_count=16 | Full vitals with custom params |
|
||||
| `tdm-3node` | tdm_slot=1, tdm_nodes=3, node_id=1 | TDM mesh timing |
|
||||
| `wasm-signed` | wasm_max=4, wasm_verify=1, wasm_pubkey=<32B> | WASM with Ed25519 verification |
|
||||
| `wasm-unsigned` | wasm_max=2, wasm_verify=0 | WASM without signature check |
|
||||
| `5ghz-channel` | channel=36, filter_mac=... | 5 GHz CSI collection |
|
||||
| `boundary-max` | target_port=65535, node_id=255, top_k=32, vital_win=256 | Max-range values |
|
||||
| `boundary-min` | target_port=1, node_id=0, top_k=1, vital_win=32 | Min-range values |
|
||||
| `power-save` | power_duty=10, edge_tier=0 | Low-power mode |
|
||||
| `corrupt-nvs` | (partial/corrupt partition) | Graceful fallback to defaults |
|
||||
|
||||
Generate all configs for CI testing:
|
||||
|
||||
```bash
|
||||
python scripts/generate_nvs_matrix.py
|
||||
```
|
||||
|
||||
### Validation Checks
|
||||
|
||||
The output validation script (`scripts/validate_qemu_output.py`) parses UART logs and checks:
|
||||
|
||||
| Check | Pass Criteria | Severity |
|
||||
|-------|---------------|----------|
|
||||
| Boot | `app_main()` called, no panic/assert | FATAL |
|
||||
| NVS load | `nvs_config:` log line present | FATAL |
|
||||
| Mock CSI init | `mock_csi: Starting mock CSI generator` | FATAL |
|
||||
| Frame generation | `mock_csi: Generated N frames` where N > 0 | ERROR |
|
||||
| Edge pipeline | `edge_processing: DSP task started on Core 1` | ERROR |
|
||||
| Vitals output | At least one `vitals:` log line with valid BPM | ERROR |
|
||||
| Presence detection | `presence=1` during person scenarios | WARN |
|
||||
| Fall detection | `fall=1` during fall scenario | WARN |
|
||||
| MAC filter | `csi_collector: MAC filter dropped N frames` where N > 0 | WARN |
|
||||
| ADR-018 serialize | `csi_collector: Serialized N frames` where N > 0 | ERROR |
|
||||
| No crash | No `Guru Meditation Error`, no `assert failed`, no `abort()` | FATAL |
|
||||
| Clean exit | Firmware reaches end of scenario sequence | ERROR |
|
||||
| Heap OK | No `HEAP_ERROR` or `out of memory` | FATAL |
|
||||
| Stack OK | No `Stack overflow` detected | FATAL |
|
||||
|
||||
Exit codes: `0` = all pass, `1` = WARN only, `2` = ERROR, `3` = FATAL.
|
||||
|
||||
### GDB Debugging
|
||||
|
||||
QEMU provides a built-in GDB stub for zero-cost breakpoint debugging without JTAG hardware:
|
||||
|
||||
```bash
|
||||
# Launch QEMU paused, with GDB stub on port 1234
|
||||
qemu-system-xtensa \
|
||||
-machine esp32s3 -nographic \
|
||||
-drive file=build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-serial mon:stdio \
|
||||
-s -S
|
||||
|
||||
# In another terminal, attach GDB
|
||||
xtensa-esp-elf-gdb build/esp32-csi-node.elf \
|
||||
-ex "target remote :1234" \
|
||||
-ex "b edge_processing.c:dsp_task" \
|
||||
-ex "b csi_collector.c:csi_serialize_frame" \
|
||||
-ex "b mock_csi.c:mock_generate_csi_frame" \
|
||||
-ex "watch g_nvs_config.csi_channel" \
|
||||
-ex "continue"
|
||||
```
|
||||
|
||||
Key breakpoints:
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `edge_processing.c:dsp_task` | DSP consumer loop entry |
|
||||
| `edge_processing.c:presence_detect` | Threshold comparison |
|
||||
| `edge_processing.c:fall_detect` | Phase acceleration check |
|
||||
| `csi_collector.c:csi_serialize_frame` | ADR-018 serialization |
|
||||
| `nvs_config.c:nvs_config_load` | NVS parse logic |
|
||||
| `wasm_runtime.c:wasm_on_csi` | WASM module dispatch |
|
||||
| `mock_csi.c:mock_generate_csi_frame` | Synthetic frame generation |
|
||||
|
||||
VS Code integration -- add to `.vscode/launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{ "text": "set remote hardware-breakpoint-limit 2" },
|
||||
{ "text": "set remote hardware-watchpoint-limit 2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Code Coverage
|
||||
|
||||
Build with gcov enabled and collect coverage after a QEMU run:
|
||||
|
||||
```bash
|
||||
# Build with coverage overlay
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu;sdkconfig.coverage" build
|
||||
|
||||
# After QEMU run, generate HTML report
|
||||
lcov --capture --directory build --output-file coverage.info
|
||||
lcov --remove coverage.info '*/esp-idf/*' '*/test/*' --output-file coverage_filtered.info
|
||||
genhtml coverage_filtered.info --output-directory build/coverage_report
|
||||
```
|
||||
|
||||
Coverage targets:
|
||||
|
||||
| Module | Target |
|
||||
|--------|--------|
|
||||
| `edge_processing.c` | >= 80% |
|
||||
| `csi_collector.c` | >= 90% |
|
||||
| `nvs_config.c` | >= 95% |
|
||||
| `mock_csi.c` | >= 95% |
|
||||
| `stream_sender.c` | >= 80% |
|
||||
| `wasm_runtime.c` | >= 70% |
|
||||
|
||||
### Fuzz Testing
|
||||
|
||||
Host-native fuzz targets compiled with libFuzzer + AddressSanitizer (no QEMU needed):
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node/test
|
||||
|
||||
# Build fuzz target
|
||||
clang -fsanitize=fuzzer,address -I../main \
|
||||
fuzz_csi_serialize.c ../main/csi_collector.c \
|
||||
-o fuzz_serialize
|
||||
|
||||
# Run for 5 minutes
|
||||
timeout 300 ./fuzz_serialize corpus/ || true
|
||||
```
|
||||
|
||||
Fuzz targets:
|
||||
|
||||
| Target | Input | Looking For |
|
||||
|--------|-------|-------------|
|
||||
| `csi_serialize_frame()` | Random `wifi_csi_info_t` | Buffer overflow, NULL deref |
|
||||
| `nvs_config_load()` | Crafted NVS partition binary | No crash, fallback to defaults |
|
||||
| `edge_enqueue_csi()` | Rapid-fire 10,000 frames | Ring overflow, no data corruption |
|
||||
| `rvf_parser.c` | Malformed RVF packets | Parse rejection, no crash |
|
||||
| `wasm_upload.c` | Corrupt WASM blobs | Rejection without crash |
|
||||
|
||||
### QEMU CI Workflow
|
||||
|
||||
The GitHub Actions workflow (`.github/workflows/firmware-qemu.yml`) runs on every push or PR touching `firmware/**`:
|
||||
|
||||
1. Uses the `espressif/idf:v5.4` container image
|
||||
2. Builds Espressif's QEMU fork from source
|
||||
3. Runs a CI matrix across NVS configurations: `default`, `nvs-full`, `nvs-edge-tier0`, `nvs-tdm-3node`
|
||||
4. For each config: provisions NVS, builds with mock CSI, runs in QEMU with timeout, validates UART output
|
||||
5. Uploads QEMU logs as build artifacts for debugging failures
|
||||
|
||||
No physical ESP32 hardware is needed in CI.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
@@ -556,6 +781,9 @@ This firmware implements or references the following ADRs:
|
||||
| [ADR-029](../../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | Channel hopping and TDM protocol | Accepted |
|
||||
| [ADR-039](../../docs/adr/ADR-039-esp32-edge-intelligence.md) | Edge intelligence tiers 0-2 | Accepted |
|
||||
| [ADR-040](../../docs/adr/) | WASM programmable sensing (Tier 3) with RVF container format | Alpha |
|
||||
| [ADR-057](../../docs/adr/ADR-057-build-time-csi-guard.md) | Build-time CSI guard (`CONFIG_ESP_WIFI_CSI_ENABLED`) | Accepted |
|
||||
| [ADR-060](../../docs/adr/ADR-060-channel-mac-filter.md) | Channel override and MAC address filter | Accepted |
|
||||
| [ADR-061](../../docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) | QEMU ESP32-S3 emulation for firmware testing | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Remove MSYS environment variables that trigger ESP-IDF's MinGW rejection
|
||||
Remove-Item env:MSYSTEM -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MSYSTEM_CARCH -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MSYSTEM_CHOST -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MSYSTEM_PREFIX -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MINGW_CHOST -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MINGW_PACKAGE_PREFIX -ErrorAction SilentlyContinue
|
||||
Remove-Item env:MINGW_PREFIX -ErrorAction SilentlyContinue
|
||||
|
||||
$env:IDF_PATH = "C:\Users\ruv\esp\v5.4\esp-idf"
|
||||
$env:IDF_TOOLS_PATH = "C:\Espressif\tools"
|
||||
$env:IDF_PYTHON_ENV_PATH = "C:\Espressif\tools\python\v5.4\venv"
|
||||
$env:PATH = "C:\Espressif\tools\xtensa-esp-elf\esp-14.2.0_20241119\xtensa-esp-elf\bin;C:\Espressif\tools\cmake\3.30.2\cmake-3.30.2-windows-x86_64\bin;C:\Espressif\tools\ninja\1.12.1;C:\Espressif\tools\ccache\4.10.2\ccache-4.10.2-windows-x86_64;C:\Espressif\tools\idf-exe\1.0.3;C:\Espressif\tools\python\v5.4\venv\Scripts;$env:PATH"
|
||||
|
||||
Set-Location "C:\Users\ruv\Projects\wifi-densepose\firmware\esp32-csi-node"
|
||||
|
||||
$python = "$env:IDF_PYTHON_ENV_PATH\Scripts\python.exe"
|
||||
$idf = "$env:IDF_PATH\tools\idf.py"
|
||||
|
||||
Write-Host "=== Cleaning stale build cache ==="
|
||||
& $python $idf fullclean
|
||||
|
||||
Write-Host "=== Building firmware (SSID=ruv.net, target=192.168.1.20:5005) ==="
|
||||
& $python $idf build
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "=== Build succeeded! Flashing to COM7 ==="
|
||||
& $python $idf -p COM7 flash
|
||||
} else {
|
||||
Write-Host "=== Build failed with exit code $LASTEXITCODE ==="
|
||||
}
|
||||
@@ -6,6 +6,11 @@ set(SRCS
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c")
|
||||
endif()
|
||||
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
if(CONFIG_DISPLAY_ENABLE)
|
||||
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
|
||||
|
||||
@@ -201,3 +201,40 @@ menu "WASM Programmable Sensing (ADR-040)"
|
||||
Default 1000 ms = 1 Hz.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Mock CSI (QEMU Testing)"
|
||||
config CSI_MOCK_ENABLED
|
||||
bool "Enable mock CSI generator (for QEMU testing)"
|
||||
default n
|
||||
help
|
||||
Replace real WiFi CSI with synthetic frame generator.
|
||||
Use with QEMU emulation for automated testing.
|
||||
|
||||
config CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
bool "Skip WiFi STA connection"
|
||||
depends on CSI_MOCK_ENABLED
|
||||
default y
|
||||
help
|
||||
Skip WiFi initialization when using mock CSI.
|
||||
|
||||
config CSI_MOCK_SCENARIO
|
||||
int "Mock scenario (0-9, 255=all)"
|
||||
depends on CSI_MOCK_ENABLED
|
||||
default 255
|
||||
range 0 255
|
||||
help
|
||||
0=empty, 1=static, 2=walking, 3=fall, 4=multi-person,
|
||||
5=channel-sweep, 6=mac-filter, 7=ring-overflow,
|
||||
8=boundary-rssi, 9=zero-length, 255=run all.
|
||||
|
||||
config CSI_MOCK_SCENARIO_DURATION_MS
|
||||
int "Scenario duration (ms)"
|
||||
depends on CSI_MOCK_ENABLED
|
||||
default 5000
|
||||
range 1000 60000
|
||||
|
||||
config CSI_MOCK_LOG_FRAMES
|
||||
bool "Log every mock frame (verbose)"
|
||||
depends on CSI_MOCK_ENABLED
|
||||
default n
|
||||
endmenu
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "nvs_config.h"
|
||||
#include "stream_sender.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
@@ -21,6 +22,9 @@
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
/* ADR-057: Build-time guard — fail early if CSI is not enabled in sdkconfig.
|
||||
* Without this, the firmware compiles but crashes at runtime with:
|
||||
* "E (xxxx) wifi:CSI not enabled in menuconfig!"
|
||||
@@ -151,6 +155,14 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
{
|
||||
(void)ctx;
|
||||
|
||||
/* ADR-060: MAC address filtering — drop frames from non-matching sources. */
|
||||
if (g_nvs_config.filter_mac_set) {
|
||||
if (memcmp(info->mac, g_nvs_config.filter_mac, 6) != 0) {
|
||||
return; /* Source MAC doesn't match filter — skip frame. */
|
||||
}
|
||||
}
|
||||
|
||||
s_cb_count++;
|
||||
|
||||
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
|
||||
@@ -203,6 +215,29 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
|
||||
|
||||
void csi_collector_init(void)
|
||||
{
|
||||
/* ADR-060: Determine the CSI channel.
|
||||
* Priority: 1) NVS override (--channel), 2) connected AP channel, 3) Kconfig default. */
|
||||
uint8_t csi_channel = (uint8_t)CONFIG_CSI_WIFI_CHANNEL;
|
||||
|
||||
if (g_nvs_config.csi_channel > 0) {
|
||||
/* Explicit NVS override via provision.py --channel */
|
||||
csi_channel = g_nvs_config.csi_channel;
|
||||
ESP_LOGI(TAG, "Using NVS channel override: %u", (unsigned)csi_channel);
|
||||
} else {
|
||||
/* Auto-detect from connected AP */
|
||||
wifi_ap_record_t ap_info;
|
||||
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK && ap_info.primary > 0) {
|
||||
csi_channel = ap_info.primary;
|
||||
ESP_LOGI(TAG, "Auto-detected AP channel: %u", (unsigned)csi_channel);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Could not detect AP channel, using Kconfig default: %u",
|
||||
(unsigned)csi_channel);
|
||||
}
|
||||
}
|
||||
|
||||
/* Update the hop table's first channel to match. */
|
||||
s_hop_channels[0] = csi_channel;
|
||||
|
||||
/* Enable promiscuous mode — required for reliable CSI callbacks.
|
||||
* Without this, CSI only fires on frames destined to this station,
|
||||
* which may be very infrequent on a quiet network. */
|
||||
@@ -230,8 +265,15 @@ void csi_collector_init(void)
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_csi(true));
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%d)",
|
||||
CONFIG_CSI_NODE_ID, CONFIG_CSI_WIFI_CHANNEL);
|
||||
if (g_nvs_config.filter_mac_set) {
|
||||
ESP_LOGI(TAG, "MAC filter active: %02x:%02x:%02x:%02x:%02x:%02x",
|
||||
g_nvs_config.filter_mac[0], g_nvs_config.filter_mac[1],
|
||||
g_nvs_config.filter_mac[2], g_nvs_config.filter_mac[3],
|
||||
g_nvs_config.filter_mac[4], g_nvs_config.filter_mac[5]);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%u)",
|
||||
CONFIG_CSI_NODE_ID, (unsigned)csi_channel);
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
#include "display_task.h"
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "mock_csi.h"
|
||||
#endif
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
@@ -134,17 +137,35 @@ void app_main(void)
|
||||
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — Node ID: %d", g_nvs_config.node_id);
|
||||
|
||||
/* Initialize WiFi STA */
|
||||
/* Initialize WiFi STA (skip entirely under QEMU mock — no RF hardware) */
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
wifi_init_sta();
|
||||
#else
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping WiFi init (CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT)");
|
||||
#endif
|
||||
|
||||
/* Initialize UDP sender with runtime target */
|
||||
#ifdef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping UDP sender init (no network)");
|
||||
#else
|
||||
if (stream_sender_init_with(g_nvs_config.target_ip, g_nvs_config.target_port) != 0) {
|
||||
ESP_LOGE(TAG, "Failed to initialize UDP sender");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Initialize CSI collection */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
/* ADR-061: Start mock CSI generator (replaces real WiFi CSI in QEMU) */
|
||||
esp_err_t mock_ret = mock_csi_init(CONFIG_CSI_MOCK_SCENARIO);
|
||||
if (mock_ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Mock CSI init failed: %s", esp_err_to_name(mock_ret));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
|
||||
}
|
||||
#else
|
||||
csi_collector_init();
|
||||
#endif
|
||||
|
||||
/* ADR-039: Initialize edge processing pipeline. */
|
||||
edge_config_t edge_cfg = {
|
||||
@@ -162,12 +183,17 @@ void app_main(void)
|
||||
esp_err_to_name(edge_ret));
|
||||
}
|
||||
|
||||
/* Initialize OTA update HTTP server. */
|
||||
/* Initialize OTA update HTTP server (requires network). */
|
||||
httpd_handle_t ota_server = NULL;
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
esp_err_t ota_ret = ota_update_init_ex(&ota_server);
|
||||
if (ota_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "OTA server init failed: %s", esp_err_to_name(ota_ret));
|
||||
}
|
||||
#else
|
||||
esp_err_t ota_ret = ESP_ERR_NOT_SUPPORTED;
|
||||
ESP_LOGI(TAG, "Mock CSI mode: skipping OTA server (no network)");
|
||||
#endif
|
||||
|
||||
/* ADR-040: Initialize WASM programmable sensing runtime. */
|
||||
esp_err_t wasm_ret = wasm_runtime_init();
|
||||
@@ -205,10 +231,12 @@ void app_main(void)
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
/* ADR-045: Start AMOLED display task (gracefully skips if no display). */
|
||||
#ifdef CONFIG_DISPLAY_ENABLE
|
||||
esp_err_t disp_ret = display_task_start();
|
||||
if (disp_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Display init returned: %s", esp_err_to_name(disp_ret));
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* @file mock_csi.c
|
||||
* @brief ADR-061 Mock CSI generator for ESP32-S3 QEMU testing.
|
||||
*
|
||||
* Generates synthetic CSI frames at 20 Hz using an esp_timer callback,
|
||||
* injecting them directly into the edge processing pipeline. This allows
|
||||
* full-stack testing of the CSI signal processing, vitals extraction,
|
||||
* and presence detection pipeline under QEMU without WiFi hardware.
|
||||
*
|
||||
* Signal model per subcarrier k at time t:
|
||||
* A_k(t) = A_base + A_person * exp(-d_k^2 / sigma^2) + noise
|
||||
* phi_k(t) = phi_base + (2*pi*d / lambda) + breathing_mod(t) + noise
|
||||
*
|
||||
* The entire file is guarded by CONFIG_CSI_MOCK_ENABLED so it compiles
|
||||
* to nothing on production builds.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
|
||||
#include "mock_csi.h"
|
||||
#include "edge_processing.h"
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "mock_csi";
|
||||
|
||||
/* ---- Configuration defaults ---- */
|
||||
|
||||
/** Scenario duration in ms. Kconfig-overridable. */
|
||||
#ifndef CONFIG_CSI_MOCK_SCENARIO_DURATION_MS
|
||||
#define CONFIG_CSI_MOCK_SCENARIO_DURATION_MS 5000
|
||||
#endif
|
||||
|
||||
/* ---- Physical constants ---- */
|
||||
|
||||
#define SPEED_OF_LIGHT_MHZ 300.0f /**< c in m * MHz (simplified). */
|
||||
#define FREQ_CH6_MHZ 2437.0f /**< Center frequency of WiFi channel 6. */
|
||||
#define LAMBDA_CH6 (SPEED_OF_LIGHT_MHZ / FREQ_CH6_MHZ) /**< ~0.123 m */
|
||||
|
||||
/** Breathing rate: ~15 breaths/min = 0.25 Hz. */
|
||||
#define BREATHING_FREQ_HZ 0.25f
|
||||
|
||||
/** Breathing modulation amplitude in radians. */
|
||||
#define BREATHING_AMP_RAD 0.3f
|
||||
|
||||
/** Walking speed in m/s. */
|
||||
#define WALK_SPEED_MS 1.0f
|
||||
|
||||
/** Room width for position wrapping (meters). */
|
||||
#define ROOM_WIDTH_M 6.0f
|
||||
|
||||
/** Gaussian sigma for person influence on subcarriers. */
|
||||
#define PERSON_SIGMA 8.0f
|
||||
|
||||
/** Base amplitude for all subcarriers. */
|
||||
#define A_BASE 80.0f
|
||||
|
||||
/** Person-induced amplitude perturbation. */
|
||||
#define A_PERSON 40.0f
|
||||
|
||||
/** Noise amplitude (peak). */
|
||||
#define NOISE_AMP 3.0f
|
||||
|
||||
/** Phase noise amplitude (radians). */
|
||||
#define PHASE_NOISE_AMP 0.05f
|
||||
|
||||
/** Number of frames in the ring overflow burst (scenario 7). */
|
||||
#define OVERFLOW_BURST_COUNT 1000
|
||||
|
||||
/** Fall detection: number of frames with abrupt phase jump. */
|
||||
#define FALL_FRAME_COUNT 5
|
||||
|
||||
/** Fall phase acceleration magnitude (radians). */
|
||||
#define FALL_PHASE_JUMP 3.14f
|
||||
|
||||
/** Pi constant. */
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
/* ---- Channel sweep table ---- */
|
||||
|
||||
static const uint8_t s_sweep_channels[] = {1, 6, 11, 36};
|
||||
#define SWEEP_CHANNEL_COUNT (sizeof(s_sweep_channels) / sizeof(s_sweep_channels[0]))
|
||||
|
||||
/* ---- MAC addresses for filter test ---- */
|
||||
|
||||
/** "Correct" MAC that matches a typical filter_mac. */
|
||||
static const uint8_t s_good_mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
|
||||
|
||||
/** "Wrong" MAC that should be rejected by the filter. */
|
||||
static const uint8_t s_bad_mac[6] __attribute__((unused)) = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
|
||||
/* ---- LFSR pseudo-random number generator ---- */
|
||||
|
||||
/**
|
||||
* 32-bit Galois LFSR for deterministic pseudo-random noise.
|
||||
* Avoids stdlib rand() which may not be available on ESP32 bare-metal.
|
||||
* Taps: bits 32, 31, 29, 1 (Galois LFSR polynomial 0xD0000001).
|
||||
*/
|
||||
static uint32_t s_lfsr = 0xDEADBEEF;
|
||||
|
||||
static uint32_t lfsr_next(void)
|
||||
{
|
||||
uint32_t lsb = s_lfsr & 1u;
|
||||
s_lfsr >>= 1;
|
||||
if (lsb) {
|
||||
s_lfsr ^= 0xD0000001u; /* x^32 + x^31 + x^29 + x^1 */
|
||||
}
|
||||
return s_lfsr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a pseudo-random float in [-1.0, +1.0].
|
||||
*/
|
||||
static float lfsr_float(void)
|
||||
{
|
||||
uint32_t r = lfsr_next();
|
||||
/* Map [0, 65535] to [-1.0, +1.0] using 65535/2 = 32767.5 */
|
||||
return ((float)(r & 0xFFFF) / 32768.0f) - 1.0f;
|
||||
}
|
||||
|
||||
/* ---- Module state ---- */
|
||||
|
||||
static mock_state_t s_state;
|
||||
static esp_timer_handle_t s_timer = NULL;
|
||||
|
||||
/** Tracks whether the MAC filter has been set up in gen_mac_filter. */
|
||||
static bool s_mac_filter_initialized = false;
|
||||
|
||||
/** Tracks whether the overflow burst has fired in gen_ring_overflow. */
|
||||
static bool s_overflow_burst_done = false;
|
||||
|
||||
/* External NVS config (for MAC filter scenario). */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
|
||||
/* ---- Helper: compute channel frequency ---- */
|
||||
|
||||
static uint32_t channel_to_freq_mhz(uint8_t channel)
|
||||
{
|
||||
if (channel >= 1 && channel <= 13) {
|
||||
return 2412 + (channel - 1) * 5;
|
||||
} else if (channel == 14) {
|
||||
return 2484;
|
||||
} else if (channel >= 36 && channel <= 177) {
|
||||
return 5000 + channel * 5;
|
||||
}
|
||||
return 2437; /* Default to ch 6. */
|
||||
}
|
||||
|
||||
/* ---- Helper: compute wavelength for a channel ---- */
|
||||
|
||||
static float channel_to_lambda(uint8_t channel)
|
||||
{
|
||||
float freq = (float)channel_to_freq_mhz(channel);
|
||||
return SPEED_OF_LIGHT_MHZ / freq;
|
||||
}
|
||||
|
||||
/* ---- Helper: elapsed ms since scenario start ---- */
|
||||
|
||||
static int64_t scenario_elapsed_ms(void)
|
||||
{
|
||||
int64_t now = esp_timer_get_time() / 1000;
|
||||
return now - s_state.scenario_start_ms;
|
||||
}
|
||||
|
||||
/* ---- Helper: clamp int8 ---- */
|
||||
|
||||
static int8_t clamp_i8(int32_t val)
|
||||
{
|
||||
if (val < -128) return -128;
|
||||
if (val > 127) return 127;
|
||||
return (int8_t)val;
|
||||
}
|
||||
|
||||
/* ---- Core signal generation ---- */
|
||||
|
||||
/**
|
||||
* Generate one I/Q frame for a single person at position person_x.
|
||||
*
|
||||
* @param iq_buf Output buffer (MOCK_IQ_LEN bytes).
|
||||
* @param person_x Person X position in meters.
|
||||
* @param breathing Breathing phase in radians.
|
||||
* @param has_person Whether a person is present.
|
||||
* @param lambda Wavelength in meters.
|
||||
*/
|
||||
static void generate_person_iq(uint8_t *iq_buf, float person_x,
|
||||
float breathing, bool has_person,
|
||||
float lambda)
|
||||
{
|
||||
for (int k = 0; k < MOCK_N_SUBCARRIERS; k++) {
|
||||
/* Distance of subcarrier k's spatial sample from person. */
|
||||
float d_k = (float)k - person_x * (MOCK_N_SUBCARRIERS / ROOM_WIDTH_M);
|
||||
|
||||
/* Amplitude model. */
|
||||
float amp = A_BASE;
|
||||
if (has_person) {
|
||||
float gauss = expf(-(d_k * d_k) / (2.0f * PERSON_SIGMA * PERSON_SIGMA));
|
||||
amp += A_PERSON * gauss;
|
||||
}
|
||||
amp += NOISE_AMP * lfsr_float();
|
||||
|
||||
/* Phase model. */
|
||||
float phase = (float)k * 0.1f; /* Base phase gradient. */
|
||||
if (has_person) {
|
||||
float d_meters = fabsf(d_k) * (ROOM_WIDTH_M / MOCK_N_SUBCARRIERS);
|
||||
phase += (2.0f * M_PI * d_meters) / lambda;
|
||||
phase += BREATHING_AMP_RAD * sinf(breathing);
|
||||
}
|
||||
phase += PHASE_NOISE_AMP * lfsr_float();
|
||||
|
||||
/* Convert to I/Q (int8). */
|
||||
float i_f = amp * cosf(phase);
|
||||
float q_f = amp * sinf(phase);
|
||||
|
||||
iq_buf[k * 2] = (uint8_t)clamp_i8((int32_t)i_f);
|
||||
iq_buf[k * 2 + 1] = (uint8_t)clamp_i8((int32_t)q_f);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Scenario generators ---- */
|
||||
|
||||
/**
|
||||
* Scenario 0: Empty room.
|
||||
* Low-amplitude noise on all subcarriers, no person present.
|
||||
*/
|
||||
static void gen_empty(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
generate_person_iq(iq_buf, 0.0f, 0.0f, false, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = -60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 1: Static person.
|
||||
* Person at fixed position with breathing modulation.
|
||||
*/
|
||||
static void gen_static_person(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
s_state.breathing_phase += 2.0f * M_PI * BREATHING_FREQ_HZ
|
||||
* (MOCK_CSI_INTERVAL_MS / 1000.0f);
|
||||
if (s_state.breathing_phase > 2.0f * M_PI) {
|
||||
s_state.breathing_phase -= 2.0f * M_PI;
|
||||
}
|
||||
|
||||
generate_person_iq(iq_buf, 3.0f, s_state.breathing_phase, true, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = -45;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 2: Walking person.
|
||||
* Person moves across the room and wraps around.
|
||||
*/
|
||||
static void gen_walking(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
s_state.breathing_phase += 2.0f * M_PI * BREATHING_FREQ_HZ
|
||||
* (MOCK_CSI_INTERVAL_MS / 1000.0f);
|
||||
if (s_state.breathing_phase > 2.0f * M_PI) {
|
||||
s_state.breathing_phase -= 2.0f * M_PI;
|
||||
}
|
||||
|
||||
s_state.person_x += s_state.person_speed * (MOCK_CSI_INTERVAL_MS / 1000.0f);
|
||||
if (s_state.person_x > ROOM_WIDTH_M) {
|
||||
s_state.person_x -= ROOM_WIDTH_M;
|
||||
}
|
||||
|
||||
generate_person_iq(iq_buf, s_state.person_x, s_state.breathing_phase,
|
||||
true, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = -40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 3: Fall event.
|
||||
* Normal walking for most frames, then an abrupt phase discontinuity
|
||||
* simulating a fall (rapid vertical displacement).
|
||||
*/
|
||||
static void gen_fall(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
int64_t elapsed = scenario_elapsed_ms();
|
||||
uint32_t duration = CONFIG_CSI_MOCK_SCENARIO_DURATION_MS;
|
||||
|
||||
/* Fall occurs at 70% of scenario duration. */
|
||||
uint32_t fall_start = (duration * 70) / 100;
|
||||
uint32_t fall_end = fall_start + (FALL_FRAME_COUNT * MOCK_CSI_INTERVAL_MS);
|
||||
|
||||
s_state.breathing_phase += 2.0f * M_PI * BREATHING_FREQ_HZ
|
||||
* (MOCK_CSI_INTERVAL_MS / 1000.0f);
|
||||
|
||||
s_state.person_x += 0.5f * (MOCK_CSI_INTERVAL_MS / 1000.0f);
|
||||
if (s_state.person_x > ROOM_WIDTH_M) {
|
||||
s_state.person_x = ROOM_WIDTH_M;
|
||||
}
|
||||
|
||||
float extra_phase = 0.0f;
|
||||
if (elapsed >= fall_start && elapsed < fall_end) {
|
||||
/* Abrupt phase jump simulating rapid downward motion. */
|
||||
extra_phase = FALL_PHASE_JUMP;
|
||||
}
|
||||
|
||||
/* Build I/Q with fall perturbation. */
|
||||
float lambda = LAMBDA_CH6;
|
||||
for (int k = 0; k < MOCK_N_SUBCARRIERS; k++) {
|
||||
float d_k = (float)k - s_state.person_x * (MOCK_N_SUBCARRIERS / ROOM_WIDTH_M);
|
||||
float gauss = expf(-(d_k * d_k) / (2.0f * PERSON_SIGMA * PERSON_SIGMA));
|
||||
|
||||
float amp = A_BASE + A_PERSON * gauss + NOISE_AMP * lfsr_float();
|
||||
|
||||
float d_meters = fabsf(d_k) * (ROOM_WIDTH_M / MOCK_N_SUBCARRIERS);
|
||||
float phase = (float)k * 0.1f
|
||||
+ (2.0f * M_PI * d_meters) / lambda
|
||||
+ BREATHING_AMP_RAD * sinf(s_state.breathing_phase)
|
||||
+ extra_phase * gauss /* Fall affects nearby subcarriers. */
|
||||
+ PHASE_NOISE_AMP * lfsr_float();
|
||||
|
||||
iq_buf[k * 2] = (uint8_t)clamp_i8((int32_t)(amp * cosf(phase)));
|
||||
iq_buf[k * 2 + 1] = (uint8_t)clamp_i8((int32_t)(amp * sinf(phase)));
|
||||
}
|
||||
|
||||
*channel = 6;
|
||||
*rssi = -42;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 4: Multiple people.
|
||||
* Two people at different positions with independent breathing.
|
||||
*/
|
||||
static void gen_multi_person(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
float dt = MOCK_CSI_INTERVAL_MS / 1000.0f;
|
||||
|
||||
s_state.breathing_phase += 2.0f * M_PI * BREATHING_FREQ_HZ * dt;
|
||||
float breathing2 = s_state.breathing_phase * 1.3f; /* Slightly different rate. */
|
||||
|
||||
s_state.person_x += s_state.person_speed * dt;
|
||||
s_state.person2_x += s_state.person2_speed * dt;
|
||||
|
||||
/* Wrap positions. */
|
||||
if (s_state.person_x > ROOM_WIDTH_M) s_state.person_x -= ROOM_WIDTH_M;
|
||||
if (s_state.person2_x > ROOM_WIDTH_M) s_state.person2_x -= ROOM_WIDTH_M;
|
||||
|
||||
float lambda = LAMBDA_CH6;
|
||||
|
||||
for (int k = 0; k < MOCK_N_SUBCARRIERS; k++) {
|
||||
/* Superpose contributions from both people. */
|
||||
float d1 = (float)k - s_state.person_x * (MOCK_N_SUBCARRIERS / ROOM_WIDTH_M);
|
||||
float d2 = (float)k - s_state.person2_x * (MOCK_N_SUBCARRIERS / ROOM_WIDTH_M);
|
||||
|
||||
float g1 = expf(-(d1 * d1) / (2.0f * PERSON_SIGMA * PERSON_SIGMA));
|
||||
float g2 = expf(-(d2 * d2) / (2.0f * PERSON_SIGMA * PERSON_SIGMA));
|
||||
|
||||
float amp = A_BASE + A_PERSON * g1 + (A_PERSON * 0.7f) * g2
|
||||
+ NOISE_AMP * lfsr_float();
|
||||
|
||||
float dm1 = fabsf(d1) * (ROOM_WIDTH_M / MOCK_N_SUBCARRIERS);
|
||||
float dm2 = fabsf(d2) * (ROOM_WIDTH_M / MOCK_N_SUBCARRIERS);
|
||||
|
||||
float phase = (float)k * 0.1f
|
||||
+ (2.0f * M_PI * dm1) / lambda * g1
|
||||
+ (2.0f * M_PI * dm2) / lambda * g2
|
||||
+ BREATHING_AMP_RAD * sinf(s_state.breathing_phase) * g1
|
||||
+ BREATHING_AMP_RAD * sinf(breathing2) * g2
|
||||
+ PHASE_NOISE_AMP * lfsr_float();
|
||||
|
||||
iq_buf[k * 2] = (uint8_t)clamp_i8((int32_t)(amp * cosf(phase)));
|
||||
iq_buf[k * 2 + 1] = (uint8_t)clamp_i8((int32_t)(amp * sinf(phase)));
|
||||
}
|
||||
|
||||
*channel = 6;
|
||||
*rssi = -38;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 5: Channel sweep.
|
||||
* Cycles through channels 1, 6, 11, 36 every 20 frames.
|
||||
*/
|
||||
static void gen_channel_sweep(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
/* Switch channel every 20 frames (1 second at 20 Hz). */
|
||||
if ((s_state.frame_count % 20) == 0 && s_state.frame_count > 0) {
|
||||
s_state.channel_idx = (s_state.channel_idx + 1) % SWEEP_CHANNEL_COUNT;
|
||||
}
|
||||
|
||||
uint8_t ch = s_sweep_channels[s_state.channel_idx];
|
||||
float lambda = channel_to_lambda(ch);
|
||||
|
||||
generate_person_iq(iq_buf, 3.0f, 0.0f, true, lambda);
|
||||
*channel = ch;
|
||||
*rssi = -50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 6: MAC filter test.
|
||||
* Alternates between a "good" MAC (should pass filter) and a "bad" MAC
|
||||
* (should be rejected). Even frames use good MAC, odd frames use bad MAC.
|
||||
*
|
||||
* Note: Since we inject via edge_enqueue_csi() which bypasses the MAC
|
||||
* filter (that happens in wifi_csi_callback), this scenario instead
|
||||
* sets/clears the NVS filter_mac and logs which frames would pass.
|
||||
* The test harness can verify frame_count vs expected.
|
||||
*/
|
||||
static void gen_mac_filter(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi,
|
||||
bool *skip_inject)
|
||||
{
|
||||
/* Set up the filter MAC to match s_good_mac on first frame of this scenario. */
|
||||
if (!s_mac_filter_initialized) {
|
||||
memcpy(g_nvs_config.filter_mac, s_good_mac, 6);
|
||||
g_nvs_config.filter_mac_set = 1;
|
||||
s_mac_filter_initialized = true;
|
||||
ESP_LOGI(TAG, "MAC filter scenario: filter set to %02X:%02X:%02X:%02X:%02X:%02X",
|
||||
s_good_mac[0], s_good_mac[1], s_good_mac[2],
|
||||
s_good_mac[3], s_good_mac[4], s_good_mac[5]);
|
||||
}
|
||||
|
||||
generate_person_iq(iq_buf, 3.0f, 0.0f, true, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = -50;
|
||||
|
||||
/* Odd frames: simulate "wrong" MAC by skipping injection. */
|
||||
if ((s_state.frame_count & 1) != 0) {
|
||||
*skip_inject = true;
|
||||
ESP_LOGD(TAG, "MAC filter: frame %lu skipped (bad MAC)",
|
||||
(unsigned long)s_state.frame_count);
|
||||
} else {
|
||||
*skip_inject = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 7: Ring buffer overflow.
|
||||
* Burst OVERFLOW_BURST_COUNT frames as fast as possible to test
|
||||
* the SPSC ring buffer's overflow handling.
|
||||
*/
|
||||
static void gen_ring_overflow(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi,
|
||||
uint16_t *burst_count)
|
||||
{
|
||||
generate_person_iq(iq_buf, 3.0f, 0.0f, true, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = -50;
|
||||
|
||||
/* Burst once on the first timer tick of this scenario. */
|
||||
if (!s_overflow_burst_done) {
|
||||
*burst_count = OVERFLOW_BURST_COUNT;
|
||||
s_overflow_burst_done = true;
|
||||
} else {
|
||||
*burst_count = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 8: Boundary RSSI sweep.
|
||||
* Sweeps RSSI from -90 dBm to -10 dBm linearly over the scenario duration.
|
||||
*/
|
||||
static void gen_boundary_rssi(uint8_t *iq_buf, uint8_t *channel, int8_t *rssi)
|
||||
{
|
||||
int64_t elapsed = scenario_elapsed_ms();
|
||||
uint32_t duration = CONFIG_CSI_MOCK_SCENARIO_DURATION_MS;
|
||||
|
||||
/* Linear sweep: -90 to -10 dBm. */
|
||||
float frac = (float)elapsed / (float)duration;
|
||||
if (frac > 1.0f) frac = 1.0f;
|
||||
int8_t sweep_rssi = (int8_t)(-90.0f + 80.0f * frac);
|
||||
|
||||
generate_person_iq(iq_buf, 3.0f, 0.0f, true, LAMBDA_CH6);
|
||||
*channel = 6;
|
||||
*rssi = sweep_rssi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 9: Zero-length I/Q.
|
||||
* Injects a frame with iq_len = 0 to test error handling.
|
||||
*/
|
||||
/* Handled inline in the timer callback. */
|
||||
|
||||
/* ---- Scenario transition ---- */
|
||||
|
||||
/**
|
||||
* Advance to the next scenario when running SCENARIO_ALL.
|
||||
*/
|
||||
/** Flag: set when all scenarios are done so timer callback exits early. */
|
||||
static bool s_all_done = false;
|
||||
|
||||
static void advance_scenario(void)
|
||||
{
|
||||
s_state.all_idx++;
|
||||
if (s_state.all_idx >= MOCK_SCENARIO_COUNT) {
|
||||
ESP_LOGI(TAG, "All %d scenarios complete (%lu total frames)",
|
||||
MOCK_SCENARIO_COUNT, (unsigned long)s_state.frame_count);
|
||||
s_all_done = true;
|
||||
return; /* Stop generating — timer callback will check s_all_done. */
|
||||
}
|
||||
|
||||
s_state.scenario = s_state.all_idx;
|
||||
s_state.scenario_start_ms = esp_timer_get_time() / 1000;
|
||||
|
||||
/* Reset per-scenario state. */
|
||||
s_state.person_x = 1.0f;
|
||||
s_state.person_speed = WALK_SPEED_MS;
|
||||
s_state.person2_x = 4.0f;
|
||||
s_state.person2_speed = WALK_SPEED_MS * 0.6f;
|
||||
s_state.breathing_phase = 0.0f;
|
||||
s_state.channel_idx = 0;
|
||||
s_state.rssi_sweep = -90;
|
||||
|
||||
ESP_LOGI(TAG, "=== Scenario %u started ===", (unsigned)s_state.scenario);
|
||||
}
|
||||
|
||||
/* ---- Timer callback ---- */
|
||||
|
||||
static void mock_timer_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
/* All scenarios finished — stop generating. */
|
||||
if (s_all_done) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Check for scenario timeout in SCENARIO_ALL mode. */
|
||||
if (s_state.scenario == MOCK_SCENARIO_ALL ||
|
||||
(s_state.all_idx > 0 && s_state.all_idx < MOCK_SCENARIO_COUNT)) {
|
||||
/* We're running in sequential mode. */
|
||||
int64_t elapsed = scenario_elapsed_ms();
|
||||
if (elapsed >= CONFIG_CSI_MOCK_SCENARIO_DURATION_MS) {
|
||||
advance_scenario();
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t iq_buf[MOCK_IQ_LEN];
|
||||
uint8_t channel = 6;
|
||||
int8_t rssi = -50;
|
||||
uint16_t iq_len = MOCK_IQ_LEN;
|
||||
uint16_t burst = 1;
|
||||
bool skip = false;
|
||||
|
||||
uint8_t active_scenario = s_state.scenario;
|
||||
|
||||
switch (active_scenario) {
|
||||
case MOCK_SCENARIO_EMPTY:
|
||||
gen_empty(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_STATIC_PERSON:
|
||||
gen_static_person(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_WALKING:
|
||||
gen_walking(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_FALL:
|
||||
gen_fall(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_MULTI_PERSON:
|
||||
gen_multi_person(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_CHANNEL_SWEEP:
|
||||
gen_channel_sweep(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_MAC_FILTER:
|
||||
gen_mac_filter(iq_buf, &channel, &rssi, &skip);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_RING_OVERFLOW:
|
||||
gen_ring_overflow(iq_buf, &channel, &rssi, &burst);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_BOUNDARY_RSSI:
|
||||
gen_boundary_rssi(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
|
||||
case MOCK_SCENARIO_ZERO_LENGTH:
|
||||
/* Deliberately inject zero-length data to test error path. */
|
||||
iq_len = 0;
|
||||
memset(iq_buf, 0, sizeof(iq_buf));
|
||||
break;
|
||||
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unknown scenario %u, defaulting to empty", active_scenario);
|
||||
gen_empty(iq_buf, &channel, &rssi);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Inject frame(s) into the edge processing pipeline. */
|
||||
if (!skip) {
|
||||
for (uint16_t i = 0; i < burst; i++) {
|
||||
edge_enqueue_csi(iq_buf, iq_len, rssi, channel);
|
||||
s_state.frame_count++;
|
||||
}
|
||||
} else {
|
||||
/* Count skipped frames for MAC filter validation. */
|
||||
s_state.frame_count++;
|
||||
}
|
||||
|
||||
/* Periodic logging (every 20 frames = 1 second). */
|
||||
if ((s_state.frame_count % 20) == 0) {
|
||||
ESP_LOGI(TAG, "scenario=%u frames=%lu ch=%u rssi=%d",
|
||||
active_scenario, (unsigned long)s_state.frame_count,
|
||||
(unsigned)channel, (int)rssi);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
esp_err_t mock_csi_init(uint8_t scenario)
|
||||
{
|
||||
if (s_timer != NULL) {
|
||||
ESP_LOGW(TAG, "Mock CSI already running");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Initialize state. */
|
||||
memset(&s_state, 0, sizeof(s_state));
|
||||
s_state.person_x = 1.0f;
|
||||
s_state.person_speed = WALK_SPEED_MS;
|
||||
s_state.person2_x = 4.0f;
|
||||
s_state.person2_speed = WALK_SPEED_MS * 0.6f;
|
||||
s_state.scenario_start_ms = esp_timer_get_time() / 1000;
|
||||
s_all_done = false;
|
||||
s_mac_filter_initialized = false;
|
||||
s_overflow_burst_done = false;
|
||||
|
||||
/* Reset LFSR to deterministic seed. */
|
||||
s_lfsr = 0xDEADBEEF;
|
||||
|
||||
if (scenario == MOCK_SCENARIO_ALL) {
|
||||
s_state.scenario = 0;
|
||||
s_state.all_idx = 0;
|
||||
ESP_LOGI(TAG, "Mock CSI: running ALL %d scenarios sequentially (%u ms each)",
|
||||
MOCK_SCENARIO_COUNT, CONFIG_CSI_MOCK_SCENARIO_DURATION_MS);
|
||||
} else {
|
||||
s_state.scenario = scenario;
|
||||
s_state.all_idx = 0;
|
||||
ESP_LOGI(TAG, "Mock CSI: scenario=%u, interval=%u ms, duration=%u ms",
|
||||
(unsigned)scenario, MOCK_CSI_INTERVAL_MS,
|
||||
CONFIG_CSI_MOCK_SCENARIO_DURATION_MS);
|
||||
}
|
||||
|
||||
/* Create periodic timer. */
|
||||
esp_timer_create_args_t timer_args = {
|
||||
.callback = mock_timer_cb,
|
||||
.arg = NULL,
|
||||
.name = "mock_csi",
|
||||
};
|
||||
|
||||
esp_err_t err = esp_timer_create(&timer_args, &s_timer);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to create mock CSI timer: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
uint64_t period_us = (uint64_t)MOCK_CSI_INTERVAL_MS * 1000;
|
||||
err = esp_timer_start_periodic(s_timer, period_us);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start mock CSI timer: %s", esp_err_to_name(err));
|
||||
esp_timer_delete(s_timer);
|
||||
s_timer = NULL;
|
||||
return err;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Mock CSI generator started (20 Hz, %u subcarriers, %u bytes/frame)",
|
||||
MOCK_N_SUBCARRIERS, MOCK_IQ_LEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void mock_csi_stop(void)
|
||||
{
|
||||
if (s_timer == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_timer_stop(s_timer);
|
||||
esp_timer_delete(s_timer);
|
||||
s_timer = NULL;
|
||||
|
||||
ESP_LOGI(TAG, "Mock CSI stopped after %lu frames",
|
||||
(unsigned long)s_state.frame_count);
|
||||
}
|
||||
|
||||
uint32_t mock_csi_get_frame_count(void)
|
||||
{
|
||||
return s_state.frame_count;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_CSI_MOCK_ENABLED */
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file mock_csi.h
|
||||
* @brief ADR-061 Mock CSI generator for ESP32-S3 QEMU testing.
|
||||
*
|
||||
* Generates synthetic CSI frames at 20 Hz using an esp_timer, injecting
|
||||
* them directly into the edge processing pipeline via edge_enqueue_csi().
|
||||
* Ten scenarios exercise the full signal processing and edge intelligence
|
||||
* pipeline without requiring real WiFi hardware.
|
||||
*
|
||||
* Signal model per subcarrier k at time t:
|
||||
* A_k(t) = A_base + A_person * exp(-d_k^2 / sigma^2) + noise
|
||||
* phi_k(t) = phi_base + (2*pi*d / lambda) + breathing_mod(t) + noise
|
||||
*
|
||||
* Enable via: idf.py menuconfig -> CSI Mock Generator -> Enable
|
||||
* Or add CONFIG_CSI_MOCK_ENABLED=y to sdkconfig.defaults.
|
||||
*/
|
||||
|
||||
#ifndef MOCK_CSI_H
|
||||
#define MOCK_CSI_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Timing ---- */
|
||||
|
||||
/** Mock CSI frame interval in milliseconds (20 Hz). */
|
||||
#define MOCK_CSI_INTERVAL_MS 50
|
||||
|
||||
/* ---- HT20 subcarrier geometry ---- */
|
||||
|
||||
/** Number of OFDM subcarriers for HT20 (802.11n). */
|
||||
#define MOCK_N_SUBCARRIERS 52
|
||||
|
||||
/** I/Q data length in bytes: 52 subcarriers * 2 bytes (I + Q). */
|
||||
#define MOCK_IQ_LEN (MOCK_N_SUBCARRIERS * 2)
|
||||
|
||||
/* ---- Scenarios ---- */
|
||||
|
||||
/** Scenario identifiers for mock CSI generation. */
|
||||
typedef enum {
|
||||
MOCK_SCENARIO_EMPTY = 0, /**< Empty room: low-noise baseline. */
|
||||
MOCK_SCENARIO_STATIC_PERSON = 1, /**< Static person: amplitude dip, no motion. */
|
||||
MOCK_SCENARIO_WALKING = 2, /**< Walking person: moving reflector. */
|
||||
MOCK_SCENARIO_FALL = 3, /**< Fall event: abrupt phase acceleration. */
|
||||
MOCK_SCENARIO_MULTI_PERSON = 4, /**< Multiple people at different positions. */
|
||||
MOCK_SCENARIO_CHANNEL_SWEEP = 5, /**< Sweep through channels 1, 6, 11, 36. */
|
||||
MOCK_SCENARIO_MAC_FILTER = 6, /**< Alternate correct/wrong MAC for filter test. */
|
||||
MOCK_SCENARIO_RING_OVERFLOW = 7, /**< Burst 1000 frames rapidly to overflow ring. */
|
||||
MOCK_SCENARIO_BOUNDARY_RSSI = 8, /**< Sweep RSSI from -90 to -10 dBm. */
|
||||
MOCK_SCENARIO_ZERO_LENGTH = 9, /**< Zero-length I/Q payload (error case). */
|
||||
|
||||
MOCK_SCENARIO_COUNT = 10, /**< Total number of individual scenarios. */
|
||||
MOCK_SCENARIO_ALL = 255 /**< Meta: run all scenarios sequentially. */
|
||||
} mock_scenario_t;
|
||||
|
||||
/* ---- State ---- */
|
||||
|
||||
/** Internal state for the mock CSI generator. */
|
||||
typedef struct {
|
||||
uint8_t scenario; /**< Current active scenario. */
|
||||
uint32_t frame_count; /**< Total frames emitted since init. */
|
||||
float person_x; /**< Person X position in meters (walking). */
|
||||
float person_speed; /**< Person movement speed in m/s. */
|
||||
float breathing_phase; /**< Breathing oscillator phase in radians. */
|
||||
float person2_x; /**< Second person X position (multi-person). */
|
||||
float person2_speed; /**< Second person movement speed. */
|
||||
uint8_t channel_idx; /**< Index into channel sweep table. */
|
||||
int8_t rssi_sweep; /**< Current RSSI for boundary sweep. */
|
||||
int64_t scenario_start_ms; /**< Timestamp when current scenario started. */
|
||||
uint8_t all_idx; /**< Current scenario index in SCENARIO_ALL mode. */
|
||||
} mock_state_t;
|
||||
|
||||
/**
|
||||
* Initialize and start the mock CSI generator.
|
||||
*
|
||||
* Creates a periodic esp_timer that fires every MOCK_CSI_INTERVAL_MS
|
||||
* and injects synthetic CSI frames into edge_enqueue_csi().
|
||||
*
|
||||
* @param scenario Scenario to run (0-9), or MOCK_SCENARIO_ALL (255)
|
||||
* to run all scenarios sequentially.
|
||||
* @return ESP_OK on success, ESP_ERR_INVALID_STATE if already running.
|
||||
*/
|
||||
esp_err_t mock_csi_init(uint8_t scenario);
|
||||
|
||||
/**
|
||||
* Stop and destroy the mock CSI timer.
|
||||
*
|
||||
* Safe to call even if the timer is not running.
|
||||
*/
|
||||
void mock_csi_stop(void);
|
||||
|
||||
/**
|
||||
* Get the total number of mock frames emitted since init.
|
||||
*
|
||||
* @return Frame count (useful for test validation).
|
||||
*/
|
||||
uint32_t mock_csi_get_frame_count(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* MOCK_CSI_H */
|
||||
@@ -91,6 +91,11 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->wasm_verify = 0; /* Kconfig disabled signature verification. */
|
||||
#endif
|
||||
|
||||
/* ADR-060: Channel override and MAC filter defaults. */
|
||||
cfg->csi_channel = 0; /* 0 = auto-detect from connected AP. */
|
||||
cfg->filter_mac_set = 0;
|
||||
memset(cfg->filter_mac, 0, 6);
|
||||
|
||||
/* Try to override from NVS */
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
|
||||
@@ -277,6 +282,26 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
ESP_LOGW(TAG, "wasm_verify=1 but no wasm_pubkey in NVS — uploads will be rejected");
|
||||
}
|
||||
|
||||
/* ADR-060: CSI channel override. */
|
||||
uint8_t csi_ch_val;
|
||||
if (nvs_get_u8(handle, "csi_channel", &csi_ch_val) == ESP_OK) {
|
||||
if ((csi_ch_val >= 1 && csi_ch_val <= 14) || (csi_ch_val >= 36 && csi_ch_val <= 177)) {
|
||||
cfg->csi_channel = csi_ch_val;
|
||||
ESP_LOGI(TAG, "NVS override: csi_channel=%u", (unsigned)cfg->csi_channel);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "NVS csi_channel=%u invalid, ignored", (unsigned)csi_ch_val);
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-060: MAC address filter (6-byte blob). */
|
||||
size_t mac_len = 6;
|
||||
if (nvs_get_blob(handle, "filter_mac", cfg->filter_mac, &mac_len) == ESP_OK && mac_len == 6) {
|
||||
cfg->filter_mac_set = 1;
|
||||
ESP_LOGI(TAG, "NVS override: filter_mac=%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]);
|
||||
}
|
||||
|
||||
/* 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",
|
||||
|
||||
@@ -50,6 +50,11 @@ typedef struct {
|
||||
uint8_t wasm_verify; /**< Require Ed25519 signature for uploads. */
|
||||
uint8_t wasm_pubkey[32]; /**< Ed25519 public key for WASM signature. */
|
||||
uint8_t wasm_pubkey_valid; /**< 1 if pubkey was loaded from NVS. */
|
||||
|
||||
/* ADR-060: Channel override and MAC address filtering */
|
||||
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
|
||||
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
|
||||
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
|
||||
} nvs_config_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,13 @@ def build_nvs_csv(args):
|
||||
writer.writerow(["vital_int", "data", "u16", str(args.vital_int)])
|
||||
if args.subk_count is not None:
|
||||
writer.writerow(["subk_count", "data", "u8", str(args.subk_count)])
|
||||
# ADR-060: Channel override and MAC filter
|
||||
if args.channel is not None:
|
||||
writer.writerow(["csi_channel", "data", "u8", str(args.channel)])
|
||||
if args.filter_mac is not None:
|
||||
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
|
||||
# NVS blob: write as hex-encoded string for CSV compatibility
|
||||
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@@ -165,6 +172,10 @@ def main():
|
||||
parser.add_argument("--vital-win", type=int, help="Phase history window in frames (default: 300)")
|
||||
parser.add_argument("--vital-int", type=int, help="Vitals packet interval in ms (default: 1000)")
|
||||
parser.add_argument("--subk-count", type=int, help="Top-K subcarrier count (default: 32)")
|
||||
# ADR-060: Channel override and MAC filter
|
||||
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
|
||||
"Overrides auto-detection from connected AP.")
|
||||
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -176,6 +187,7 @@ def main():
|
||||
args.edge_tier is not None, args.pres_thresh is not None,
|
||||
args.fall_thresh is not None, args.vital_win is not None,
|
||||
args.vital_int is not None, args.subk_count is not None,
|
||||
args.channel is not None, args.filter_mac is not None,
|
||||
])
|
||||
if not has_value:
|
||||
parser.error("At least one config value must be specified")
|
||||
@@ -186,6 +198,22 @@ def main():
|
||||
if args.tdm_slot is not None and args.tdm_slot >= args.tdm_total:
|
||||
parser.error(f"--tdm-slot ({args.tdm_slot}) must be less than --tdm-total ({args.tdm_total})")
|
||||
|
||||
# ADR-060: Validate channel and MAC filter
|
||||
if args.channel is not None:
|
||||
if not ((1 <= args.channel <= 14) or (36 <= args.channel <= 177)):
|
||||
parser.error(f"--channel must be 1-14 (2.4GHz) or 36-177 (5GHz), got {args.channel}")
|
||||
if args.filter_mac is not None:
|
||||
parts = args.filter_mac.split(":")
|
||||
if len(parts) != 6:
|
||||
parser.error(f"--filter-mac must be in AA:BB:CC:DD:EE:FF format, got '{args.filter_mac}'")
|
||||
try:
|
||||
for p in parts:
|
||||
val = int(p, 16)
|
||||
if val < 0 or val > 255:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
parser.error(f"--filter-mac contains invalid hex bytes: '{args.filter_mac}'")
|
||||
|
||||
print("Building NVS configuration:")
|
||||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
@@ -212,6 +240,10 @@ def main():
|
||||
print(f" Vital Interval:{args.vital_int} ms")
|
||||
if args.subk_count is not None:
|
||||
print(f" Top-K Subcarr: {args.subk_count}")
|
||||
if args.channel is not None:
|
||||
print(f" CSI Channel: {args.channel}")
|
||||
if args.filter_mac is not None:
|
||||
print(f" Filter MAC: {args.filter_mac}")
|
||||
|
||||
csv_content = build_nvs_csv(args)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
$p = New-Object System.IO.Ports.SerialPort('COM7', 115200)
|
||||
$p.ReadTimeout = 5000
|
||||
$p.Open()
|
||||
Start-Sleep -Milliseconds 200
|
||||
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
try {
|
||||
$line = $p.ReadLine()
|
||||
Write-Host $line
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
$p.Close()
|
||||
@@ -0,0 +1,54 @@
|
||||
# sdkconfig.coverage -- ESP-IDF sdkconfig overlay for gcov/lcov code coverage
|
||||
#
|
||||
# This overlay enables GCC code coverage instrumentation (gcov) and the
|
||||
# application-level trace (apptrace) channel required to extract .gcda
|
||||
# files from the target via JTAG/QEMU GDB.
|
||||
#
|
||||
# Usage (combine with sdkconfig.defaults as the base):
|
||||
#
|
||||
# idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.coverage" build
|
||||
#
|
||||
# After running the firmware under QEMU, dump coverage data through GDB:
|
||||
#
|
||||
# (gdb) mon gcov dump
|
||||
#
|
||||
# Then process the .gcda files on the host with lcov/genhtml:
|
||||
#
|
||||
# lcov --capture --directory build --output-file coverage.info \
|
||||
# --gcov-tool xtensa-esp-elf-gcov
|
||||
# genhtml coverage.info --output-directory coverage_html
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compiler: disable optimizations so every source line maps 1:1 to object code
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_COMPILER_OPTIMIZATION_NONE=y
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application-level trace: enables the gcov data channel over JTAG
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_APPTRACE_ENABLE=y
|
||||
CONFIG_APPTRACE_DEST_JTAG=y
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSI mock mode: identical to sdkconfig.qemu so coverage runs use the same
|
||||
# deterministic mock data path (no real WiFi hardware needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_CSI_MOCK_ENABLED=y
|
||||
CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT=y
|
||||
CONFIG_CSI_MOCK_SCENARIO=255
|
||||
CONFIG_CSI_TARGET_IP="10.0.2.2"
|
||||
CONFIG_CSI_MOCK_SCENARIO_DURATION_MS=5000
|
||||
CONFIG_CSI_MOCK_LOG_FRAMES=y
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FreeRTOS and watchdog: match sdkconfig.qemu for QEMU timing tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||
CONFIG_ESP_INT_WDT_TIMEOUT_MS=800
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging and display
|
||||
# ---------------------------------------------------------------------------
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_DISPLAY_ENABLE=n
|
||||
@@ -0,0 +1,27 @@
|
||||
# QEMU ESP32-S3 sdkconfig overlay (ADR-061)
|
||||
#
|
||||
# Merge with: idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
|
||||
# ---- Mock CSI generator (replaces real WiFi CSI) ----
|
||||
CONFIG_CSI_MOCK_ENABLED=y
|
||||
CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT=y
|
||||
CONFIG_CSI_MOCK_SCENARIO=255
|
||||
CONFIG_CSI_MOCK_SCENARIO_DURATION_MS=5000
|
||||
CONFIG_CSI_MOCK_LOG_FRAMES=y
|
||||
|
||||
# ---- Network (QEMU SLIRP provides 10.0.2.x) ----
|
||||
CONFIG_CSI_TARGET_IP="10.0.2.2"
|
||||
|
||||
# ---- Logging (verbose for validation) ----
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
|
||||
# ---- FreeRTOS tuning for QEMU ----
|
||||
# Increase timer task stack to prevent overflow from mock_csi timer callback
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=4096
|
||||
|
||||
# ---- Watchdog (relaxed for emulation — QEMU timing is not cycle-accurate) ----
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||
CONFIG_ESP_INT_WDT_TIMEOUT_MS=800
|
||||
|
||||
# ---- Disable hardware-dependent features ----
|
||||
CONFIG_DISPLAY_ENABLE=n
|
||||
@@ -0,0 +1,79 @@
|
||||
# Makefile for ESP32 CSI firmware fuzz testing targets (ADR-061 Layer 6).
|
||||
#
|
||||
# Requirements:
|
||||
# - clang with libFuzzer support (clang 6.0+)
|
||||
# - Linux or macOS (host-based fuzzing, no ESP-IDF needed)
|
||||
#
|
||||
# Usage:
|
||||
# make all # Build all fuzz targets
|
||||
# make fuzz_serialize # Build serialize target only
|
||||
# make fuzz_edge # Build edge enqueue target only
|
||||
# make fuzz_nvs # Build NVS config target only
|
||||
# make run_serialize # Build and run serialize fuzzer (30s)
|
||||
# make run_edge # Build and run edge fuzzer (30s)
|
||||
# make run_nvs # Build and run NVS fuzzer (30s)
|
||||
# make run_all # Run all fuzzers (30s each)
|
||||
# make clean # Remove build artifacts
|
||||
#
|
||||
# Environment variables:
|
||||
# FUZZ_DURATION=60 # Override fuzz duration in seconds
|
||||
# FUZZ_JOBS=4 # Parallel fuzzing jobs
|
||||
|
||||
CC = clang
|
||||
CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
|
||||
-Istubs -I../main \
|
||||
-DCONFIG_CSI_NODE_ID=1 \
|
||||
-DCONFIG_CSI_WIFI_CHANNEL=6 \
|
||||
-DCONFIG_CSI_WIFI_SSID=\"test\" \
|
||||
-DCONFIG_CSI_TARGET_IP=\"192.168.1.1\" \
|
||||
-DCONFIG_CSI_TARGET_PORT=5500 \
|
||||
-DCONFIG_ESP_WIFI_CSI_ENABLED=1 \
|
||||
-Wno-unused-function
|
||||
|
||||
STUBS_SRC = stubs/esp_stubs.c
|
||||
MAIN_DIR = ../main
|
||||
|
||||
# Default fuzz duration (seconds) and jobs
|
||||
FUZZ_DURATION ?= 30
|
||||
FUZZ_JOBS ?= 1
|
||||
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
# Links against the real csi_collector.c (with stubs for ESP-IDF).
|
||||
fuzz_serialize: fuzz_csi_serialize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
|
||||
$(CC) $(CFLAGS) $^ -o $@ -lm
|
||||
|
||||
# --- Edge enqueue fuzzer ---
|
||||
# Tests the SPSC ring buffer push/pop logic with rapid-fire enqueues.
|
||||
# Self-contained: reproduces ring buffer logic from edge_processing.c.
|
||||
fuzz_edge: fuzz_edge_enqueue.c $(STUBS_SRC)
|
||||
$(CC) $(CFLAGS) $^ -o $@ -lm
|
||||
|
||||
# --- NVS config validation fuzzer ---
|
||||
# Tests all NVS config validation ranges with random values.
|
||||
# Self-contained: reproduces validation logic from nvs_config.c.
|
||||
fuzz_nvs: fuzz_nvs_config.c $(STUBS_SRC)
|
||||
$(CC) $(CFLAGS) $^ -o $@ -lm
|
||||
|
||||
# --- Run targets ---
|
||||
run_serialize: fuzz_serialize
|
||||
@mkdir -p corpus_serialize
|
||||
./fuzz_serialize corpus_serialize/ -max_total_time=$(FUZZ_DURATION) -max_len=2048 -jobs=$(FUZZ_JOBS)
|
||||
|
||||
run_edge: fuzz_edge
|
||||
@mkdir -p corpus_edge
|
||||
./fuzz_edge corpus_edge/ -max_total_time=$(FUZZ_DURATION) -max_len=4096 -jobs=$(FUZZ_JOBS)
|
||||
|
||||
run_nvs: fuzz_nvs
|
||||
@mkdir -p corpus_nvs
|
||||
./fuzz_nvs corpus_nvs/ -max_total_time=$(FUZZ_DURATION) -max_len=256 -jobs=$(FUZZ_JOBS)
|
||||
|
||||
run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file fuzz_csi_serialize.c
|
||||
* @brief libFuzzer target for csi_serialize_frame() (ADR-061 Layer 6).
|
||||
*
|
||||
* Takes fuzz input and constructs wifi_csi_info_t structs with random
|
||||
* field values including extreme boundaries. Verifies that
|
||||
* csi_serialize_frame() never crashes, triggers ASAN, or causes UBSAN.
|
||||
*
|
||||
* Build (Linux/macOS with clang):
|
||||
* make fuzz_serialize
|
||||
*
|
||||
* Run:
|
||||
* ./fuzz_serialize corpus/ -max_len=2048
|
||||
*/
|
||||
|
||||
#include "esp_stubs.h"
|
||||
|
||||
/* Provide the globals that csi_collector.c references. */
|
||||
#include "nvs_config.h"
|
||||
nvs_config_t g_nvs_config;
|
||||
|
||||
/* Pull in the serialization function. */
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* Helper: read a value from the fuzz data, advancing the cursor.
|
||||
* Returns 0 if insufficient data remains.
|
||||
*/
|
||||
static size_t fuzz_read(const uint8_t **data, size_t *size,
|
||||
void *out, size_t n)
|
||||
{
|
||||
if (*size < n) {
|
||||
memset(out, 0, n);
|
||||
return 0;
|
||||
}
|
||||
memcpy(out, *data, n);
|
||||
*data += n;
|
||||
*size -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
{
|
||||
if (size < 8) {
|
||||
return 0; /* Need at least a few control bytes. */
|
||||
}
|
||||
|
||||
const uint8_t *cursor = data;
|
||||
size_t remaining = size;
|
||||
|
||||
/* Parse control bytes from fuzz input. */
|
||||
uint8_t test_case;
|
||||
int16_t iq_len_raw;
|
||||
int8_t rssi;
|
||||
uint8_t channel;
|
||||
int8_t noise_floor;
|
||||
uint8_t out_buf_scale; /* Controls output buffer size: 0-255. */
|
||||
|
||||
fuzz_read(&cursor, &remaining, &test_case, 1);
|
||||
fuzz_read(&cursor, &remaining, &iq_len_raw, 2);
|
||||
fuzz_read(&cursor, &remaining, &rssi, 1);
|
||||
fuzz_read(&cursor, &remaining, &channel, 1);
|
||||
fuzz_read(&cursor, &remaining, &noise_floor, 1);
|
||||
fuzz_read(&cursor, &remaining, &out_buf_scale, 1);
|
||||
|
||||
/* --- Test case 0: Normal operation with fuzz-controlled values --- */
|
||||
|
||||
wifi_csi_info_t info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
info.rx_ctrl.rssi = rssi;
|
||||
info.rx_ctrl.channel = channel & 0x0F; /* 4-bit field */
|
||||
info.rx_ctrl.noise_floor = noise_floor;
|
||||
|
||||
/* Use remaining fuzz data as I/Q buffer content. */
|
||||
uint16_t iq_len;
|
||||
if (iq_len_raw < 0) {
|
||||
iq_len = 0;
|
||||
} else if (iq_len_raw > (int16_t)remaining) {
|
||||
iq_len = (uint16_t)remaining;
|
||||
} else {
|
||||
iq_len = (uint16_t)iq_len_raw;
|
||||
}
|
||||
|
||||
int8_t iq_buf[CSI_MAX_FRAME_SIZE];
|
||||
if (iq_len > 0 && remaining > 0) {
|
||||
uint16_t copy = (iq_len > remaining) ? (uint16_t)remaining : iq_len;
|
||||
memcpy(iq_buf, cursor, copy);
|
||||
/* Zero-fill the rest if iq_len > available data. */
|
||||
if (copy < iq_len) {
|
||||
memset(iq_buf + copy, 0, iq_len - copy);
|
||||
}
|
||||
info.buf = iq_buf;
|
||||
} else {
|
||||
info.buf = iq_buf;
|
||||
memset(iq_buf, 0, sizeof(iq_buf));
|
||||
}
|
||||
info.len = (int16_t)iq_len;
|
||||
|
||||
/* Output buffer: scale from tiny (1 byte) to full size. */
|
||||
uint8_t out_buf[CSI_MAX_FRAME_SIZE + 64];
|
||||
size_t out_len;
|
||||
if (out_buf_scale == 0) {
|
||||
out_len = 0;
|
||||
} else if (out_buf_scale < 20) {
|
||||
/* Small buffer: test buffer-too-small path. */
|
||||
out_len = (size_t)out_buf_scale;
|
||||
} else {
|
||||
/* Normal/large buffer. */
|
||||
out_len = sizeof(out_buf);
|
||||
}
|
||||
|
||||
/* Call the function under test. Must not crash. */
|
||||
size_t result = csi_serialize_frame(&info, out_buf, out_len);
|
||||
|
||||
/* Basic sanity: result must be 0 (error) or <= out_len. */
|
||||
if (result > out_len) {
|
||||
__builtin_trap(); /* Buffer overflow detected. */
|
||||
}
|
||||
|
||||
/* --- Test case 1: NULL info pointer --- */
|
||||
if (test_case & 0x01) {
|
||||
result = csi_serialize_frame(NULL, out_buf, sizeof(out_buf));
|
||||
if (result != 0) {
|
||||
__builtin_trap(); /* NULL info should return 0. */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Test case 2: NULL output buffer --- */
|
||||
if (test_case & 0x02) {
|
||||
result = csi_serialize_frame(&info, NULL, sizeof(out_buf));
|
||||
if (result != 0) {
|
||||
__builtin_trap(); /* NULL buf should return 0. */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Test case 3: NULL I/Q buffer in info --- */
|
||||
if (test_case & 0x04) {
|
||||
wifi_csi_info_t null_iq_info = info;
|
||||
null_iq_info.buf = NULL;
|
||||
result = csi_serialize_frame(&null_iq_info, out_buf, sizeof(out_buf));
|
||||
if (result != 0) {
|
||||
__builtin_trap(); /* NULL info->buf should return 0. */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Test case 4: Extreme channel values --- */
|
||||
if (test_case & 0x08) {
|
||||
wifi_csi_info_t extreme_info = info;
|
||||
extreme_info.buf = iq_buf;
|
||||
|
||||
/* Channel 0 (invalid). */
|
||||
extreme_info.rx_ctrl.channel = 0;
|
||||
csi_serialize_frame(&extreme_info, out_buf, sizeof(out_buf));
|
||||
|
||||
/* Channel 15 (max 4-bit value, invalid for WiFi). */
|
||||
extreme_info.rx_ctrl.channel = 15;
|
||||
csi_serialize_frame(&extreme_info, out_buf, sizeof(out_buf));
|
||||
}
|
||||
|
||||
/* --- Test case 5: Extreme RSSI values --- */
|
||||
if (test_case & 0x10) {
|
||||
wifi_csi_info_t rssi_info = info;
|
||||
rssi_info.buf = iq_buf;
|
||||
|
||||
rssi_info.rx_ctrl.rssi = -128;
|
||||
csi_serialize_frame(&rssi_info, out_buf, sizeof(out_buf));
|
||||
|
||||
rssi_info.rx_ctrl.rssi = 127;
|
||||
csi_serialize_frame(&rssi_info, out_buf, sizeof(out_buf));
|
||||
}
|
||||
|
||||
/* --- Test case 6: Zero-length I/Q --- */
|
||||
if (test_case & 0x20) {
|
||||
wifi_csi_info_t zero_info = info;
|
||||
zero_info.buf = iq_buf;
|
||||
zero_info.len = 0;
|
||||
result = csi_serialize_frame(&zero_info, out_buf, sizeof(out_buf));
|
||||
/* len=0 means frame_size = CSI_HEADER_SIZE + 0 = 20 bytes. */
|
||||
if (result != 0 && result != CSI_HEADER_SIZE) {
|
||||
/* Either 0 (rejected) or exactly the header size is acceptable. */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Test case 7: Output buffer exactly header size --- */
|
||||
if (test_case & 0x40) {
|
||||
wifi_csi_info_t hdr_info = info;
|
||||
hdr_info.buf = iq_buf;
|
||||
hdr_info.len = 4; /* Small I/Q. */
|
||||
/* Buffer exactly header_size + iq_len = 24 bytes. */
|
||||
uint8_t tight_buf[CSI_HEADER_SIZE + 4];
|
||||
result = csi_serialize_frame(&hdr_info, tight_buf, sizeof(tight_buf));
|
||||
if (result > sizeof(tight_buf)) {
|
||||
__builtin_trap();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @file fuzz_edge_enqueue.c
|
||||
* @brief libFuzzer target for edge_enqueue_csi() (ADR-061 Layer 6).
|
||||
*
|
||||
* Rapid-fire enqueues with varying iq_len from 0 to beyond
|
||||
* EDGE_MAX_IQ_BYTES, testing the SPSC ring buffer overflow behavior
|
||||
* and verifying no out-of-bounds writes occur.
|
||||
*
|
||||
* Build (Linux/macOS with clang):
|
||||
* make fuzz_edge
|
||||
*
|
||||
* Run:
|
||||
* ./fuzz_edge corpus/ -max_len=4096
|
||||
*/
|
||||
|
||||
#include "esp_stubs.h"
|
||||
|
||||
/*
|
||||
* We cannot include edge_processing.c directly because it references
|
||||
* FreeRTOS task creation and other ESP-IDF APIs in edge_processing_init().
|
||||
* Instead, we re-implement the SPSC ring buffer and edge_enqueue_csi()
|
||||
* logic identically to the production code, testing the same algorithm.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ---- Reproduce the ring buffer from edge_processing.h ---- */
|
||||
#define EDGE_RING_SLOTS 16
|
||||
#define EDGE_MAX_IQ_BYTES 1024
|
||||
#define EDGE_MAX_SUBCARRIERS 128
|
||||
|
||||
typedef struct {
|
||||
uint8_t iq_data[EDGE_MAX_IQ_BYTES];
|
||||
uint16_t iq_len;
|
||||
int8_t rssi;
|
||||
uint8_t channel;
|
||||
uint32_t timestamp_us;
|
||||
} fuzz_ring_slot_t;
|
||||
|
||||
typedef struct {
|
||||
fuzz_ring_slot_t slots[EDGE_RING_SLOTS];
|
||||
volatile uint32_t head;
|
||||
volatile uint32_t tail;
|
||||
} fuzz_ring_buf_t;
|
||||
|
||||
static fuzz_ring_buf_t s_ring;
|
||||
|
||||
/**
|
||||
* ring_push: identical logic to edge_processing.c::ring_push().
|
||||
* This is the code path exercised by edge_enqueue_csi().
|
||||
*/
|
||||
static bool ring_push(const uint8_t *iq, uint16_t len,
|
||||
int8_t rssi, uint8_t channel)
|
||||
{
|
||||
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
||||
if (next == s_ring.tail) {
|
||||
return false; /* Full. */
|
||||
}
|
||||
|
||||
fuzz_ring_slot_t *slot = &s_ring.slots[s_ring.head];
|
||||
uint16_t copy_len = (len > EDGE_MAX_IQ_BYTES) ? EDGE_MAX_IQ_BYTES : len;
|
||||
memcpy(slot->iq_data, iq, copy_len);
|
||||
slot->iq_len = copy_len;
|
||||
slot->rssi = rssi;
|
||||
slot->channel = channel;
|
||||
slot->timestamp_us = (uint32_t)(esp_timer_get_time() & 0xFFFFFFFF);
|
||||
|
||||
__sync_synchronize();
|
||||
s_ring.head = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ring_pop: identical logic to edge_processing.c::ring_pop().
|
||||
*/
|
||||
static bool ring_pop(fuzz_ring_slot_t *out)
|
||||
{
|
||||
if (s_ring.tail == s_ring.head) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(out, &s_ring.slots[s_ring.tail], sizeof(fuzz_ring_slot_t));
|
||||
|
||||
__sync_synchronize();
|
||||
s_ring.tail = (s_ring.tail + 1) % EDGE_RING_SLOTS;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canary pattern: write to a buffer zone after ring memory to detect
|
||||
* out-of-bounds writes. If the canary is overwritten, we trap.
|
||||
*/
|
||||
#define CANARY_SIZE 64
|
||||
#define CANARY_BYTE 0xCD
|
||||
static uint8_t s_canary_before[CANARY_SIZE];
|
||||
/* s_ring is between the canaries (static allocation order not guaranteed,
|
||||
* but ASAN will catch OOB writes regardless). */
|
||||
static uint8_t s_canary_after[CANARY_SIZE];
|
||||
|
||||
static void init_canaries(void)
|
||||
{
|
||||
memset(s_canary_before, CANARY_BYTE, CANARY_SIZE);
|
||||
memset(s_canary_after, CANARY_BYTE, CANARY_SIZE);
|
||||
}
|
||||
|
||||
static void check_canaries(void)
|
||||
{
|
||||
for (int i = 0; i < CANARY_SIZE; i++) {
|
||||
if (s_canary_before[i] != CANARY_BYTE) __builtin_trap();
|
||||
if (s_canary_after[i] != CANARY_BYTE) __builtin_trap();
|
||||
}
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
{
|
||||
if (size < 4) return 0;
|
||||
|
||||
/* Reset ring buffer state for each fuzz iteration. */
|
||||
memset(&s_ring, 0, sizeof(s_ring));
|
||||
init_canaries();
|
||||
|
||||
const uint8_t *cursor = data;
|
||||
size_t remaining = size;
|
||||
|
||||
/*
|
||||
* Protocol: each "enqueue command" is:
|
||||
* [0..1] iq_len (LE u16)
|
||||
* [2] rssi (i8)
|
||||
* [3] channel (u8)
|
||||
* [4..] iq_data (up to iq_len bytes, zero-padded if short)
|
||||
*
|
||||
* We consume commands until data is exhausted.
|
||||
*/
|
||||
uint32_t enqueue_count = 0;
|
||||
uint32_t full_count = 0;
|
||||
uint32_t pop_count = 0;
|
||||
|
||||
while (remaining >= 4) {
|
||||
uint16_t iq_len = (uint16_t)cursor[0] | ((uint16_t)cursor[1] << 8);
|
||||
int8_t rssi = (int8_t)cursor[2];
|
||||
uint8_t channel = cursor[3];
|
||||
cursor += 4;
|
||||
remaining -= 4;
|
||||
|
||||
/* Prepare I/Q data buffer.
|
||||
* Even if iq_len > EDGE_MAX_IQ_BYTES, we pass it to ring_push
|
||||
* which must clamp it internally. We need a source buffer that
|
||||
* is at least iq_len bytes to avoid reading OOB. */
|
||||
uint8_t iq_buf[EDGE_MAX_IQ_BYTES + 128];
|
||||
memset(iq_buf, 0, sizeof(iq_buf));
|
||||
|
||||
/* Copy available fuzz data into iq_buf. */
|
||||
uint16_t avail = (remaining > sizeof(iq_buf))
|
||||
? (uint16_t)sizeof(iq_buf)
|
||||
: (uint16_t)remaining;
|
||||
if (avail > 0) {
|
||||
memcpy(iq_buf, cursor, avail);
|
||||
}
|
||||
|
||||
/* Advance cursor past the I/Q data portion.
|
||||
* We consume min(iq_len, remaining) bytes. */
|
||||
uint16_t consume = (iq_len > remaining) ? (uint16_t)remaining : iq_len;
|
||||
cursor += consume;
|
||||
remaining -= consume;
|
||||
|
||||
/* The key test: iq_len can be 0, normal, EDGE_MAX_IQ_BYTES,
|
||||
* or larger (up to 65535). ring_push must clamp to EDGE_MAX_IQ_BYTES. */
|
||||
bool ok = ring_push(iq_buf, iq_len, rssi, channel);
|
||||
if (ok) {
|
||||
enqueue_count++;
|
||||
} else {
|
||||
full_count++;
|
||||
|
||||
/* When ring is full, drain one slot to make room.
|
||||
* This tests the interleaved push/pop pattern. */
|
||||
fuzz_ring_slot_t popped;
|
||||
if (ring_pop(&popped)) {
|
||||
pop_count++;
|
||||
|
||||
/* Verify popped data is sane. */
|
||||
if (popped.iq_len > EDGE_MAX_IQ_BYTES) {
|
||||
__builtin_trap(); /* Clamping failed. */
|
||||
}
|
||||
}
|
||||
|
||||
/* Retry the enqueue after popping. */
|
||||
ring_push(iq_buf, iq_len, rssi, channel);
|
||||
}
|
||||
|
||||
/* Periodically check canaries. */
|
||||
if ((enqueue_count + full_count) % 8 == 0) {
|
||||
check_canaries();
|
||||
}
|
||||
}
|
||||
|
||||
/* Drain remaining items and verify each. */
|
||||
fuzz_ring_slot_t popped;
|
||||
while (ring_pop(&popped)) {
|
||||
pop_count++;
|
||||
if (popped.iq_len > EDGE_MAX_IQ_BYTES) {
|
||||
__builtin_trap();
|
||||
}
|
||||
}
|
||||
|
||||
/* Final canary check. */
|
||||
check_canaries();
|
||||
|
||||
/* Verify ring is now empty. */
|
||||
if (s_ring.head != s_ring.tail) {
|
||||
__builtin_trap();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file fuzz_nvs_config.c
|
||||
* @brief libFuzzer target for NVS config validation logic (ADR-061 Layer 6).
|
||||
*
|
||||
* Since we cannot easily mock the full ESP-IDF NVS API under libFuzzer,
|
||||
* this target extracts and tests the validation ranges used by
|
||||
* nvs_config_load() when processing NVS values. Each validation check
|
||||
* from nvs_config.c is reproduced here with fuzz-driven inputs.
|
||||
*
|
||||
* Build (Linux/macOS with clang):
|
||||
* clang -fsanitize=fuzzer,address -g -I stubs fuzz_nvs_config.c \
|
||||
* stubs/esp_stubs.c -o fuzz_nvs_config -lm
|
||||
*
|
||||
* Run:
|
||||
* ./fuzz_nvs_config corpus/ -max_len=256
|
||||
*/
|
||||
|
||||
#include "esp_stubs.h"
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
/**
|
||||
* Validate a hop_count value using the same logic as nvs_config_load().
|
||||
* Returns the validated value (0 = rejected).
|
||||
*/
|
||||
static uint8_t validate_hop_count(uint8_t val)
|
||||
{
|
||||
if (val >= 1 && val <= NVS_CFG_HOP_MAX) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate dwell_ms using the same logic as nvs_config_load().
|
||||
* Returns the validated value (0 = rejected).
|
||||
*/
|
||||
static uint32_t validate_dwell_ms(uint32_t val)
|
||||
{
|
||||
if (val >= 10) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TDM node count.
|
||||
*/
|
||||
static uint8_t validate_tdm_node_count(uint8_t val)
|
||||
{
|
||||
if (val >= 1) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate edge_tier (0-2).
|
||||
*/
|
||||
static uint8_t validate_edge_tier(uint8_t val)
|
||||
{
|
||||
if (val <= 2) return val;
|
||||
return 0xFF; /* Invalid. */
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate vital_window (32-256).
|
||||
*/
|
||||
static uint16_t validate_vital_window(uint16_t val)
|
||||
{
|
||||
if (val >= 32 && val <= 256) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate vital_interval_ms (>= 100).
|
||||
*/
|
||||
static uint16_t validate_vital_interval(uint16_t val)
|
||||
{
|
||||
if (val >= 100) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate top_k_count (1-32).
|
||||
*/
|
||||
static uint8_t validate_top_k(uint8_t val)
|
||||
{
|
||||
if (val >= 1 && val <= 32) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate power_duty (10-100).
|
||||
*/
|
||||
static uint8_t validate_power_duty(uint8_t val)
|
||||
{
|
||||
if (val >= 10 && val <= 100) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wasm_max_modules (1-8).
|
||||
*/
|
||||
static uint8_t validate_wasm_max(uint8_t val)
|
||||
{
|
||||
if (val >= 1 && val <= 8) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSI channel: 1-14 (2.4 GHz) or 36-177 (5 GHz).
|
||||
*/
|
||||
static uint8_t validate_csi_channel(uint8_t val)
|
||||
{
|
||||
if ((val >= 1 && val <= 14) || (val >= 36 && val <= 177)) return val;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tdm_slot_index < tdm_node_count (clamp to 0 on violation).
|
||||
*/
|
||||
static uint8_t validate_tdm_slot(uint8_t slot, uint8_t node_count)
|
||||
{
|
||||
if (slot >= node_count) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test string field handling: ensure NVS_CFG_SSID_MAX length is respected.
|
||||
*/
|
||||
static void test_string_bounds(const uint8_t *data, size_t len)
|
||||
{
|
||||
char ssid[NVS_CFG_SSID_MAX];
|
||||
char password[NVS_CFG_PASS_MAX];
|
||||
char ip[NVS_CFG_IP_MAX];
|
||||
|
||||
/* Simulate strncpy with NVS_CFG_*_MAX bounds. */
|
||||
size_t ssid_len = (len > NVS_CFG_SSID_MAX - 1) ? NVS_CFG_SSID_MAX - 1 : len;
|
||||
memcpy(ssid, data, ssid_len);
|
||||
ssid[ssid_len] = '\0';
|
||||
|
||||
size_t pass_len = (len > NVS_CFG_PASS_MAX - 1) ? NVS_CFG_PASS_MAX - 1 : len;
|
||||
memcpy(password, data, pass_len);
|
||||
password[pass_len] = '\0';
|
||||
|
||||
size_t ip_len = (len > NVS_CFG_IP_MAX - 1) ? NVS_CFG_IP_MAX - 1 : len;
|
||||
memcpy(ip, data, ip_len);
|
||||
ip[ip_len] = '\0';
|
||||
|
||||
/* Ensure null termination holds. */
|
||||
if (ssid[NVS_CFG_SSID_MAX - 1] != '\0' && ssid_len == NVS_CFG_SSID_MAX - 1) {
|
||||
/* OK: we set terminator above. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test presence_thresh and fall_thresh fixed-point conversion.
|
||||
* nvs_config.c stores as u16 with value * 1000.
|
||||
*/
|
||||
static void test_thresh_conversion(uint16_t pres_raw, uint16_t fall_raw)
|
||||
{
|
||||
float pres = (float)pres_raw / 1000.0f;
|
||||
float fall = (float)fall_raw / 1000.0f;
|
||||
|
||||
/* Ensure no NaN or Inf from valid integer inputs. */
|
||||
if (pres != pres) __builtin_trap(); /* NaN check. */
|
||||
if (fall != fall) __builtin_trap(); /* NaN check. */
|
||||
|
||||
/* Range: 0.0 to 65.535 for u16/1000. Both should be finite. */
|
||||
if (pres < 0.0f || pres > 65.536f) __builtin_trap();
|
||||
if (fall < 0.0f || fall > 65.536f) __builtin_trap();
|
||||
}
|
||||
|
||||
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
{
|
||||
if (size < 32) return 0;
|
||||
|
||||
const uint8_t *p = data;
|
||||
|
||||
/* Extract fuzz-driven config field values. */
|
||||
uint8_t hop_count = p[0];
|
||||
uint32_t dwell_ms = (uint32_t)p[1] | ((uint32_t)p[2] << 8)
|
||||
| ((uint32_t)p[3] << 16) | ((uint32_t)p[4] << 24);
|
||||
uint8_t tdm_slot = p[5];
|
||||
uint8_t tdm_nodes = p[6];
|
||||
uint8_t edge_tier = p[7];
|
||||
uint16_t vital_win = (uint16_t)p[8] | ((uint16_t)p[9] << 8);
|
||||
uint16_t vital_int = (uint16_t)p[10] | ((uint16_t)p[11] << 8);
|
||||
uint8_t top_k = p[12];
|
||||
uint8_t power_duty = p[13];
|
||||
uint8_t wasm_max = p[14];
|
||||
uint8_t csi_channel = p[15];
|
||||
uint16_t pres_thresh = (uint16_t)p[16] | ((uint16_t)p[17] << 8);
|
||||
uint16_t fall_thresh = (uint16_t)p[18] | ((uint16_t)p[19] << 8);
|
||||
uint8_t node_id = p[20];
|
||||
uint16_t target_port = (uint16_t)p[21] | ((uint16_t)p[22] << 8);
|
||||
uint8_t wasm_verify = p[23];
|
||||
|
||||
/* Run all validators. These must not crash regardless of input. */
|
||||
(void)validate_hop_count(hop_count);
|
||||
(void)validate_dwell_ms(dwell_ms);
|
||||
(void)validate_tdm_node_count(tdm_nodes);
|
||||
(void)validate_edge_tier(edge_tier);
|
||||
(void)validate_vital_window(vital_win);
|
||||
(void)validate_vital_interval(vital_int);
|
||||
(void)validate_top_k(top_k);
|
||||
(void)validate_power_duty(power_duty);
|
||||
(void)validate_wasm_max(wasm_max);
|
||||
(void)validate_csi_channel(csi_channel);
|
||||
|
||||
/* Validate TDM slot with validated node count. */
|
||||
uint8_t valid_nodes = validate_tdm_node_count(tdm_nodes);
|
||||
if (valid_nodes > 0) {
|
||||
(void)validate_tdm_slot(tdm_slot, valid_nodes);
|
||||
}
|
||||
|
||||
/* Test threshold conversions. */
|
||||
test_thresh_conversion(pres_thresh, fall_thresh);
|
||||
|
||||
/* Test string field bounds with remaining data. */
|
||||
if (size > 24) {
|
||||
test_string_bounds(data + 24, size - 24);
|
||||
}
|
||||
|
||||
/* Construct a full nvs_config_t and verify field assignments don't overflow. */
|
||||
nvs_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
|
||||
cfg.target_port = target_port;
|
||||
cfg.node_id = node_id;
|
||||
|
||||
uint8_t valid_hop = validate_hop_count(hop_count);
|
||||
cfg.channel_hop_count = valid_hop ? valid_hop : 1;
|
||||
|
||||
/* Fill channel list from fuzz data. */
|
||||
for (uint8_t i = 0; i < NVS_CFG_HOP_MAX && (24 + i) < size; i++) {
|
||||
cfg.channel_list[i] = data[24 + i];
|
||||
}
|
||||
|
||||
cfg.dwell_ms = validate_dwell_ms(dwell_ms) ? dwell_ms : 50;
|
||||
cfg.tdm_slot_index = 0;
|
||||
cfg.tdm_node_count = valid_nodes ? valid_nodes : 1;
|
||||
|
||||
if (cfg.tdm_slot_index >= cfg.tdm_node_count) {
|
||||
cfg.tdm_slot_index = 0;
|
||||
}
|
||||
|
||||
uint8_t valid_tier = validate_edge_tier(edge_tier);
|
||||
cfg.edge_tier = (valid_tier != 0xFF) ? valid_tier : 2;
|
||||
|
||||
cfg.presence_thresh = (float)pres_thresh / 1000.0f;
|
||||
cfg.fall_thresh = (float)fall_thresh / 1000.0f;
|
||||
|
||||
uint16_t valid_win = validate_vital_window(vital_win);
|
||||
cfg.vital_window = valid_win ? valid_win : 256;
|
||||
|
||||
uint16_t valid_int = validate_vital_interval(vital_int);
|
||||
cfg.vital_interval_ms = valid_int ? valid_int : 1000;
|
||||
|
||||
uint8_t valid_topk = validate_top_k(top_k);
|
||||
cfg.top_k_count = valid_topk ? valid_topk : 8;
|
||||
|
||||
uint8_t valid_duty = validate_power_duty(power_duty);
|
||||
cfg.power_duty = valid_duty ? valid_duty : 100;
|
||||
|
||||
uint8_t valid_wasm = validate_wasm_max(wasm_max);
|
||||
cfg.wasm_max_modules = valid_wasm ? valid_wasm : 4;
|
||||
cfg.wasm_verify = wasm_verify ? 1 : 0;
|
||||
|
||||
uint8_t valid_ch = validate_csi_channel(csi_channel);
|
||||
cfg.csi_channel = valid_ch;
|
||||
|
||||
/* MAC filter: use 6 bytes from fuzz data if available. */
|
||||
if (size >= 32) {
|
||||
memcpy(cfg.filter_mac, data + 24, 6);
|
||||
cfg.filter_mac_set = (data[30] & 0x01) ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Verify struct is self-consistent — no field should be in an impossible state. */
|
||||
if (cfg.channel_hop_count > NVS_CFG_HOP_MAX) __builtin_trap();
|
||||
if (cfg.tdm_slot_index >= cfg.tdm_node_count) __builtin_trap();
|
||||
if (cfg.edge_tier > 2) __builtin_trap();
|
||||
if (cfg.wasm_max_modules > 8 || cfg.wasm_max_modules < 1) __builtin_trap();
|
||||
if (cfg.top_k_count > 32 || cfg.top_k_count < 1) __builtin_trap();
|
||||
if (cfg.power_duty > 100 || cfg.power_duty < 10) __builtin_trap();
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef ESP_ERR_H_STUB
|
||||
#define ESP_ERR_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef ESP_LOG_H_STUB
|
||||
#define ESP_LOG_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @file esp_stubs.c
|
||||
* @brief Implementation of ESP-IDF stubs for host-based fuzz testing.
|
||||
*
|
||||
* Must be compiled with: -Istubs -I../main
|
||||
* so that ESP-IDF headers resolve to stubs/ and firmware headers
|
||||
* resolve to ../main/.
|
||||
*/
|
||||
|
||||
#include "esp_stubs.h"
|
||||
#include "edge_processing.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include <stdint.h>
|
||||
|
||||
/** Monotonically increasing microsecond counter for esp_timer_get_time(). */
|
||||
static int64_t s_fake_time_us = 0;
|
||||
|
||||
int64_t esp_timer_get_time(void)
|
||||
{
|
||||
/* Advance by 50ms each call (~20 Hz CSI rate simulation). */
|
||||
s_fake_time_us += 50000;
|
||||
return s_fake_time_us;
|
||||
}
|
||||
|
||||
/* ---- stream_sender stubs ---- */
|
||||
|
||||
int stream_sender_send(const uint8_t *data, size_t len)
|
||||
{
|
||||
(void)data;
|
||||
return (int)len;
|
||||
}
|
||||
|
||||
int stream_sender_init(void)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int stream_sender_init_with(const char *ip, uint16_t port)
|
||||
{
|
||||
(void)ip; (void)port;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void stream_sender_deinit(void)
|
||||
{
|
||||
}
|
||||
|
||||
/* ---- wasm_runtime stubs ---- */
|
||||
|
||||
void wasm_runtime_on_frame(const float *phases, const float *amplitudes,
|
||||
const float *variances, uint16_t n_sc,
|
||||
const edge_vitals_pkt_t *vitals)
|
||||
{
|
||||
(void)phases; (void)amplitudes; (void)variances;
|
||||
(void)n_sc; (void)vitals;
|
||||
}
|
||||
|
||||
esp_err_t wasm_runtime_init(void) { return ESP_OK; }
|
||||
esp_err_t wasm_runtime_load(const uint8_t *d, uint32_t l, uint8_t *id) { (void)d; (void)l; (void)id; return ESP_OK; }
|
||||
esp_err_t wasm_runtime_start(uint8_t id) { (void)id; return ESP_OK; }
|
||||
esp_err_t wasm_runtime_stop(uint8_t id) { (void)id; return ESP_OK; }
|
||||
esp_err_t wasm_runtime_unload(uint8_t id) { (void)id; return ESP_OK; }
|
||||
void wasm_runtime_on_timer(void) {}
|
||||
void wasm_runtime_get_info(wasm_module_info_t *info, uint8_t *count) { (void)info; if(count) *count = 0; }
|
||||
esp_err_t wasm_runtime_set_manifest(uint8_t id, const char *n, uint32_t c, uint32_t m) { (void)id; (void)n; (void)c; (void)m; return ESP_OK; }
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file esp_stubs.h
|
||||
* @brief Minimal ESP-IDF type stubs for host-based fuzz testing.
|
||||
*
|
||||
* Provides just enough type definitions and macros to compile
|
||||
* csi_collector.c and edge_processing.c on a Linux/macOS host
|
||||
* without the full ESP-IDF SDK.
|
||||
*/
|
||||
|
||||
#ifndef ESP_STUBS_H
|
||||
#define ESP_STUBS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---- esp_err.h ---- */
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL (-1)
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
|
||||
/* ---- esp_log.h ---- */
|
||||
#define ESP_LOGI(tag, fmt, ...) ((void)0)
|
||||
#define ESP_LOGW(tag, fmt, ...) ((void)0)
|
||||
#define ESP_LOGE(tag, fmt, ...) ((void)0)
|
||||
#define ESP_LOGD(tag, fmt, ...) ((void)0)
|
||||
#define ESP_ERROR_CHECK(x) ((void)(x))
|
||||
|
||||
/* ---- esp_timer.h ---- */
|
||||
typedef void *esp_timer_handle_t;
|
||||
|
||||
/**
|
||||
* Stub: returns a monotonically increasing microsecond counter.
|
||||
* Declared here, defined in esp_stubs.c.
|
||||
*/
|
||||
int64_t esp_timer_get_time(void);
|
||||
|
||||
/* ---- esp_wifi_types.h ---- */
|
||||
|
||||
/** Minimal rx_ctrl fields needed by csi_serialize_frame. */
|
||||
typedef struct {
|
||||
signed rssi : 8;
|
||||
unsigned channel : 4;
|
||||
unsigned noise_floor : 8;
|
||||
unsigned rx_ant : 2;
|
||||
/* Padding to fill out the struct so it compiles. */
|
||||
unsigned _pad : 10;
|
||||
} wifi_pkt_rx_ctrl_t;
|
||||
|
||||
/** Minimal wifi_csi_info_t needed by csi_serialize_frame. */
|
||||
typedef struct {
|
||||
wifi_pkt_rx_ctrl_t rx_ctrl;
|
||||
uint8_t mac[6];
|
||||
int16_t len; /**< Length of the I/Q buffer in bytes. */
|
||||
int8_t *buf; /**< Pointer to I/Q data. */
|
||||
} wifi_csi_info_t;
|
||||
|
||||
/* ---- Kconfig defaults ---- */
|
||||
#ifndef CONFIG_CSI_NODE_ID
|
||||
#define CONFIG_CSI_NODE_ID 1
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_CSI_WIFI_CHANNEL
|
||||
#define CONFIG_CSI_WIFI_CHANNEL 6
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_CSI_WIFI_SSID
|
||||
#define CONFIG_CSI_WIFI_SSID "test_ssid"
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_CSI_TARGET_IP
|
||||
#define CONFIG_CSI_TARGET_IP "192.168.1.1"
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_CSI_TARGET_PORT
|
||||
#define CONFIG_CSI_TARGET_PORT 5500
|
||||
#endif
|
||||
|
||||
/* Suppress the build-time guard in csi_collector.c */
|
||||
#ifndef CONFIG_ESP_WIFI_CSI_ENABLED
|
||||
#define CONFIG_ESP_WIFI_CSI_ENABLED 1
|
||||
#endif
|
||||
|
||||
/* ---- sdkconfig.h stub ---- */
|
||||
/* (empty — all needed CONFIG_ macros are above) */
|
||||
|
||||
/* ---- FreeRTOS stubs ---- */
|
||||
#define pdMS_TO_TICKS(x) ((x))
|
||||
#define pdPASS 1
|
||||
typedef int BaseType_t;
|
||||
|
||||
static inline int xPortGetCoreID(void) { return 0; }
|
||||
static inline void vTaskDelay(uint32_t ticks) { (void)ticks; }
|
||||
static inline BaseType_t xTaskCreatePinnedToCore(
|
||||
void (*fn)(void *), const char *name, uint32_t stack,
|
||||
void *arg, int prio, void *handle, int core)
|
||||
{
|
||||
(void)fn; (void)name; (void)stack; (void)arg;
|
||||
(void)prio; (void)handle; (void)core;
|
||||
return pdPASS;
|
||||
}
|
||||
|
||||
/* ---- WiFi API stubs (no-ops) ---- */
|
||||
typedef int wifi_interface_t;
|
||||
typedef int wifi_second_chan_t;
|
||||
#define WIFI_IF_STA 0
|
||||
#define WIFI_SECOND_CHAN_NONE 0
|
||||
|
||||
typedef struct {
|
||||
unsigned filter_mask;
|
||||
} wifi_promiscuous_filter_t;
|
||||
|
||||
typedef int wifi_promiscuous_pkt_type_t;
|
||||
#define WIFI_PROMIS_FILTER_MASK_MGMT 1
|
||||
#define WIFI_PROMIS_FILTER_MASK_DATA 2
|
||||
|
||||
typedef struct {
|
||||
int lltf_en;
|
||||
int htltf_en;
|
||||
int stbc_htltf2_en;
|
||||
int ltf_merge_en;
|
||||
int channel_filter_en;
|
||||
int manu_scale;
|
||||
int shift;
|
||||
} wifi_csi_config_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t primary;
|
||||
} wifi_ap_record_t;
|
||||
|
||||
static inline esp_err_t esp_wifi_set_promiscuous(bool en) { (void)en; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_rx_cb(void *cb) { (void)cb; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_promiscuous_filter(wifi_promiscuous_filter_t *f) { (void)f; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_csi_config(wifi_csi_config_t *c) { (void)c; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_csi_rx_cb(void *cb, void *ctx) { (void)cb; (void)ctx; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_csi(bool en) { (void)en; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_set_channel(uint8_t ch, wifi_second_chan_t sc) { (void)ch; (void)sc; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_80211_tx(wifi_interface_t ifx, const void *b, int len, bool en) { (void)ifx; (void)b; (void)len; (void)en; return ESP_OK; }
|
||||
static inline esp_err_t esp_wifi_sta_get_ap_info(wifi_ap_record_t *ap) { (void)ap; return ESP_FAIL; }
|
||||
static inline const char *esp_err_to_name(esp_err_t code) { (void)code; return "STUB"; }
|
||||
|
||||
/* ---- NVS stubs ---- */
|
||||
typedef uint32_t nvs_handle_t;
|
||||
#define NVS_READONLY 0
|
||||
static inline esp_err_t nvs_open(const char *ns, int mode, nvs_handle_t *h) { (void)ns; (void)mode; (void)h; return ESP_FAIL; }
|
||||
static inline void nvs_close(nvs_handle_t h) { (void)h; }
|
||||
static inline esp_err_t nvs_get_str(nvs_handle_t h, const char *k, char *v, size_t *l) { (void)h; (void)k; (void)v; (void)l; return ESP_FAIL; }
|
||||
static inline esp_err_t nvs_get_u8(nvs_handle_t h, const char *k, uint8_t *v) { (void)h; (void)k; (void)v; return ESP_FAIL; }
|
||||
static inline esp_err_t nvs_get_u16(nvs_handle_t h, const char *k, uint16_t *v) { (void)h; (void)k; (void)v; return ESP_FAIL; }
|
||||
static inline esp_err_t nvs_get_u32(nvs_handle_t h, const char *k, uint32_t *v) { (void)h; (void)k; (void)v; return ESP_FAIL; }
|
||||
static inline esp_err_t nvs_get_blob(nvs_handle_t h, const char *k, void *v, size_t *l) { (void)h; (void)k; (void)v; (void)l; return ESP_FAIL; }
|
||||
|
||||
/* ---- stream_sender stubs (defined in esp_stubs.c) ---- */
|
||||
int stream_sender_send(const uint8_t *data, size_t len);
|
||||
int stream_sender_init(void);
|
||||
int stream_sender_init_with(const char *ip, uint16_t port);
|
||||
void stream_sender_deinit(void);
|
||||
|
||||
/*
|
||||
* wasm_runtime stubs: defined in esp_stubs.c.
|
||||
* The actual prototype comes from ../main/wasm_runtime.h (via csi_collector.c).
|
||||
* We just need the definition in esp_stubs.c to link.
|
||||
*/
|
||||
|
||||
#endif /* ESP_STUBS_H */
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef ESP_TIMER_H_STUB
|
||||
#define ESP_TIMER_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef ESP_WIFI_H_STUB
|
||||
#define ESP_WIFI_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef ESP_WIFI_TYPES_H_STUB
|
||||
#define ESP_WIFI_TYPES_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef FREERTOS_H_STUB
|
||||
#define FREERTOS_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef FREERTOS_TASK_H_STUB
|
||||
#define FREERTOS_TASK_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef NVS_H_STUB
|
||||
#define NVS_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: redirect to unified stubs header. */
|
||||
#ifndef NVS_FLASH_H_STUB
|
||||
#define NVS_FLASH_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Stub: sdkconfig.h — all CONFIG_ macros provided by esp_stubs.h. */
|
||||
#ifndef SDKCONFIG_H_STUB
|
||||
#define SDKCONFIG_H_STUB
|
||||
#include "esp_stubs.h"
|
||||
#endif
|
||||
Executable
+290
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QEMU Post-Fault Health Checker — ADR-061 Layer 9
|
||||
|
||||
Reads a log segment captured after a fault injection and checks whether
|
||||
the firmware is still healthy. Used by qemu-chaos-test.sh after each
|
||||
fault in the chaos testing loop.
|
||||
|
||||
Health checks:
|
||||
1. No crash patterns (Guru Meditation, assert, panic, abort)
|
||||
2. No heap errors (OOM, heap corruption, alloc failure)
|
||||
3. No stack overflow (FreeRTOS stack overflow hook)
|
||||
4. Firmware still producing frames (CSI frame activity)
|
||||
|
||||
Exit codes:
|
||||
0 HEALTHY — all checks pass
|
||||
1 DEGRADED — no crash, but missing expected activity
|
||||
2 UNHEALTHY — crash, heap error, or stack overflow detected
|
||||
|
||||
Usage:
|
||||
python3 check_health.py --log /path/to/fault_segment.log --after-fault wifi_kill
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
# ANSI colors
|
||||
USE_COLOR = sys.stdout.isatty()
|
||||
|
||||
|
||||
def color(text: str, code: str) -> str:
|
||||
if not USE_COLOR:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def green(t: str) -> str:
|
||||
return color(t, "32")
|
||||
|
||||
|
||||
def yellow(t: str) -> str:
|
||||
return color(t, "33")
|
||||
|
||||
|
||||
def red(t: str) -> str:
|
||||
return color(t, "1;31")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthCheck:
|
||||
name: str
|
||||
passed: bool
|
||||
message: str
|
||||
severity: int # 0=pass, 1=degraded, 2=unhealthy
|
||||
|
||||
|
||||
def check_no_crash(lines: List[str]) -> HealthCheck:
|
||||
"""Check for crash indicators in the log."""
|
||||
crash_patterns = [
|
||||
r"Guru Meditation",
|
||||
r"assert failed",
|
||||
r"abort\(\)",
|
||||
r"panic",
|
||||
r"LoadProhibited",
|
||||
r"StoreProhibited",
|
||||
r"InstrFetchProhibited",
|
||||
r"IllegalInstruction",
|
||||
r"Unhandled debug exception",
|
||||
r"Fatal exception",
|
||||
]
|
||||
|
||||
for line in lines:
|
||||
for pat in crash_patterns:
|
||||
if re.search(pat, line):
|
||||
return HealthCheck(
|
||||
name="No crash",
|
||||
passed=False,
|
||||
message=f"Crash detected: {line.strip()[:120]}",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
name="No crash",
|
||||
passed=True,
|
||||
message="No crash indicators found",
|
||||
severity=0,
|
||||
)
|
||||
|
||||
|
||||
def check_no_heap_errors(lines: List[str]) -> HealthCheck:
|
||||
"""Check for heap/memory errors."""
|
||||
heap_patterns = [
|
||||
r"HEAP_ERROR",
|
||||
r"out of memory",
|
||||
r"heap_caps_alloc.*failed",
|
||||
r"malloc.*fail",
|
||||
r"heap corruption",
|
||||
r"CORRUPT HEAP",
|
||||
r"multi_heap",
|
||||
r"heap_lock",
|
||||
]
|
||||
|
||||
for line in lines:
|
||||
for pat in heap_patterns:
|
||||
if re.search(pat, line, re.IGNORECASE):
|
||||
return HealthCheck(
|
||||
name="No heap errors",
|
||||
passed=False,
|
||||
message=f"Heap error: {line.strip()[:120]}",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
name="No heap errors",
|
||||
passed=True,
|
||||
message="No heap errors found",
|
||||
severity=0,
|
||||
)
|
||||
|
||||
|
||||
def check_no_stack_overflow(lines: List[str]) -> HealthCheck:
|
||||
"""Check for FreeRTOS stack overflow."""
|
||||
stack_patterns = [
|
||||
r"[Ss]tack overflow",
|
||||
r"stack_overflow",
|
||||
r"vApplicationStackOverflowHook",
|
||||
r"stack smashing",
|
||||
]
|
||||
|
||||
for line in lines:
|
||||
for pat in stack_patterns:
|
||||
if re.search(pat, line):
|
||||
return HealthCheck(
|
||||
name="No stack overflow",
|
||||
passed=False,
|
||||
message=f"Stack overflow: {line.strip()[:120]}",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
name="No stack overflow",
|
||||
passed=True,
|
||||
message="No stack overflow detected",
|
||||
severity=0,
|
||||
)
|
||||
|
||||
|
||||
def check_frame_activity(lines: List[str]) -> HealthCheck:
|
||||
"""Check that the firmware is still producing CSI frames."""
|
||||
frame_patterns = [
|
||||
r"frame",
|
||||
r"CSI",
|
||||
r"mock_csi",
|
||||
r"iq_data",
|
||||
r"subcarrier",
|
||||
r"csi_collector",
|
||||
r"enqueue",
|
||||
r"presence",
|
||||
r"vitals",
|
||||
r"breathing",
|
||||
]
|
||||
|
||||
activity_lines = 0
|
||||
for line in lines:
|
||||
for pat in frame_patterns:
|
||||
if re.search(pat, line, re.IGNORECASE):
|
||||
activity_lines += 1
|
||||
break
|
||||
|
||||
if activity_lines > 0:
|
||||
return HealthCheck(
|
||||
name="Frame activity",
|
||||
passed=True,
|
||||
message=f"Firmware producing output ({activity_lines} activity lines)",
|
||||
severity=0,
|
||||
)
|
||||
else:
|
||||
return HealthCheck(
|
||||
name="Frame activity",
|
||||
passed=False,
|
||||
message="No frame/CSI activity detected after fault",
|
||||
severity=1, # Degraded, not fatal
|
||||
)
|
||||
|
||||
|
||||
def run_health_checks(
|
||||
log_path: Path,
|
||||
fault_name: str,
|
||||
tail_lines: int = 200,
|
||||
) -> int:
|
||||
"""Run all health checks and report results.
|
||||
|
||||
Returns:
|
||||
0 = healthy, 1 = degraded, 2 = unhealthy
|
||||
"""
|
||||
if not log_path.exists():
|
||||
print(f" ERROR: Log file not found: {log_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
all_lines = text.splitlines()
|
||||
|
||||
# Use last N lines (most recent, after fault injection)
|
||||
lines = all_lines[-tail_lines:] if len(all_lines) > tail_lines else all_lines
|
||||
|
||||
if not lines:
|
||||
print(f" WARNING: Log file is empty (fault may have killed output)")
|
||||
# Empty log after fault is degraded, not necessarily unhealthy
|
||||
return 1
|
||||
|
||||
print(f" Health check after fault: {fault_name}")
|
||||
print(f" Log lines analyzed: {len(lines)} (of {len(all_lines)} total)")
|
||||
print()
|
||||
|
||||
# Run checks
|
||||
checks = [
|
||||
check_no_crash(lines),
|
||||
check_no_heap_errors(lines),
|
||||
check_no_stack_overflow(lines),
|
||||
check_frame_activity(lines),
|
||||
]
|
||||
|
||||
max_severity = 0
|
||||
for check in checks:
|
||||
if check.passed:
|
||||
icon = green("PASS")
|
||||
elif check.severity == 1:
|
||||
icon = yellow("WARN")
|
||||
else:
|
||||
icon = red("FAIL")
|
||||
|
||||
print(f" [{icon}] {check.name}: {check.message}")
|
||||
max_severity = max(max_severity, check.severity)
|
||||
|
||||
print()
|
||||
|
||||
# Summary
|
||||
passed = sum(1 for c in checks if c.passed)
|
||||
total = len(checks)
|
||||
|
||||
if max_severity == 0:
|
||||
print(f" {green(f'HEALTHY')} — {passed}/{total} checks passed")
|
||||
elif max_severity == 1:
|
||||
print(f" {yellow(f'DEGRADED')} — {passed}/{total} checks passed")
|
||||
else:
|
||||
print(f" {red(f'UNHEALTHY')} — {passed}/{total} checks passed")
|
||||
|
||||
return max_severity
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="QEMU Post-Fault Health Checker — ADR-061 Layer 9",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Example output:\n"
|
||||
" [HEALTHY] t=30s frames=150 (5.0 fps) crashes=0 heap_err=0 wdt=0 reboots=0\n"
|
||||
" \n"
|
||||
" VERDICT: Firmware is healthy. No critical issues detected."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log", required=True,
|
||||
help="Path to the log file (or log segment) to check",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--after-fault", required=True,
|
||||
help="Name of the fault that was injected (for reporting)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tail", type=int, default=200,
|
||||
help="Number of lines from end of log to analyze (default: 200)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
exit_code = run_health_checks(
|
||||
log_path=Path(args.log),
|
||||
fault_name=args.after_fault,
|
||||
tail_lines=args.tail,
|
||||
)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NVS Test Matrix Generator (ADR-061)
|
||||
|
||||
Generates NVS partition binaries for 14 test configurations using the
|
||||
provision.py script's CSV builder and NVS binary generator. Each binary
|
||||
can be injected into a QEMU flash image at offset 0x9000 for automated
|
||||
firmware testing under different NVS configurations.
|
||||
|
||||
Usage:
|
||||
python3 generate_nvs_matrix.py --output-dir build/nvs_matrix
|
||||
|
||||
# Generate only specific configs:
|
||||
python3 generate_nvs_matrix.py --output-dir build/nvs_matrix --only default,full-adr060
|
||||
|
||||
Requirements:
|
||||
- esp_idf_nvs_partition_gen (pip install) or ESP-IDF nvs_partition_gen.py
|
||||
- Python 3.8+
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# NVS partition size must match partitions_display.csv: 0x6000 = 24576 bytes
|
||||
NVS_PARTITION_SIZE = 0x6000
|
||||
|
||||
|
||||
@dataclass
|
||||
class NvsEntry:
|
||||
"""A single NVS key-value entry."""
|
||||
key: str
|
||||
type: str # "data" or "namespace"
|
||||
encoding: str # "string", "u8", "u16", "u32", "hex2bin", ""
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class NvsConfig:
|
||||
"""A named NVS configuration with a list of entries."""
|
||||
name: str
|
||||
description: str
|
||||
entries: List[NvsEntry] = field(default_factory=list)
|
||||
|
||||
def to_csv(self) -> str:
|
||||
"""Generate NVS CSV content."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["key", "type", "encoding", "value"])
|
||||
writer.writerow(["csi_cfg", "namespace", "", ""])
|
||||
for entry in self.entries:
|
||||
writer.writerow([entry.key, entry.type, entry.encoding, entry.value])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def define_configs() -> List[NvsConfig]:
|
||||
"""Define all 14 NVS test configurations."""
|
||||
configs = []
|
||||
|
||||
# 1. default - no NVS entries (firmware uses Kconfig defaults)
|
||||
configs.append(NvsConfig(
|
||||
name="default",
|
||||
description="No NVS entries; firmware uses Kconfig defaults",
|
||||
entries=[],
|
||||
))
|
||||
|
||||
# 2. wifi-only - just WiFi credentials
|
||||
configs.append(NvsConfig(
|
||||
name="wifi-only",
|
||||
description="WiFi SSID and password only",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
],
|
||||
))
|
||||
|
||||
# 3. full-adr060 - channel override + MAC filter
|
||||
configs.append(NvsConfig(
|
||||
name="full-adr060",
|
||||
description="ADR-060: channel override + MAC filter + full config",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("target_port", "data", "u16", "5005"),
|
||||
NvsEntry("node_id", "data", "u8", "1"),
|
||||
NvsEntry("csi_channel", "data", "u8", "6"),
|
||||
NvsEntry("filter_mac", "data", "hex2bin", "aabbccddeeff"),
|
||||
],
|
||||
))
|
||||
|
||||
# 4. edge-tier0 - raw passthrough (no DSP)
|
||||
configs.append(NvsConfig(
|
||||
name="edge-tier0",
|
||||
description="Edge tier 0: raw CSI passthrough, no on-device DSP",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "0"),
|
||||
],
|
||||
))
|
||||
|
||||
# 5. edge-tier1 - basic presence/motion detection
|
||||
configs.append(NvsConfig(
|
||||
name="edge-tier1",
|
||||
description="Edge tier 1: basic presence and motion detection",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "1"),
|
||||
NvsEntry("pres_thresh", "data", "u16", "50"),
|
||||
],
|
||||
))
|
||||
|
||||
# 6. edge-tier2-custom - full pipeline with custom thresholds
|
||||
configs.append(NvsConfig(
|
||||
name="edge-tier2-custom",
|
||||
description="Edge tier 2: full pipeline with custom thresholds",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "2"),
|
||||
NvsEntry("pres_thresh", "data", "u16", "100"),
|
||||
NvsEntry("fall_thresh", "data", "u16", "3000"),
|
||||
NvsEntry("vital_win", "data", "u16", "256"),
|
||||
NvsEntry("vital_int", "data", "u16", "500"),
|
||||
NvsEntry("subk_count", "data", "u8", "16"),
|
||||
],
|
||||
))
|
||||
|
||||
# 7. tdm-3node - TDM mesh with 3 nodes (slot 0)
|
||||
configs.append(NvsConfig(
|
||||
name="tdm-3node",
|
||||
description="TDM mesh: 3-node schedule, this node is slot 0",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("node_id", "data", "u8", "0"),
|
||||
NvsEntry("tdm_slot", "data", "u8", "0"),
|
||||
NvsEntry("tdm_nodes", "data", "u8", "3"),
|
||||
],
|
||||
))
|
||||
|
||||
# 8. wasm-signed - WASM runtime with signature verification
|
||||
configs.append(NvsConfig(
|
||||
name="wasm-signed",
|
||||
description="WASM runtime enabled with Ed25519 signature verification",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "2"),
|
||||
# wasm_verify=1 + a 32-byte dummy Ed25519 pubkey
|
||||
NvsEntry("wasm_verify", "data", "u8", "1"),
|
||||
NvsEntry("wasm_pubkey", "data", "hex2bin",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
|
||||
],
|
||||
))
|
||||
|
||||
# 9. wasm-unsigned - WASM runtime without signature verification
|
||||
configs.append(NvsConfig(
|
||||
name="wasm-unsigned",
|
||||
description="WASM runtime with signature verification disabled",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "2"),
|
||||
NvsEntry("wasm_verify", "data", "u8", "0"),
|
||||
NvsEntry("wasm_max", "data", "u8", "2"),
|
||||
],
|
||||
))
|
||||
|
||||
# 10. 5ghz-channel - 5 GHz channel override
|
||||
configs.append(NvsConfig(
|
||||
name="5ghz-channel",
|
||||
description="ADR-060: 5 GHz channel 36 override",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork5G"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("csi_channel", "data", "u8", "36"),
|
||||
],
|
||||
))
|
||||
|
||||
# 11. boundary-max - maximum VALID values for all numeric fields
|
||||
# Uses firmware-validated max ranges (not raw u8/u16 max):
|
||||
# vital_win: 32-256, top_k: 1-32, power_duty: 10-100
|
||||
configs.append(NvsConfig(
|
||||
name="boundary-max",
|
||||
description="Boundary test: maximum valid values per firmware validation ranges",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("target_port", "data", "u16", "65535"),
|
||||
NvsEntry("node_id", "data", "u8", "255"),
|
||||
NvsEntry("edge_tier", "data", "u8", "2"),
|
||||
NvsEntry("pres_thresh", "data", "u16", "65535"),
|
||||
NvsEntry("fall_thresh", "data", "u16", "65535"),
|
||||
NvsEntry("vital_win", "data", "u16", "256"), # max validated
|
||||
NvsEntry("vital_int", "data", "u16", "10000"),
|
||||
NvsEntry("subk_count", "data", "u8", "32"),
|
||||
NvsEntry("power_duty", "data", "u8", "100"),
|
||||
],
|
||||
))
|
||||
|
||||
# 12. boundary-min - minimum VALID values for all numeric fields
|
||||
configs.append(NvsConfig(
|
||||
name="boundary-min",
|
||||
description="Boundary test: minimum valid values per firmware validation ranges",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("target_port", "data", "u16", "1024"),
|
||||
NvsEntry("node_id", "data", "u8", "0"),
|
||||
NvsEntry("edge_tier", "data", "u8", "0"),
|
||||
NvsEntry("pres_thresh", "data", "u16", "1"),
|
||||
NvsEntry("fall_thresh", "data", "u16", "100"), # min valid (0.1 rad/s²)
|
||||
NvsEntry("vital_win", "data", "u16", "32"), # min validated
|
||||
NvsEntry("vital_int", "data", "u16", "100"),
|
||||
NvsEntry("subk_count", "data", "u8", "1"),
|
||||
NvsEntry("power_duty", "data", "u8", "10"),
|
||||
],
|
||||
))
|
||||
|
||||
# 13. power-save - low power duty cycle configuration
|
||||
configs.append(NvsConfig(
|
||||
name="power-save",
|
||||
description="Power-save mode: 10% duty cycle for battery-powered nodes",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", "TestNetwork"),
|
||||
NvsEntry("password", "data", "string", "testpass123"),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
NvsEntry("edge_tier", "data", "u8", "1"),
|
||||
NvsEntry("power_duty", "data", "u8", "10"),
|
||||
],
|
||||
))
|
||||
|
||||
# 14. empty-strings - empty SSID/password to test fallback to Kconfig
|
||||
configs.append(NvsConfig(
|
||||
name="empty-strings",
|
||||
description="Empty SSID and password to verify Kconfig fallback",
|
||||
entries=[
|
||||
NvsEntry("ssid", "data", "string", ""),
|
||||
NvsEntry("password", "data", "string", ""),
|
||||
NvsEntry("target_ip", "data", "string", "10.0.2.2"),
|
||||
],
|
||||
))
|
||||
|
||||
return configs
|
||||
|
||||
|
||||
def generate_nvs_binary(csv_content: str, size: int) -> bytes:
|
||||
"""Generate an NVS partition binary from CSV content.
|
||||
|
||||
Tries multiple methods to find nvs_partition_gen:
|
||||
1. esp_idf_nvs_partition_gen pip package
|
||||
2. Legacy nvs_partition_gen pip package
|
||||
3. ESP-IDF bundled script (via IDF_PATH)
|
||||
4. Module invocation
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
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 pip-installed version first
|
||||
try:
|
||||
from esp_idf_nvs_partition_gen 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
|
||||
|
||||
# Try legacy import
|
||||
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
|
||||
|
||||
# Try ESP-IDF bundled script
|
||||
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
|
||||
try:
|
||||
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()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("ERROR: NVS partition generator tool not found.", file=sys.stderr)
|
||||
print("Install: pip install esp-idf-nvs-partition-gen", file=sys.stderr)
|
||||
print("Or set IDF_PATH to your ESP-IDF installation", file=sys.stderr)
|
||||
raise RuntimeError(
|
||||
"NVS partition generator not available. "
|
||||
"Install: pip install esp-idf-nvs-partition-gen"
|
||||
)
|
||||
|
||||
finally:
|
||||
for p in set((csv_path, bin_path)): # deduplicate in case paths are identical
|
||||
if os.path.isfile(p):
|
||||
os.unlink(p)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate NVS partition binaries for QEMU firmware test matrix (ADR-061)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir", required=True,
|
||||
help="Directory to write NVS binary files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only", type=str, default=None,
|
||||
help="Comma-separated list of config names to generate (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv-only", action="store_true",
|
||||
help="Only generate CSV files, skip binary generation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list", action="store_true", dest="list_configs",
|
||||
help="List all available configurations and exit",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
all_configs = define_configs()
|
||||
|
||||
if args.list_configs:
|
||||
print(f"{'Name':<20} {'Description'}")
|
||||
print("-" * 70)
|
||||
for cfg in all_configs:
|
||||
print(f"{cfg.name:<20} {cfg.description}")
|
||||
sys.exit(0)
|
||||
|
||||
# Filter configs if --only specified
|
||||
if args.only:
|
||||
selected = set(args.only.split(","))
|
||||
configs = [c for c in all_configs if c.name in selected]
|
||||
missing = selected - {c.name for c in configs}
|
||||
if missing:
|
||||
print(f"WARNING: Unknown config names: {', '.join(sorted(missing))}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
configs = all_configs
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Generating {len(configs)} NVS configurations in {output_dir}/")
|
||||
print()
|
||||
|
||||
success = 0
|
||||
errors = 0
|
||||
|
||||
for cfg in configs:
|
||||
csv_content = cfg.to_csv()
|
||||
|
||||
# Always write the CSV for reference
|
||||
csv_path = output_dir / f"nvs_{cfg.name}.csv"
|
||||
csv_path.write_text(csv_content)
|
||||
|
||||
if cfg.name == "default" and not cfg.entries:
|
||||
# "default" means no NVS — just produce an empty marker
|
||||
print(f" [{cfg.name}] No NVS entries (uses Kconfig defaults)")
|
||||
# Write a zero-filled NVS partition (erased state = 0xFF)
|
||||
bin_path = output_dir / f"nvs_{cfg.name}.bin"
|
||||
bin_path.write_bytes(b"\xff" * NVS_PARTITION_SIZE)
|
||||
success += 1
|
||||
continue
|
||||
|
||||
if args.csv_only:
|
||||
print(f" [{cfg.name}] CSV only: {csv_path}")
|
||||
success += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)
|
||||
bin_path = output_dir / f"nvs_{cfg.name}.bin"
|
||||
bin_path.write_bytes(nvs_bin)
|
||||
print(f" [{cfg.name}] {len(nvs_bin)} bytes -> {bin_path}")
|
||||
success += 1
|
||||
except Exception as e:
|
||||
print(f" [{cfg.name}] ERROR: {e}", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
print()
|
||||
print(f"Done: {success} succeeded, {errors} failed")
|
||||
|
||||
if errors > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QEMU Fault Injector — ADR-061 Layer 9
|
||||
|
||||
Connects to a QEMU monitor socket and injects a specified fault type.
|
||||
Used by qemu-chaos-test.sh to stress-test firmware resilience.
|
||||
|
||||
Supported faults:
|
||||
wifi_kill - Pause/resume VM (simulates WiFi reconnect)
|
||||
ring_flood - Send 1000 rapid commands to stress ring buffer
|
||||
heap_exhaust - Write to heap metadata region to simulate OOM
|
||||
timer_starvation - Pause VM for 500ms to starve FreeRTOS timers
|
||||
corrupt_frame - Write bad magic bytes to CSI frame buffer area
|
||||
nvs_corrupt - Write garbage to NVS flash region (offset 0x9000)
|
||||
|
||||
Usage:
|
||||
python3 inject_fault.py --socket /path/to/qemu.sock --fault wifi_kill
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
# Timeout for each monitor command (seconds)
|
||||
CMD_TIMEOUT = 5.0
|
||||
|
||||
# QEMU monitor response buffer size
|
||||
RECV_BUFSIZE = 4096
|
||||
|
||||
|
||||
def connect_monitor(sock_path: str, timeout: float = CMD_TIMEOUT) -> socket.socket:
|
||||
"""Connect to the QEMU monitor Unix domain socket."""
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect(sock_path)
|
||||
except (socket.error, FileNotFoundError) as e:
|
||||
print(f"ERROR: Cannot connect to QEMU monitor at {sock_path}: {e}",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Read the initial QEMU monitor banner/prompt
|
||||
try:
|
||||
banner = s.recv(RECV_BUFSIZE).decode("utf-8", errors="replace")
|
||||
if banner:
|
||||
pass # Consume silently
|
||||
else:
|
||||
print(f"WARNING: Connected to {sock_path} but received no banner data. "
|
||||
f"QEMU monitor may not be ready.", file=sys.stderr)
|
||||
except socket.timeout:
|
||||
print(f"WARNING: Connected to {sock_path} but timed out waiting for banner "
|
||||
f"after {timeout}s. QEMU monitor may be unresponsive.", file=sys.stderr)
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def send_cmd(s: socket.socket, cmd: str, timeout: float = CMD_TIMEOUT) -> str:
|
||||
"""Send a command to the QEMU monitor and return the response."""
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.sendall((cmd + "\n").encode("utf-8"))
|
||||
except (BrokenPipeError, ConnectionResetError) as e:
|
||||
print(f"ERROR: Lost connection to QEMU monitor: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
# Read response (may be multi-line)
|
||||
response = ""
|
||||
try:
|
||||
while True:
|
||||
chunk = s.recv(RECV_BUFSIZE).decode("utf-8", errors="replace")
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
# QEMU monitor prompt ends with "(qemu) "
|
||||
if "(qemu)" in chunk:
|
||||
break
|
||||
except socket.timeout:
|
||||
pass # Response may not have a clean prompt
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def fault_wifi_kill(s: socket.socket) -> None:
|
||||
"""Pause VM for 2s then resume — simulates WiFi disconnect/reconnect."""
|
||||
print("[wifi_kill] Pausing VM...")
|
||||
send_cmd(s, "stop")
|
||||
time.sleep(2.0)
|
||||
print("[wifi_kill] Resuming VM...")
|
||||
send_cmd(s, "cont")
|
||||
print("[wifi_kill] Injected: 2s pause/resume cycle")
|
||||
|
||||
|
||||
def fault_ring_flood(s: socket.socket) -> None:
|
||||
"""Send 1000 rapid NMI injections to stress the ring buffer.
|
||||
|
||||
On real hardware, scenario 7 is a high-rate CSI burst. Under QEMU
|
||||
we simulate this by rapidly triggering NMIs which the mock CSI
|
||||
handler processes as frame events.
|
||||
"""
|
||||
print("[ring_flood] Sending 1000 rapid commands...")
|
||||
sent = 0
|
||||
for i in range(1000):
|
||||
try:
|
||||
# Use 'nmi' to trigger interrupt handler (mock CSI frame path)
|
||||
s.sendall(b"nmi\n")
|
||||
sent += 1
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
print(f"[ring_flood] Connection lost after {sent} commands")
|
||||
break
|
||||
|
||||
# Drain any accumulated responses
|
||||
s.settimeout(1.0)
|
||||
try:
|
||||
while True:
|
||||
chunk = s.recv(RECV_BUFSIZE)
|
||||
if not chunk:
|
||||
break
|
||||
except socket.timeout:
|
||||
pass
|
||||
|
||||
print(f"[ring_flood] Injected: {sent}/1000 rapid NMI triggers")
|
||||
|
||||
|
||||
def fault_heap_exhaust(s: socket.socket, flash_path: str = None) -> None:
|
||||
"""Simulate memory pressure by pausing VM to trigger watchdog/heap checks.
|
||||
|
||||
Actual heap memory writes require a GDB stub (-gdb tcp::1234).
|
||||
This function probes the heap region and pauses the VM to stress
|
||||
heap management as a realistic simulation.
|
||||
"""
|
||||
heap_base = 0x3FC88000
|
||||
print("[heap_exhaust] Probing heap region...")
|
||||
resp = send_cmd(s, f"xp /4xw 0x{heap_base:08x}")
|
||||
print(f"[heap_exhaust] Heap header: {resp.strip()}")
|
||||
# Pause VM to stress memory management
|
||||
print("[heap_exhaust] Pausing VM for 3s to stress heap management...")
|
||||
send_cmd(s, "stop")
|
||||
time.sleep(3.0)
|
||||
send_cmd(s, "cont")
|
||||
print("[heap_exhaust] WARNING: Actual heap corruption requires GDB stub (-gdb tcp::1234)")
|
||||
print("[heap_exhaust] Injected: 3s VM pause (simulates memory pressure)")
|
||||
|
||||
|
||||
def fault_timer_starvation(s: socket.socket) -> None:
|
||||
"""Pause VM for 500ms — starves FreeRTOS tick and timer callbacks."""
|
||||
print("[timer_starvation] Pausing VM for 500ms...")
|
||||
send_cmd(s, "stop")
|
||||
time.sleep(0.5)
|
||||
send_cmd(s, "cont")
|
||||
print("[timer_starvation] Injected: 500ms execution pause")
|
||||
|
||||
|
||||
def fault_corrupt_frame(s: socket.socket, flash_path: str = None) -> None:
|
||||
"""Simulate CSI frame corruption by pausing VM during frame processing.
|
||||
|
||||
Actual memory writes to the frame buffer require a GDB stub
|
||||
(-gdb tcp::1234). This function probes the frame buffer region
|
||||
and pauses the VM mid-frame to simulate corruption effects.
|
||||
"""
|
||||
frame_buf_addr = 0x3FCA0000
|
||||
print(f"[corrupt_frame] Probing frame buffer at 0x{frame_buf_addr:08X}...")
|
||||
resp = send_cmd(s, f"xp /4xb 0x{frame_buf_addr:08x}")
|
||||
print(f"[corrupt_frame] Frame buffer: {resp.strip()}")
|
||||
# Pause VM briefly to disrupt frame processing timing
|
||||
print("[corrupt_frame] Pausing VM for 1s to disrupt frame processing...")
|
||||
send_cmd(s, "stop")
|
||||
time.sleep(1.0)
|
||||
send_cmd(s, "cont")
|
||||
print("[corrupt_frame] WARNING: Actual frame corruption requires GDB stub (-gdb tcp::1234)")
|
||||
print(f"[corrupt_frame] Injected: 1s VM pause during frame processing")
|
||||
|
||||
|
||||
def fault_nvs_corrupt(s: socket.socket, flash_path: str = None) -> None:
|
||||
"""Write garbage to the NVS flash region on disk.
|
||||
|
||||
When a flash image path is provided, writes random bytes directly
|
||||
to the NVS partition offset (0x9000) in the flash image file.
|
||||
Without a flash path, falls back to a read-only probe via monitor.
|
||||
"""
|
||||
if flash_path and os.path.isfile(flash_path):
|
||||
nvs_offset = 0x9000
|
||||
garbage = bytes(random.randint(0, 255) for _ in range(16))
|
||||
with open(flash_path, "r+b") as f:
|
||||
f.seek(nvs_offset)
|
||||
f.write(garbage)
|
||||
print(f"[nvs_corrupt] Wrote 16 garbage bytes at flash offset 0x{nvs_offset:X}")
|
||||
print(f"[nvs_corrupt] Flash image: {flash_path}")
|
||||
else:
|
||||
# Fallback: attempt via monitor (read-only probe)
|
||||
resp = send_cmd(s, f"xp /8xb 0x3C009000")
|
||||
print(f"[nvs_corrupt] NVS region (read-only probe): {resp.strip()}")
|
||||
print(f"[nvs_corrupt] WARNING: No --flash path provided; NVS corruption was NOT injected")
|
||||
print(f"[nvs_corrupt] Pass --flash /path/to/flash.bin for actual corruption")
|
||||
|
||||
|
||||
# Map fault names to injection functions
|
||||
FAULT_MAP = {
|
||||
"wifi_kill": fault_wifi_kill,
|
||||
"ring_flood": fault_ring_flood,
|
||||
"heap_exhaust": fault_heap_exhaust,
|
||||
"timer_starvation": fault_timer_starvation,
|
||||
"corrupt_frame": fault_corrupt_frame,
|
||||
"nvs_corrupt": fault_nvs_corrupt,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="QEMU Fault Injector — ADR-061 Layer 9",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--socket", required=True,
|
||||
help="Path to QEMU monitor Unix domain socket",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fault", required=True, choices=list(FAULT_MAP.keys()),
|
||||
help="Fault type to inject",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=float, default=CMD_TIMEOUT,
|
||||
help=f"Per-command timeout in seconds (default: {CMD_TIMEOUT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flash", default=None,
|
||||
help="Path to flash image (for nvs_corrupt direct file writes)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"[inject_fault] Connecting to {args.socket}...")
|
||||
s = connect_monitor(args.socket, timeout=args.timeout)
|
||||
|
||||
print(f"[inject_fault] Injecting fault: {args.fault}")
|
||||
try:
|
||||
fault_fn = FAULT_MAP[args.fault]
|
||||
# Pass flash_path to faults that accept it
|
||||
import inspect
|
||||
sig = inspect.signature(fault_fn)
|
||||
if "flash_path" in sig.parameters:
|
||||
fault_fn(s, flash_path=args.flash)
|
||||
else:
|
||||
fault_fn(s)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Fault injection failed: {e}", file=sys.stderr)
|
||||
s.close()
|
||||
sys.exit(1)
|
||||
|
||||
s.close()
|
||||
print(f"[inject_fault] Complete: {args.fault}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/bin/bash
|
||||
# install-qemu.sh — Install QEMU with ESP32-S3 support (Espressif fork)
|
||||
# Usage: bash scripts/install-qemu.sh [OPTIONS]
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "\n${CYAN}${BOLD}▶ $*${NC}"; }
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="$HOME/.espressif/qemu"
|
||||
BRANCH="esp-develop"
|
||||
JOBS=""
|
||||
SKIP_DEPS=false
|
||||
UNINSTALL=false
|
||||
CHECK_ONLY=false
|
||||
QEMU_REPO="https://github.com/espressif/qemu.git"
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
${BOLD}install-qemu.sh${NC} — Install QEMU with ESP32-S3 support (Espressif fork)
|
||||
|
||||
${BOLD}USAGE${NC}
|
||||
bash scripts/install-qemu.sh [OPTIONS]
|
||||
|
||||
${BOLD}OPTIONS${NC}
|
||||
--install-dir DIR Installation directory (default: ~/.espressif/qemu)
|
||||
--branch TAG QEMU branch or tag to build (default: esp-develop)
|
||||
--jobs N Parallel build jobs (default: nproc)
|
||||
--skip-deps Skip system dependency installation
|
||||
--uninstall Remove QEMU installation
|
||||
--check Verify existing installation and exit
|
||||
-h, --help Show this help
|
||||
|
||||
${BOLD}EXIT CODES${NC}
|
||||
0 Success
|
||||
1 Dependency installation failed
|
||||
2 Build failed
|
||||
3 Unsupported OS
|
||||
|
||||
${BOLD}EXAMPLES${NC}
|
||||
bash scripts/install-qemu.sh
|
||||
bash scripts/install-qemu.sh --install-dir /opt/qemu-esp --jobs 8
|
||||
bash scripts/install-qemu.sh --check
|
||||
bash scripts/install-qemu.sh --uninstall
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
--jobs) JOBS="$2"; shift 2 ;;
|
||||
--skip-deps) SKIP_DEPS=true; shift ;;
|
||||
--uninstall) UNINSTALL=true; shift ;;
|
||||
--check) CHECK_ONLY=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Unknown option: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── OS detection ──────────────────────────────────────────────────────────────
|
||||
detect_os() {
|
||||
OS="unknown"
|
||||
DISTRO="unknown"
|
||||
IS_WSL=false
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
OS="linux"
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
IS_WSL=true
|
||||
fi
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
case "$ID" in
|
||||
ubuntu|debian|pop|linuxmint|elementary) DISTRO="debian" ;;
|
||||
fedora|rhel|centos|rocky|alma) DISTRO="fedora" ;;
|
||||
arch|manjaro|endeavouros) DISTRO="arch" ;;
|
||||
opensuse*|sles) DISTRO="suse" ;;
|
||||
*) DISTRO="$ID" ;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
Darwin) OS="macos"; DISTRO="macos" ;;
|
||||
MINGW*|MSYS*)
|
||||
err "Native Windows/MINGW detected."
|
||||
err "QEMU ESP32-S3 must be built on Linux or macOS."
|
||||
err "Options:"
|
||||
err " 1. Use WSL: wsl bash scripts/install-qemu.sh"
|
||||
err " 2. Use Docker: docker run -it ubuntu:22.04 bash"
|
||||
err " 3. Download pre-built: https://github.com/espressif/qemu/releases"
|
||||
exit 3
|
||||
;;
|
||||
*) err "Unsupported OS: $(uname -s)"; exit 3 ;;
|
||||
esac
|
||||
|
||||
info "Detected: OS=${OS} Distro=${DISTRO} WSL=${IS_WSL}"
|
||||
}
|
||||
|
||||
# ── Check existing installation ───────────────────────────────────────────────
|
||||
check_installation() {
|
||||
local qemu_bin="$INSTALL_DIR/build/qemu-system-xtensa"
|
||||
if [ -x "$qemu_bin" ]; then
|
||||
local version
|
||||
version=$("$qemu_bin" --version 2>/dev/null | head -1) || true
|
||||
if [ -n "$version" ]; then
|
||||
ok "QEMU installed: $version"
|
||||
ok "Binary: $qemu_bin"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Check PATH
|
||||
if command -v qemu-system-xtensa &>/dev/null; then
|
||||
local version
|
||||
version=$(qemu-system-xtensa --version 2>/dev/null | head -1) || true
|
||||
ok "QEMU found in PATH: $version"
|
||||
return 0
|
||||
fi
|
||||
warn "QEMU with ESP32-S3 support not found"
|
||||
return 1
|
||||
}
|
||||
|
||||
if $CHECK_ONLY; then
|
||||
detect_os
|
||||
if check_installation; then exit 0; else exit 1; fi
|
||||
fi
|
||||
|
||||
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
||||
if $UNINSTALL; then
|
||||
step "Uninstalling QEMU from $INSTALL_DIR"
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
rm -rf "$INSTALL_DIR"
|
||||
ok "Removed $INSTALL_DIR"
|
||||
else
|
||||
warn "Directory not found: $INSTALL_DIR"
|
||||
fi
|
||||
# Remove symlink
|
||||
local_bin="$HOME/.local/bin/qemu-system-xtensa"
|
||||
if [ -L "$local_bin" ]; then
|
||||
rm -f "$local_bin"
|
||||
ok "Removed symlink $local_bin"
|
||||
fi
|
||||
ok "Uninstall complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Main install flow ─────────────────────────────────────────────────────────
|
||||
detect_os
|
||||
|
||||
# Default jobs = nproc
|
||||
if [ -z "$JOBS" ]; then
|
||||
if command -v nproc &>/dev/null; then
|
||||
JOBS=$(nproc)
|
||||
elif command -v sysctl &>/dev/null; then
|
||||
JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
else
|
||||
JOBS=4
|
||||
fi
|
||||
fi
|
||||
info "Build parallelism: $JOBS jobs"
|
||||
|
||||
# ── Step 1: Install dependencies ──────────────────────────────────────────────
|
||||
install_deps() {
|
||||
step "Installing build dependencies"
|
||||
|
||||
case "$DISTRO" in
|
||||
debian)
|
||||
info "Using apt (Debian/Ubuntu)"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
git build-essential python3 python3-pip python3-venv \
|
||||
ninja-build pkg-config libglib2.0-dev libpixman-1-dev \
|
||||
libslirp-dev libgcrypt-dev
|
||||
;;
|
||||
fedora)
|
||||
info "Using dnf (Fedora/RHEL)"
|
||||
sudo dnf install -y \
|
||||
git gcc gcc-c++ make python3 python3-pip \
|
||||
ninja-build pkgconfig glib2-devel pixman-devel \
|
||||
libslirp-devel libgcrypt-devel
|
||||
;;
|
||||
arch)
|
||||
info "Using pacman (Arch)"
|
||||
sudo pacman -S --needed --noconfirm \
|
||||
git base-devel python python-pip \
|
||||
ninja pkgconf glib2 pixman libslirp libgcrypt
|
||||
;;
|
||||
suse)
|
||||
info "Using zypper (openSUSE)"
|
||||
sudo zypper install -y \
|
||||
git gcc gcc-c++ make python3 python3-pip \
|
||||
ninja pkg-config glib2-devel libpixman-1-0-devel \
|
||||
libslirp-devel libgcrypt-devel
|
||||
;;
|
||||
macos)
|
||||
info "Using Homebrew"
|
||||
if ! command -v brew &>/dev/null; then
|
||||
err "Homebrew not found. Install from https://brew.sh"
|
||||
exit 1
|
||||
fi
|
||||
brew install glib pixman ninja pkg-config libslirp libgcrypt || true
|
||||
;;
|
||||
*)
|
||||
warn "Unknown distro '$DISTRO' — install these manually:"
|
||||
warn " git, gcc/g++, python3, ninja, pkg-config, glib2-dev, pixman-dev, libslirp-dev"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
ok "Dependencies installed"
|
||||
}
|
||||
|
||||
if ! $SKIP_DEPS; then
|
||||
install_deps || { err "Dependency installation failed"; exit 1; }
|
||||
else
|
||||
info "Skipping dependency installation (--skip-deps)"
|
||||
fi
|
||||
|
||||
# ── Step 2: Clone Espressif QEMU fork ─────────────────────────────────────────
|
||||
step "Cloning Espressif QEMU fork"
|
||||
|
||||
SRC_DIR="$INSTALL_DIR"
|
||||
if [ -d "$SRC_DIR/.git" ]; then
|
||||
info "Repository already exists at $SRC_DIR"
|
||||
info "Fetching latest changes on branch $BRANCH"
|
||||
git -C "$SRC_DIR" fetch origin "$BRANCH" --depth=1
|
||||
git -C "$SRC_DIR" checkout "$BRANCH" 2>/dev/null || git -C "$SRC_DIR" checkout "origin/$BRANCH"
|
||||
ok "Updated to latest $BRANCH"
|
||||
else
|
||||
info "Cloning $QEMU_REPO (branch: $BRANCH)"
|
||||
mkdir -p "$(dirname "$SRC_DIR")"
|
||||
git clone --depth=1 --branch "$BRANCH" "$QEMU_REPO" "$SRC_DIR"
|
||||
ok "Cloned to $SRC_DIR"
|
||||
fi
|
||||
|
||||
# ── Step 3: Configure and build ───────────────────────────────────────────────
|
||||
step "Configuring QEMU (target: xtensa-softmmu)"
|
||||
|
||||
BUILD_DIR="$SRC_DIR/build"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cd "$SRC_DIR"
|
||||
|
||||
./configure \
|
||||
--target-list=xtensa-softmmu \
|
||||
--enable-slirp \
|
||||
--enable-gcrypt \
|
||||
--prefix="$INSTALL_DIR/dist" \
|
||||
2>&1 | tail -5
|
||||
|
||||
step "Building QEMU ($JOBS parallel jobs)"
|
||||
make -j"$JOBS" -C "$BUILD_DIR" 2>&1 | tail -20
|
||||
|
||||
if [ ! -x "$BUILD_DIR/qemu-system-xtensa" ]; then
|
||||
err "Build failed — qemu-system-xtensa binary not found"
|
||||
err "Troubleshooting:"
|
||||
err " 1. Check build output above for errors"
|
||||
err " 2. Ensure all dependencies are installed: re-run without --skip-deps"
|
||||
err " 3. Try with fewer jobs: --jobs 1"
|
||||
err " 4. On macOS, ensure Xcode CLT: xcode-select --install"
|
||||
exit 2
|
||||
fi
|
||||
ok "Build succeeded: $BUILD_DIR/qemu-system-xtensa"
|
||||
|
||||
# ── Step 4: Create symlink / add to PATH ──────────────────────────────────────
|
||||
step "Setting up PATH access"
|
||||
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
ln -sf "$BUILD_DIR/qemu-system-xtensa" "$LOCAL_BIN/qemu-system-xtensa"
|
||||
ok "Symlinked to $LOCAL_BIN/qemu-system-xtensa"
|
||||
|
||||
# Check if ~/.local/bin is in PATH
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$LOCAL_BIN"; then
|
||||
warn "$LOCAL_BIN is not in your PATH"
|
||||
warn "Add this to your shell profile (~/.bashrc or ~/.zshrc):"
|
||||
echo -e " ${BOLD}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}"
|
||||
fi
|
||||
|
||||
# ── Step 5: Verify ────────────────────────────────────────────────────────────
|
||||
step "Verifying installation"
|
||||
|
||||
QEMU_VERSION=$("$BUILD_DIR/qemu-system-xtensa" --version | head -1)
|
||||
ok "$QEMU_VERSION"
|
||||
|
||||
# Check ESP32-S3 machine support
|
||||
if "$BUILD_DIR/qemu-system-xtensa" -machine help 2>/dev/null | grep -q esp32s3; then
|
||||
ok "ESP32-S3 machine type available"
|
||||
else
|
||||
warn "ESP32-S3 machine type not listed (may still work with newer builds)"
|
||||
fi
|
||||
|
||||
# ── Step 6: Install Python packages ──────────────────────────────────────────
|
||||
step "Installing Python packages (esptool, pyyaml, nvs-partition-gen)"
|
||||
|
||||
PIP_CMD="pip3"
|
||||
if ! command -v pip3 &>/dev/null; then
|
||||
PIP_CMD="python3 -m pip"
|
||||
fi
|
||||
|
||||
$PIP_CMD install --user --quiet \
|
||||
esptool \
|
||||
pyyaml \
|
||||
esp-idf-nvs-partition-gen \
|
||||
2>&1 || warn "Some Python packages failed to install (non-fatal)"
|
||||
|
||||
ok "Python packages installed"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}Installation complete!${NC}"
|
||||
echo ""
|
||||
echo -e "${BOLD}Next steps:${NC}"
|
||||
echo ""
|
||||
echo " 1. Run a smoke test:"
|
||||
echo -e " ${CYAN}qemu-system-xtensa -nographic -machine esp32s3 \\${NC}"
|
||||
echo -e " ${CYAN} -drive file=firmware.bin,if=mtd,format=raw \\${NC}"
|
||||
echo -e " ${CYAN} -serial mon:stdio${NC}"
|
||||
echo ""
|
||||
echo " 2. Run the project QEMU tests:"
|
||||
echo -e " ${CYAN}cd $(dirname "$0")/.."
|
||||
echo -e " pytest firmware/esp32-csi-node/tests/qemu/ -v${NC}"
|
||||
echo ""
|
||||
echo " 3. Binary location:"
|
||||
echo -e " ${CYAN}$BUILD_DIR/qemu-system-xtensa${NC}"
|
||||
echo ""
|
||||
echo -e " 4. Uninstall:"
|
||||
echo -e " ${CYAN}bash scripts/install-qemu.sh --uninstall${NC}"
|
||||
echo ""
|
||||
Executable
+397
@@ -0,0 +1,397 @@
|
||||
#!/bin/bash
|
||||
# QEMU Chaos / Fault Injection Test Runner — ADR-061 Layer 9
|
||||
#
|
||||
# Launches firmware under QEMU and injects a series of faults to verify
|
||||
# the firmware's resilience. Each fault is injected via the QEMU monitor
|
||||
# socket (or GDB stub), followed by a recovery window and health check.
|
||||
#
|
||||
# Fault types:
|
||||
# 1. wifi_kill — Pause/resume VM to simulate WiFi reconnect
|
||||
# 2. ring_flood — Inject 1000 rapid mock frames (ring buffer stress)
|
||||
# 3. heap_exhaust — Write to heap metadata to simulate low memory
|
||||
# 4. timer_starvation — Pause VM for 500ms to starve FreeRTOS timers
|
||||
# 5. corrupt_frame — Inject a CSI frame with bad magic bytes
|
||||
# 6. nvs_corrupt — Write garbage to NVS flash region
|
||||
#
|
||||
# Environment variables:
|
||||
# QEMU_PATH - Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
# QEMU_TIMEOUT - Boot timeout in seconds (default: 15)
|
||||
# FLASH_IMAGE - Path to merged flash image (default: build/qemu_flash.bin)
|
||||
# FAULT_WAIT - Seconds to wait after fault injection (default: 5)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 PASS — all checks passed
|
||||
# 1 WARN — non-critical checks failed
|
||||
# 2 FAIL — critical checks failed
|
||||
# 3 FATAL — build error, crash, or infrastructure failure
|
||||
|
||||
# ── Help ──────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: qemu-chaos-test.sh [OPTIONS]
|
||||
|
||||
Launch firmware under QEMU and inject a series of faults to verify the
|
||||
firmware's resilience. Each fault is injected via the QEMU monitor socket,
|
||||
followed by a recovery window and health check.
|
||||
|
||||
Fault types:
|
||||
wifi_kill Pause/resume VM to simulate WiFi reconnect
|
||||
ring_flood Inject 1000 rapid mock frames (ring buffer stress)
|
||||
heap_exhaust Write to heap metadata to simulate low memory
|
||||
timer_starvation Pause VM for 500ms to starve FreeRTOS timers
|
||||
corrupt_frame Inject a CSI frame with bad magic bytes
|
||||
nvs_corrupt Write garbage to NVS flash region
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables:
|
||||
QEMU_PATH Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
QEMU_TIMEOUT Boot timeout in seconds (default: 15)
|
||||
FLASH_IMAGE Path to merged flash image (default: build/qemu_flash.bin)
|
||||
FAULT_WAIT Seconds to wait after injection (default: 5)
|
||||
|
||||
Examples:
|
||||
./qemu-chaos-test.sh
|
||||
QEMU_TIMEOUT=30 FAULT_WAIT=10 ./qemu-chaos-test.sh
|
||||
FLASH_IMAGE=/path/to/image.bin ./qemu-chaos-test.sh
|
||||
|
||||
Exit codes:
|
||||
0 PASS — all checks passed
|
||||
1 WARN — non-critical checks failed
|
||||
2 FAIL — critical checks failed
|
||||
3 FATAL — build error, crash, or infrastructure failure
|
||||
HELP
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in -h|--help) usage ;; esac
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/firmware/esp32-csi-node"
|
||||
BUILD_DIR="$FIRMWARE_DIR/build"
|
||||
QEMU_BIN="${QEMU_PATH:-qemu-system-xtensa}"
|
||||
FLASH_IMAGE="${FLASH_IMAGE:-$BUILD_DIR/qemu_flash.bin}"
|
||||
BOOT_TIMEOUT="${QEMU_TIMEOUT:-15}"
|
||||
FAULT_WAIT="${FAULT_WAIT:-5}"
|
||||
MONITOR_SOCK="$BUILD_DIR/qemu-chaos.sock"
|
||||
LOG_DIR="$BUILD_DIR/chaos-tests"
|
||||
UART_LOG="$LOG_DIR/qemu_uart.log"
|
||||
QEMU_PID=""
|
||||
|
||||
# Fault definitions
|
||||
FAULTS=("wifi_kill" "ring_flood" "heap_exhaust" "timer_starvation" "corrupt_frame" "nvs_corrupt")
|
||||
declare -a FAULT_RESULTS=()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Cleanup
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "[cleanup] Shutting down QEMU and removing socket..."
|
||||
if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
kill "$QEMU_PID" 2>/dev/null || true
|
||||
wait "$QEMU_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$MONITOR_SOCK"
|
||||
echo "[cleanup] Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
monitor_cmd() {
|
||||
local cmd="$1"
|
||||
local timeout="${2:-5}"
|
||||
echo "$cmd" | socat - "UNIX-CONNECT:$MONITOR_SOCK,connect-timeout=$timeout" 2>/dev/null
|
||||
}
|
||||
|
||||
log_line_count() {
|
||||
wc -l < "$UART_LOG" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
wait_for_boot() {
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt "$BOOT_TIMEOUT" ]; do
|
||||
if [ -f "$UART_LOG" ] && grep -qE "app_main|main_task|ESP32-S3|mock_csi" "$UART_LOG" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Fault injection functions
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
inject_wifi_kill() {
|
||||
# Simulate WiFi disconnect/reconnect by pausing and resuming the VM.
|
||||
# The firmware should handle the time gap gracefully.
|
||||
echo " [inject] Pausing VM for 2s (simulating WiFi disconnect)..."
|
||||
monitor_cmd "stop"
|
||||
sleep 2
|
||||
echo " [inject] Resuming VM (simulating WiFi reconnect)..."
|
||||
monitor_cmd "cont"
|
||||
}
|
||||
|
||||
inject_ring_flood() {
|
||||
# Send 1000 rapid mock frames by triggering scenario 7 repeatedly.
|
||||
# This stresses the ring buffer and tests backpressure handling.
|
||||
echo " [inject] Flooding ring buffer with 1000 rapid frame triggers..."
|
||||
python3 "$SCRIPT_DIR/inject_fault.py" \
|
||||
--socket "$MONITOR_SOCK" \
|
||||
--fault ring_flood
|
||||
}
|
||||
|
||||
inject_heap_exhaust() {
|
||||
# Simulate memory pressure by pausing the VM to stress heap management.
|
||||
# Actual heap memory writes require GDB stub.
|
||||
echo " [inject] Simulating heap pressure via VM pause..."
|
||||
python3 "$SCRIPT_DIR/inject_fault.py" \
|
||||
--socket "$MONITOR_SOCK" \
|
||||
--fault heap_exhaust
|
||||
}
|
||||
|
||||
inject_timer_starvation() {
|
||||
# Pause execution for 500ms to starve FreeRTOS timer callbacks.
|
||||
# Tests watchdog recovery and timer resilience.
|
||||
echo " [inject] Starving timers (500ms pause)..."
|
||||
monitor_cmd "stop"
|
||||
sleep 0.5
|
||||
monitor_cmd "cont"
|
||||
}
|
||||
|
||||
inject_corrupt_frame() {
|
||||
# Inject a CSI frame with bad magic bytes via monitor memory write.
|
||||
# The frame parser should reject it without crashing.
|
||||
echo " [inject] Injecting corrupt CSI frame (bad magic)..."
|
||||
python3 "$SCRIPT_DIR/inject_fault.py" \
|
||||
--socket "$MONITOR_SOCK" \
|
||||
--fault corrupt_frame
|
||||
}
|
||||
|
||||
inject_nvs_corrupt() {
|
||||
# Write garbage to the NVS flash region (offset 0x9000) via direct file write.
|
||||
# The firmware should detect NVS corruption and fall back to defaults.
|
||||
echo " [inject] Corrupting NVS flash region..."
|
||||
python3 "$SCRIPT_DIR/inject_fault.py" \
|
||||
--socket "$MONITOR_SOCK" \
|
||||
--fault nvs_corrupt \
|
||||
--flash "$FLASH_IMAGE"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Pre-flight checks
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== QEMU Chaos Test Runner — ADR-061 Layer 9 ==="
|
||||
echo "QEMU binary: $QEMU_BIN"
|
||||
echo "Flash image: $FLASH_IMAGE"
|
||||
echo "Boot timeout: ${BOOT_TIMEOUT}s"
|
||||
echo "Fault wait: ${FAULT_WAIT}s"
|
||||
echo "Faults: ${FAULTS[*]}"
|
||||
echo ""
|
||||
|
||||
if ! command -v "$QEMU_BIN" &>/dev/null; then
|
||||
echo "ERROR: QEMU binary not found: $QEMU_BIN"
|
||||
echo " Install: sudo apt install qemu-system-misc # Debian/Ubuntu"
|
||||
echo " Install: brew install qemu # macOS"
|
||||
echo " Or set QEMU_PATH to the qemu-system-xtensa binary."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v socat &>/dev/null; then
|
||||
echo "ERROR: socat not found (needed for QEMU monitor communication)."
|
||||
echo " Install: sudo apt install socat # Debian/Ubuntu"
|
||||
echo " Install: brew install socat # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
echo "ERROR: python3 not found (needed for fault injection scripts)."
|
||||
echo " Install: sudo apt install python3 # Debian/Ubuntu"
|
||||
echo " Install: brew install python # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [ ! -f "$FLASH_IMAGE" ]; then
|
||||
echo "ERROR: Flash image not found: $FLASH_IMAGE"
|
||||
echo "Run qemu-esp32s3-test.sh first to build the flash image."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Launch QEMU
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Launching QEMU ──"
|
||||
echo ""
|
||||
|
||||
rm -f "$MONITOR_SOCK"
|
||||
> "$UART_LOG"
|
||||
|
||||
QEMU_ARGS=(
|
||||
-machine esp32s3
|
||||
-nographic
|
||||
-drive "file=$FLASH_IMAGE,if=mtd,format=raw"
|
||||
-serial "file:$UART_LOG"
|
||||
-no-reboot
|
||||
-monitor "unix:$MONITOR_SOCK,server,nowait"
|
||||
)
|
||||
|
||||
"$QEMU_BIN" "${QEMU_ARGS[@]}" &
|
||||
QEMU_PID=$!
|
||||
echo "[qemu] PID=$QEMU_PID"
|
||||
|
||||
# Wait for monitor socket
|
||||
waited=0
|
||||
while [ ! -S "$MONITOR_SOCK" ] && [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
if [ ! -S "$MONITOR_SOCK" ]; then
|
||||
echo "ERROR: QEMU monitor socket did not appear after 10s"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Wait for boot
|
||||
echo "[boot] Waiting for firmware boot (up to ${BOOT_TIMEOUT}s)..."
|
||||
if wait_for_boot; then
|
||||
echo "[boot] Firmware booted successfully."
|
||||
else
|
||||
echo "[boot] No boot indicator found (continuing anyway)."
|
||||
fi
|
||||
|
||||
# Let firmware stabilize for a few seconds
|
||||
echo "[boot] Stabilizing (3s)..."
|
||||
sleep 3
|
||||
echo ""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Fault injection loop
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Fault Injection ──"
|
||||
echo ""
|
||||
|
||||
MAX_EXIT=0
|
||||
|
||||
for fault in "${FAULTS[@]}"; do
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Fault: $fault"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Record log position before injection
|
||||
pre_lines=$(log_line_count)
|
||||
|
||||
# Check QEMU is still alive
|
||||
if ! kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo " ERROR: QEMU process died before fault injection"
|
||||
FAULT_RESULTS+=("${fault}:3")
|
||||
MAX_EXIT=3
|
||||
break
|
||||
fi
|
||||
|
||||
# Inject the fault
|
||||
case "$fault" in
|
||||
wifi_kill) inject_wifi_kill ;;
|
||||
ring_flood) inject_ring_flood ;;
|
||||
heap_exhaust) inject_heap_exhaust ;;
|
||||
timer_starvation) inject_timer_starvation ;;
|
||||
corrupt_frame) inject_corrupt_frame ;;
|
||||
nvs_corrupt) inject_nvs_corrupt ;;
|
||||
*)
|
||||
echo " ERROR: Unknown fault type: $fault"
|
||||
FAULT_RESULTS+=("${fault}:2")
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Wait for firmware to respond/recover
|
||||
echo " [recovery] Waiting ${FAULT_WAIT}s for recovery..."
|
||||
sleep "$FAULT_WAIT"
|
||||
|
||||
# Extract post-fault log segment
|
||||
post_lines=$(log_line_count)
|
||||
new_lines=$((post_lines - pre_lines))
|
||||
fault_log="$LOG_DIR/fault_${fault}.log"
|
||||
|
||||
if [ "$new_lines" -gt 0 ]; then
|
||||
tail -n "$new_lines" "$UART_LOG" > "$fault_log"
|
||||
else
|
||||
# Grab last 50 lines as context
|
||||
tail -n 50 "$UART_LOG" > "$fault_log"
|
||||
fi
|
||||
|
||||
echo " [check] Captured $new_lines new log lines"
|
||||
|
||||
# Health check
|
||||
fault_exit=0
|
||||
python3 "$SCRIPT_DIR/check_health.py" \
|
||||
--log "$fault_log" \
|
||||
--after-fault "$fault" || fault_exit=$?
|
||||
|
||||
case "$fault_exit" in
|
||||
0) echo " [result] HEALTHY — firmware recovered gracefully" ;;
|
||||
1) echo " [result] DEGRADED — firmware running but with issues" ;;
|
||||
*) echo " [result] UNHEALTHY — firmware in bad state" ;;
|
||||
esac
|
||||
|
||||
FAULT_RESULTS+=("${fault}:${fault_exit}")
|
||||
if [ "$fault_exit" -gt "$MAX_EXIT" ]; then
|
||||
MAX_EXIT=$fault_exit
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Chaos Test Results ──"
|
||||
echo ""
|
||||
|
||||
PASS=0
|
||||
DEGRADED=0
|
||||
FAIL=0
|
||||
|
||||
for result in "${FAULT_RESULTS[@]}"; do
|
||||
name="${result%%:*}"
|
||||
code="${result##*:}"
|
||||
case "$code" in
|
||||
0) echo " [PASS] $name"; PASS=$((PASS + 1)) ;;
|
||||
1) echo " [DEGRADED] $name"; DEGRADED=$((DEGRADED + 1)) ;;
|
||||
*) echo " [FAIL] $name"; FAIL=$((FAIL + 1)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " $PASS passed, $DEGRADED degraded, $FAIL failed out of ${#FAULTS[@]} faults"
|
||||
echo ""
|
||||
|
||||
# Check if QEMU survived all faults
|
||||
if kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo " QEMU process survived all fault injections."
|
||||
else
|
||||
echo " WARNING: QEMU process died during fault injection."
|
||||
if [ "$MAX_EXIT" -lt 3 ]; then
|
||||
MAX_EXIT=3
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Chaos Test Complete (exit code: $MAX_EXIT) ==="
|
||||
exit "$MAX_EXIT"
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# qemu-cli.sh — Unified QEMU ESP32-S3 testing CLI (ADR-061)
|
||||
# Version: 1.0.0
|
||||
#
|
||||
# Single entry point for all QEMU testing operations.
|
||||
# Run `qemu-cli.sh help` or `qemu-cli.sh --help` for usage.
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
|
||||
# --- Colors ----------------------------------------------------------------
|
||||
if [[ -t 1 ]]; then
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RST='\033[0m'
|
||||
else
|
||||
RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; BOLD=''; RST=''
|
||||
fi
|
||||
|
||||
# --- Resolve paths ---------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/firmware/esp32-csi-node"
|
||||
FUZZ_DIR="$FIRMWARE_DIR/test"
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
info() { echo -e "${BLUE}[INFO]${RST} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${RST} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RST} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${RST} $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
need_qemu() {
|
||||
detect_qemu >/dev/null 2>&1 || \
|
||||
die "QEMU not found. Install with: ${CYAN}qemu-cli.sh install${RST}"
|
||||
}
|
||||
|
||||
detect_qemu() {
|
||||
# 1. Explicit env var
|
||||
if [[ -n "${QEMU_PATH:-}" ]] && [[ -x "$QEMU_PATH" ]]; then
|
||||
echo "$QEMU_PATH"; return 0
|
||||
fi
|
||||
# 2. On PATH
|
||||
local qemu
|
||||
qemu="$(command -v qemu-system-xtensa 2>/dev/null || true)"
|
||||
if [[ -n "$qemu" ]]; then echo "$qemu"; return 0; fi
|
||||
# 3. Espressif default build location
|
||||
local espressif_qemu="$HOME/.espressif/qemu/build/qemu-system-xtensa"
|
||||
if [[ -x "$espressif_qemu" ]]; then echo "$espressif_qemu"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_python() {
|
||||
command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "python3"
|
||||
}
|
||||
|
||||
# --- Command: help ---------------------------------------------------------
|
||||
cmd_help() {
|
||||
cat <<EOF
|
||||
${BOLD}qemu-cli.sh${RST} v${VERSION} — Unified QEMU ESP32-S3 testing CLI
|
||||
|
||||
${BOLD}USAGE${RST}
|
||||
qemu-cli.sh <command> [options]
|
||||
|
||||
${BOLD}COMMANDS${RST}
|
||||
${CYAN}install${RST} Install QEMU with ESP32-S3 support
|
||||
${CYAN}test${RST} Run single-node firmware test
|
||||
${CYAN}mesh${RST} [N] Run multi-node mesh test (default: 3 nodes)
|
||||
${CYAN}swarm${RST} [args] Run swarm configurator (qemu_swarm.py)
|
||||
${CYAN}snapshot${RST} [args] Run snapshot-based tests
|
||||
${CYAN}chaos${RST} [args] Run chaos / fault injection tests
|
||||
${CYAN}fuzz${RST} [--duration N] Run all 3 fuzz targets (clang libFuzzer)
|
||||
${CYAN}nvs${RST} [args] Generate NVS test matrix
|
||||
${CYAN}health${RST} <logfile> Check firmware health from QEMU log
|
||||
${CYAN}status${RST} Show installation status and versions
|
||||
${CYAN}help${RST} Show this help message
|
||||
|
||||
${BOLD}EXAMPLES${RST}
|
||||
qemu-cli.sh install # Install QEMU
|
||||
qemu-cli.sh test # Run basic firmware test
|
||||
qemu-cli.sh test --timeout 120 # Test with longer timeout
|
||||
qemu-cli.sh swarm --preset smoke # Quick swarm test
|
||||
qemu-cli.sh swarm --preset standard # Standard 3-node test
|
||||
qemu-cli.sh swarm --list-presets # List available presets
|
||||
qemu-cli.sh mesh 3 # 3-node mesh test
|
||||
qemu-cli.sh chaos # Run chaos tests
|
||||
qemu-cli.sh fuzz --duration 60 # Fuzz for 60 seconds
|
||||
qemu-cli.sh nvs --list # List NVS configs
|
||||
qemu-cli.sh health build/qemu_output.log
|
||||
qemu-cli.sh status # Show what's installed
|
||||
|
||||
${BOLD}TAB COMPLETION${RST}
|
||||
Source the completions in your shell:
|
||||
eval "\$(qemu-cli.sh --completions)"
|
||||
|
||||
${BOLD}ENVIRONMENT${RST}
|
||||
QEMU_PATH Path to qemu-system-xtensa binary (auto-detected)
|
||||
FUZZ_DURATION Override fuzz duration in seconds (default: 30)
|
||||
FUZZ_JOBS Parallel fuzzing jobs (default: 1)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- Command: install ------------------------------------------------------
|
||||
cmd_install() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh install"
|
||||
echo "Install QEMU with Espressif ESP32-S3 support."
|
||||
return 0
|
||||
fi
|
||||
local installer="$SCRIPT_DIR/install-qemu.sh"
|
||||
if [[ -f "$installer" ]]; then
|
||||
info "Running install-qemu.sh ..."
|
||||
bash "$installer" "$@"
|
||||
else
|
||||
info "No install-qemu.sh found. Showing manual install steps."
|
||||
cat <<EOF
|
||||
|
||||
${BOLD}Manual QEMU ESP32-S3 installation:${RST}
|
||||
1. git clone https://github.com/espressif/qemu.git ~/.espressif/qemu-src
|
||||
2. cd ~/.espressif/qemu-src
|
||||
3. ./configure --target-list=xtensa-softmmu --prefix=\$HOME/.espressif/qemu/build \\
|
||||
--enable-gcrypt --disable-bsd-user --disable-docs
|
||||
4. make -j\$(nproc) && make install
|
||||
5. Add to PATH: export PATH="\$HOME/.espressif/qemu/build/bin:\$PATH"
|
||||
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Command: test ----------------------------------------------------------
|
||||
cmd_test() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh test [--timeout N] [extra args...]"
|
||||
echo "Run single-node QEMU ESP32-S3 firmware test."
|
||||
return 0
|
||||
fi
|
||||
need_qemu
|
||||
info "Running single-node firmware test ..."
|
||||
bash "$SCRIPT_DIR/qemu-esp32s3-test.sh" "$@"
|
||||
}
|
||||
|
||||
# --- Command: mesh ----------------------------------------------------------
|
||||
cmd_mesh() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh mesh [N] [extra args...]"
|
||||
echo "Run multi-node mesh test. N = number of nodes (default: 3)."
|
||||
return 0
|
||||
fi
|
||||
need_qemu
|
||||
local nodes="${1:-3}"
|
||||
shift 2>/dev/null || true
|
||||
info "Running ${nodes}-node mesh test ..."
|
||||
bash "$SCRIPT_DIR/qemu-mesh-test.sh" "$nodes" "$@"
|
||||
}
|
||||
|
||||
# --- Command: swarm ---------------------------------------------------------
|
||||
cmd_swarm() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh swarm [--preset NAME] [--list-presets] [args...]"
|
||||
echo "Run QEMU swarm configurator (qemu_swarm.py)."
|
||||
echo ""
|
||||
echo "Presets: smoke, standard, full, stress"
|
||||
echo "List: qemu-cli.sh swarm --list-presets"
|
||||
return 0
|
||||
fi
|
||||
need_qemu
|
||||
local py; py="$(detect_python)"
|
||||
info "Running swarm configurator ..."
|
||||
"$py" "$SCRIPT_DIR/qemu_swarm.py" "$@"
|
||||
}
|
||||
|
||||
# --- Command: snapshot ------------------------------------------------------
|
||||
cmd_snapshot() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh snapshot [args...]"
|
||||
echo "Run snapshot-based QEMU tests."
|
||||
return 0
|
||||
fi
|
||||
need_qemu
|
||||
info "Running snapshot tests ..."
|
||||
bash "$SCRIPT_DIR/qemu-snapshot-test.sh" "$@"
|
||||
}
|
||||
|
||||
# --- Command: chaos ---------------------------------------------------------
|
||||
cmd_chaos() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh chaos [args...]"
|
||||
echo "Run chaos / fault injection tests."
|
||||
return 0
|
||||
fi
|
||||
need_qemu
|
||||
info "Running chaos tests ..."
|
||||
bash "$SCRIPT_DIR/qemu-chaos-test.sh" "$@"
|
||||
}
|
||||
|
||||
# --- Command: fuzz ----------------------------------------------------------
|
||||
cmd_fuzz() {
|
||||
local duration="${FUZZ_DURATION:-30}"
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh fuzz [--duration N]"
|
||||
echo "Build and run all 3 fuzz targets (clang libFuzzer)."
|
||||
echo "Requires: clang with libFuzzer support."
|
||||
return 0
|
||||
fi
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--duration) duration="$2"; shift 2 ;;
|
||||
*) warn "Unknown fuzz option: $1"; shift ;;
|
||||
esac
|
||||
done
|
||||
if ! command -v clang >/dev/null 2>&1; then
|
||||
die "clang not found. Fuzz targets require clang with libFuzzer."
|
||||
fi
|
||||
info "Building and running fuzz targets (${duration}s each) ..."
|
||||
make -C "$FUZZ_DIR" run_all FUZZ_DURATION="$duration"
|
||||
ok "Fuzz testing complete."
|
||||
}
|
||||
|
||||
# --- Command: nvs -----------------------------------------------------------
|
||||
cmd_nvs() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh nvs [--list] [args...]"
|
||||
echo "Generate NVS test configuration matrix."
|
||||
return 0
|
||||
fi
|
||||
local py; py="$(detect_python)"
|
||||
info "Running NVS matrix generator ..."
|
||||
"$py" "$SCRIPT_DIR/generate_nvs_matrix.py" "$@"
|
||||
}
|
||||
|
||||
# --- Command: health --------------------------------------------------------
|
||||
cmd_health() {
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: qemu-cli.sh health <logfile>"
|
||||
echo "Analyze firmware health from a QEMU output log."
|
||||
return 0
|
||||
fi
|
||||
local logfile="${1:-}"
|
||||
if [[ -z "$logfile" ]]; then
|
||||
die "Usage: qemu-cli.sh health <logfile>"
|
||||
fi
|
||||
if [[ ! -f "$logfile" ]]; then
|
||||
die "Log file not found: $logfile"
|
||||
fi
|
||||
local py; py="$(detect_python)"
|
||||
info "Analyzing health from: $logfile"
|
||||
"$py" "$SCRIPT_DIR/check_health.py" --log "$logfile" --after-fault manual
|
||||
}
|
||||
|
||||
# --- Command: status --------------------------------------------------------
|
||||
cmd_status() {
|
||||
# Status should never fail — disable errexit locally
|
||||
set +e
|
||||
echo -e "${BOLD}=== QEMU ESP32-S3 Testing Status ===${RST}"
|
||||
echo ""
|
||||
|
||||
# QEMU
|
||||
local qemu_bin
|
||||
qemu_bin="$(detect_qemu 2>/dev/null)"
|
||||
if [[ -n "$qemu_bin" ]]; then
|
||||
local qemu_ver
|
||||
qemu_ver="$("$qemu_bin" --version 2>/dev/null | head -1 || echo "unknown")"
|
||||
ok "QEMU: ${GREEN}installed${RST} ($qemu_ver)"
|
||||
echo " Path: $qemu_bin"
|
||||
else
|
||||
warn "QEMU: ${YELLOW}not found${RST} (run: qemu-cli.sh install)"
|
||||
fi
|
||||
|
||||
# ESP-IDF
|
||||
if [[ -n "${IDF_PATH:-}" ]] && [[ -d "$IDF_PATH" ]]; then
|
||||
ok "ESP-IDF: ${GREEN}available${RST} ($IDF_PATH)"
|
||||
else
|
||||
warn "ESP-IDF: ${YELLOW}IDF_PATH not set${RST}"
|
||||
fi
|
||||
|
||||
# Python
|
||||
local py; py="$(detect_python)"
|
||||
if command -v "$py" >/dev/null 2>&1; then
|
||||
ok "Python: ${GREEN}$("$py" --version 2>&1)${RST}"
|
||||
else
|
||||
warn "Python: ${YELLOW}not found${RST}"
|
||||
fi
|
||||
|
||||
# Clang (for fuzz)
|
||||
if command -v clang >/dev/null 2>&1; then
|
||||
ok "Clang: ${GREEN}$(clang --version 2>/dev/null | head -1)${RST}"
|
||||
else
|
||||
warn "Clang: ${YELLOW}not found${RST} (needed for fuzz targets only)"
|
||||
fi
|
||||
|
||||
# Firmware binary
|
||||
local fw_bin="$FIRMWARE_DIR/build/esp32-csi-node.bin"
|
||||
if [[ -f "$fw_bin" ]]; then
|
||||
local fw_size
|
||||
fw_size="$(stat -c%s "$fw_bin" 2>/dev/null || stat -f%z "$fw_bin" 2>/dev/null || echo "?")"
|
||||
ok "Firmware: ${GREEN}built${RST} ($fw_bin, ${fw_size} bytes)"
|
||||
else
|
||||
warn "Firmware: ${YELLOW}not built${RST} (expected at $fw_bin)"
|
||||
fi
|
||||
|
||||
# Swarm presets
|
||||
local preset_dir="$SCRIPT_DIR/swarm_presets"
|
||||
if [[ -d "$preset_dir" ]]; then
|
||||
local presets
|
||||
presets="$(ls "$preset_dir"/ 2>/dev/null | \
|
||||
sed 's/\.\(yaml\|json\)$//' | sort -u | tr '\n' ', ' | sed 's/,$//')"
|
||||
if [[ -n "$presets" ]]; then
|
||||
ok "Presets: ${GREEN}${presets}${RST}"
|
||||
else
|
||||
warn "Presets: ${YELLOW}none found${RST} in $preset_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
set -e
|
||||
}
|
||||
|
||||
# --- Completions output -----------------------------------------------------
|
||||
print_completions() {
|
||||
cat <<'COMP'
|
||||
_qemu_cli_completions() {
|
||||
local cmds="install test mesh swarm snapshot chaos fuzz nvs health status help"
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
if [[ $COMP_CWORD -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "$cmds" -- "$cur") )
|
||||
fi
|
||||
}
|
||||
complete -F _qemu_cli_completions qemu-cli.sh
|
||||
COMP
|
||||
}
|
||||
|
||||
# --- Main dispatch ----------------------------------------------------------
|
||||
main() {
|
||||
local cmd="${1:-help}"
|
||||
shift 2>/dev/null || true
|
||||
|
||||
case "$cmd" in
|
||||
install) cmd_install "$@" ;;
|
||||
test) cmd_test "$@" ;;
|
||||
mesh) cmd_mesh "$@" ;;
|
||||
swarm) cmd_swarm "$@" ;;
|
||||
snapshot) cmd_snapshot "$@" ;;
|
||||
chaos) cmd_chaos "$@" ;;
|
||||
fuzz) cmd_fuzz "$@" ;;
|
||||
nvs) cmd_nvs "$@" ;;
|
||||
health) cmd_health "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
help|-h|--help) cmd_help ;;
|
||||
--version) echo "qemu-cli.sh v${VERSION}" ;;
|
||||
--completions) print_completions ;;
|
||||
*)
|
||||
err "Unknown command: ${BOLD}${cmd}${RST}"
|
||||
echo ""
|
||||
cmd_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/bin/bash
|
||||
# QEMU ESP32-S3 Firmware Test Runner (ADR-061)
|
||||
#
|
||||
# Builds the firmware with mock CSI enabled, merges binaries into a single
|
||||
# flash image, optionally injects a pre-provisioned NVS partition, runs the
|
||||
# image under QEMU with a timeout, and validates the UART output.
|
||||
#
|
||||
# Environment variables:
|
||||
# QEMU_PATH - Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
# QEMU_TIMEOUT - Timeout in seconds (default: 60)
|
||||
# SKIP_BUILD - Set to "1" to skip the idf.py build step
|
||||
# NVS_BIN - Path to a pre-built NVS binary to inject (optional)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 PASS — all checks passed
|
||||
# 1 WARN — non-critical checks failed
|
||||
# 2 FAIL — critical checks failed
|
||||
# 3 FATAL — build error, crash, or infrastructure failure
|
||||
|
||||
# ── Help ──────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: qemu-esp32s3-test.sh [OPTIONS]
|
||||
|
||||
Build ESP32-S3 firmware with mock CSI, merge binaries into a single flash
|
||||
image, run under QEMU with a timeout, and validate the UART output.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables:
|
||||
QEMU_PATH Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
QEMU_TIMEOUT Timeout in seconds (default: 60)
|
||||
SKIP_BUILD Set to "1" to skip idf.py build (default: unset)
|
||||
NVS_BIN Path to pre-built NVS binary (optional)
|
||||
QEMU_NET Set to "0" to disable networking (default: 1)
|
||||
|
||||
Examples:
|
||||
./qemu-esp32s3-test.sh
|
||||
SKIP_BUILD=1 ./qemu-esp32s3-test.sh
|
||||
QEMU_PATH=/opt/qemu/bin/qemu-system-xtensa QEMU_TIMEOUT=120 ./qemu-esp32s3-test.sh
|
||||
|
||||
Exit codes:
|
||||
0 PASS — all checks passed
|
||||
1 WARN — non-critical checks failed
|
||||
2 FAIL — critical checks failed
|
||||
3 FATAL — build error, crash, or infrastructure failure
|
||||
HELP
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in -h|--help) usage ;; esac
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/firmware/esp32-csi-node"
|
||||
BUILD_DIR="$FIRMWARE_DIR/build"
|
||||
QEMU_BIN="${QEMU_PATH:-qemu-system-xtensa}"
|
||||
FLASH_IMAGE="$BUILD_DIR/qemu_flash.bin"
|
||||
LOG_FILE="$BUILD_DIR/qemu_output.log"
|
||||
TIMEOUT_SEC="${QEMU_TIMEOUT:-60}"
|
||||
|
||||
echo "=== QEMU ESP32-S3 Firmware Test (ADR-061) ==="
|
||||
echo "Firmware dir: $FIRMWARE_DIR"
|
||||
echo "QEMU binary: $QEMU_BIN"
|
||||
echo "Timeout: ${TIMEOUT_SEC}s"
|
||||
echo ""
|
||||
|
||||
# ── Prerequisite checks ───────────────────────────────────────────────
|
||||
if ! command -v "$QEMU_BIN" &>/dev/null; then
|
||||
echo "ERROR: QEMU binary not found: $QEMU_BIN"
|
||||
echo " Install: sudo apt install qemu-system-misc # Debian/Ubuntu"
|
||||
echo " Install: brew install qemu # macOS"
|
||||
echo " Or set QEMU_PATH to the qemu-system-xtensa binary."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
echo "ERROR: python3 not found."
|
||||
echo " Install: sudo apt install python3 # Debian/Ubuntu"
|
||||
echo " Install: brew install python # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! python3 -m esptool version &>/dev/null 2>&1; then
|
||||
echo "ERROR: esptool not found (needed to merge flash binaries)."
|
||||
echo " Install: pip install esptool"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# ── SKIP_BUILD precheck ──────────────────────────────────────────────
|
||||
if [ "${SKIP_BUILD:-}" = "1" ] && [ ! -f "$BUILD_DIR/esp32-csi-node.bin" ]; then
|
||||
echo "ERROR: SKIP_BUILD=1 but flash image not found: $BUILD_DIR/esp32-csi-node.bin"
|
||||
echo "Build the firmware first: ./qemu-esp32s3-test.sh (without SKIP_BUILD)"
|
||||
echo "Or unset SKIP_BUILD to build automatically."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# 1. Build with mock CSI enabled (skip if already built)
|
||||
if [ "${SKIP_BUILD:-}" != "1" ]; then
|
||||
echo "[1/4] Building firmware (mock CSI mode)..."
|
||||
idf.py -C "$FIRMWARE_DIR" \
|
||||
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \
|
||||
build
|
||||
echo ""
|
||||
else
|
||||
echo "[1/4] Skipping build (SKIP_BUILD=1)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Verify build artifacts exist
|
||||
for artifact in \
|
||||
"$BUILD_DIR/bootloader/bootloader.bin" \
|
||||
"$BUILD_DIR/partition_table/partition-table.bin" \
|
||||
"$BUILD_DIR/esp32-csi-node.bin"; do
|
||||
if [ ! -f "$artifact" ]; then
|
||||
echo "ERROR: Build artifact not found: $artifact"
|
||||
echo "Run without SKIP_BUILD=1 or build the firmware first."
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Merge binaries into single flash image
|
||||
echo "[2/4] Creating merged flash image..."
|
||||
|
||||
# Check for ota_data_initial.bin; some builds don't produce it
|
||||
OTA_DATA_ARGS=""
|
||||
if [ -f "$BUILD_DIR/ota_data_initial.bin" ]; then
|
||||
OTA_DATA_ARGS="0xf000 $BUILD_DIR/ota_data_initial.bin"
|
||||
fi
|
||||
|
||||
python3 -m esptool --chip esp32s3 merge_bin -o "$FLASH_IMAGE" \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
0x0 "$BUILD_DIR/bootloader/bootloader.bin" \
|
||||
0x8000 "$BUILD_DIR/partition_table/partition-table.bin" \
|
||||
$OTA_DATA_ARGS \
|
||||
0x20000 "$BUILD_DIR/esp32-csi-node.bin"
|
||||
|
||||
echo "Flash image: $FLASH_IMAGE ($(stat -c%s "$FLASH_IMAGE" 2>/dev/null || stat -f%z "$FLASH_IMAGE") bytes)"
|
||||
|
||||
# 2b. Optionally inject pre-provisioned NVS partition
|
||||
NVS_FILE="${NVS_BIN:-$BUILD_DIR/nvs_test.bin}"
|
||||
if [ -f "$NVS_FILE" ]; then
|
||||
echo "[2b] Injecting NVS partition from: $NVS_FILE"
|
||||
# NVS partition offset = 0x9000 = 36864
|
||||
dd if="$NVS_FILE" of="$FLASH_IMAGE" \
|
||||
bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null
|
||||
echo "NVS injected ($(stat -c%s "$NVS_FILE" 2>/dev/null || stat -f%z "$NVS_FILE") bytes at 0x9000)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 3. Run in QEMU with timeout, capture UART output
|
||||
echo "[3/4] Running QEMU (timeout: ${TIMEOUT_SEC}s)..."
|
||||
echo "------- QEMU UART output -------"
|
||||
|
||||
# Use timeout command; fall back to gtimeout on macOS
|
||||
TIMEOUT_CMD="timeout"
|
||||
if ! command -v timeout &>/dev/null; then
|
||||
if command -v gtimeout &>/dev/null; then
|
||||
TIMEOUT_CMD="gtimeout"
|
||||
else
|
||||
echo "WARNING: 'timeout' command not found. QEMU may run indefinitely."
|
||||
TIMEOUT_CMD=""
|
||||
fi
|
||||
fi
|
||||
|
||||
QEMU_EXIT=0
|
||||
|
||||
# Common QEMU arguments
|
||||
QEMU_ARGS=(
|
||||
-machine esp32s3
|
||||
-nographic
|
||||
-drive "file=$FLASH_IMAGE,if=mtd,format=raw"
|
||||
-serial mon:stdio
|
||||
-no-reboot
|
||||
)
|
||||
|
||||
# Enable SLIRP user-mode networking for UDP if available
|
||||
if [ "${QEMU_NET:-1}" != "0" ]; then
|
||||
QEMU_ARGS+=(-nic "user,model=open_eth,net=10.0.2.0/24,host=10.0.2.2")
|
||||
fi
|
||||
|
||||
if [ -n "$TIMEOUT_CMD" ]; then
|
||||
$TIMEOUT_CMD "$TIMEOUT_SEC" "$QEMU_BIN" "${QEMU_ARGS[@]}" \
|
||||
2>&1 | tee "$LOG_FILE" || QEMU_EXIT=$?
|
||||
else
|
||||
"$QEMU_BIN" "${QEMU_ARGS[@]}" \
|
||||
2>&1 | tee "$LOG_FILE" || QEMU_EXIT=$?
|
||||
fi
|
||||
|
||||
echo "------- End QEMU output -------"
|
||||
echo ""
|
||||
|
||||
# timeout returns 124 when the process is killed by timeout — that's expected
|
||||
if [ "$QEMU_EXIT" -eq 124 ]; then
|
||||
echo "QEMU exited via timeout (expected for firmware that loops forever)."
|
||||
elif [ "$QEMU_EXIT" -ne 0 ]; then
|
||||
echo "WARNING: QEMU exited with code $QEMU_EXIT"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. Validate expected output
|
||||
echo "[4/4] Validating output..."
|
||||
python3 "$SCRIPT_DIR/validate_qemu_output.py" "$LOG_FILE"
|
||||
VALIDATE_EXIT=$?
|
||||
|
||||
echo ""
|
||||
echo "=== Test Complete (exit code: $VALIDATE_EXIT) ==="
|
||||
exit $VALIDATE_EXIT
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/bin/bash
|
||||
# QEMU ESP32-S3 Multi-Node Mesh Simulation (ADR-061 Layer 3)
|
||||
#
|
||||
# Spawns N ESP32-S3 QEMU instances connected via a Linux bridge, each with
|
||||
# unique NVS provisioning (node ID, TDM slot), and a Rust aggregator that
|
||||
# collects frames from all nodes. After a configurable timeout the script
|
||||
# tears everything down and runs validate_mesh_test.py.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./qemu-mesh-test.sh [N_NODES]
|
||||
#
|
||||
# Environment variables:
|
||||
# QEMU_PATH - Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
# QEMU_TIMEOUT - Timeout in seconds (default: 45)
|
||||
# MESH_TIMEOUT - Deprecated alias for QEMU_TIMEOUT
|
||||
# SKIP_BUILD - Set to "1" to skip the idf.py build step
|
||||
# BRIDGE_NAME - Bridge interface name (default: qemu-br0)
|
||||
# BRIDGE_SUBNET - Bridge IP/mask (default: 10.0.0.1/24)
|
||||
# AGGREGATOR_PORT - UDP port the aggregator listens on (default: 5005)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Linux with bridge-utils and iproute2
|
||||
# - QEMU with ESP32-S3 machine support (qemu-system-xtensa)
|
||||
# - provision.py capable of --dry-run NVS generation
|
||||
# - Rust workspace with wifi-densepose-hardware crate (aggregator binary)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 PASS — all checks passed
|
||||
# 1 WARN — non-critical checks failed
|
||||
# 2 FAIL — critical checks failed
|
||||
# 3 FATAL — build error, crash, or infrastructure failure
|
||||
|
||||
# ── Help ──────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: sudo ./qemu-mesh-test.sh [OPTIONS] [N_NODES]
|
||||
|
||||
Spawn N ESP32-S3 QEMU instances connected via a Linux bridge, each with
|
||||
unique NVS provisioning (node ID, TDM slot), and a Rust aggregator that
|
||||
collects frames from all nodes.
|
||||
|
||||
NOTE: Requires root/sudo for TAP/bridge creation.
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Positional:
|
||||
N_NODES Number of mesh nodes (default: 3, minimum: 2)
|
||||
|
||||
Environment variables:
|
||||
QEMU_PATH Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
QEMU_TIMEOUT Timeout in seconds (default: 45)
|
||||
MESH_TIMEOUT Alias for QEMU_TIMEOUT (deprecated)(default: 45)
|
||||
SKIP_BUILD Set to "1" to skip idf.py build (default: unset)
|
||||
BRIDGE_NAME Bridge interface name (default: qemu-br0)
|
||||
BRIDGE_SUBNET Bridge IP/mask (default: 10.0.0.1/24)
|
||||
AGGREGATOR_PORT UDP port for aggregator (default: 5005)
|
||||
|
||||
Examples:
|
||||
sudo ./qemu-mesh-test.sh
|
||||
sudo QEMU_TIMEOUT=90 ./qemu-mesh-test.sh 5
|
||||
sudo SKIP_BUILD=1 ./qemu-mesh-test.sh 4
|
||||
|
||||
Exit codes:
|
||||
0 PASS — all checks passed
|
||||
1 WARN — non-critical checks failed
|
||||
2 FAIL — critical checks failed
|
||||
3 FATAL — build error, crash, or infrastructure failure
|
||||
HELP
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in -h|--help) usage ;; esac
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/firmware/esp32-csi-node"
|
||||
BUILD_DIR="$FIRMWARE_DIR/build"
|
||||
RUST_DIR="$PROJECT_ROOT/rust-port/wifi-densepose-rs"
|
||||
PROVISION_SCRIPT="$FIRMWARE_DIR/provision.py"
|
||||
VALIDATE_SCRIPT="$SCRIPT_DIR/validate_mesh_test.py"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
N_NODES="${1:-3}"
|
||||
QEMU_BIN="${QEMU_PATH:-qemu-system-xtensa}"
|
||||
TIMEOUT="${QEMU_TIMEOUT:-${MESH_TIMEOUT:-45}}"
|
||||
BRIDGE="${BRIDGE_NAME:-qemu-br0}"
|
||||
BRIDGE_IP="${BRIDGE_SUBNET:-10.0.0.1/24}"
|
||||
AGG_PORT="${AGGREGATOR_PORT:-5005}"
|
||||
RESULTS_FILE="$BUILD_DIR/mesh_test_results.json"
|
||||
|
||||
echo "=== QEMU Multi-Node Mesh Test (ADR-061 Layer 3) ==="
|
||||
echo "Nodes: $N_NODES"
|
||||
echo "Bridge: $BRIDGE ($BRIDGE_IP)"
|
||||
echo "Aggregator: 0.0.0.0:$AGG_PORT"
|
||||
echo "QEMU binary: $QEMU_BIN"
|
||||
echo "Timeout: ${TIMEOUT}s"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$N_NODES" -lt 2 ]; then
|
||||
echo "ERROR: Need at least 2 nodes for mesh simulation (got $N_NODES)"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v "$QEMU_BIN" &>/dev/null; then
|
||||
echo "ERROR: QEMU binary not found: $QEMU_BIN"
|
||||
echo " Install: sudo apt install qemu-system-misc # Debian/Ubuntu"
|
||||
echo " Install: brew install qemu # macOS"
|
||||
echo " Or set QEMU_PATH to the qemu-system-xtensa binary."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
echo "ERROR: python3 not found."
|
||||
echo " Install: sudo apt install python3 # Debian/Ubuntu"
|
||||
echo " Install: brew install python # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v ip &>/dev/null; then
|
||||
echo "ERROR: 'ip' command not found."
|
||||
echo " Install: sudo apt install iproute2 # Debian/Ubuntu"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v brctl &>/dev/null && ! ip link help bridge &>/dev/null 2>&1; then
|
||||
echo "WARNING: bridge-utils not found; will use 'ip link' for bridge creation."
|
||||
fi
|
||||
|
||||
if command -v socat &>/dev/null; then
|
||||
true # optional, available
|
||||
else
|
||||
echo "NOTE: socat not found (optional, used for advanced monitor communication)."
|
||||
echo " Install: sudo apt install socat # Debian/Ubuntu"
|
||||
echo " Install: brew install socat # macOS"
|
||||
fi
|
||||
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
echo "ERROR: cargo not found (needed to build the Rust aggregator)."
|
||||
echo " Install: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (for TAP/bridge creation)."
|
||||
echo "Usage: sudo $0 [N_NODES]"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup trap — runs on EXIT regardless of success/failure
|
||||
# ---------------------------------------------------------------------------
|
||||
QEMU_PIDS=()
|
||||
AGG_PID=""
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "--- Cleaning up ---"
|
||||
|
||||
# Kill QEMU instances
|
||||
for pid in "${QEMU_PIDS[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Kill aggregator
|
||||
if [ -n "$AGG_PID" ] && kill -0 "$AGG_PID" 2>/dev/null; then
|
||||
kill "$AGG_PID" 2>/dev/null || true
|
||||
wait "$AGG_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Tear down TAP interfaces and bridge
|
||||
for i in $(seq 0 $((N_NODES - 1))); do
|
||||
local tap="tap${i}"
|
||||
if ip link show "$tap" &>/dev/null; then
|
||||
ip link set "$tap" down 2>/dev/null || true
|
||||
ip link delete "$tap" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
if ip link show "$BRIDGE" &>/dev/null; then
|
||||
ip link set "$BRIDGE" down 2>/dev/null || true
|
||||
ip link delete "$BRIDGE" type bridge 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Cleanup complete."
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Build flash image (if not already built)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${SKIP_BUILD:-}" != "1" ]; then
|
||||
echo "[1/6] Building firmware (mock CSI + QEMU overlay)..."
|
||||
idf.py -C "$FIRMWARE_DIR" \
|
||||
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \
|
||||
build
|
||||
echo ""
|
||||
else
|
||||
echo "[1/6] Skipping build (SKIP_BUILD=1)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Verify build artifacts
|
||||
FLASH_IMAGE_BASE="$BUILD_DIR/qemu_flash_base.bin"
|
||||
for artifact in \
|
||||
"$BUILD_DIR/bootloader/bootloader.bin" \
|
||||
"$BUILD_DIR/partition_table/partition-table.bin" \
|
||||
"$BUILD_DIR/esp32-csi-node.bin"; do
|
||||
if [ ! -f "$artifact" ]; then
|
||||
echo "ERROR: Build artifact not found: $artifact"
|
||||
echo "Run without SKIP_BUILD=1 or build the firmware first."
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
|
||||
# Merge into base flash image
|
||||
echo "[2/6] Creating base flash image..."
|
||||
OTA_DATA_ARGS=""
|
||||
if [ -f "$BUILD_DIR/ota_data_initial.bin" ]; then
|
||||
OTA_DATA_ARGS="0xf000 $BUILD_DIR/ota_data_initial.bin"
|
||||
fi
|
||||
|
||||
python3 -m esptool --chip esp32s3 merge_bin -o "$FLASH_IMAGE_BASE" \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
0x0 "$BUILD_DIR/bootloader/bootloader.bin" \
|
||||
0x8000 "$BUILD_DIR/partition_table/partition-table.bin" \
|
||||
$OTA_DATA_ARGS \
|
||||
0x20000 "$BUILD_DIR/esp32-csi-node.bin"
|
||||
|
||||
echo "Base flash image: $FLASH_IMAGE_BASE ($(stat -c%s "$FLASH_IMAGE_BASE" 2>/dev/null || stat -f%z "$FLASH_IMAGE_BASE") bytes)"
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Generate per-node NVS and flash images
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[3/6] Generating per-node NVS images..."
|
||||
|
||||
# Extract the aggregator IP from the bridge subnet (first host)
|
||||
AGG_IP="${BRIDGE_IP%%/*}"
|
||||
|
||||
for i in $(seq 0 $((N_NODES - 1))); do
|
||||
NVS_BIN="$BUILD_DIR/nvs_node${i}.bin"
|
||||
NODE_FLASH="$BUILD_DIR/qemu_flash_node${i}.bin"
|
||||
|
||||
# Generate NVS with provision.py --dry-run
|
||||
# --port is required by argparse but unused in dry-run; pass a dummy
|
||||
python3 "$PROVISION_SCRIPT" \
|
||||
--port /dev/null \
|
||||
--dry-run \
|
||||
--node-id "$i" \
|
||||
--tdm-slot "$i" \
|
||||
--tdm-total "$N_NODES" \
|
||||
--target-ip "$AGG_IP" \
|
||||
--target-port "$AGG_PORT"
|
||||
|
||||
# provision.py --dry-run writes to nvs_provision.bin in CWD
|
||||
if [ -f "nvs_provision.bin" ]; then
|
||||
mv "nvs_provision.bin" "$NVS_BIN"
|
||||
else
|
||||
echo "ERROR: provision.py did not produce nvs_provision.bin for node $i"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Copy base image and inject NVS at 0x9000
|
||||
cp "$FLASH_IMAGE_BASE" "$NODE_FLASH"
|
||||
dd if="$NVS_BIN" of="$NODE_FLASH" \
|
||||
bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null
|
||||
|
||||
echo " Node $i: flash=$NODE_FLASH nvs=$NVS_BIN (TDM slot $i/$N_NODES)"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Create bridge and TAP interfaces
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[4/6] Setting up network bridge and TAP interfaces..."
|
||||
|
||||
# Create bridge
|
||||
ip link add name "$BRIDGE" type bridge 2>/dev/null || true
|
||||
ip addr add "$BRIDGE_IP" dev "$BRIDGE" 2>/dev/null || true
|
||||
ip link set "$BRIDGE" up
|
||||
|
||||
# Create TAP interfaces and attach to bridge
|
||||
for i in $(seq 0 $((N_NODES - 1))); do
|
||||
TAP="tap${i}"
|
||||
ip tuntap add dev "$TAP" mode tap 2>/dev/null || true
|
||||
ip link set "$TAP" master "$BRIDGE"
|
||||
ip link set "$TAP" up
|
||||
echo " $TAP -> $BRIDGE"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Start aggregator and QEMU instances
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[5/6] Starting aggregator and $N_NODES QEMU nodes..."
|
||||
|
||||
# Start Rust aggregator in background
|
||||
echo " Starting aggregator: listen=0.0.0.0:$AGG_PORT expect-nodes=$N_NODES"
|
||||
cargo run --manifest-path "$RUST_DIR/Cargo.toml" \
|
||||
-p wifi-densepose-hardware --bin aggregator -- \
|
||||
--listen "0.0.0.0:$AGG_PORT" \
|
||||
--expect-nodes "$N_NODES" \
|
||||
--output "$RESULTS_FILE" \
|
||||
> "$BUILD_DIR/aggregator.log" 2>&1 &
|
||||
AGG_PID=$!
|
||||
echo " Aggregator PID: $AGG_PID"
|
||||
|
||||
# Give aggregator a moment to bind
|
||||
sleep 1
|
||||
|
||||
if ! kill -0 "$AGG_PID" 2>/dev/null; then
|
||||
echo "ERROR: Aggregator failed to start. Check $BUILD_DIR/aggregator.log"
|
||||
cat "$BUILD_DIR/aggregator.log" 2>/dev/null || true
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Launch QEMU instances
|
||||
for i in $(seq 0 $((N_NODES - 1))); do
|
||||
TAP="tap${i}"
|
||||
NODE_FLASH="$BUILD_DIR/qemu_flash_node${i}.bin"
|
||||
NODE_LOG="$BUILD_DIR/qemu_node${i}.log"
|
||||
NODE_MAC=$(printf "52:54:00:00:00:%02x" "$i")
|
||||
|
||||
echo " Starting QEMU node $i (tap=$TAP, mac=$NODE_MAC)..."
|
||||
|
||||
"$QEMU_BIN" \
|
||||
-machine esp32s3 \
|
||||
-nographic \
|
||||
-drive "file=$NODE_FLASH,if=mtd,format=raw" \
|
||||
-serial "file:$NODE_LOG" \
|
||||
-no-reboot \
|
||||
-nic "tap,ifname=$TAP,script=no,downscript=no,mac=$NODE_MAC" \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
QEMU_PIDS+=($!)
|
||||
echo " PID: ${QEMU_PIDS[-1]}, log: $NODE_LOG"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "All nodes launched. Waiting ${TIMEOUT}s for mesh simulation..."
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
sleep "$TIMEOUT"
|
||||
|
||||
echo "Timeout reached. Stopping all processes..."
|
||||
|
||||
# Kill QEMU instances (aggregator killed in cleanup)
|
||||
for pid in "${QEMU_PIDS[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Give aggregator a moment to flush results
|
||||
sleep 2
|
||||
|
||||
# Kill aggregator
|
||||
if [ -n "$AGG_PID" ] && kill -0 "$AGG_PID" 2>/dev/null; then
|
||||
kill "$AGG_PID" 2>/dev/null || true
|
||||
wait "$AGG_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Validate results
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[6/6] Validating mesh test results..."
|
||||
|
||||
VALIDATE_ARGS=("--nodes" "$N_NODES")
|
||||
|
||||
# Pass results file if it was produced
|
||||
if [ -f "$RESULTS_FILE" ]; then
|
||||
VALIDATE_ARGS+=("--results" "$RESULTS_FILE")
|
||||
else
|
||||
echo "WARNING: Aggregator results file not found: $RESULTS_FILE"
|
||||
echo "Validation will rely on node logs only."
|
||||
fi
|
||||
|
||||
# Pass node log files
|
||||
for i in $(seq 0 $((N_NODES - 1))); do
|
||||
NODE_LOG="$BUILD_DIR/qemu_node${i}.log"
|
||||
if [ -f "$NODE_LOG" ]; then
|
||||
VALIDATE_ARGS+=("--log" "$NODE_LOG")
|
||||
fi
|
||||
done
|
||||
|
||||
python3 "$VALIDATE_SCRIPT" "${VALIDATE_ARGS[@]}"
|
||||
VALIDATE_EXIT=$?
|
||||
|
||||
echo ""
|
||||
echo "=== Mesh Test Complete (exit code: $VALIDATE_EXIT) ==="
|
||||
exit $VALIDATE_EXIT
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/bin/bash
|
||||
# QEMU Snapshot-Based Test Runner — ADR-061 Layer 8
|
||||
#
|
||||
# Uses QEMU VM snapshots to accelerate repeated test runs.
|
||||
# Instead of rebooting and re-initializing for each test scenario,
|
||||
# we snapshot the VM state after boot and after the first CSI frame,
|
||||
# then restore from the snapshot for each individual test.
|
||||
#
|
||||
# This dramatically reduces per-test wall time from ~15s (full boot)
|
||||
# to ~2s (snapshot restore + execution).
|
||||
#
|
||||
# Environment variables:
|
||||
# QEMU_PATH - Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
# QEMU_TIMEOUT - Per-test timeout in seconds (default: 10)
|
||||
# FLASH_IMAGE - Path to merged flash image (default: build/qemu_flash.bin)
|
||||
# SKIP_SNAPSHOT - Set to "1" to run without snapshots (baseline timing)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 PASS — all checks passed
|
||||
# 1 WARN — non-critical checks failed
|
||||
# 2 FAIL — critical checks failed
|
||||
# 3 FATAL — build error, crash, or infrastructure failure
|
||||
|
||||
# ── Help ──────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<'HELP'
|
||||
Usage: qemu-snapshot-test.sh [OPTIONS]
|
||||
|
||||
Use QEMU VM snapshots to accelerate repeated test runs. Snapshots the VM
|
||||
state after boot and after the first CSI frame, then restores from the
|
||||
snapshot for each individual test (~2s vs ~15s per test).
|
||||
|
||||
Options:
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables:
|
||||
QEMU_PATH Path to qemu-system-xtensa (default: qemu-system-xtensa)
|
||||
QEMU_TIMEOUT Per-test timeout in seconds (default: 10)
|
||||
FLASH_IMAGE Path to merged flash image (default: build/qemu_flash.bin)
|
||||
SKIP_SNAPSHOT Set to "1" to run without snapshots (baseline timing)
|
||||
|
||||
Examples:
|
||||
./qemu-snapshot-test.sh
|
||||
QEMU_TIMEOUT=20 ./qemu-snapshot-test.sh
|
||||
FLASH_IMAGE=/path/to/image.bin ./qemu-snapshot-test.sh
|
||||
|
||||
Exit codes:
|
||||
0 PASS — all checks passed
|
||||
1 WARN — non-critical checks failed
|
||||
2 FAIL — critical checks failed
|
||||
3 FATAL — build error, crash, or infrastructure failure
|
||||
HELP
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in -h|--help) usage ;; esac
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
FIRMWARE_DIR="$PROJECT_ROOT/firmware/esp32-csi-node"
|
||||
BUILD_DIR="$FIRMWARE_DIR/build"
|
||||
QEMU_BIN="${QEMU_PATH:-qemu-system-xtensa}"
|
||||
FLASH_IMAGE="${FLASH_IMAGE:-$BUILD_DIR/qemu_flash.bin}"
|
||||
TIMEOUT_SEC="${QEMU_TIMEOUT:-10}"
|
||||
MONITOR_SOCK="$BUILD_DIR/qemu-monitor.sock"
|
||||
LOG_DIR="$BUILD_DIR/snapshot-tests"
|
||||
QEMU_PID=""
|
||||
|
||||
# Timing accumulators
|
||||
SNAPSHOT_TOTAL_MS=0
|
||||
BASELINE_TOTAL_MS=0
|
||||
|
||||
# Track test results: array of "test_name:exit_code"
|
||||
declare -a TEST_RESULTS=()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Cleanup
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "[cleanup] Shutting down QEMU and removing socket..."
|
||||
if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
kill "$QEMU_PID" 2>/dev/null || true
|
||||
wait "$QEMU_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$MONITOR_SOCK"
|
||||
echo "[cleanup] Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
now_ms() {
|
||||
# Millisecond timestamp (portable: Linux date +%s%N, macOS perl fallback)
|
||||
local ns
|
||||
ns=$(date +%s%N 2>/dev/null)
|
||||
if [[ "$ns" =~ ^[0-9]+$ ]]; then
|
||||
echo $(( ns / 1000000 ))
|
||||
else
|
||||
perl -MTime::HiRes=time -e 'printf "%d\n", time()*1000' 2>/dev/null || \
|
||||
echo $(( $(date +%s) * 1000 ))
|
||||
fi
|
||||
}
|
||||
|
||||
monitor_cmd() {
|
||||
# Send a command to QEMU monitor via socat and capture response
|
||||
local cmd="$1"
|
||||
local timeout="${2:-5}"
|
||||
if ! command -v socat &>/dev/null; then
|
||||
echo "ERROR: socat not found (required for QEMU monitor)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$cmd" | socat - "UNIX-CONNECT:$MONITOR_SOCK,connect-timeout=$timeout" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_pattern() {
|
||||
# Wait until a pattern appears in the log file, or timeout
|
||||
local log_file="$1"
|
||||
local pattern="$2"
|
||||
local timeout="$3"
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
if [ -f "$log_file" ] && grep -q "$pattern" "$log_file" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start_qemu() {
|
||||
# Launch QEMU in background with monitor socket
|
||||
echo "[qemu] Launching QEMU with monitor socket..."
|
||||
|
||||
rm -f "$MONITOR_SOCK"
|
||||
|
||||
local qemu_args=(
|
||||
-machine esp32s3
|
||||
-nographic
|
||||
-drive "file=$FLASH_IMAGE,if=mtd,format=raw"
|
||||
-serial "file:$LOG_DIR/qemu_uart.log"
|
||||
-no-reboot
|
||||
-monitor "unix:$MONITOR_SOCK,server,nowait"
|
||||
)
|
||||
|
||||
"$QEMU_BIN" "${qemu_args[@]}" &
|
||||
QEMU_PID=$!
|
||||
echo "[qemu] PID=$QEMU_PID"
|
||||
|
||||
# Wait for monitor socket to appear
|
||||
local waited=0
|
||||
while [ ! -S "$MONITOR_SOCK" ] && [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
if [ ! -S "$MONITOR_SOCK" ]; then
|
||||
echo "ERROR: QEMU monitor socket did not appear after 10s"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify QEMU is still running
|
||||
if ! kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo "ERROR: QEMU process exited prematurely"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[qemu] Monitor socket ready: $MONITOR_SOCK"
|
||||
}
|
||||
|
||||
save_snapshot() {
|
||||
local name="$1"
|
||||
echo "[snapshot] Saving snapshot: $name"
|
||||
monitor_cmd "savevm $name" 5
|
||||
echo "[snapshot] Saved: $name"
|
||||
}
|
||||
|
||||
restore_snapshot() {
|
||||
local name="$1"
|
||||
echo "[snapshot] Restoring snapshot: $name"
|
||||
monitor_cmd "loadvm $name" 5
|
||||
echo "[snapshot] Restored: $name"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Pre-flight checks
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== QEMU Snapshot Test Runner — ADR-061 Layer 8 ==="
|
||||
echo "QEMU binary: $QEMU_BIN"
|
||||
echo "Flash image: $FLASH_IMAGE"
|
||||
echo "Timeout/test: ${TIMEOUT_SEC}s"
|
||||
echo ""
|
||||
|
||||
if ! command -v "$QEMU_BIN" &>/dev/null; then
|
||||
echo "ERROR: QEMU binary not found: $QEMU_BIN"
|
||||
echo " Install: sudo apt install qemu-system-misc # Debian/Ubuntu"
|
||||
echo " Install: brew install qemu # macOS"
|
||||
echo " Or set QEMU_PATH to the qemu-system-xtensa binary."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v qemu-img &>/dev/null; then
|
||||
echo "ERROR: qemu-img not found (needed for snapshot disk management)."
|
||||
echo " Install: sudo apt install qemu-utils # Debian/Ubuntu"
|
||||
echo " Install: brew install qemu # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if ! command -v socat &>/dev/null; then
|
||||
echo "ERROR: socat not found (needed for QEMU monitor communication)."
|
||||
echo " Install: sudo apt install socat # Debian/Ubuntu"
|
||||
echo " Install: brew install socat # macOS"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [ ! -f "$FLASH_IMAGE" ]; then
|
||||
echo "ERROR: Flash image not found: $FLASH_IMAGE"
|
||||
echo "Run qemu-esp32s3-test.sh first to build the flash image."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Phase 1: Boot and create snapshots
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Phase 1: Boot and snapshot creation ──"
|
||||
echo ""
|
||||
|
||||
# Clear any previous UART log
|
||||
> "$LOG_DIR/qemu_uart.log"
|
||||
|
||||
start_qemu
|
||||
|
||||
# Wait for boot (look for boot indicators, max 5s)
|
||||
echo "[boot] Waiting for firmware boot (up to 5s)..."
|
||||
if wait_for_pattern "$LOG_DIR/qemu_uart.log" "app_main\|main_task\|ESP32-S3" 5; then
|
||||
echo "[boot] Firmware booted successfully."
|
||||
else
|
||||
echo "[boot] No boot indicator found after 5s (continuing anyway)."
|
||||
fi
|
||||
|
||||
# Save post-boot snapshot
|
||||
save_snapshot "post_boot"
|
||||
echo ""
|
||||
|
||||
# Wait for first mock CSI frame (additional 5s)
|
||||
echo "[frame] Waiting for first CSI frame (up to 5s)..."
|
||||
if wait_for_pattern "$LOG_DIR/qemu_uart.log" "frame\|CSI\|mock_csi\|iq_data\|subcarrier" 5; then
|
||||
echo "[frame] First CSI frame detected."
|
||||
else
|
||||
echo "[frame] No frame indicator found after 5s (continuing anyway)."
|
||||
fi
|
||||
|
||||
# Save post-first-frame snapshot
|
||||
save_snapshot "post_first_frame"
|
||||
echo ""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Run tests from snapshot
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Phase 2: Running tests from snapshot ──"
|
||||
echo ""
|
||||
|
||||
TESTS=("test_presence" "test_fall" "test_multi_person")
|
||||
MAX_EXIT=0
|
||||
|
||||
for test_name in "${TESTS[@]}"; do
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Test: $test_name"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
test_log="$LOG_DIR/${test_name}.log"
|
||||
t_start=$(now_ms)
|
||||
|
||||
# Restore to post_first_frame state
|
||||
restore_snapshot "post_first_frame"
|
||||
|
||||
# Record current log length so we can extract only new lines
|
||||
pre_lines=$(wc -l < "$LOG_DIR/qemu_uart.log" 2>/dev/null || echo 0)
|
||||
|
||||
# Let execution continue for TIMEOUT_SEC seconds
|
||||
echo "[test] Running for ${TIMEOUT_SEC}s..."
|
||||
sleep "$TIMEOUT_SEC"
|
||||
|
||||
# Capture only the new log lines produced during this test
|
||||
tail -n +$((pre_lines + 1)) "$LOG_DIR/qemu_uart.log" > "$test_log"
|
||||
|
||||
t_end=$(now_ms)
|
||||
elapsed_ms=$((t_end - t_start))
|
||||
SNAPSHOT_TOTAL_MS=$((SNAPSHOT_TOTAL_MS + elapsed_ms))
|
||||
|
||||
echo "[test] Captured $(wc -l < "$test_log") lines in ${elapsed_ms}ms"
|
||||
|
||||
# Validate
|
||||
echo "[test] Validating..."
|
||||
test_exit=0
|
||||
python3 "$SCRIPT_DIR/validate_qemu_output.py" "$test_log" || test_exit=$?
|
||||
|
||||
TEST_RESULTS+=("${test_name}:${test_exit}")
|
||||
if [ "$test_exit" -gt "$MAX_EXIT" ]; then
|
||||
MAX_EXIT=$test_exit
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Phase 3: Baseline timing (without snapshots) for comparison
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Phase 3: Timing comparison ──"
|
||||
echo ""
|
||||
|
||||
# Estimate baseline: full boot (5s) + frame wait (5s) + test run per test
|
||||
BASELINE_PER_TEST=$((5 + 5 + TIMEOUT_SEC))
|
||||
BASELINE_TOTAL_MS=$((BASELINE_PER_TEST * ${#TESTS[@]} * 1000))
|
||||
SNAPSHOT_PER_TEST=$((SNAPSHOT_TOTAL_MS / ${#TESTS[@]}))
|
||||
|
||||
echo "Timing Summary:"
|
||||
echo " Tests run: ${#TESTS[@]}"
|
||||
echo " With snapshots:"
|
||||
echo " Total wall time: ${SNAPSHOT_TOTAL_MS}ms"
|
||||
echo " Per-test average: ${SNAPSHOT_PER_TEST}ms"
|
||||
echo " Without snapshots (estimated):"
|
||||
echo " Total wall time: ${BASELINE_TOTAL_MS}ms"
|
||||
echo " Per-test average: $((BASELINE_PER_TEST * 1000))ms"
|
||||
echo ""
|
||||
|
||||
if [ "$SNAPSHOT_TOTAL_MS" -gt 0 ] && [ "$BASELINE_TOTAL_MS" -gt 0 ]; then
|
||||
SPEEDUP=$((BASELINE_TOTAL_MS * 100 / SNAPSHOT_TOTAL_MS))
|
||||
echo " Speedup: ${SPEEDUP}% (${SPEEDUP}x/100)"
|
||||
else
|
||||
echo " Speedup: N/A (insufficient data)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "── Test Results Summary ──"
|
||||
echo ""
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
for result in "${TEST_RESULTS[@]}"; do
|
||||
name="${result%%:*}"
|
||||
code="${result##*:}"
|
||||
if [ "$code" -le 1 ]; then
|
||||
echo " [PASS] $name (exit=$code)"
|
||||
PASS_COUNT=$((PASS_COUNT + 1))
|
||||
else
|
||||
echo " [FAIL] $name (exit=$code)"
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " $PASS_COUNT passed, $FAIL_COUNT failed out of ${#TESTS[@]} tests"
|
||||
echo ""
|
||||
echo "=== Snapshot Test Complete (exit code: $MAX_EXIT) ==="
|
||||
exit "$MAX_EXIT"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,671 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QEMU Swarm Health Oracle (ADR-062)
|
||||
|
||||
Validates collective health of a multi-node ESP32-S3 QEMU swarm.
|
||||
Checks cross-node assertions like TDM ordering, inter-node communication,
|
||||
and swarm-level frame rates.
|
||||
|
||||
Usage:
|
||||
python3 swarm_health.py --config swarm_config.yaml --log-dir build/swarm_logs/
|
||||
python3 swarm_health.py --log-dir build/swarm_logs/ --assertions all_nodes_boot no_crashes
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANSI helpers (disabled when not a TTY)
|
||||
# ---------------------------------------------------------------------------
|
||||
USE_COLOR = sys.stdout.isatty()
|
||||
|
||||
|
||||
def _color(text: str, code: str) -> str:
|
||||
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
|
||||
|
||||
|
||||
def green(t: str) -> str:
|
||||
return _color(t, "32")
|
||||
|
||||
|
||||
def yellow(t: str) -> str:
|
||||
return _color(t, "33")
|
||||
|
||||
|
||||
def red(t: str) -> str:
|
||||
return _color(t, "1;31")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AssertionResult:
|
||||
"""Result of a single swarm-level assertion."""
|
||||
name: str
|
||||
passed: bool
|
||||
message: str
|
||||
severity: int # 0 = pass, 1 = warn, 2 = fail
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeLog:
|
||||
"""Parsed log for a single QEMU node."""
|
||||
node_id: int
|
||||
lines: List[str]
|
||||
text: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_logs(log_dir: Path, node_count: int) -> List[NodeLog]:
|
||||
"""Load qemu_node{i}.log (or node_{i}.log fallback) from *log_dir*."""
|
||||
logs: List[NodeLog] = []
|
||||
for i in range(node_count):
|
||||
path = log_dir / f"qemu_node{i}.log"
|
||||
if not path.exists():
|
||||
path = log_dir / f"node_{i}.log"
|
||||
if path.exists():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
text = ""
|
||||
logs.append(NodeLog(node_id=i, lines=text.splitlines(), text=text))
|
||||
return logs
|
||||
|
||||
|
||||
def _node_count_from_dir(log_dir: Path) -> int:
|
||||
"""Auto-detect node count by scanning for qemu_node*.log (or node_*.log) files."""
|
||||
count = 0
|
||||
while (log_dir / f"qemu_node{count}.log").exists() or (log_dir / f"node_{count}.log").exists():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BOOT_PATTERNS = [
|
||||
r"app_main\(\)", r"main_task:", r"main:", r"ESP32-S3 CSI Node",
|
||||
]
|
||||
|
||||
_CRASH_PATTERNS = [
|
||||
r"Guru Meditation", r"assert failed", r"abort\(\)", r"panic",
|
||||
r"LoadProhibited", r"StoreProhibited", r"InstrFetchProhibited",
|
||||
r"IllegalInstruction", r"Unhandled debug exception", r"Fatal exception",
|
||||
]
|
||||
|
||||
_HEAP_PATTERNS = [
|
||||
r"HEAP_ERROR", r"out of memory", r"heap_caps_alloc.*failed",
|
||||
r"malloc.*fail", r"heap corruption", r"CORRUPT HEAP",
|
||||
r"multi_heap", r"heap_lock",
|
||||
]
|
||||
|
||||
_FRAME_PATTERNS = [
|
||||
r"frame", r"CSI", r"mock_csi", r"iq_data", r"subcarrier",
|
||||
r"csi_collector", r"enqueue",
|
||||
]
|
||||
|
||||
_FALL_PATTERNS = [r"fall[=: ]+1", r"fall detected", r"fall_event"]
|
||||
|
||||
|
||||
def assert_all_nodes_boot(logs: List[NodeLog], timeout_s: float = 10.0) -> AssertionResult:
|
||||
"""Check each node's log for boot patterns."""
|
||||
missing: List[int] = []
|
||||
for nl in logs:
|
||||
found = any(
|
||||
re.search(p, nl.text) for p in _BOOT_PATTERNS
|
||||
)
|
||||
if not found:
|
||||
missing.append(nl.node_id)
|
||||
|
||||
if not missing:
|
||||
return AssertionResult(
|
||||
name="all_nodes_boot", passed=True,
|
||||
message=f"All {len(logs)} nodes booted (timeout={timeout_s}s)",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="all_nodes_boot", passed=False,
|
||||
message=f"Nodes missing boot indicator: {missing}",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
|
||||
def assert_no_crashes(logs: List[NodeLog]) -> AssertionResult:
|
||||
"""Check no node has crash patterns."""
|
||||
crashed: List[str] = []
|
||||
for nl in logs:
|
||||
for line in nl.lines:
|
||||
for pat in _CRASH_PATTERNS:
|
||||
if re.search(pat, line):
|
||||
crashed.append(f"node_{nl.node_id}: {line.strip()[:100]}")
|
||||
break
|
||||
if crashed and crashed[-1].startswith(f"node_{nl.node_id}:"):
|
||||
break # one crash per node is enough
|
||||
|
||||
if not crashed:
|
||||
return AssertionResult(
|
||||
name="no_crashes", passed=True,
|
||||
message="No crash indicators in any node",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="no_crashes", passed=False,
|
||||
message=f"Crashes found: {crashed[0]}" + (
|
||||
f" (+{len(crashed)-1} more)" if len(crashed) > 1 else ""
|
||||
),
|
||||
severity=2,
|
||||
)
|
||||
|
||||
|
||||
def assert_tdm_no_collision(logs: List[NodeLog]) -> AssertionResult:
|
||||
"""Parse TDM slot assignments from logs, verify uniqueness."""
|
||||
slot_map: Dict[int, List[int]] = {} # slot -> [node_ids]
|
||||
tdm_pat = re.compile(r"tdm[_ ]?slot[=: ]+(\d+)", re.IGNORECASE)
|
||||
|
||||
for nl in logs:
|
||||
for line in nl.lines:
|
||||
m = tdm_pat.search(line)
|
||||
if m:
|
||||
slot = int(m.group(1))
|
||||
slot_map.setdefault(slot, [])
|
||||
if nl.node_id not in slot_map[slot]:
|
||||
slot_map[slot].append(nl.node_id)
|
||||
break # first occurrence per node
|
||||
|
||||
collisions = {s: nids for s, nids in slot_map.items() if len(nids) > 1}
|
||||
|
||||
if not slot_map:
|
||||
return AssertionResult(
|
||||
name="tdm_no_collision", passed=True,
|
||||
message="No TDM slot assignments found (may be N/A)",
|
||||
severity=0,
|
||||
)
|
||||
if not collisions:
|
||||
return AssertionResult(
|
||||
name="tdm_no_collision", passed=True,
|
||||
message=f"TDM slots unique across {len(slot_map)} assignments",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="tdm_no_collision", passed=False,
|
||||
message=f"TDM collisions: {collisions}",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
|
||||
def assert_all_nodes_produce_frames(
|
||||
logs: List[NodeLog],
|
||||
sensor_ids: Optional[List[int]] = None,
|
||||
) -> AssertionResult:
|
||||
"""Each sensor node has CSI frame output.
|
||||
|
||||
Args:
|
||||
logs: Parsed node logs.
|
||||
sensor_ids: If provided, only check these node IDs (skip coordinators).
|
||||
If None, check all nodes (legacy behavior).
|
||||
"""
|
||||
silent: List[int] = []
|
||||
for nl in logs:
|
||||
if sensor_ids is not None and nl.node_id not in sensor_ids:
|
||||
continue
|
||||
found = any(
|
||||
re.search(p, line, re.IGNORECASE)
|
||||
for line in nl.lines for p in _FRAME_PATTERNS
|
||||
)
|
||||
if not found:
|
||||
silent.append(nl.node_id)
|
||||
|
||||
checked = len(sensor_ids) if sensor_ids is not None else len(logs)
|
||||
if not silent:
|
||||
return AssertionResult(
|
||||
name="all_nodes_produce_frames", passed=True,
|
||||
message=f"All {checked} checked nodes show frame activity",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="all_nodes_produce_frames", passed=False,
|
||||
message=f"Nodes with no frame activity: {silent}",
|
||||
severity=1,
|
||||
)
|
||||
|
||||
|
||||
def assert_coordinator_receives_from_all(
|
||||
logs: List[NodeLog],
|
||||
coordinator_id: int = 0,
|
||||
sensor_ids: Optional[List[int]] = None,
|
||||
) -> AssertionResult:
|
||||
"""Coordinator log shows frames from each sensor's node_id."""
|
||||
coord_log = None
|
||||
for nl in logs:
|
||||
if nl.node_id == coordinator_id:
|
||||
coord_log = nl
|
||||
break
|
||||
|
||||
if coord_log is None:
|
||||
return AssertionResult(
|
||||
name="coordinator_receives_from_all", passed=False,
|
||||
message=f"Coordinator node_{coordinator_id} log not found",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
if sensor_ids is None:
|
||||
sensor_ids = [nl.node_id for nl in logs if nl.node_id != coordinator_id]
|
||||
|
||||
missing: List[int] = []
|
||||
recv_pat = re.compile(r"(from|node_id|src)[=: ]+(\d+)", re.IGNORECASE)
|
||||
received_ids: set = set()
|
||||
for line in coord_log.lines:
|
||||
m = recv_pat.search(line)
|
||||
if m:
|
||||
received_ids.add(int(m.group(2)))
|
||||
|
||||
for sid in sensor_ids:
|
||||
if sid not in received_ids:
|
||||
missing.append(sid)
|
||||
|
||||
if not missing:
|
||||
return AssertionResult(
|
||||
name="coordinator_receives_from_all", passed=True,
|
||||
message=f"Coordinator received from all sensors: {sensor_ids}",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="coordinator_receives_from_all", passed=False,
|
||||
message=f"Coordinator missing frames from nodes: {missing}",
|
||||
severity=1,
|
||||
)
|
||||
|
||||
|
||||
def assert_fall_detected(logs: List[NodeLog], node_id: int) -> AssertionResult:
|
||||
"""Specific node reports fall detection."""
|
||||
for nl in logs:
|
||||
if nl.node_id == node_id:
|
||||
found = any(
|
||||
re.search(p, line, re.IGNORECASE)
|
||||
for line in nl.lines for p in _FALL_PATTERNS
|
||||
)
|
||||
if found:
|
||||
return AssertionResult(
|
||||
name=f"fall_detected_node_{node_id}", passed=True,
|
||||
message=f"Node {node_id} reported fall event",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name=f"fall_detected_node_{node_id}", passed=False,
|
||||
message=f"Node {node_id} did not report fall event",
|
||||
severity=1,
|
||||
)
|
||||
|
||||
return AssertionResult(
|
||||
name=f"fall_detected_node_{node_id}", passed=False,
|
||||
message=f"Node {node_id} log not found",
|
||||
severity=2,
|
||||
)
|
||||
|
||||
|
||||
def assert_frame_rate_above(logs: List[NodeLog], min_fps: float = 10.0) -> AssertionResult:
|
||||
"""Each node meets minimum frame rate."""
|
||||
fps_pat = re.compile(r"(?:fps|frame.?rate)[=: ]+([0-9.]+)", re.IGNORECASE)
|
||||
count_pat = re.compile(r"(?:frame[_ ]?count|frames)[=: ]+(\d+)", re.IGNORECASE)
|
||||
below: List[str] = []
|
||||
|
||||
for nl in logs:
|
||||
best_fps: Optional[float] = None
|
||||
# Try explicit FPS
|
||||
for line in nl.lines:
|
||||
m = fps_pat.search(line)
|
||||
if m:
|
||||
try:
|
||||
best_fps = max(best_fps or 0.0, float(m.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
# Fallback: estimate from frame count (assume 1-second intervals)
|
||||
if best_fps is None:
|
||||
counts = []
|
||||
for line in nl.lines:
|
||||
m = count_pat.search(line)
|
||||
if m:
|
||||
try:
|
||||
counts.append(int(m.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
if len(counts) >= 2:
|
||||
best_fps = float(counts[-1] - counts[0]) / max(len(counts) - 1, 1)
|
||||
|
||||
if best_fps is not None and best_fps < min_fps:
|
||||
below.append(f"node_{nl.node_id}={best_fps:.1f}")
|
||||
|
||||
if not below:
|
||||
return AssertionResult(
|
||||
name="frame_rate_above", passed=True,
|
||||
message=f"All nodes meet minimum {min_fps} fps",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="frame_rate_above", passed=False,
|
||||
message=f"Nodes below {min_fps} fps: {', '.join(below)}",
|
||||
severity=1,
|
||||
)
|
||||
|
||||
|
||||
def assert_max_boot_time(logs: List[NodeLog], max_seconds: float = 10.0) -> AssertionResult:
|
||||
"""All nodes boot within N seconds (based on timestamp in log)."""
|
||||
boot_time_pat = re.compile(r"\((\d+)\)\s", re.IGNORECASE)
|
||||
slow: List[str] = []
|
||||
|
||||
for nl in logs:
|
||||
boot_found = False
|
||||
for line in nl.lines:
|
||||
if any(re.search(p, line) for p in _BOOT_PATTERNS):
|
||||
boot_found = True
|
||||
m = boot_time_pat.search(line)
|
||||
if m:
|
||||
ms = int(m.group(1))
|
||||
if ms > max_seconds * 1000:
|
||||
slow.append(f"node_{nl.node_id}={ms}ms")
|
||||
break
|
||||
if not boot_found:
|
||||
slow.append(f"node_{nl.node_id}=no_boot")
|
||||
|
||||
if not slow:
|
||||
return AssertionResult(
|
||||
name="max_boot_time", passed=True,
|
||||
message=f"All nodes booted within {max_seconds}s",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="max_boot_time", passed=False,
|
||||
message=f"Slow/missing boot: {', '.join(slow)}",
|
||||
severity=1,
|
||||
)
|
||||
|
||||
|
||||
def assert_no_heap_errors(logs: List[NodeLog]) -> AssertionResult:
|
||||
"""No OOM/heap errors in any log."""
|
||||
errors: List[str] = []
|
||||
for nl in logs:
|
||||
for line in nl.lines:
|
||||
for pat in _HEAP_PATTERNS:
|
||||
if re.search(pat, line, re.IGNORECASE):
|
||||
errors.append(f"node_{nl.node_id}: {line.strip()[:100]}")
|
||||
break
|
||||
if errors and errors[-1].startswith(f"node_{nl.node_id}:"):
|
||||
break
|
||||
|
||||
if not errors:
|
||||
return AssertionResult(
|
||||
name="no_heap_errors", passed=True,
|
||||
message="No heap errors in any node",
|
||||
severity=0,
|
||||
)
|
||||
return AssertionResult(
|
||||
name="no_heap_errors", passed=False,
|
||||
message=f"Heap errors: {errors[0]}" + (
|
||||
f" (+{len(errors)-1} more)" if len(errors) > 1 else ""
|
||||
),
|
||||
severity=2,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assertion registry & dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ASSERTION_REGISTRY: Dict[str, Any] = {
|
||||
"all_nodes_boot": assert_all_nodes_boot,
|
||||
"no_crashes": assert_no_crashes,
|
||||
"tdm_no_collision": assert_tdm_no_collision,
|
||||
"all_nodes_produce_frames": assert_all_nodes_produce_frames,
|
||||
"coordinator_receives_from_all": assert_coordinator_receives_from_all,
|
||||
"frame_rate_above": assert_frame_rate_above,
|
||||
"max_boot_time": assert_max_boot_time,
|
||||
"no_heap_errors": assert_no_heap_errors,
|
||||
# fall_detected is parameterized, handled separately
|
||||
}
|
||||
|
||||
|
||||
def _parse_assertion_spec(spec: Any) -> tuple:
|
||||
"""Parse a YAML assertion entry into (name, kwargs).
|
||||
|
||||
Supported forms:
|
||||
- "all_nodes_boot" -> ("all_nodes_boot", {})
|
||||
- {"frame_rate_above": 15} -> ("frame_rate_above", {"min_fps": 15})
|
||||
- "fall_detected_by_node_2" -> ("fall_detected", {"node_id": 2})
|
||||
- {"max_boot_time_s": 10} -> ("max_boot_time", {"max_seconds": 10})
|
||||
"""
|
||||
if isinstance(spec, str):
|
||||
# Check for fall_detected_by_node_N pattern
|
||||
m = re.match(r"fall_detected_by_node_(\d+)", spec)
|
||||
if m:
|
||||
return ("fall_detected", {"node_id": int(m.group(1))})
|
||||
return (spec, {})
|
||||
|
||||
if isinstance(spec, dict):
|
||||
for key, val in spec.items():
|
||||
m = re.match(r"fall_detected_by_node_(\d+)", str(key))
|
||||
if m:
|
||||
return ("fall_detected", {"node_id": int(m.group(1))})
|
||||
if key == "frame_rate_above":
|
||||
return ("frame_rate_above", {"min_fps": float(val)})
|
||||
if key == "max_boot_time_s":
|
||||
return ("max_boot_time", {"max_seconds": float(val)})
|
||||
if key == "coordinator_receives_from_all":
|
||||
return ("coordinator_receives_from_all", {})
|
||||
return (str(key), {})
|
||||
|
||||
return (str(spec), {})
|
||||
|
||||
|
||||
def run_assertions(
|
||||
logs: List[NodeLog],
|
||||
assertion_specs: List[Any],
|
||||
config: Optional[Dict] = None,
|
||||
) -> List[AssertionResult]:
|
||||
"""Run all requested assertions against loaded logs."""
|
||||
results: List[AssertionResult] = []
|
||||
|
||||
# Derive coordinator/sensor IDs from config if available
|
||||
coordinator_id = 0
|
||||
sensor_ids: Optional[List[int]] = None
|
||||
if config and "nodes" in config:
|
||||
for node_def in config["nodes"]:
|
||||
if node_def.get("role") == "coordinator":
|
||||
coordinator_id = node_def.get("node_id", 0)
|
||||
sensor_ids = [
|
||||
n["node_id"] for n in config["nodes"]
|
||||
if n.get("role") == "sensor"
|
||||
]
|
||||
|
||||
for spec in assertion_specs:
|
||||
name, kwargs = _parse_assertion_spec(spec)
|
||||
|
||||
if name == "fall_detected":
|
||||
results.append(assert_fall_detected(logs, **kwargs))
|
||||
elif name == "coordinator_receives_from_all":
|
||||
results.append(assert_coordinator_receives_from_all(
|
||||
logs, coordinator_id=coordinator_id, sensor_ids=sensor_ids,
|
||||
))
|
||||
elif name == "all_nodes_produce_frames":
|
||||
results.append(assert_all_nodes_produce_frames(
|
||||
logs, sensor_ids=sensor_ids, **kwargs,
|
||||
))
|
||||
elif name in ASSERTION_REGISTRY:
|
||||
fn = ASSERTION_REGISTRY[name]
|
||||
results.append(fn(logs, **kwargs))
|
||||
else:
|
||||
results.append(AssertionResult(
|
||||
name=name, passed=False,
|
||||
message=f"Unknown assertion: {name}",
|
||||
severity=1,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report printing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_report(results: List[AssertionResult], swarm_name: str = "") -> int:
|
||||
"""Print the assertion report and return max severity."""
|
||||
header = "QEMU Swarm Health Report (ADR-062)"
|
||||
if swarm_name:
|
||||
header += f" - {swarm_name}"
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" {header}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
max_sev = 0
|
||||
for r in results:
|
||||
if r.severity == 0:
|
||||
icon = green("PASS")
|
||||
elif r.severity == 1:
|
||||
icon = yellow("WARN")
|
||||
else:
|
||||
icon = red("FAIL")
|
||||
|
||||
print(f" [{icon}] {r.name}: {r.message}")
|
||||
max_sev = max(max_sev, r.severity)
|
||||
|
||||
print()
|
||||
passed = sum(1 for r in results if r.passed)
|
||||
total = len(results)
|
||||
summary = f" {passed}/{total} assertions passed"
|
||||
|
||||
if max_sev == 0:
|
||||
print(green(summary))
|
||||
elif max_sev == 1:
|
||||
print(yellow(summary + " (with warnings)"))
|
||||
else:
|
||||
print(red(summary + " (with failures)"))
|
||||
|
||||
print()
|
||||
return max_sev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="QEMU Swarm Health Oracle (ADR-062)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Example:\n"
|
||||
" python3 swarm_health.py --config scripts/swarm_presets/standard.yaml \\\n"
|
||||
" --log-dir build/swarm_logs/\n"
|
||||
"\n"
|
||||
" python3 swarm_health.py --log-dir build/swarm_logs/ \\\n"
|
||||
" --assertions all_nodes_boot no_crashes\n"
|
||||
"\n"
|
||||
"Example output:\n"
|
||||
" ============================================================\n"
|
||||
" QEMU Swarm Health Report (ADR-062) - standard\n"
|
||||
" ============================================================\n"
|
||||
"\n"
|
||||
" [PASS] all_nodes_boot: All 3 nodes booted (timeout=10.0s)\n"
|
||||
" [PASS] no_crashes: No crash indicators in any node\n"
|
||||
" [PASS] tdm_no_collision: TDM slots unique across 3 assignments\n"
|
||||
" [PASS] all_nodes_produce_frames: All 3 nodes show frame activity\n"
|
||||
" [PASS] coordinator_receives_from_all: Coordinator received from all\n"
|
||||
" [WARN] fall_detected_node_2: Node 2 did not report fall event\n"
|
||||
" [PASS] frame_rate_above: All nodes meet minimum 15.0 fps\n"
|
||||
"\n"
|
||||
" 6/7 assertions passed (with warnings)\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", type=str, default=None,
|
||||
help="Path to swarm YAML config (defines nodes and assertions)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-dir", type=str, required=True,
|
||||
help="Directory containing node_0.log, node_1.log, etc.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assertions", nargs="*", default=None,
|
||||
help="Override assertions (space-separated). Ignores YAML assertion list.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--node-count", type=int, default=None,
|
||||
help="Number of nodes (auto-detected from log files if omitted)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log_dir = Path(args.log_dir)
|
||||
if not log_dir.is_dir():
|
||||
print(f"ERROR: Log directory not found: {log_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Load YAML config if provided
|
||||
config: Optional[Dict] = None
|
||||
swarm_name = ""
|
||||
yaml_assertions: List[Any] = []
|
||||
|
||||
if args.config:
|
||||
if yaml is None:
|
||||
print("ERROR: PyYAML is required for --config. Install with: pip install pyyaml",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
config_path = Path(args.config)
|
||||
if not config_path.exists():
|
||||
print(f"ERROR: Config file not found: {config_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
with open(config_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
swarm_name = config.get("swarm", {}).get("name", "")
|
||||
yaml_assertions = config.get("assertions", [])
|
||||
|
||||
# Determine node count
|
||||
if args.node_count is not None:
|
||||
node_count = args.node_count
|
||||
elif config and "nodes" in config:
|
||||
node_count = len(config["nodes"])
|
||||
else:
|
||||
node_count = _node_count_from_dir(log_dir)
|
||||
|
||||
if node_count == 0:
|
||||
print("ERROR: No node logs found and node count not specified.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Load logs
|
||||
logs = load_logs(log_dir, node_count)
|
||||
|
||||
# Determine which assertions to run
|
||||
if args.assertions is not None:
|
||||
assertion_specs = args.assertions
|
||||
elif yaml_assertions:
|
||||
assertion_specs = yaml_assertions
|
||||
else:
|
||||
# Default set
|
||||
assertion_specs = ["all_nodes_boot", "no_crashes", "no_heap_errors"]
|
||||
|
||||
# Run assertions
|
||||
results = run_assertions(logs, assertion_specs, config)
|
||||
|
||||
# Print report and exit
|
||||
max_sev = print_report(results, swarm_name)
|
||||
sys.exit(max_sev)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
# CI-optimized preset: 3 nodes, star topology, 30s, minimal assertions
|
||||
swarm:
|
||||
name: ci-matrix
|
||||
duration_s: 30
|
||||
topology: star
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- max_boot_time_s: 10
|
||||
@@ -0,0 +1,49 @@
|
||||
# Mixed scenarios: 5 nodes with different CSI scenarios, star topology, 90s
|
||||
swarm:
|
||||
name: heterogeneous
|
||||
duration_s: 90
|
||||
topology: star
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
- role: sensor
|
||||
node_id: 3
|
||||
scenario: 3
|
||||
channel: 6
|
||||
tdm_slot: 3
|
||||
|
||||
- role: sensor
|
||||
node_id: 4
|
||||
scenario: 5
|
||||
channel: 11
|
||||
tdm_slot: 4
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- coordinator_receives_from_all
|
||||
- fall_detected_by_node_3
|
||||
- no_heap_errors
|
||||
- frame_rate_above: 12
|
||||
- max_boot_time_s: 12
|
||||
@@ -0,0 +1,54 @@
|
||||
# Scale test: 6 fully-connected nodes in mesh topology, 90s
|
||||
swarm:
|
||||
name: large-mesh
|
||||
duration_s: 90
|
||||
topology: mesh
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
- role: sensor
|
||||
node_id: 3
|
||||
scenario: 3
|
||||
channel: 6
|
||||
tdm_slot: 3
|
||||
|
||||
- role: sensor
|
||||
node_id: 4
|
||||
scenario: 4
|
||||
channel: 6
|
||||
tdm_slot: 4
|
||||
|
||||
- role: sensor
|
||||
node_id: 5
|
||||
scenario: 5
|
||||
channel: 6
|
||||
tdm_slot: 5
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- coordinator_receives_from_all
|
||||
- no_heap_errors
|
||||
- frame_rate_above: 10
|
||||
- max_boot_time_s: 15
|
||||
@@ -0,0 +1,39 @@
|
||||
# Multi-hop relay chain: 4 nodes in line topology, 60s
|
||||
swarm:
|
||||
name: line-relay
|
||||
duration_s: 60
|
||||
topology: line
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: gateway
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true
|
||||
|
||||
- role: coordinator
|
||||
node_id: 1
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
- role: sensor
|
||||
node_id: 3
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 3
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- max_boot_time_s: 12
|
||||
@@ -0,0 +1,41 @@
|
||||
# Ring topology with fault injection: 4 nodes, 75s
|
||||
swarm:
|
||||
name: ring-fault
|
||||
duration_s: 75
|
||||
topology: ring
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
- role: sensor
|
||||
node_id: 3
|
||||
scenario: 3
|
||||
channel: 6
|
||||
tdm_slot: 3
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- coordinator_receives_from_all
|
||||
- no_heap_errors
|
||||
- max_boot_time_s: 12
|
||||
@@ -0,0 +1,24 @@
|
||||
# Quick CI smoke test: 2 nodes, star topology, 15s duration
|
||||
swarm:
|
||||
name: smoke
|
||||
duration_s: 15
|
||||
topology: star
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 1
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- max_boot_time_s: 10
|
||||
@@ -0,0 +1,36 @@
|
||||
# Standard 3-node test: 2 sensors + 1 coordinator, star topology, 60s
|
||||
swarm:
|
||||
name: standard
|
||||
duration_s: 60
|
||||
topology: star
|
||||
aggregator_port: 5005
|
||||
|
||||
nodes:
|
||||
- role: coordinator
|
||||
node_id: 0
|
||||
scenario: 0
|
||||
channel: 6
|
||||
edge_tier: 2
|
||||
is_gateway: true
|
||||
|
||||
- role: sensor
|
||||
node_id: 1
|
||||
scenario: 2
|
||||
channel: 6
|
||||
tdm_slot: 1
|
||||
|
||||
- role: sensor
|
||||
node_id: 2
|
||||
scenario: 3
|
||||
channel: 6
|
||||
tdm_slot: 2
|
||||
|
||||
assertions:
|
||||
- all_nodes_boot
|
||||
- no_crashes
|
||||
- tdm_no_collision
|
||||
- all_nodes_produce_frames
|
||||
- coordinator_receives_from_all
|
||||
- fall_detected_by_node_2
|
||||
- frame_rate_above: 15
|
||||
- max_boot_time_s: 10
|
||||
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QEMU Multi-Node Mesh Validation (ADR-061 Layer 3)
|
||||
|
||||
Validates the output of a multi-node mesh simulation run by qemu-mesh-test.sh.
|
||||
Parses the aggregator results JSON and per-node UART logs, then runs 6 checks:
|
||||
|
||||
1. All nodes booted - every node log contains a boot indicator
|
||||
2. TDM ordering - slot assignments are sequential 0..N-1
|
||||
3. No slot collision - no two nodes share a TDM slot
|
||||
4. Frame count balance - per-node frame counts within +/-10%
|
||||
5. ADR-018 compliance - magic 0xC5110001 present in frames
|
||||
6. Vitals per node - each node produced vitals output
|
||||
|
||||
Usage:
|
||||
python3 validate_mesh_test.py --nodes N [results.json] [--log node0.log] ...
|
||||
|
||||
Exit codes:
|
||||
0 All checks passed (or only SKIP-level)
|
||||
1 Warnings (non-critical checks failed)
|
||||
2 Errors (critical checks failed)
|
||||
3 Fatal (crash or missing nodes)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Severity / reporting (matches validate_qemu_output.py pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Severity(IntEnum):
|
||||
PASS = 0
|
||||
SKIP = 1
|
||||
WARN = 2
|
||||
ERROR = 3
|
||||
FATAL = 4
|
||||
|
||||
|
||||
USE_COLOR = sys.stdout.isatty()
|
||||
|
||||
|
||||
def color(text: str, code: str) -> str:
|
||||
if not USE_COLOR:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def green(text: str) -> str:
|
||||
return color(text, "32")
|
||||
|
||||
|
||||
def yellow(text: str) -> str:
|
||||
return color(text, "33")
|
||||
|
||||
|
||||
def red(text: str) -> str:
|
||||
return color(text, "31")
|
||||
|
||||
|
||||
def bold_red(text: str) -> str:
|
||||
return color(text, "1;31")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
severity: Severity
|
||||
message: str
|
||||
count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
checks: List[CheckResult] = field(default_factory=list)
|
||||
|
||||
def add(self, name: str, severity: Severity, message: str, count: int = 0):
|
||||
self.checks.append(CheckResult(name, severity, message, count))
|
||||
|
||||
@property
|
||||
def max_severity(self) -> Severity:
|
||||
if not self.checks:
|
||||
return Severity.PASS
|
||||
return max(c.severity for c in self.checks)
|
||||
|
||||
def print_report(self):
|
||||
print("\n" + "=" * 60)
|
||||
print(" Multi-Node Mesh Validation Report (ADR-061 Layer 3)")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
for check in self.checks:
|
||||
if check.severity == Severity.PASS:
|
||||
icon = green("PASS")
|
||||
elif check.severity == Severity.SKIP:
|
||||
icon = yellow("SKIP")
|
||||
elif check.severity == Severity.WARN:
|
||||
icon = yellow("WARN")
|
||||
elif check.severity == Severity.ERROR:
|
||||
icon = red("FAIL")
|
||||
else:
|
||||
icon = bold_red("FATAL")
|
||||
|
||||
count_str = f" (count={check.count})" if check.count > 0 else ""
|
||||
print(f" [{icon}] {check.name}: {check.message}{count_str}")
|
||||
|
||||
print()
|
||||
|
||||
passed = sum(1 for c in self.checks if c.severity <= Severity.SKIP)
|
||||
total = len(self.checks)
|
||||
summary = f" {passed}/{total} checks passed"
|
||||
|
||||
max_sev = self.max_severity
|
||||
if max_sev <= Severity.SKIP:
|
||||
print(green(summary))
|
||||
elif max_sev == Severity.WARN:
|
||||
print(yellow(summary + " (with warnings)"))
|
||||
elif max_sev == Severity.ERROR:
|
||||
print(red(summary + " (with errors)"))
|
||||
else:
|
||||
print(bold_red(summary + " (FATAL issues detected)"))
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_node_booted(log_text: str) -> bool:
|
||||
"""Return True if the log shows a boot indicator."""
|
||||
boot_patterns = [r"app_main\(\)", r"main_task:", r"main:", r"ESP32-S3 CSI Node"]
|
||||
return any(re.search(p, log_text) for p in boot_patterns)
|
||||
|
||||
|
||||
def check_node_crashed(log_text: str) -> Optional[str]:
|
||||
"""Return first crash line or None."""
|
||||
crash_patterns = [
|
||||
r"Guru Meditation", r"assert failed", r"abort\(\)",
|
||||
r"panic", r"LoadProhibited", r"StoreProhibited",
|
||||
r"InstrFetchProhibited", r"IllegalInstruction",
|
||||
]
|
||||
for line in log_text.splitlines():
|
||||
for pat in crash_patterns:
|
||||
if re.search(pat, line):
|
||||
return line.strip()[:120]
|
||||
return None
|
||||
|
||||
|
||||
def extract_node_id_from_log(log_text: str) -> Optional[int]:
|
||||
"""Try to extract the node_id from UART log lines."""
|
||||
patterns = [
|
||||
r"node_id[=: ]+(\d+)",
|
||||
r"Node ID[=: ]+(\d+)",
|
||||
r"TDM slot[=: ]+(\d+)",
|
||||
]
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
m = re.search(pat, line, re.IGNORECASE)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_vitals_in_log(log_text: str) -> bool:
|
||||
"""Return True if the log contains vitals output."""
|
||||
vitals_patterns = [r"vitals", r"breathing", r"breathing_bpm",
|
||||
r"heart_rate", r"heartrate"]
|
||||
return any(
|
||||
re.search(p, line, re.IGNORECASE)
|
||||
for line in log_text.splitlines()
|
||||
for p in vitals_patterns
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def validate_mesh(
|
||||
n_nodes: int,
|
||||
results_path: Optional[Path],
|
||||
log_paths: List[Path],
|
||||
) -> ValidationReport:
|
||||
"""Run all 6 mesh validation checks."""
|
||||
report = ValidationReport()
|
||||
|
||||
# Load aggregator results if available
|
||||
results: Optional[dict] = None
|
||||
if results_path:
|
||||
if not results_path.exists():
|
||||
print(f"WARNING: Aggregator results file not found: {results_path}",
|
||||
file=sys.stderr)
|
||||
report.add("Results JSON", Severity.WARN,
|
||||
f"Results file not found: {results_path}")
|
||||
else:
|
||||
try:
|
||||
results = json.loads(results_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
report.add("Results JSON", Severity.ERROR,
|
||||
f"Failed to parse results: {exc}")
|
||||
|
||||
# Load per-node logs
|
||||
node_logs: Dict[int, str] = {}
|
||||
for idx, lp in enumerate(log_paths):
|
||||
if lp.exists():
|
||||
node_logs[idx] = lp.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
node_logs[idx] = ""
|
||||
|
||||
# ---- Check 1: All nodes booted ----
|
||||
booted = []
|
||||
not_booted = []
|
||||
crashed = []
|
||||
for idx in range(n_nodes):
|
||||
log_text = node_logs.get(idx, "")
|
||||
if not log_text.strip():
|
||||
not_booted.append(idx)
|
||||
continue
|
||||
crash_line = check_node_crashed(log_text)
|
||||
if crash_line:
|
||||
crashed.append((idx, crash_line))
|
||||
if check_node_booted(log_text):
|
||||
booted.append(idx)
|
||||
else:
|
||||
not_booted.append(idx)
|
||||
|
||||
if crashed:
|
||||
crash_desc = "; ".join(f"node {i}: {msg}" for i, msg in crashed)
|
||||
report.add("All nodes booted", Severity.FATAL,
|
||||
f"Crash detected: {crash_desc}", count=len(crashed))
|
||||
elif len(booted) == n_nodes:
|
||||
report.add("All nodes booted", Severity.PASS,
|
||||
f"All {n_nodes} nodes booted successfully", count=n_nodes)
|
||||
elif len(booted) == 0:
|
||||
report.add("All nodes booted", Severity.FATAL,
|
||||
f"No nodes booted (expected {n_nodes})")
|
||||
else:
|
||||
missing = ", ".join(str(i) for i in not_booted)
|
||||
report.add("All nodes booted", Severity.ERROR,
|
||||
f"{len(booted)}/{n_nodes} booted; missing: [{missing}]",
|
||||
count=len(booted))
|
||||
|
||||
# ---- Check 2: TDM ordering ----
|
||||
# Extract TDM slots either from aggregator results or from logs
|
||||
tdm_slots: Dict[int, int] = {}
|
||||
|
||||
# Try aggregator results first
|
||||
if results and "nodes" in results:
|
||||
for node_entry in results["nodes"]:
|
||||
nid = node_entry.get("node_id")
|
||||
slot = node_entry.get("tdm_slot")
|
||||
if nid is not None and slot is not None:
|
||||
tdm_slots[int(nid)] = int(slot)
|
||||
|
||||
# Fall back to log extraction
|
||||
if not tdm_slots:
|
||||
for idx in range(n_nodes):
|
||||
log_text = node_logs.get(idx, "")
|
||||
nid = extract_node_id_from_log(log_text)
|
||||
if nid is not None:
|
||||
tdm_slots[idx] = nid
|
||||
|
||||
if len(tdm_slots) == n_nodes:
|
||||
expected = list(range(n_nodes))
|
||||
actual = [tdm_slots.get(i, -1) for i in range(n_nodes)]
|
||||
if actual == expected:
|
||||
report.add("TDM ordering", Severity.PASS,
|
||||
f"Slots sequential 0..{n_nodes - 1}")
|
||||
else:
|
||||
report.add("TDM ordering", Severity.ERROR,
|
||||
f"Expected slots {expected}, got {actual}")
|
||||
elif len(tdm_slots) > 0:
|
||||
report.add("TDM ordering", Severity.WARN,
|
||||
f"Only {len(tdm_slots)}/{n_nodes} TDM slots detected",
|
||||
count=len(tdm_slots))
|
||||
else:
|
||||
report.add("TDM ordering", Severity.SKIP,
|
||||
"No TDM slot info found in results or logs")
|
||||
|
||||
# ---- Check 3: No slot collision ----
|
||||
if tdm_slots:
|
||||
slot_to_nodes: Dict[int, List[int]] = {}
|
||||
for nid, slot in tdm_slots.items():
|
||||
slot_to_nodes.setdefault(slot, []).append(nid)
|
||||
|
||||
collisions = {s: nodes for s, nodes in slot_to_nodes.items() if len(nodes) > 1}
|
||||
if not collisions:
|
||||
report.add("No slot collision", Severity.PASS,
|
||||
f"All {len(tdm_slots)} slots unique")
|
||||
else:
|
||||
desc = "; ".join(f"slot {s}: nodes {ns}" for s, ns in collisions.items())
|
||||
report.add("No slot collision", Severity.ERROR,
|
||||
f"Slot collisions: {desc}", count=len(collisions))
|
||||
else:
|
||||
report.add("No slot collision", Severity.SKIP,
|
||||
"No TDM slot data to check for collisions")
|
||||
|
||||
# ---- Check 4: Frame count balance (within +/-10%) ----
|
||||
frame_counts: Dict[int, int] = {}
|
||||
|
||||
# Try aggregator results
|
||||
if results and "nodes" in results:
|
||||
for node_entry in results["nodes"]:
|
||||
nid = node_entry.get("node_id")
|
||||
fc = node_entry.get("frame_count", node_entry.get("frames", 0))
|
||||
if nid is not None:
|
||||
frame_counts[int(nid)] = int(fc)
|
||||
|
||||
# Fall back to log extraction
|
||||
if not frame_counts:
|
||||
for idx in range(n_nodes):
|
||||
log_text = node_logs.get(idx, "")
|
||||
frame_pats = [
|
||||
r"frame[_ ]count[=: ]+(\d+)",
|
||||
r"frames?[=: ]+(\d+)",
|
||||
r"emitted[=: ]+(\d+)",
|
||||
]
|
||||
max_fc = 0
|
||||
for line in log_text.splitlines():
|
||||
for pat in frame_pats:
|
||||
m = re.search(pat, line, re.IGNORECASE)
|
||||
if m:
|
||||
try:
|
||||
max_fc = max(max_fc, int(m.group(1)))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if max_fc > 0:
|
||||
frame_counts[idx] = max_fc
|
||||
|
||||
if len(frame_counts) >= 2:
|
||||
counts = list(frame_counts.values())
|
||||
avg = sum(counts) / len(counts)
|
||||
if avg > 0:
|
||||
max_deviation = max(abs(c - avg) / avg for c in counts)
|
||||
details = ", ".join(f"node {nid}={fc}" for nid, fc in sorted(frame_counts.items()))
|
||||
if max_deviation <= 0.10:
|
||||
report.add("Frame count balance", Severity.PASS,
|
||||
f"Within +/-10% (avg={avg:.0f}): {details}",
|
||||
count=int(avg))
|
||||
elif max_deviation <= 0.25:
|
||||
report.add("Frame count balance", Severity.WARN,
|
||||
f"Deviation {max_deviation:.0%} exceeds 10%: {details}",
|
||||
count=int(avg))
|
||||
else:
|
||||
report.add("Frame count balance", Severity.ERROR,
|
||||
f"Severe imbalance {max_deviation:.0%}: {details}",
|
||||
count=int(avg))
|
||||
else:
|
||||
report.add("Frame count balance", Severity.ERROR,
|
||||
"All frame counts are zero")
|
||||
elif len(frame_counts) == 1:
|
||||
report.add("Frame count balance", Severity.WARN,
|
||||
f"Only 1 node reported frames: {frame_counts}")
|
||||
else:
|
||||
report.add("Frame count balance", Severity.WARN,
|
||||
"No frame count data found")
|
||||
|
||||
# ---- Check 5: ADR-018 compliance (magic 0xC5110001) ----
|
||||
ADR018_MAGIC = "c5110001"
|
||||
magic_found = False
|
||||
|
||||
# Check aggregator results
|
||||
if results:
|
||||
results_str = json.dumps(results).lower()
|
||||
if ADR018_MAGIC in results_str or "0xc5110001" in results_str:
|
||||
magic_found = True
|
||||
# Also check a dedicated field
|
||||
if results.get("adr018_magic") or results.get("magic"):
|
||||
magic_found = True
|
||||
# Check per-node entries
|
||||
if "nodes" in results:
|
||||
for node_entry in results["nodes"]:
|
||||
magic = node_entry.get("magic", "")
|
||||
if isinstance(magic, str) and ADR018_MAGIC in magic.lower():
|
||||
magic_found = True
|
||||
elif isinstance(magic, int) and magic == 0xC5110001:
|
||||
magic_found = True
|
||||
|
||||
# Check logs for serialization/ADR-018 markers
|
||||
if not magic_found:
|
||||
for idx in range(n_nodes):
|
||||
log_text = node_logs.get(idx, "")
|
||||
adr018_pats = [
|
||||
r"0xC5110001",
|
||||
r"c5110001",
|
||||
r"ADR-018",
|
||||
r"magic[=: ]+0x[Cc]5110001",
|
||||
]
|
||||
if any(re.search(p, log_text, re.IGNORECASE) for p in adr018_pats):
|
||||
magic_found = True
|
||||
break
|
||||
|
||||
if magic_found:
|
||||
report.add("ADR-018 compliance", Severity.PASS,
|
||||
"Magic 0xC5110001 found in frame data")
|
||||
else:
|
||||
report.add("ADR-018 compliance", Severity.WARN,
|
||||
"Magic 0xC5110001 not found (may require deeper frame inspection)")
|
||||
|
||||
# ---- Check 6: Vitals per node ----
|
||||
vitals_nodes = []
|
||||
no_vitals_nodes = []
|
||||
for idx in range(n_nodes):
|
||||
log_text = node_logs.get(idx, "")
|
||||
if check_vitals_in_log(log_text):
|
||||
vitals_nodes.append(idx)
|
||||
else:
|
||||
no_vitals_nodes.append(idx)
|
||||
|
||||
# Also check aggregator results for vitals data
|
||||
if results and "nodes" in results:
|
||||
for node_entry in results["nodes"]:
|
||||
nid = node_entry.get("node_id")
|
||||
has_vitals = (
|
||||
node_entry.get("vitals") is not None
|
||||
or node_entry.get("breathing_bpm") is not None
|
||||
or node_entry.get("heart_rate") is not None
|
||||
)
|
||||
if has_vitals and nid is not None and int(nid) not in vitals_nodes:
|
||||
vitals_nodes.append(int(nid))
|
||||
if int(nid) in no_vitals_nodes:
|
||||
no_vitals_nodes.remove(int(nid))
|
||||
|
||||
if len(vitals_nodes) == n_nodes:
|
||||
report.add("Vitals per node", Severity.PASS,
|
||||
f"All {n_nodes} nodes produced vitals output",
|
||||
count=n_nodes)
|
||||
elif len(vitals_nodes) > 0:
|
||||
missing = ", ".join(str(i) for i in no_vitals_nodes)
|
||||
report.add("Vitals per node", Severity.WARN,
|
||||
f"{len(vitals_nodes)}/{n_nodes} nodes have vitals; "
|
||||
f"missing: [{missing}]",
|
||||
count=len(vitals_nodes))
|
||||
else:
|
||||
report.add("Vitals per node", Severity.WARN,
|
||||
"No vitals output found from any node")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate multi-node mesh QEMU test output (ADR-061 Layer 3)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python3 validate_mesh_test.py --nodes 3 --results mesh_results.json\n"
|
||||
" python3 validate_mesh_test.py --nodes 4 --log node0.log --log node1.log"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--results", default=None,
|
||||
help="Path to mesh_test_results.json from aggregator")
|
||||
parser.add_argument("--nodes", "-n", type=int, required=True,
|
||||
help="Expected number of mesh nodes")
|
||||
parser.add_argument("--log", action="append", default=[],
|
||||
help="Path to a per-node QEMU log (can be repeated)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.nodes < 2:
|
||||
print("ERROR: --nodes must be >= 2", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
results_path = Path(args.results) if args.results else None
|
||||
log_paths = [Path(lp) for lp in args.log]
|
||||
|
||||
# If no log files given, try the conventional paths
|
||||
if not log_paths:
|
||||
for i in range(args.nodes):
|
||||
candidate = Path(f"build/qemu_node{i}.log")
|
||||
if candidate.exists():
|
||||
log_paths.append(candidate)
|
||||
|
||||
report = validate_mesh(args.nodes, results_path, log_paths)
|
||||
report.print_report()
|
||||
|
||||
# Map max severity to exit code
|
||||
max_sev = report.max_severity
|
||||
if max_sev <= Severity.SKIP:
|
||||
sys.exit(0)
|
||||
elif max_sev == Severity.WARN:
|
||||
sys.exit(1)
|
||||
elif max_sev == Severity.ERROR:
|
||||
sys.exit(2)
|
||||
else:
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
QEMU ESP32-S3 UART Output Validator (ADR-061)
|
||||
|
||||
Parses the UART log captured from a QEMU firmware run and validates
|
||||
16 checks covering boot, NVS, mock CSI, edge processing, vitals,
|
||||
presence/fall detection, serialization, crash indicators, scenario
|
||||
completion, and frame rate sanity.
|
||||
|
||||
Usage:
|
||||
python3 validate_qemu_output.py <log_file>
|
||||
|
||||
Exit codes:
|
||||
0 All checks passed (or only INFO-level skips)
|
||||
1 Warnings (non-critical checks failed)
|
||||
2 Errors (critical checks failed)
|
||||
3 Fatal (crash or corruption detected)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
PASS = 0
|
||||
SKIP = 1
|
||||
WARN = 2
|
||||
ERROR = 3
|
||||
FATAL = 4
|
||||
|
||||
|
||||
# ANSI color codes (disabled if not a TTY)
|
||||
USE_COLOR = sys.stdout.isatty()
|
||||
|
||||
|
||||
def color(text: str, code: str) -> str:
|
||||
if not USE_COLOR:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def green(text: str) -> str:
|
||||
return color(text, "32")
|
||||
|
||||
|
||||
def yellow(text: str) -> str:
|
||||
return color(text, "33")
|
||||
|
||||
|
||||
def red(text: str) -> str:
|
||||
return color(text, "31")
|
||||
|
||||
|
||||
def bold_red(text: str) -> str:
|
||||
return color(text, "1;31")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
severity: Severity
|
||||
message: str
|
||||
count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
checks: List[CheckResult] = field(default_factory=list)
|
||||
|
||||
def add(self, name: str, severity: Severity, message: str, count: int = 0):
|
||||
self.checks.append(CheckResult(name, severity, message, count))
|
||||
|
||||
@property
|
||||
def max_severity(self) -> Severity:
|
||||
if not self.checks:
|
||||
return Severity.PASS
|
||||
return max(c.severity for c in self.checks)
|
||||
|
||||
def print_report(self):
|
||||
print("\n" + "=" * 60)
|
||||
print(" QEMU Firmware Validation Report (ADR-061)")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
for check in self.checks:
|
||||
if check.severity == Severity.PASS:
|
||||
icon = green("PASS")
|
||||
elif check.severity == Severity.SKIP:
|
||||
icon = yellow("SKIP")
|
||||
elif check.severity == Severity.WARN:
|
||||
icon = yellow("WARN")
|
||||
elif check.severity == Severity.ERROR:
|
||||
icon = red("FAIL")
|
||||
else:
|
||||
icon = bold_red("FATAL")
|
||||
|
||||
count_str = f" (count={check.count})" if check.count > 0 else ""
|
||||
print(f" [{icon}] {check.name}: {check.message}{count_str}")
|
||||
|
||||
print()
|
||||
|
||||
passed = sum(1 for c in self.checks if c.severity <= Severity.SKIP)
|
||||
total = len(self.checks)
|
||||
summary = f" {passed}/{total} checks passed"
|
||||
|
||||
max_sev = self.max_severity
|
||||
if max_sev <= Severity.SKIP:
|
||||
print(green(summary))
|
||||
elif max_sev == Severity.WARN:
|
||||
print(yellow(summary + " (with warnings)"))
|
||||
elif max_sev == Severity.ERROR:
|
||||
print(red(summary + " (with errors)"))
|
||||
else:
|
||||
print(bold_red(summary + " (FATAL issues detected)"))
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def validate_log(log_text: str) -> ValidationReport:
|
||||
"""Run all 16 validation checks against the UART log text."""
|
||||
report = ValidationReport()
|
||||
lines = log_text.splitlines()
|
||||
log_lower = log_text.lower()
|
||||
|
||||
# ---- Check 1: Boot ----
|
||||
# Look for app_main() entry or main_task: tag
|
||||
boot_patterns = [r"app_main\(\)", r"main_task:", r"main:", r"ESP32-S3 CSI Node"]
|
||||
boot_found = any(re.search(p, log_text) for p in boot_patterns)
|
||||
if boot_found:
|
||||
report.add("Boot", Severity.PASS, "Firmware booted successfully")
|
||||
else:
|
||||
report.add("Boot", Severity.FATAL, "No boot indicator found (app_main / main_task)")
|
||||
|
||||
# ---- Check 2: NVS load ----
|
||||
nvs_patterns = [r"nvs_config:", r"nvs_config_load", r"NVS", r"csi_cfg"]
|
||||
nvs_found = any(re.search(p, log_text) for p in nvs_patterns)
|
||||
if nvs_found:
|
||||
report.add("NVS load", Severity.PASS, "NVS configuration loaded")
|
||||
else:
|
||||
report.add("NVS load", Severity.WARN, "No NVS load indicator found")
|
||||
|
||||
# ---- Check 3: Mock CSI init ----
|
||||
mock_patterns = [r"mock_csi:", r"mock_csi_init", r"Mock CSI", r"MOCK_CSI"]
|
||||
mock_found = any(re.search(p, log_text) for p in mock_patterns)
|
||||
if mock_found:
|
||||
report.add("Mock CSI init", Severity.PASS, "Mock CSI generator initialized")
|
||||
else:
|
||||
# This is only expected when mock is enabled
|
||||
report.add("Mock CSI init", Severity.SKIP,
|
||||
"No mock CSI indicator (expected if mock not enabled)")
|
||||
|
||||
# ---- Check 4: Frame generation ----
|
||||
# Count frame-related log lines
|
||||
frame_patterns = [
|
||||
r"frame[_ ]count[=: ]+(\d+)",
|
||||
r"frames?[=: ]+(\d+)",
|
||||
r"emitted[=: ]+(\d+)",
|
||||
r"mock_csi:.*frame",
|
||||
r"csi_collector:.*frame",
|
||||
r"CSI frame",
|
||||
]
|
||||
frame_count = 0
|
||||
for line in lines:
|
||||
for pat in frame_patterns:
|
||||
m = re.search(pat, line, re.IGNORECASE)
|
||||
if m:
|
||||
if m.lastindex and m.lastindex >= 1:
|
||||
try:
|
||||
frame_count = max(frame_count, int(m.group(1)))
|
||||
except (ValueError, IndexError):
|
||||
frame_count = max(frame_count, 1)
|
||||
else:
|
||||
frame_count = max(frame_count, 1)
|
||||
|
||||
if frame_count > 0:
|
||||
report.add("Frame generation", Severity.PASS,
|
||||
f"Frames detected", count=frame_count)
|
||||
else:
|
||||
# Also count lines mentioning IQ data or subcarriers
|
||||
iq_lines = sum(1 for line in lines
|
||||
if re.search(r"(iq_data|subcarrier|I/Q|enqueue)", line, re.IGNORECASE))
|
||||
if iq_lines > 0:
|
||||
report.add("Frame generation", Severity.PASS,
|
||||
"I/Q data activity detected", count=iq_lines)
|
||||
else:
|
||||
report.add("Frame generation", Severity.WARN,
|
||||
"No frame generation activity detected")
|
||||
|
||||
# ---- Check 5: Edge pipeline ----
|
||||
edge_patterns = [r"edge_processing:", r"DSP task", r"edge_init", r"edge_tier"]
|
||||
edge_found = any(re.search(p, log_text) for p in edge_patterns)
|
||||
if edge_found:
|
||||
report.add("Edge pipeline", Severity.PASS, "Edge processing pipeline active")
|
||||
else:
|
||||
report.add("Edge pipeline", Severity.WARN,
|
||||
"No edge processing indicator found")
|
||||
|
||||
# ---- Check 6: Vitals output ----
|
||||
vitals_patterns = [r"vitals", r"breathing", r"presence", r"heartrate",
|
||||
r"breathing_bpm", r"heart_rate"]
|
||||
vitals_count = sum(1 for line in lines
|
||||
if any(re.search(p, line, re.IGNORECASE) for p in vitals_patterns))
|
||||
if vitals_count > 0:
|
||||
report.add("Vitals output", Severity.PASS,
|
||||
"Vitals/breathing/presence output detected", count=vitals_count)
|
||||
else:
|
||||
report.add("Vitals output", Severity.WARN,
|
||||
"No vitals output lines found")
|
||||
|
||||
# ---- Check 7: Presence detection ----
|
||||
presence_patterns = [
|
||||
r"presence[=: ]+1",
|
||||
r"presence_score[=: ]+([0-9.]+)",
|
||||
r"presence detected",
|
||||
]
|
||||
presence_found = False
|
||||
for line in lines:
|
||||
for pat in presence_patterns:
|
||||
m = re.search(pat, line, re.IGNORECASE)
|
||||
if m:
|
||||
if m.lastindex and m.lastindex >= 1:
|
||||
try:
|
||||
score = float(m.group(1))
|
||||
if score > 0:
|
||||
presence_found = True
|
||||
except (ValueError, IndexError):
|
||||
presence_found = True
|
||||
else:
|
||||
presence_found = True
|
||||
|
||||
if presence_found:
|
||||
report.add("Presence detection", Severity.PASS, "Presence detected in output")
|
||||
else:
|
||||
report.add("Presence detection", Severity.WARN,
|
||||
"No presence=1 or presence_score>0 found")
|
||||
|
||||
# ---- Check 8: Fall detection ----
|
||||
fall_patterns = [r"fall[=: ]+1", r"fall detected", r"fall_event"]
|
||||
fall_found = any(
|
||||
re.search(p, line, re.IGNORECASE)
|
||||
for line in lines for p in fall_patterns
|
||||
)
|
||||
if fall_found:
|
||||
report.add("Fall detection", Severity.PASS, "Fall event detected in output")
|
||||
else:
|
||||
report.add("Fall detection", Severity.SKIP,
|
||||
"No fall event (expected if fall scenario not run)")
|
||||
|
||||
# ---- Check 9: MAC filter ----
|
||||
mac_patterns = [r"MAC filter", r"mac_filter", r"dropped.*MAC",
|
||||
r"filter_mac", r"filtered"]
|
||||
mac_found = any(
|
||||
re.search(p, line, re.IGNORECASE)
|
||||
for line in lines for p in mac_patterns
|
||||
)
|
||||
if mac_found:
|
||||
report.add("MAC filter", Severity.PASS, "MAC filter activity detected")
|
||||
else:
|
||||
report.add("MAC filter", Severity.SKIP,
|
||||
"No MAC filter activity (expected if filter scenario not run)")
|
||||
|
||||
# ---- Check 10: ADR-018 serialize ----
|
||||
serialize_patterns = [r"[Ss]erializ", r"ADR-018", r"stream_sender",
|
||||
r"UDP.*send", r"udp.*sent"]
|
||||
serialize_count = sum(1 for line in lines
|
||||
if any(re.search(p, line) for p in serialize_patterns))
|
||||
if serialize_count > 0:
|
||||
report.add("ADR-018 serialize", Severity.PASS,
|
||||
"Serialization/streaming activity detected", count=serialize_count)
|
||||
else:
|
||||
report.add("ADR-018 serialize", Severity.WARN,
|
||||
"No serialization activity detected")
|
||||
|
||||
# ---- Check 11: No crash ----
|
||||
crash_patterns = [r"Guru Meditation", r"assert failed", r"abort\(\)",
|
||||
r"panic", r"LoadProhibited", r"StoreProhibited",
|
||||
r"InstrFetchProhibited", r"IllegalInstruction"]
|
||||
crash_found = []
|
||||
for line in lines:
|
||||
for pat in crash_patterns:
|
||||
if re.search(pat, line):
|
||||
crash_found.append(line.strip()[:120])
|
||||
|
||||
if not crash_found:
|
||||
report.add("No crash", Severity.PASS, "No crash indicators found")
|
||||
else:
|
||||
report.add("No crash", Severity.FATAL,
|
||||
f"Crash detected: {crash_found[0]}",
|
||||
count=len(crash_found))
|
||||
|
||||
# ---- Check 12: Heap OK ----
|
||||
heap_patterns = [r"HEAP_ERROR", r"out of memory", r"heap_caps_alloc.*failed",
|
||||
r"malloc.*fail", r"heap corruption"]
|
||||
heap_errors = [line.strip()[:120] for line in lines
|
||||
if any(re.search(p, line, re.IGNORECASE) for p in heap_patterns)]
|
||||
if not heap_errors:
|
||||
report.add("Heap OK", Severity.PASS, "No heap errors found")
|
||||
else:
|
||||
report.add("Heap OK", Severity.ERROR,
|
||||
f"Heap error: {heap_errors[0]}",
|
||||
count=len(heap_errors))
|
||||
|
||||
# ---- Check 13: Stack OK ----
|
||||
stack_patterns = [r"[Ss]tack overflow", r"stack_overflow",
|
||||
r"vApplicationStackOverflowHook"]
|
||||
stack_errors = [line.strip()[:120] for line in lines
|
||||
if any(re.search(p, line) for p in stack_patterns)]
|
||||
if not stack_errors:
|
||||
report.add("Stack OK", Severity.PASS, "No stack overflow detected")
|
||||
else:
|
||||
report.add("Stack OK", Severity.FATAL,
|
||||
f"Stack overflow: {stack_errors[0]}",
|
||||
count=len(stack_errors))
|
||||
|
||||
# ---- Check 14: Clean exit ----
|
||||
reboot_patterns = [r"Rebooting\.\.\.", r"rst:0x"]
|
||||
reboot_found = any(
|
||||
re.search(p, line)
|
||||
for line in lines for p in reboot_patterns
|
||||
)
|
||||
if not reboot_found:
|
||||
report.add("Clean exit", Severity.PASS,
|
||||
"No unexpected reboot detected")
|
||||
else:
|
||||
report.add("Clean exit", Severity.WARN,
|
||||
"Reboot detected (may indicate crash or watchdog)")
|
||||
|
||||
# ---- Check 15: Scenario completion (when running all scenarios) ----
|
||||
all_scenarios_pattern = r"All (\d+) scenarios complete"
|
||||
scenario_match = re.search(all_scenarios_pattern, log_text)
|
||||
if scenario_match:
|
||||
n_scenarios = int(scenario_match.group(1))
|
||||
report.add("Scenario completion", Severity.PASS,
|
||||
f"All {n_scenarios} scenarios completed", count=n_scenarios)
|
||||
else:
|
||||
# Check if individual scenario started indicators exist
|
||||
scenario_starts = re.findall(r"=== Scenario (\d+) started ===", log_text)
|
||||
if scenario_starts:
|
||||
report.add("Scenario completion", Severity.WARN,
|
||||
f"Started {len(scenario_starts)} scenarios but no completion marker",
|
||||
count=len(scenario_starts))
|
||||
else:
|
||||
report.add("Scenario completion", Severity.SKIP,
|
||||
"No scenario tracking (single scenario or mock not enabled)")
|
||||
|
||||
# ---- Check 16: Frame rate sanity ----
|
||||
# Extract scenario frame counts and check they're reasonable
|
||||
frame_reports = re.findall(r"scenario=\d+ frames=(\d+)", log_text)
|
||||
if frame_reports:
|
||||
max_frames = max(int(f) for f in frame_reports)
|
||||
if max_frames > 0:
|
||||
report.add("Frame rate", Severity.PASS,
|
||||
f"Peak frame counter: {max_frames}", count=max_frames)
|
||||
else:
|
||||
report.add("Frame rate", Severity.ERROR,
|
||||
"Frame counters are all zero")
|
||||
else:
|
||||
report.add("Frame rate", Severity.SKIP,
|
||||
"No periodic frame reports found")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate QEMU ESP32-S3 UART output (ADR-061)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="Example: python3 validate_qemu_output.py build/qemu_output.log",
|
||||
)
|
||||
parser.add_argument(
|
||||
"log_file",
|
||||
help="Path to QEMU UART log file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
if not log_path.exists():
|
||||
print(f"ERROR: Log file not found: {log_path}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if not log_text.strip():
|
||||
print("ERROR: Log file is empty. QEMU may have failed to start.",
|
||||
file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
report = validate_log(log_text)
|
||||
report.print_report()
|
||||
|
||||
# Map max severity to exit code
|
||||
max_sev = report.max_severity
|
||||
if max_sev <= Severity.SKIP:
|
||||
sys.exit(0)
|
||||
elif max_sev == Severity.WARN:
|
||||
sys.exit(1)
|
||||
elif max_sev == Severity.ERROR:
|
||||
sys.exit(2)
|
||||
else:
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,6 +29,7 @@
|
||||
<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>
|
||||
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
|
||||
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView — Dual-Modal Pose Estimation</title>
|
||||
<link rel="stylesheet" href="pose-fusion/css/style.css?v=13">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo"><span class="pi">π</span> RuView</div>
|
||||
<div class="header-title">Dual-Modal Pose Estimation — Live Video + WiFi CSI Fusion</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<select id="mode-select" class="mode-select">
|
||||
<option value="dual">Dual Mode (Video + CSI)</option>
|
||||
<option value="video">Video Only</option>
|
||||
<option value="csi">CSI Only (WiFi)</option>
|
||||
</select>
|
||||
<div class="status-badge">
|
||||
<span id="status-dot" class="status-dot offline"></span>
|
||||
<span id="status-label">READY</span>
|
||||
</div>
|
||||
<span id="fps-display" class="fps-badge">-- FPS</span>
|
||||
<a href="index.html" class="back-link">← Dashboard</a>
|
||||
<a href="observatory.html" class="back-link">Observatory →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="main-grid">
|
||||
|
||||
<!-- Video + Skeleton Panel -->
|
||||
<div class="video-panel">
|
||||
<video id="webcam" autoplay playsinline muted></video>
|
||||
<canvas id="skeleton-canvas"></canvas>
|
||||
<div class="video-overlay-label" id="mode-label">DUAL FUSION</div>
|
||||
|
||||
<div id="camera-prompt" class="camera-prompt">
|
||||
<div class="camera-prompt-label" id="prompt-mode-label">DUAL FUSION</div>
|
||||
<p>Enable your webcam for live video pose estimation.<br>
|
||||
Or switch to <strong>CSI Only</strong> mode for WiFi-based sensing.</p>
|
||||
<button id="start-camera-btn">Enable Camera</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side Panels -->
|
||||
<div class="side-panels">
|
||||
|
||||
<!-- Fusion Confidence -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ Fusion Confidence</div>
|
||||
<div class="fusion-bars">
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">Video</span>
|
||||
<div class="bar-track"><div class="bar-fill video" id="video-bar" style="width:0%"></div></div>
|
||||
<span class="bar-value" id="video-bar-val">0%</span>
|
||||
</div>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">CSI</span>
|
||||
<div class="bar-track"><div class="bar-fill csi" id="csi-bar" style="width:0%"></div></div>
|
||||
<span class="bar-value" id="csi-bar-val">0%</span>
|
||||
</div>
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">Fused</span>
|
||||
<div class="bar-track"><div class="bar-fill fused" id="fused-bar" style="width:0%"></div></div>
|
||||
<span class="bar-value" id="fused-bar-val">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px; font-size:10px; color:var(--text-label)">
|
||||
Cross-modal: <span id="cross-modal-sim" style="color:var(--green-glow)">0.000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSI Heatmap -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ CSI Amplitude Heatmap</div>
|
||||
<div class="csi-canvas-wrapper">
|
||||
<canvas id="csi-canvas" width="320" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSSI Signal Strength -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ RSSI Signal Strength</div>
|
||||
<div class="rssi-row">
|
||||
<div class="rssi-gauge">
|
||||
<div class="rssi-bar-track">
|
||||
<div class="rssi-bar-fill" id="rssi-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="rssi-values">
|
||||
<span class="rssi-dbm" id="rssi-value">-- dBm</span>
|
||||
<span class="rssi-quality" id="rssi-quality">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="rssi-sparkline" width="160" height="32"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Space -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ Embedding Space (2D Projection)</div>
|
||||
<div class="embedding-canvas-wrapper">
|
||||
<canvas id="embedding-canvas" width="320" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RuVector Attention Pipeline -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ RuVector WASM Attention Pipeline</div>
|
||||
<div class="rv-pipeline">
|
||||
<div class="rv-stage" id="rv-flash">Flash</div>
|
||||
<div class="rv-arrow">→</div>
|
||||
<div class="rv-stage" id="rv-mha">MHA</div>
|
||||
<div class="rv-arrow">→</div>
|
||||
<div class="rv-stage" id="rv-hyp">Hyper</div>
|
||||
<div class="rv-arrow">→</div>
|
||||
<div class="rv-stage" id="rv-lin">Linear</div>
|
||||
<div class="rv-arrow">→</div>
|
||||
<div class="rv-stage" id="rv-moe">MoE</div>
|
||||
<div class="rv-arrow">→</div>
|
||||
<div class="rv-stage" id="rv-lg">L+G</div>
|
||||
</div>
|
||||
<div class="rv-stats">
|
||||
<span>Energy: <span id="rv-energy" style="color:var(--green-glow)">--</span></span>
|
||||
<span>Refinement: <span id="rv-refine" style="color:var(--cyan)">--</span></span>
|
||||
<span>Pose Impact: <span id="rv-impact" style="color:var(--amber)">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ Pipeline Latency</div>
|
||||
<div class="latency-grid">
|
||||
<div class="latency-item">
|
||||
<div class="latency-value" id="lat-video">--</div>
|
||||
<div class="latency-label">Video CNN</div>
|
||||
</div>
|
||||
<div class="latency-item">
|
||||
<div class="latency-value" id="lat-csi">--</div>
|
||||
<div class="latency-label">CSI CNN</div>
|
||||
</div>
|
||||
<div class="latency-item">
|
||||
<div class="latency-value" id="lat-fusion">--</div>
|
||||
<div class="latency-label">Fusion</div>
|
||||
</div>
|
||||
<div class="latency-item">
|
||||
<div class="latency-value" id="lat-total">--</div>
|
||||
<div class="latency-label">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">◆ Controls</div>
|
||||
<div class="controls-row">
|
||||
<button class="btn" id="pause-btn">⏸ Pause</button>
|
||||
</div>
|
||||
|
||||
<div class="slider-row">
|
||||
<label>Confidence</label>
|
||||
<input type="range" id="confidence-slider" min="0" max="1" step="0.05" value="0.3">
|
||||
<span class="slider-val" id="confidence-value">0.30</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:10px">
|
||||
<div class="panel-title" style="margin-bottom:6px">◆ Live CSI Source</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input type="text" id="ws-url" placeholder="ws://localhost:3030/ws/csi"
|
||||
style="flex:1;background:rgba(255,255,255,0.05);border:1px solid var(--bg-panel-border);
|
||||
color:var(--text-primary);padding:5px 8px;border-radius:4px;font-size:11px;
|
||||
font-family:'JetBrains Mono',monospace">
|
||||
<button class="btn" id="connect-ws-btn">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /side-panels -->
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="bottom-bar">
|
||||
<div>
|
||||
RuView · Dual-Modal Pose Estimation ·
|
||||
Architecture: Conv2D → RuVector 6-Stage Attention (Flash+MHA+Hyperbolic+Linear+MoE+L/G) → Fusion → 26-Keypoint Pose
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/ruvnet/RuView">GitHub</a> ·
|
||||
CNN: <span id="cnn-backend">ruvector-cnn (loading…)</span> ·
|
||||
<a href="observatory.html">Observatory</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /main-grid -->
|
||||
|
||||
<script type="module" src="pose-fusion/js/main.js?v=13"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Build WASM packages for the dual-modal pose estimation demo.
|
||||
# Requires: wasm-pack (cargo install wasm-pack)
|
||||
#
|
||||
# Usage: ./build.sh
|
||||
#
|
||||
# Output: pkg/ruvector_cnn_wasm/ — WASM CNN embedder for browser
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
VENDOR_DIR="$SCRIPT_DIR/../../vendor/ruvector"
|
||||
OUT_DIR="$SCRIPT_DIR/pkg/ruvector_cnn_wasm"
|
||||
|
||||
echo "Building ruvector-cnn-wasm..."
|
||||
wasm-pack build "$VENDOR_DIR/crates/ruvector-cnn-wasm" \
|
||||
--target web \
|
||||
--out-dir "$OUT_DIR" \
|
||||
--no-typescript
|
||||
|
||||
# Remove .gitignore so we can commit the build output for GitHub Pages
|
||||
rm -f "$OUT_DIR/.gitignore"
|
||||
|
||||
echo ""
|
||||
echo "Build complete!"
|
||||
echo " WASM: $(du -sh "$OUT_DIR/ruvector_cnn_wasm_bg.wasm" | cut -f1)"
|
||||
echo " JS: $(du -sh "$OUT_DIR/ruvector_cnn_wasm.js" | cut -f1)"
|
||||
echo ""
|
||||
echo "Serve the demo: cd $SCRIPT_DIR/.. && python3 -m http.server 8080"
|
||||
echo "Open: http://localhost:8080/pose-fusion.html"
|
||||
@@ -0,0 +1,536 @@
|
||||
/* RuView — Dual-Modal Pose Fusion Demo
|
||||
Dark theme matching Observatory */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-deep: #080c14;
|
||||
--bg-panel: rgba(8, 16, 28, 0.92);
|
||||
--bg-panel-border: rgba(0, 210, 120, 0.25);
|
||||
--green-glow: #00d878;
|
||||
--green-bright:#3eff8a;
|
||||
--green-dim: #0a6b3a;
|
||||
--amber: #ffb020;
|
||||
--amber-dim: #a06800;
|
||||
--blue-signal: #2090ff;
|
||||
--blue-dim: #0a3060;
|
||||
--red-alert: #ff3040;
|
||||
--cyan: #00e5ff;
|
||||
--text-primary: #e8ece0;
|
||||
--text-secondary: rgba(232,236,224, 0.55);
|
||||
--text-label: rgba(232,236,224, 0.35);
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Header === */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--bg-panel-border);
|
||||
background: var(--bg-panel);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.logo .pi { font-style: normal; }
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mode-select {
|
||||
background: rgba(0,210,120,0.1);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-select option { background: #0c1420; }
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0,210,120,0.1);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green-glow);
|
||||
box-shadow: 0 0 8px var(--green-glow);
|
||||
animation: pulse-dot 2s ease infinite;
|
||||
}
|
||||
|
||||
.status-dot.offline { background: #555; box-shadow: none; animation: none; }
|
||||
.status-dot.warning { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.fps-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link:hover { color: var(--green-glow); }
|
||||
|
||||
/* === Main Layout === */
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
height: calc(100vh - 72px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-panel {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.side-panels {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
/* === Video Panel === */
|
||||
.video-panel {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.video-panel video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.video-panel canvas {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.video-overlay-label {
|
||||
position: absolute;
|
||||
top: 12px; left: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border-radius: 4px;
|
||||
color: var(--green-glow);
|
||||
z-index: 5;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.camera-prompt {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 24px;
|
||||
z-index: 6;
|
||||
background: radial-gradient(ellipse at center, rgba(0,210,120,0.08) 0%, rgba(8,12,20,0.95) 70%);
|
||||
}
|
||||
|
||||
.camera-prompt button {
|
||||
margin-top: 16px;
|
||||
padding: 10px 24px;
|
||||
background: var(--green-glow);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.camera-prompt button:hover { background: var(--green-bright); }
|
||||
|
||||
.camera-prompt-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: var(--green-glow);
|
||||
text-shadow: 0 0 12px rgba(0,216,120,0.4);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* === Side Panels === */
|
||||
.side-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--green-dim) transparent;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-label);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* === CSI Heatmap === */
|
||||
.csi-canvas-wrapper {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.csi-canvas-wrapper canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === Fusion Bars === */
|
||||
.fusion-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
width: 55px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-fill.video { background: var(--cyan); }
|
||||
.bar-fill.csi { background: var(--amber); }
|
||||
.bar-fill.fused { background: var(--green-glow); box-shadow: 0 0 8px var(--green-glow); }
|
||||
|
||||
.bar-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* === Embedding Space === */
|
||||
.embedding-canvas-wrapper {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.embedding-canvas-wrapper canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === RuVector Pipeline === */
|
||||
.rv-pipeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rv-stage {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0,210,120,0.12);
|
||||
border: 1px solid rgba(0,210,120,0.3);
|
||||
color: var(--green-glow);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.rv-stage.active {
|
||||
background: rgba(0,210,120,0.25);
|
||||
box-shadow: 0 0 6px rgba(0,210,120,0.3);
|
||||
}
|
||||
|
||||
.rv-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
}
|
||||
|
||||
.rv-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* === Latency Panel === */
|
||||
.latency-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.latency-item {
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.latency-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.latency-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* === Controls === */
|
||||
.controls-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
background: rgba(0,210,120,0.08);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn:hover { background: rgba(0,210,120,0.2); }
|
||||
.btn.active { background: var(--green-glow); color: #000; font-weight: 600; }
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.slider-row label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slider-row input[type=range] {
|
||||
flex: 1;
|
||||
accent-color: var(--green-glow);
|
||||
}
|
||||
|
||||
.slider-row .slider-val {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
width: 32px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* === Bottom Bar === */
|
||||
.bottom-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: var(--radius);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bottom-bar a {
|
||||
color: var(--green-glow);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* === RSSI Signal Strength === */
|
||||
.rssi-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rssi-gauge { flex: 1; }
|
||||
|
||||
.rssi-bar-track {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rssi-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--red-alert), var(--amber), var(--green-glow));
|
||||
transition: width 0.4s ease;
|
||||
position: relative;
|
||||
box-shadow: 0 0 6px rgba(0,210,120,0.3);
|
||||
}
|
||||
|
||||
.rssi-bar-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%);
|
||||
animation: rssi-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rssi-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.rssi-values {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rssi-dbm {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.rssi-quality {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#rssi-sparkline {
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* === Skeleton colors === */
|
||||
.skeleton-joint { fill: var(--green-glow); }
|
||||
.skeleton-limb { stroke: var(--green-bright); }
|
||||
.skeleton-joint-csi { fill: var(--amber); }
|
||||
.skeleton-limb-csi { stroke: var(--amber); }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 900px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
.video-panel { aspect-ratio: 16/9; max-height: 50vh; }
|
||||
.side-panels { max-height: none; overflow: visible; }
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* CanvasRenderer — Renders skeleton overlay on video, CSI heatmap,
|
||||
* embedding space visualization, and fusion confidence bars.
|
||||
*/
|
||||
|
||||
import { SKELETON_CONNECTIONS } from './pose-decoder.js';
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor() {
|
||||
this.colors = {
|
||||
joint: '#00d878',
|
||||
jointGlow: 'rgba(0, 216, 120, 0.4)',
|
||||
limb: '#3eff8a',
|
||||
limbGlow: 'rgba(62, 255, 138, 0.15)',
|
||||
csiJoint: '#ffb020',
|
||||
csiLimb: '#ffc850',
|
||||
fused: '#00e5ff',
|
||||
confidence: 'rgba(255,255,255,0.3)',
|
||||
videoEmb: '#00e5ff',
|
||||
csiEmb: '#ffb020',
|
||||
fusedEmb: '#00d878',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw skeleton overlay on the video canvas
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {Array<{x,y,confidence}>} keypoints - Normalized [0,1] coordinates
|
||||
* @param {number} width - Canvas width
|
||||
* @param {number} height - Canvas height
|
||||
* @param {object} opts
|
||||
*/
|
||||
drawSkeleton(ctx, keypoints, width, height, opts = {}) {
|
||||
const minConf = opts.minConfidence || 0.3;
|
||||
const color = opts.color || 'green';
|
||||
const jointColor = color === 'amber' ? this.colors.csiJoint : this.colors.joint;
|
||||
const limbColor = color === 'amber' ? this.colors.csiLimb : this.colors.limb;
|
||||
const glowColor = color === 'amber' ? 'rgba(255,176,32,0.4)' : this.colors.jointGlow;
|
||||
|
||||
// Extended keypoint styling
|
||||
const fingerColor = '#ff6ef0'; // Magenta for finger tips
|
||||
const fingerGlow = 'rgba(255,110,240,0.4)';
|
||||
const fingerLimb = 'rgba(255,110,240,0.5)';
|
||||
const toeColor = '#6ef0ff'; // Cyan for toes
|
||||
const neckColor = '#ffffff'; // White for neck
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
if (!keypoints || keypoints.length === 0) return;
|
||||
|
||||
// Draw limbs first (behind joints)
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
for (const [i, j] of SKELETON_CONNECTIONS) {
|
||||
const kpA = keypoints[i];
|
||||
const kpB = keypoints[j];
|
||||
if (!kpA || !kpB || kpA.confidence < minConf || kpB.confidence < minConf) continue;
|
||||
|
||||
const ax = kpA.x * width, ay = kpA.y * height;
|
||||
const bx = kpB.x * width, by = kpB.y * height;
|
||||
const avgConf = (kpA.confidence + kpB.confidence) / 2;
|
||||
|
||||
// Is this a hand/finger connection? (indices 17-22)
|
||||
const isFingerLink = i >= 17 && i <= 22 || j >= 17 && j <= 22;
|
||||
const isToeLink = i >= 23 && i <= 24 || j >= 23 && j <= 24;
|
||||
|
||||
// Glow
|
||||
ctx.strokeStyle = isFingerLink ? fingerLimb : this.colors.limbGlow;
|
||||
ctx.lineWidth = isFingerLink ? 4 : 8;
|
||||
ctx.globalAlpha = avgConf * (isFingerLink ? 0.3 : 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.lineTo(bx, by);
|
||||
ctx.stroke();
|
||||
|
||||
// Main line
|
||||
ctx.strokeStyle = isFingerLink ? fingerColor : isToeLink ? toeColor : limbColor;
|
||||
ctx.lineWidth = isFingerLink || isToeLink ? 1.5 : 2.5;
|
||||
ctx.globalAlpha = avgConf;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.lineTo(bx, by);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw joints
|
||||
ctx.globalAlpha = 1;
|
||||
for (let idx = 0; idx < keypoints.length; idx++) {
|
||||
const kp = keypoints[idx];
|
||||
if (!kp || kp.confidence < minConf) continue;
|
||||
|
||||
const x = kp.x * width;
|
||||
const y = kp.y * height;
|
||||
const isFinger = idx >= 17 && idx <= 22;
|
||||
const isToe = idx >= 23 && idx <= 24;
|
||||
const isNeck = idx === 25;
|
||||
const r = isFinger ? 2 + kp.confidence * 2 : isToe ? 2 : 3 + kp.confidence * 3;
|
||||
const jColor = isFinger ? fingerColor : isToe ? toeColor : isNeck ? neckColor : jointColor;
|
||||
const gColor = isFinger ? fingerGlow : glowColor;
|
||||
|
||||
// Glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r + (isFinger ? 3 : 4), 0, Math.PI * 2);
|
||||
ctx.fillStyle = gColor;
|
||||
ctx.globalAlpha = kp.confidence * (isFinger ? 0.5 : 0.6);
|
||||
ctx.fill();
|
||||
|
||||
// Joint dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = jColor;
|
||||
ctx.globalAlpha = kp.confidence;
|
||||
ctx.fill();
|
||||
|
||||
// White center (body joints only)
|
||||
if (!isFinger && !isToe) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r * 0.4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.globalAlpha = kp.confidence * 0.8;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Confidence label + keypoint count
|
||||
if (opts.label) {
|
||||
const visCount = keypoints.filter(kp => kp && kp.confidence >= minConf).length;
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = jointColor;
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillText(`${opts.label} · ${visCount} joints`, 8, height - 8);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw CSI amplitude heatmap
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {{ data: Float32Array, width: number, height: number }} heatmap
|
||||
* @param {number} canvasW
|
||||
* @param {number} canvasH
|
||||
*/
|
||||
drawCsiHeatmap(ctx, heatmap, canvasW, canvasH) {
|
||||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||||
|
||||
if (!heatmap || !heatmap.data || heatmap.height < 2) {
|
||||
ctx.fillStyle = '#0a0e18';
|
||||
ctx.fillRect(0, 0, canvasW, canvasH);
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillText('Waiting for CSI data...', 8, canvasH / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, width: dw, height: dh } = heatmap;
|
||||
const cellW = canvasW / dw;
|
||||
const cellH = canvasH / dh;
|
||||
|
||||
for (let y = 0; y < dh; y++) {
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const val = Math.min(1, Math.max(0, data[y * dw + x]));
|
||||
ctx.fillStyle = this._heatmapColor(val);
|
||||
ctx.fillRect(x * cellW, y * cellH, cellW + 0.5, cellH + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Axis labels
|
||||
ctx.font = '9px "JetBrains Mono", monospace';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.fillText('Subcarrier →', 4, canvasH - 4);
|
||||
ctx.save();
|
||||
ctx.translate(canvasW - 4, canvasH - 4);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText('Time ↑', 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw embedding space 2D projection
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {{ video: Array, csi: Array, fused: Array }} points
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
drawEmbeddingSpace(ctx, points, w, h) {
|
||||
ctx.fillStyle = '#050810';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const x = (i / 4) * w;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||
const y = (i / 4) * h;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
|
||||
|
||||
// Auto-scale: find max extent across all point sets
|
||||
let maxExtent = 0.01;
|
||||
for (const pts of [points.video, points.csi, points.fused]) {
|
||||
if (!pts) continue;
|
||||
for (const p of pts) {
|
||||
if (!p) continue;
|
||||
maxExtent = Math.max(maxExtent, Math.abs(p[0]), Math.abs(p[1]));
|
||||
}
|
||||
}
|
||||
const scale = 0.42 / maxExtent; // Fill ~84% of half-width
|
||||
|
||||
const drawPoints = (pts, color, size) => {
|
||||
if (!pts || pts.length === 0) return;
|
||||
const len = pts.length;
|
||||
|
||||
// Draw trail line connecting recent points
|
||||
if (len >= 2) {
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const p = pts[i];
|
||||
if (!p) continue;
|
||||
const px = w / 2 + p[0] * scale * w;
|
||||
const py = h / 2 + p[1] * scale * h;
|
||||
if (px < -10 || px > w + 10 || py < -10 || py > h + 10) continue;
|
||||
if (!started) { ctx.moveTo(px, py); started = true; }
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw dots with glow on newest
|
||||
for (let i = 0; i < len; i++) {
|
||||
const p = pts[i];
|
||||
if (!p) continue;
|
||||
const age = 1 - (i / len) * 0.7;
|
||||
const px = w / 2 + p[0] * scale * w;
|
||||
const py = h / 2 + p[1] * scale * h;
|
||||
|
||||
if (px < -10 || px > w + 10 || py < -10 || py > h + 10) continue;
|
||||
|
||||
// Glow on newest point
|
||||
if (i === len - 1) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, size + 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, i === len - 1 ? size + 1 : size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = age * 0.8;
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
drawPoints(points.video, this.colors.videoEmb, 3);
|
||||
drawPoints(points.csi, this.colors.csiEmb, 3);
|
||||
drawPoints(points.fused, this.colors.fusedEmb, 4);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Legend
|
||||
ctx.font = '9px "JetBrains Mono", monospace';
|
||||
const legends = [
|
||||
{ color: this.colors.videoEmb, label: 'Video' },
|
||||
{ color: this.colors.csiEmb, label: 'CSI' },
|
||||
{ color: this.colors.fusedEmb, label: 'Fused' },
|
||||
];
|
||||
legends.forEach((l, i) => {
|
||||
const ly = 12 + i * 14;
|
||||
ctx.fillStyle = l.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(10, ly - 3, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
||||
ctx.fillText(l.label, 18, ly);
|
||||
});
|
||||
}
|
||||
|
||||
_heatmapColor(val) {
|
||||
// Dark blue → cyan → green → yellow → red
|
||||
if (val < 0.25) {
|
||||
const t = val / 0.25;
|
||||
return `rgb(${Math.floor(t * 20)}, ${Math.floor(20 + t * 60)}, ${Math.floor(60 + t * 100)})`;
|
||||
} else if (val < 0.5) {
|
||||
const t = (val - 0.25) / 0.25;
|
||||
return `rgb(${Math.floor(20 + t * 20)}, ${Math.floor(80 + t * 100)}, ${Math.floor(160 - t * 60)})`;
|
||||
} else if (val < 0.75) {
|
||||
const t = (val - 0.5) / 0.25;
|
||||
return `rgb(${Math.floor(40 + t * 180)}, ${Math.floor(180 + t * 75)}, ${Math.floor(100 - t * 80)})`;
|
||||
} else {
|
||||
const t = (val - 0.75) / 0.25;
|
||||
return `rgb(${Math.floor(220 + t * 35)}, ${Math.floor(255 - t * 120)}, ${Math.floor(20 - t * 20)})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* CNN Embedder — RuVector Attention-powered feature extractor.
|
||||
*
|
||||
* Uses the real ruvector-attention-wasm WASM module for Multi-Head Attention
|
||||
* and Flash Attention on CSI/video data. Falls back to a JS Conv2D pipeline
|
||||
* when WASM is not available.
|
||||
*
|
||||
* Pipeline: Conv2D → BatchNorm → ReLU → Pool → RuVector Attention → Project → L2 Normalize
|
||||
* Two instances are created: one for video frames, one for CSI pseudo-images.
|
||||
*/
|
||||
|
||||
// Seeded PRNG for deterministic weight initialization
|
||||
function mulberry32(seed) {
|
||||
return function() {
|
||||
let t = (seed += 0x6D2B79F5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export class CnnEmbedder {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.inputSize - Square input dimension (default 56 for speed)
|
||||
* @param {number} opts.embeddingDim - Output embedding dimension (default 128)
|
||||
* @param {boolean} opts.normalize - L2 normalize output
|
||||
* @param {number} opts.seed - PRNG seed for weight init
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.inputSize = opts.inputSize || 56;
|
||||
this.embeddingDim = opts.embeddingDim || 128;
|
||||
this.normalize = opts.normalize !== false;
|
||||
this.wasmEmbedder = null;
|
||||
this.rvAttention = null; // RuVector Multi-Head Attention (WASM)
|
||||
this.rvFlash = null; // RuVector Flash Attention (WASM)
|
||||
this.rvHyperbolic = null; // RuVector Hyperbolic Attention (hierarchical body)
|
||||
this.rvMoE = null; // RuVector Mixture-of-Experts (body-region routing)
|
||||
this.rvLinear = null; // RuVector Linear Attention (O(n) fast hand refinement)
|
||||
this.rvLocalGlobal = null; // RuVector Local-Global Attention (detail + context)
|
||||
this.rvModule = null; // RuVector WASM module reference
|
||||
this.useRuVector = false;
|
||||
|
||||
// Initialize weights with deterministic PRNG
|
||||
const rng = mulberry32(opts.seed || 42);
|
||||
const randRange = (lo, hi) => lo + rng() * (hi - lo);
|
||||
|
||||
// Conv 3x3: 3 input channels → 16 output channels
|
||||
this.convWeights = new Float32Array(3 * 3 * 3 * 16);
|
||||
for (let i = 0; i < this.convWeights.length; i++) {
|
||||
this.convWeights[i] = randRange(-0.15, 0.15);
|
||||
}
|
||||
|
||||
// BatchNorm params (16 channels)
|
||||
this.bnGamma = new Float32Array(16).fill(1.0);
|
||||
this.bnBeta = new Float32Array(16).fill(0.0);
|
||||
this.bnMean = new Float32Array(16).fill(0.0);
|
||||
this.bnVar = new Float32Array(16).fill(1.0);
|
||||
|
||||
// Projection: 16 → embeddingDim (used when RuVector not available)
|
||||
this.projWeights = new Float32Array(16 * this.embeddingDim);
|
||||
for (let i = 0; i < this.projWeights.length; i++) {
|
||||
this.projWeights[i] = randRange(-0.1, 0.1);
|
||||
}
|
||||
|
||||
// Attention projection: attention_dim → embeddingDim
|
||||
this.attnProjWeights = new Float32Array(16 * this.embeddingDim);
|
||||
for (let i = 0; i < this.attnProjWeights.length; i++) {
|
||||
this.attnProjWeights[i] = randRange(-0.08, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load RuVector attention WASM, then fall back to ruvector-cnn-wasm
|
||||
* @param {string} wasmPath - Path to the WASM package directory
|
||||
*/
|
||||
async tryLoadWasm(wasmPath) {
|
||||
// First try: RuVector Attention WASM (the real thing — browser ESM build)
|
||||
try {
|
||||
const attnBase = new URL('../pkg/ruvector-attention/ruvector_attention_browser.js', import.meta.url).href;
|
||||
const mod = await import(attnBase);
|
||||
await mod.default(); // async WASM init via fetch
|
||||
mod.init();
|
||||
|
||||
// Create all 6 attention mechanisms
|
||||
this.rvAttention = new mod.WasmMultiHeadAttention(16, 4);
|
||||
this.rvFlash = new mod.WasmFlashAttention(16, 8);
|
||||
this.rvHyperbolic = new mod.WasmHyperbolicAttention(16, -1.0);
|
||||
this.rvMoE = new mod.WasmMoEAttention(16, 3, 2);
|
||||
this.rvLinear = new mod.WasmLinearAttention(16, 16);
|
||||
this.rvLocalGlobal = new mod.WasmLocalGlobalAttention(16, 4, 2);
|
||||
this.rvModule = mod;
|
||||
this.useRuVector = true;
|
||||
|
||||
// Log available mechanisms
|
||||
const mechs = mod.available_mechanisms();
|
||||
console.log(`[CNN] RuVector WASM v${mod.version()} — all 6 attention mechanisms active`, mechs);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log('[CNN] RuVector Attention WASM not available:', e.message);
|
||||
}
|
||||
|
||||
// Second try: ruvector-cnn-wasm (legacy path)
|
||||
try {
|
||||
const mod = await import(`${wasmPath}/ruvector_cnn_wasm.js`);
|
||||
await mod.default();
|
||||
const config = new mod.EmbedderConfig();
|
||||
config.input_size = this.inputSize;
|
||||
config.embedding_dim = this.embeddingDim;
|
||||
config.normalize = this.normalize;
|
||||
this.wasmEmbedder = new mod.WasmCnnEmbedder(config);
|
||||
console.log('[CNN] WASM CNN embedder loaded successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log('[CNN] WASM CNN not available, using JS fallback:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract embedding from RGB image data
|
||||
* @param {Uint8Array} rgbData - RGB pixel data (H*W*3)
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @returns {Float32Array} embedding vector
|
||||
*/
|
||||
extract(rgbData, width, height) {
|
||||
if (this.wasmEmbedder) {
|
||||
try {
|
||||
const result = this.wasmEmbedder.extract(rgbData, width, height);
|
||||
return new Float32Array(result);
|
||||
} catch (_) { /* fallback to JS */ }
|
||||
}
|
||||
return this._extractJS(rgbData, width, height);
|
||||
}
|
||||
|
||||
_extractJS(rgbData, width, height) {
|
||||
// 1. Resize to inputSize × inputSize if needed
|
||||
const sz = this.inputSize;
|
||||
let input;
|
||||
if (width === sz && height === sz) {
|
||||
input = new Float32Array(rgbData.length);
|
||||
for (let i = 0; i < rgbData.length; i++) input[i] = rgbData[i] / 255.0;
|
||||
} else {
|
||||
input = this._resize(rgbData, width, height, sz, sz);
|
||||
}
|
||||
|
||||
// 2. ImageNet normalization
|
||||
const mean = [0.485, 0.456, 0.406];
|
||||
const std = [0.229, 0.224, 0.225];
|
||||
const pixels = sz * sz;
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
input[i * 3] = (input[i * 3] - mean[0]) / std[0];
|
||||
input[i * 3 + 1] = (input[i * 3 + 1] - mean[1]) / std[1];
|
||||
input[i * 3 + 2] = (input[i * 3 + 2] - mean[2]) / std[2];
|
||||
}
|
||||
|
||||
// 3. Conv2D 3x3 (3 → 16 channels)
|
||||
const convOut = this._conv2d3x3(input, sz, sz, 3, 16);
|
||||
|
||||
// 4. BatchNorm
|
||||
this._batchNorm(convOut, 16);
|
||||
|
||||
// 5. ReLU
|
||||
for (let i = 0; i < convOut.length; i++) {
|
||||
if (convOut[i] < 0) convOut[i] = 0;
|
||||
}
|
||||
|
||||
// 6. Global average pooling → spatial tokens (each 16-dim)
|
||||
const outH = sz - 2, outW = sz - 2;
|
||||
const spatial = outH * outW;
|
||||
|
||||
// 7. RuVector Attention (if loaded) — apply attention over spatial tokens
|
||||
if (this.useRuVector && this.rvAttention) {
|
||||
return this._extractWithAttention(convOut, spatial, 16);
|
||||
}
|
||||
|
||||
// Fallback: simple global average pool + linear projection
|
||||
const pooled = new Float32Array(16);
|
||||
for (let i = 0; i < spatial; i++) {
|
||||
for (let c = 0; c < 16; c++) {
|
||||
pooled[c] += convOut[i * 16 + c];
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < 16; c++) pooled[c] /= spatial;
|
||||
|
||||
// Linear projection → embeddingDim
|
||||
const emb = new Float32Array(this.embeddingDim);
|
||||
for (let o = 0; o < this.embeddingDim; o++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
sum += pooled[i] * this.projWeights[i * this.embeddingDim + o];
|
||||
}
|
||||
emb[o] = sum;
|
||||
}
|
||||
|
||||
// L2 normalize
|
||||
if (this.normalize) {
|
||||
let norm = 0;
|
||||
for (let i = 0; i < emb.length; i++) norm += emb[i] * emb[i];
|
||||
norm = Math.sqrt(norm);
|
||||
if (norm > 1e-8) {
|
||||
for (let i = 0; i < emb.length; i++) emb[i] /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
return emb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full 6-stage RuVector WASM attention pipeline:
|
||||
* 1. Flash Attention (efficient O(n) pre-screening of spatial tokens)
|
||||
* 2. Multi-Head Attention (global spatial reasoning)
|
||||
* 3. Hyperbolic Attention (hierarchical body-part structure, Poincaré ball)
|
||||
* 4. Linear Attention (O(n) refinement for fine detail — hands/extremities)
|
||||
* 5. MoE Attention (body-region specialized expert routing)
|
||||
* 6. Local-Global Attention (local detail + global context fusion)
|
||||
* → Weighted blend + batch_normalize + project + L2 normalize
|
||||
*/
|
||||
_extractWithAttention(convOut, numTokens, channels) {
|
||||
const mod = this.rvModule;
|
||||
|
||||
// Subsample spatial tokens for attention (max 64 for speed)
|
||||
const maxTokens = 64;
|
||||
const step = numTokens > maxTokens ? Math.floor(numTokens / maxTokens) : 1;
|
||||
const tokens = [];
|
||||
for (let i = 0; i < numTokens && tokens.length < maxTokens; i += step) {
|
||||
const token = new Float32Array(channels);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
token[c] = convOut[i * channels + c];
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
|
||||
const numQueries = Math.min(4, tokens.length);
|
||||
const queryStride = Math.floor(tokens.length / numQueries);
|
||||
|
||||
// === Stage 1: Flash Attention (efficient pre-screening) ===
|
||||
const flashOut = new Float32Array(channels);
|
||||
try {
|
||||
// Flash attention with block size 8 for efficient O(n) screening
|
||||
const result = this.rvFlash.compute(tokens[0], tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) flashOut[c] = result[c];
|
||||
} catch (_) {
|
||||
flashOut.set(tokens[0]);
|
||||
}
|
||||
|
||||
// === Stage 2: Multi-Head Attention (global spatial reasoning) ===
|
||||
const mhaOut = new Float32Array(channels);
|
||||
for (let q = 0; q < numQueries; q++) {
|
||||
const queryToken = tokens[q * queryStride];
|
||||
try {
|
||||
const result = this.rvAttention.compute(queryToken, tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) mhaOut[c] += result[c] / numQueries;
|
||||
} catch (_) {
|
||||
for (let c = 0; c < channels; c++) mhaOut[c] += queryToken[c] / numQueries;
|
||||
}
|
||||
}
|
||||
|
||||
// === Stage 3: Hyperbolic Attention (hierarchical body structure) ===
|
||||
const hyOut = new Float32Array(channels);
|
||||
try {
|
||||
const result = this.rvHyperbolic.compute(mhaOut, tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) hyOut[c] = result[c];
|
||||
} catch (_) {
|
||||
hyOut.set(mhaOut);
|
||||
}
|
||||
|
||||
// === Stage 4: Linear Attention (O(n) fast refinement for extremities) ===
|
||||
const linOut = new Float32Array(channels);
|
||||
try {
|
||||
const result = this.rvLinear.compute(hyOut, tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) linOut[c] = result[c];
|
||||
} catch (_) {
|
||||
linOut.set(hyOut);
|
||||
}
|
||||
|
||||
// === Stage 5: MoE Attention (body-region expert routing) ===
|
||||
const moeOut = new Float32Array(channels);
|
||||
try {
|
||||
const result = this.rvMoE.compute(linOut, tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) moeOut[c] = result[c];
|
||||
} catch (_) {
|
||||
moeOut.set(linOut);
|
||||
}
|
||||
|
||||
// === Stage 6: Local-Global Attention (detail + context) ===
|
||||
const lgOut = new Float32Array(channels);
|
||||
try {
|
||||
const result = this.rvLocalGlobal.compute(moeOut, tokens, tokens);
|
||||
for (let c = 0; c < channels; c++) lgOut[c] = result[c];
|
||||
} catch (_) {
|
||||
lgOut.set(moeOut);
|
||||
}
|
||||
|
||||
// === Blend all 6 outputs ===
|
||||
// Use WASM softmax on log-energy scores for dynamic stage weighting
|
||||
const blended = new Float32Array(channels);
|
||||
const stages = [flashOut, mhaOut, hyOut, linOut, moeOut, lgOut];
|
||||
// Use log-energy to prevent exp() overflow in softmax
|
||||
const logEnergies = new Float32Array(6);
|
||||
for (let s = 0; s < 6; s++) {
|
||||
const e = this._energy(stages[s]);
|
||||
logEnergies[s] = e > 1e-10 ? Math.log(e) : -20;
|
||||
}
|
||||
try { mod.softmax(logEnergies); } catch (_) {
|
||||
let max = -Infinity;
|
||||
for (let i = 0; i < 6; i++) max = Math.max(max, logEnergies[i]);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 6; i++) { logEnergies[i] = Math.exp(logEnergies[i] - max); sum += logEnergies[i]; }
|
||||
for (let i = 0; i < 6; i++) logEnergies[i] /= sum;
|
||||
}
|
||||
for (let c = 0; c < channels; c++) {
|
||||
for (let s = 0; s < 6; s++) {
|
||||
blended[c] += logEnergies[s] * stages[s][c];
|
||||
}
|
||||
}
|
||||
|
||||
// Batch normalize only when we have enough diversity (skip for single vectors)
|
||||
// Single-vector batch norm collapses to zeros, killing embedding space
|
||||
let normed = blended;
|
||||
|
||||
// Project to embeddingDim
|
||||
const emb = new Float32Array(this.embeddingDim);
|
||||
for (let o = 0; o < this.embeddingDim; o++) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < channels; i++) {
|
||||
sum += normed[i] * this.attnProjWeights[i * this.embeddingDim + o];
|
||||
}
|
||||
emb[o] = sum;
|
||||
}
|
||||
|
||||
// L2 normalize using RuVector WASM
|
||||
if (this.normalize) {
|
||||
try { mod.normalize(emb); } catch (_) {
|
||||
let norm = 0;
|
||||
for (let i = 0; i < emb.length; i++) norm += emb[i] * emb[i];
|
||||
norm = Math.sqrt(norm);
|
||||
if (norm > 1e-8) for (let i = 0; i < emb.length; i++) emb[i] /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
return emb;
|
||||
}
|
||||
|
||||
/** Compute vector energy (L2 norm squared) for attention weighting */
|
||||
_energy(vec) {
|
||||
let e = 0;
|
||||
for (let i = 0; i < vec.length; i++) e += vec[i] * vec[i];
|
||||
return e;
|
||||
}
|
||||
|
||||
_conv2d3x3(input, H, W, Cin, Cout) {
|
||||
const outH = H - 2, outW = W - 2;
|
||||
const output = new Float32Array(outH * outW * Cout);
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
for (let co = 0; co < Cout; co++) {
|
||||
let sum = 0;
|
||||
for (let ky = 0; ky < 3; ky++) {
|
||||
for (let kx = 0; kx < 3; kx++) {
|
||||
for (let ci = 0; ci < Cin; ci++) {
|
||||
const px = ((y + ky) * W + (x + kx)) * Cin + ci;
|
||||
const wt = (((ky * 3 + kx) * Cin) + ci) * Cout + co;
|
||||
sum += input[px] * this.convWeights[wt];
|
||||
}
|
||||
}
|
||||
}
|
||||
output[(y * outW + x) * Cout + co] = sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
_batchNorm(data, channels) {
|
||||
const spatial = data.length / channels;
|
||||
for (let i = 0; i < spatial; i++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const idx = i * channels + c;
|
||||
data[idx] = this.bnGamma[c] * (data[idx] - this.bnMean[c]) / Math.sqrt(this.bnVar[c] + 1e-5) + this.bnBeta[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_resize(rgbData, srcW, srcH, dstW, dstH) {
|
||||
const output = new Float32Array(dstW * dstH * 3);
|
||||
const xRatio = srcW / dstW;
|
||||
const yRatio = srcH / dstH;
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
const sx = Math.min(Math.floor(x * xRatio), srcW - 1);
|
||||
const sy = Math.min(Math.floor(y * yRatio), srcH - 1);
|
||||
const srcIdx = (sy * srcW + sx) * 3;
|
||||
const dstIdx = (y * dstW + x) * 3;
|
||||
output[dstIdx] = rgbData[srcIdx] / 255.0;
|
||||
output[dstIdx + 1] = rgbData[srcIdx + 1] / 255.0;
|
||||
output[dstIdx + 2] = rgbData[srcIdx + 2] / 255.0;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Cosine similarity using WASM when available, JS fallback */
|
||||
cosineSim(a, b) {
|
||||
if (this.rvModule) {
|
||||
try { return this.rvModule.cosine_similarity(a, b); } catch (_) { /* fallback */ }
|
||||
}
|
||||
return CnnEmbedder.cosineSimilarity(a, b);
|
||||
}
|
||||
|
||||
/** L2 norm using WASM when available */
|
||||
l2Norm(vec) {
|
||||
if (this.rvModule) {
|
||||
try { return this.rvModule.l2_norm(vec); } catch (_) { /* fallback */ }
|
||||
}
|
||||
let norm = 0;
|
||||
for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
|
||||
return Math.sqrt(norm);
|
||||
}
|
||||
|
||||
/** Pairwise distance matrix using WASM (for skeleton validation) */
|
||||
pairwiseDistances(vectors) {
|
||||
if (this.rvModule) {
|
||||
try { return this.rvModule.pairwise_distances(vectors); } catch (_) { /* fallback */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Static JS fallback for cosine similarity */
|
||||
static cosineSimilarity(a, b) {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
normA = Math.sqrt(normA);
|
||||
normB = Math.sqrt(normB);
|
||||
if (normA < 1e-8 || normB < 1e-8) return 0;
|
||||
return dot / (normA * normB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* CSI Simulator — Generates realistic WiFi Channel State Information data.
|
||||
*
|
||||
* In live mode, connects to the sensing server via WebSocket.
|
||||
* In demo mode, generates synthetic CSI that correlates with detected motion.
|
||||
*
|
||||
* Outputs: 3-channel pseudo-image (amplitude, phase, temporal diff)
|
||||
* matching the ADR-018 frame format expectations.
|
||||
*/
|
||||
|
||||
export class CsiSimulator {
|
||||
static VERSION = 'v4-drift'; // Cache-bust verification
|
||||
|
||||
constructor(opts = {}) {
|
||||
this.subcarriers = opts.subcarriers || 52; // 802.11n HT20
|
||||
this.timeWindow = opts.timeWindow || 56; // frames in sliding window
|
||||
this.mode = 'demo'; // 'demo' | 'live'
|
||||
this.ws = null;
|
||||
|
||||
// Circular buffer for CSI frames
|
||||
this.amplitudeBuffer = [];
|
||||
this.phaseBuffer = [];
|
||||
this.frameCount = 0;
|
||||
|
||||
// Noise parameters
|
||||
this._rng = this._mulberry32(opts.seed || 7);
|
||||
this._noiseState = new Float32Array(this.subcarriers);
|
||||
this._baseAmplitude = new Float32Array(this.subcarriers);
|
||||
this._basePhase = new Float32Array(this.subcarriers);
|
||||
|
||||
// Initialize base CSI profile (empty room)
|
||||
for (let i = 0; i < this.subcarriers; i++) {
|
||||
this._baseAmplitude[i] = 0.5 + 0.3 * Math.sin(i * 0.12);
|
||||
this._basePhase[i] = (i / this.subcarriers) * Math.PI * 2;
|
||||
}
|
||||
|
||||
// RSSI tracking
|
||||
this.rssiDbm = -70; // default mid-range
|
||||
this._rssiTarget = -70;
|
||||
|
||||
// Person influence (updated from video motion)
|
||||
this.personPresence = 0;
|
||||
this.personX = 0.5;
|
||||
this.personY = 0.5;
|
||||
this.personMotion = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to live sensing server WebSocket
|
||||
* @param {string} url - WebSocket URL (e.g. ws://localhost:3030/ws/csi)
|
||||
*/
|
||||
async connectLive(url) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.onmessage = (evt) => this._handleLiveFrame(evt.data);
|
||||
this.ws.onopen = () => { this.mode = 'live'; resolve(true); };
|
||||
this.ws.onerror = () => resolve(false);
|
||||
this.ws.onclose = () => { this.mode = 'demo'; };
|
||||
// Timeout after 3s
|
||||
setTimeout(() => { if (this.mode !== 'live') resolve(false); }, 3000);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) { this.ws.close(); this.ws = null; }
|
||||
this.mode = 'demo';
|
||||
}
|
||||
|
||||
get isLive() { return this.mode === 'live'; }
|
||||
|
||||
/**
|
||||
* Update person state from video detection (for correlated demo data).
|
||||
* When person exits frame, CSI maintains presence with slow decay
|
||||
* (simulating through-wall sensing capability).
|
||||
*/
|
||||
updatePersonState(presence, x, y, motion) {
|
||||
// Don't override real CSI sensing with synthetic video-derived state
|
||||
if (this.mode === 'live') return;
|
||||
|
||||
if (presence > 0.1) {
|
||||
// Person detected in video — update CSI state directly
|
||||
this.personPresence = presence;
|
||||
this.personX = x;
|
||||
this.personY = y;
|
||||
this.personMotion = motion;
|
||||
this._lastSeenTime = performance.now();
|
||||
this._lastSeenX = x;
|
||||
this._lastSeenY = y;
|
||||
} else if (this._lastSeenTime) {
|
||||
// Person NOT in video — CSI "through-wall" persistence
|
||||
const elapsed = (performance.now() - this._lastSeenTime) / 1000;
|
||||
// CSI can sense through walls for ~10 seconds with decaying confidence
|
||||
const decayRate = 0.15; // Lose ~15% per second
|
||||
this.personPresence = Math.max(0, 1.0 - elapsed * decayRate);
|
||||
// Position slowly drifts (person walking behind wall)
|
||||
this.personX = this._lastSeenX;
|
||||
this.personY = this._lastSeenY;
|
||||
this.personMotion = Math.max(0, motion * 0.5 + this.personPresence * 0.2);
|
||||
|
||||
if (this.personPresence < 0.05) {
|
||||
this._lastSeenTime = null;
|
||||
}
|
||||
} else {
|
||||
this.personPresence = 0;
|
||||
this.personMotion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next CSI frame (demo mode) or return latest live frame
|
||||
* @param {number} elapsed - Time in seconds
|
||||
* @returns {{ amplitude: Float32Array, phase: Float32Array, snr: number }}
|
||||
*/
|
||||
nextFrame(elapsed) {
|
||||
const amp = new Float32Array(this.subcarriers);
|
||||
const phase = new Float32Array(this.subcarriers);
|
||||
|
||||
if (this.mode === 'live' && this._liveAmplitude) {
|
||||
amp.set(this._liveAmplitude);
|
||||
phase.set(this._livePhase);
|
||||
} else {
|
||||
this._generateDemoFrame(amp, phase, elapsed);
|
||||
}
|
||||
|
||||
// Push to circular buffer
|
||||
this.amplitudeBuffer.push(new Float32Array(amp));
|
||||
this.phaseBuffer.push(new Float32Array(phase));
|
||||
if (this.amplitudeBuffer.length > this.timeWindow) {
|
||||
this.amplitudeBuffer.shift();
|
||||
this.phaseBuffer.shift();
|
||||
}
|
||||
|
||||
// RSSI: smooth toward target (demo mode generates synthetic RSSI)
|
||||
if (this.mode === 'demo') {
|
||||
// Simulate RSSI based on person presence and slow drift
|
||||
this._rssiTarget = -55 - 25 * (1 - this.personPresence) + Math.sin(elapsed * 0.3) * 3;
|
||||
}
|
||||
this.rssiDbm += (this._rssiTarget - this.rssiDbm) * 0.1;
|
||||
|
||||
// SNR estimate
|
||||
let signalPower = 0, noisePower = 0;
|
||||
for (let i = 0; i < this.subcarriers; i++) {
|
||||
signalPower += amp[i] * amp[i];
|
||||
noisePower += this._noiseState[i] * this._noiseState[i];
|
||||
}
|
||||
const snr = noisePower > 0 ? 10 * Math.log10(signalPower / noisePower) : 30;
|
||||
|
||||
this.frameCount++;
|
||||
return { amplitude: amp, phase, snr: Math.max(0, Math.min(40, snr)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build 3-channel pseudo-image for CNN input
|
||||
* @param {number} targetSize - Output image dimension (square)
|
||||
* @returns {Uint8Array} RGB data (targetSize * targetSize * 3)
|
||||
*/
|
||||
buildPseudoImage(targetSize = 56) {
|
||||
const buf = this.amplitudeBuffer;
|
||||
const pBuf = this.phaseBuffer;
|
||||
const frames = buf.length;
|
||||
if (frames < 2) {
|
||||
return new Uint8Array(targetSize * targetSize * 3);
|
||||
}
|
||||
|
||||
const rgb = new Uint8Array(targetSize * targetSize * 3);
|
||||
|
||||
for (let y = 0; y < targetSize; y++) {
|
||||
const fi = Math.min(Math.floor(y / targetSize * frames), frames - 1);
|
||||
for (let x = 0; x < targetSize; x++) {
|
||||
const si = Math.min(Math.floor(x / targetSize * this.subcarriers), this.subcarriers - 1);
|
||||
const idx = (y * targetSize + x) * 3;
|
||||
|
||||
// R: Amplitude (normalized to 0-255)
|
||||
const ampVal = buf[fi][si];
|
||||
rgb[idx] = Math.min(255, Math.max(0, Math.floor(ampVal * 255)));
|
||||
|
||||
// G: Phase (wrapped to 0-255)
|
||||
const phaseVal = (pBuf[fi][si] % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
|
||||
rgb[idx + 1] = Math.floor(phaseVal / (2 * Math.PI) * 255);
|
||||
|
||||
// B: Temporal difference
|
||||
if (fi > 0) {
|
||||
const diff = Math.abs(buf[fi][si] - buf[fi - 1][si]);
|
||||
rgb[idx + 2] = Math.min(255, Math.floor(diff * 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get heatmap data for visualization
|
||||
* @returns {{ data: Float32Array, width: number, height: number }}
|
||||
*/
|
||||
getHeatmapData() {
|
||||
const frames = this.amplitudeBuffer.length;
|
||||
const w = this.subcarriers;
|
||||
const h = Math.min(frames, this.timeWindow);
|
||||
const data = new Float32Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const fi = frames - h + y;
|
||||
if (fi >= 0 && fi < frames) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
data[y * w + x] = this.amplitudeBuffer[fi][x];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width: w, height: h };
|
||||
}
|
||||
|
||||
// === Private ===
|
||||
|
||||
_generateDemoFrame(amp, phase, elapsed) {
|
||||
const rng = this._rng;
|
||||
const presence = this.personPresence;
|
||||
const motion = this.personMotion;
|
||||
const px = this.personX;
|
||||
|
||||
for (let i = 0; i < this.subcarriers; i++) {
|
||||
// Base CSI profile (frequency-selective channel)
|
||||
let a = this._baseAmplitude[i];
|
||||
let p = this._basePhase[i] + elapsed * 0.05;
|
||||
|
||||
// Environmental noise (correlated across subcarriers)
|
||||
this._noiseState[i] = 0.95 * this._noiseState[i] + 0.05 * (rng() * 2 - 1) * 0.03;
|
||||
a += this._noiseState[i];
|
||||
|
||||
// Ambient temporal drift (multipath fading even in empty room)
|
||||
a += 0.06 * Math.sin(elapsed * 0.7 + i * 0.25)
|
||||
+ 0.04 * Math.sin(elapsed * 1.3 - i * 0.18)
|
||||
+ 0.03 * Math.cos(elapsed * 2.1 + i * 0.4);
|
||||
|
||||
// Person-induced CSI perturbation
|
||||
if (presence > 0.1) {
|
||||
// Subcarrier-dependent body reflection (Fresnel zone model)
|
||||
const freqOffset = (i - this.subcarriers * px) / (this.subcarriers * 0.3);
|
||||
const bodyReflection = presence * 0.25 * Math.exp(-freqOffset * freqOffset);
|
||||
|
||||
// Motion causes amplitude fluctuation
|
||||
const motionEffect = motion * 0.15 * Math.sin(elapsed * 3.5 + i * 0.3);
|
||||
|
||||
// Breathing modulation (0.2-0.3 Hz)
|
||||
const breathing = presence * 0.02 * Math.sin(elapsed * 1.5 + i * 0.05);
|
||||
|
||||
a += bodyReflection + motionEffect + breathing;
|
||||
p += presence * 0.4 * Math.sin(elapsed * 2.1 + i * 0.15);
|
||||
}
|
||||
|
||||
amp[i] = Math.max(0, Math.min(1, a));
|
||||
phase[i] = p;
|
||||
}
|
||||
}
|
||||
|
||||
_handleLiveFrame(data) {
|
||||
// Handle JSON text frames from the sensing server
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
this._handleJsonFrame(msg);
|
||||
} catch (_) { /* ignore malformed JSON */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Blob data (convert to ArrayBuffer and re-process)
|
||||
if (data instanceof Blob) {
|
||||
data.arrayBuffer().then(ab => this._handleLiveFrame(ab)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle binary ArrayBuffer frames (ADR-018 format)
|
||||
if (!(data instanceof ArrayBuffer)) return;
|
||||
const view = new DataView(data);
|
||||
// Check ADR-018 magic: 0xC5110001
|
||||
if (data.byteLength < 20) return;
|
||||
const magic = view.getUint32(0, true);
|
||||
if (magic !== 0xC5110001) return;
|
||||
|
||||
const numSub = Math.min(view.getUint16(8, true), this.subcarriers);
|
||||
this._liveAmplitude = new Float32Array(this.subcarriers);
|
||||
this._livePhase = new Float32Array(this.subcarriers);
|
||||
|
||||
const headerSize = 20;
|
||||
for (let i = 0; i < numSub && (headerSize + i * 4 + 3) < data.byteLength; i++) {
|
||||
const real = view.getInt16(headerSize + i * 4, true);
|
||||
const imag = view.getInt16(headerSize + i * 4 + 2, true);
|
||||
this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048;
|
||||
this._livePhase[i] = Math.atan2(imag, real);
|
||||
}
|
||||
}
|
||||
|
||||
_handleJsonFrame(msg) {
|
||||
// Sensing server sends: { type: "sensing_update", nodes: [{ amplitude: [...], subcarrier_count }], classification, features }
|
||||
this._liveAmplitude = new Float32Array(this.subcarriers);
|
||||
this._livePhase = new Float32Array(this.subcarriers);
|
||||
|
||||
// Extract amplitude from sensing_update node data
|
||||
const node = (msg.nodes && msg.nodes[0]) || msg;
|
||||
const ampArr = node.amplitude || msg.amplitude;
|
||||
if (ampArr && Array.isArray(ampArr)) {
|
||||
const n = Math.min(ampArr.length, this.subcarriers);
|
||||
// Server sends raw amplitude (already magnitude), normalize to 0-1
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < n; i++) maxAmp = Math.max(maxAmp, Math.abs(ampArr[i]));
|
||||
const scale = maxAmp > 0 ? 1.0 / maxAmp : 1.0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
this._liveAmplitude[i] = Math.abs(ampArr[i]) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase from node (if available)
|
||||
const phaseArr = node.phase || msg.phase;
|
||||
if (phaseArr && Array.isArray(phaseArr)) {
|
||||
const n = Math.min(phaseArr.length, this.subcarriers);
|
||||
for (let i = 0; i < n; i++) this._livePhase[i] = phaseArr[i];
|
||||
} else if (ampArr) {
|
||||
// Synthesize phase from amplitude variation (Hilbert-like estimate)
|
||||
for (let i = 1; i < this.subcarriers; i++) {
|
||||
this._livePhase[i] = this._livePhase[i - 1] + (this._liveAmplitude[i] - this._liveAmplitude[i - 1]) * Math.PI;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle raw I/Q pairs
|
||||
const iq = node.iq || msg.iq;
|
||||
if (iq && Array.isArray(iq)) {
|
||||
const n = Math.min(iq.length / 2, this.subcarriers);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const real = iq[i * 2], imag = iq[i * 2 + 1];
|
||||
this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048;
|
||||
this._livePhase[i] = Math.atan2(imag, real);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract RSSI from node data
|
||||
if (typeof node.rssi_dbm === 'number') {
|
||||
this._rssiTarget = node.rssi_dbm;
|
||||
} else if (msg.features && typeof msg.features.mean_rssi === 'number') {
|
||||
this._rssiTarget = msg.features.mean_rssi;
|
||||
}
|
||||
|
||||
// Update presence from server classification
|
||||
const cls = msg.classification;
|
||||
if (cls) {
|
||||
if (typeof cls.confidence === 'number') {
|
||||
this.personPresence = cls.presence ? cls.confidence : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mulberry32(seed) {
|
||||
return function() {
|
||||
let t = (seed += 0x6D2B79F5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* FusionEngine — Attention-weighted dual-modal embedding fusion.
|
||||
*
|
||||
* Combines visual (camera) and CSI (WiFi) embeddings with dynamic
|
||||
* confidence gating based on signal quality.
|
||||
*/
|
||||
|
||||
export class FusionEngine {
|
||||
/**
|
||||
* @param {number} embeddingDim
|
||||
* @param {object} opts
|
||||
* @param {object} opts.wasmModule - RuVector WASM module for cosine_similarity etc.
|
||||
*/
|
||||
constructor(embeddingDim = 128, opts = {}) {
|
||||
this.embeddingDim = embeddingDim;
|
||||
this.wasmModule = opts.wasmModule || null;
|
||||
|
||||
// Learnable attention weights (initialized to balanced 0.5)
|
||||
this.attentionWeights = new Float32Array(embeddingDim).fill(0.5);
|
||||
|
||||
// Dynamic modality confidence [0, 1]
|
||||
this.videoConfidence = 1.0;
|
||||
this.csiConfidence = 0.0;
|
||||
this.fusedConfidence = 0.5;
|
||||
|
||||
// Smoothing for confidence transitions
|
||||
this._smoothAlpha = 0.85;
|
||||
|
||||
// Embedding history for visualization
|
||||
this.recentVideoEmbeddings = [];
|
||||
this.recentCsiEmbeddings = [];
|
||||
this.recentFusedEmbeddings = [];
|
||||
this.maxHistory = 50;
|
||||
}
|
||||
|
||||
/** Set the WASM module reference (called after WASM loads) */
|
||||
setWasmModule(mod) { this.wasmModule = mod; }
|
||||
|
||||
/**
|
||||
* Update quality-based confidence scores
|
||||
* @param {number} videoBrightness - [0,1] video brightness quality
|
||||
* @param {number} videoMotion - [0,1] motion detected
|
||||
* @param {number} csiSnr - CSI signal-to-noise ratio in dB
|
||||
* @param {boolean} csiActive - Whether CSI source is connected
|
||||
*/
|
||||
updateConfidence(videoBrightness, videoMotion, csiSnr, csiActive) {
|
||||
// Video confidence: drops with low brightness, boosted by motion
|
||||
let vc = 0;
|
||||
if (videoBrightness > 0.05) {
|
||||
vc = Math.min(1, videoBrightness * 1.5) * 0.7 + Math.min(1, videoMotion * 3) * 0.3;
|
||||
}
|
||||
|
||||
// CSI confidence: based on SNR and connection status
|
||||
let cc = 0;
|
||||
if (csiActive) {
|
||||
cc = Math.min(1, csiSnr / 25); // 25dB = full confidence
|
||||
}
|
||||
|
||||
// Smooth transitions
|
||||
this.videoConfidence = this._smoothAlpha * this.videoConfidence + (1 - this._smoothAlpha) * vc;
|
||||
this.csiConfidence = this._smoothAlpha * this.csiConfidence + (1 - this._smoothAlpha) * cc;
|
||||
|
||||
// Fused confidence is the max of either (fusion can only help)
|
||||
this.fusedConfidence = Math.min(1, Math.sqrt(
|
||||
this.videoConfidence * this.videoConfidence + this.csiConfidence * this.csiConfidence
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuse video and CSI embeddings
|
||||
* @param {Float32Array|null} videoEmb - Visual embedding (or null if video-off)
|
||||
* @param {Float32Array|null} csiEmb - CSI embedding (or null if CSI-off)
|
||||
* @param {string} mode - 'dual' | 'video' | 'csi'
|
||||
* @returns {Float32Array} Fused embedding
|
||||
*/
|
||||
fuse(videoEmb, csiEmb, mode = 'dual') {
|
||||
const dim = this.embeddingDim;
|
||||
const fused = new Float32Array(dim);
|
||||
|
||||
if (mode === 'video' || !csiEmb) {
|
||||
if (videoEmb) fused.set(videoEmb);
|
||||
this._recordEmbedding(videoEmb, null, fused);
|
||||
return fused;
|
||||
}
|
||||
|
||||
if (mode === 'csi' || !videoEmb) {
|
||||
if (csiEmb) fused.set(csiEmb);
|
||||
this._recordEmbedding(null, csiEmb, fused);
|
||||
return fused;
|
||||
}
|
||||
|
||||
// Dual mode: attention-weighted fusion with confidence gating
|
||||
const totalConf = this.videoConfidence + this.csiConfidence;
|
||||
const videoWeight = totalConf > 0 ? this.videoConfidence / totalConf : 0.5;
|
||||
|
||||
for (let i = 0; i < dim; i++) {
|
||||
const alpha = this.attentionWeights[i] * videoWeight +
|
||||
(1 - this.attentionWeights[i]) * (1 - videoWeight);
|
||||
fused[i] = alpha * videoEmb[i] + (1 - alpha) * csiEmb[i];
|
||||
}
|
||||
|
||||
// Re-normalize using WASM when available
|
||||
if (this.wasmModule) {
|
||||
try { this.wasmModule.normalize(fused); } catch (_) { this._jsNormalize(fused); }
|
||||
} else {
|
||||
this._jsNormalize(fused);
|
||||
}
|
||||
|
||||
this._recordEmbedding(videoEmb, csiEmb, fused);
|
||||
return fused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embedding pairs for 2D visualization (PCA projection)
|
||||
* @returns {{ video: Array, csi: Array, fused: Array }}
|
||||
*/
|
||||
getEmbeddingPoints() {
|
||||
// Sparse random projection: pick a few dimensions with fixed coefficients
|
||||
// to get visible 2D spread (avoids cancellation from summing all 128 dims)
|
||||
const project = (emb) => {
|
||||
if (!emb || emb.length < 4) return null;
|
||||
// Use 8 sparse dimensions with predetermined signs (seeded, not random)
|
||||
const dim = emb.length;
|
||||
const x = emb[0] * 3.2 - emb[3] * 2.8 + emb[7] * 2.1 - emb[12] * 1.9
|
||||
+ (dim > 30 ? emb[29] * 1.5 - emb[31] * 1.3 : 0)
|
||||
+ (dim > 60 ? emb[55] * 1.1 - emb[60] * 0.9 : 0);
|
||||
const y = emb[1] * 3.0 - emb[5] * 2.5 + emb[9] * 2.3 - emb[15] * 1.7
|
||||
+ (dim > 40 ? emb[37] * 1.4 - emb[42] * 1.2 : 0)
|
||||
+ (dim > 80 ? emb[73] * 1.0 - emb[80] * 0.8 : 0);
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
return {
|
||||
video: this.recentVideoEmbeddings.map(project).filter(Boolean),
|
||||
csi: this.recentCsiEmbeddings.map(project).filter(Boolean),
|
||||
fused: this.recentFusedEmbeddings.map(project).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-modal similarity score
|
||||
* @returns {number} Cosine similarity between latest video and CSI embeddings
|
||||
*/
|
||||
getCrossModalSimilarity() {
|
||||
const v = this.recentVideoEmbeddings[this.recentVideoEmbeddings.length - 1];
|
||||
const c = this.recentCsiEmbeddings[this.recentCsiEmbeddings.length - 1];
|
||||
if (!v || !c) return 0;
|
||||
|
||||
// Use WASM cosine_similarity when available
|
||||
if (this.wasmModule) {
|
||||
try { return this.wasmModule.cosine_similarity(v, c); } catch (_) { /* fallback */ }
|
||||
}
|
||||
|
||||
let dot = 0, na = 0, nb = 0;
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
dot += v[i] * c[i];
|
||||
na += v[i] * v[i];
|
||||
nb += c[i] * c[i];
|
||||
}
|
||||
na = Math.sqrt(na); nb = Math.sqrt(nb);
|
||||
return (na > 1e-8 && nb > 1e-8) ? dot / (na * nb) : 0;
|
||||
}
|
||||
|
||||
_jsNormalize(vec) {
|
||||
let norm = 0;
|
||||
for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i];
|
||||
norm = Math.sqrt(norm);
|
||||
if (norm > 1e-8) for (let i = 0; i < vec.length; i++) vec[i] /= norm;
|
||||
}
|
||||
|
||||
_recordEmbedding(video, csi, fused) {
|
||||
if (video) {
|
||||
this.recentVideoEmbeddings.push(new Float32Array(video));
|
||||
if (this.recentVideoEmbeddings.length > this.maxHistory) this.recentVideoEmbeddings.shift();
|
||||
}
|
||||
if (csi) {
|
||||
this.recentCsiEmbeddings.push(new Float32Array(csi));
|
||||
if (this.recentCsiEmbeddings.length > this.maxHistory) this.recentCsiEmbeddings.shift();
|
||||
}
|
||||
this.recentFusedEmbeddings.push(new Float32Array(fused));
|
||||
if (this.recentFusedEmbeddings.length > this.maxHistory) this.recentFusedEmbeddings.shift();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* RuView — Dual-Modal Pose Estimation Demo
|
||||
*
|
||||
* Main orchestration: video capture → CNN embedding → CSI processing → fusion → rendering
|
||||
*/
|
||||
|
||||
import { VideoCapture } from './video-capture.js?v=13';
|
||||
import { CsiSimulator } from './csi-simulator.js?v=13';
|
||||
import { CnnEmbedder } from './cnn-embedder.js?v=13';
|
||||
import { FusionEngine } from './fusion-engine.js?v=13';
|
||||
import { PoseDecoder } from './pose-decoder.js?v=13';
|
||||
import { CanvasRenderer } from './canvas-renderer.js?v=13';
|
||||
|
||||
// === State ===
|
||||
let mode = 'dual'; // 'dual' | 'video' | 'csi'
|
||||
let isRunning = false;
|
||||
let isPaused = false;
|
||||
let startTime = 0;
|
||||
let frameCount = 0;
|
||||
let fps = 0;
|
||||
let lastFpsTime = 0;
|
||||
let confidenceThreshold = 0.3;
|
||||
|
||||
// Latency tracking
|
||||
const latency = { video: 0, csi: 0, fusion: 0, total: 0 };
|
||||
|
||||
// === Components ===
|
||||
const videoCapture = new VideoCapture(document.getElementById('webcam'));
|
||||
const csiSimulator = new CsiSimulator({ subcarriers: 52, timeWindow: 56 });
|
||||
const visualCnn = new CnnEmbedder({ inputSize: 56, embeddingDim: 128, seed: 42 });
|
||||
const csiCnn = new CnnEmbedder({ inputSize: 56, embeddingDim: 128, seed: 137 });
|
||||
const fusionEngine = new FusionEngine(128);
|
||||
const poseDecoder = new PoseDecoder(128);
|
||||
const renderer = new CanvasRenderer();
|
||||
|
||||
// === Canvas Elements ===
|
||||
const skeletonCanvas = document.getElementById('skeleton-canvas');
|
||||
const skeletonCtx = skeletonCanvas.getContext('2d');
|
||||
const csiCanvas = document.getElementById('csi-canvas');
|
||||
const csiCtx = csiCanvas.getContext('2d');
|
||||
const embeddingCanvas = document.getElementById('embedding-canvas');
|
||||
const embeddingCtx = embeddingCanvas.getContext('2d');
|
||||
|
||||
// === UI Elements ===
|
||||
const modeSelect = document.getElementById('mode-select');
|
||||
const statusDot = document.getElementById('status-dot');
|
||||
const statusLabel = document.getElementById('status-label');
|
||||
const fpsDisplay = document.getElementById('fps-display');
|
||||
const cameraPrompt = document.getElementById('camera-prompt');
|
||||
const startCameraBtn = document.getElementById('start-camera-btn');
|
||||
const pauseBtn = document.getElementById('pause-btn');
|
||||
const confSlider = document.getElementById('confidence-slider');
|
||||
const confValue = document.getElementById('confidence-value');
|
||||
const wsUrlInput = document.getElementById('ws-url');
|
||||
const connectWsBtn = document.getElementById('connect-ws-btn');
|
||||
|
||||
// Fusion bar elements
|
||||
const videoBar = document.getElementById('video-bar');
|
||||
const csiBar = document.getElementById('csi-bar');
|
||||
const fusedBar = document.getElementById('fused-bar');
|
||||
const videoBarVal = document.getElementById('video-bar-val');
|
||||
const csiBarVal = document.getElementById('csi-bar-val');
|
||||
const fusedBarVal = document.getElementById('fused-bar-val');
|
||||
|
||||
// Latency elements
|
||||
const latVideoEl = document.getElementById('lat-video');
|
||||
const latCsiEl = document.getElementById('lat-csi');
|
||||
const latFusionEl = document.getElementById('lat-fusion');
|
||||
const latTotalEl = document.getElementById('lat-total');
|
||||
|
||||
// Cross-modal similarity
|
||||
const crossModalEl = document.getElementById('cross-modal-sim');
|
||||
|
||||
// RSSI elements
|
||||
const rssiBarEl = document.getElementById('rssi-bar');
|
||||
const rssiValueEl = document.getElementById('rssi-value');
|
||||
const rssiQualityEl = document.getElementById('rssi-quality');
|
||||
const rssiSparkCanvas = document.getElementById('rssi-sparkline');
|
||||
const rssiSparkCtx = rssiSparkCanvas ? rssiSparkCanvas.getContext('2d') : null;
|
||||
const rssiHistory = [];
|
||||
const RSSI_HISTORY_MAX = 80;
|
||||
|
||||
// === Initialize ===
|
||||
function init() {
|
||||
console.log(`[PoseFusion] init() v4 — CsiSimulator=${CsiSimulator.VERSION || 'OLD'}, starting...`);
|
||||
resizeCanvases();
|
||||
console.log(`[PoseFusion] canvases: skeleton=${skeletonCanvas.width}x${skeletonCanvas.height}, csi=${csiCanvas.width}x${csiCanvas.height}, emb=${embeddingCanvas.width}x${embeddingCanvas.height}`);
|
||||
window.addEventListener('resize', resizeCanvases);
|
||||
|
||||
// Mode change
|
||||
modeSelect.addEventListener('change', (e) => {
|
||||
mode = e.target.value;
|
||||
updateModeUI();
|
||||
});
|
||||
|
||||
// Camera start
|
||||
startCameraBtn.addEventListener('click', startCamera);
|
||||
|
||||
// Pause
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
pauseBtn.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
});
|
||||
|
||||
// Confidence slider
|
||||
confSlider.addEventListener('input', (e) => {
|
||||
confidenceThreshold = parseFloat(e.target.value);
|
||||
confValue.textContent = confidenceThreshold.toFixed(2);
|
||||
});
|
||||
|
||||
// WebSocket connect
|
||||
connectWsBtn.addEventListener('click', async () => {
|
||||
const url = wsUrlInput.value.trim();
|
||||
if (!url) return;
|
||||
connectWsBtn.textContent = 'Connecting...';
|
||||
const ok = await csiSimulator.connectLive(url);
|
||||
connectWsBtn.textContent = ok ? '✓ Connected' : 'Connect';
|
||||
if (ok) {
|
||||
connectWsBtn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Try to load RuVector Attention WASM embedders (non-blocking)
|
||||
const wasmBase = new URL('../pkg/ruvector-attention', import.meta.url).href;
|
||||
visualCnn.tryLoadWasm(wasmBase).then((ok) => {
|
||||
// Share the WASM module with FusionEngine for cosine_similarity, normalize, etc.
|
||||
if (visualCnn.rvModule) fusionEngine.setWasmModule(visualCnn.rvModule);
|
||||
// Update footer backend label
|
||||
const backendEl = document.getElementById('cnn-backend');
|
||||
if (backendEl) {
|
||||
backendEl.textContent = ok && visualCnn.useRuVector
|
||||
? `RuVector WASM v${visualCnn.rvModule.version()} — 6 attention mechanisms`
|
||||
: 'ruvector-cnn (JS fallback)';
|
||||
}
|
||||
});
|
||||
csiCnn.tryLoadWasm(wasmBase);
|
||||
|
||||
// Auto-connect to local sensing server WebSocket if available
|
||||
const defaultWsUrl = 'ws://localhost:8765/ws/sensing';
|
||||
if (wsUrlInput) wsUrlInput.value = defaultWsUrl;
|
||||
csiSimulator.connectLive(defaultWsUrl).then(ok => {
|
||||
if (ok && connectWsBtn) {
|
||||
connectWsBtn.textContent = '✓ Live ESP32';
|
||||
connectWsBtn.classList.add('active');
|
||||
statusLabel.textContent = 'LIVE CSI';
|
||||
statusDot.classList.remove('offline');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-start camera for video/dual modes
|
||||
updateModeUI();
|
||||
startTime = performance.now() / 1000;
|
||||
isRunning = true;
|
||||
requestAnimationFrame(mainLoop);
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
cameraPrompt.style.display = 'none';
|
||||
const ok = await videoCapture.start();
|
||||
if (ok) {
|
||||
statusDot.classList.remove('offline');
|
||||
statusLabel.textContent = 'LIVE';
|
||||
resizeCanvases();
|
||||
} else {
|
||||
cameraPrompt.style.display = 'flex';
|
||||
cameraPrompt.querySelector('p').textContent = 'Camera access denied. Try CSI-only mode.';
|
||||
}
|
||||
}
|
||||
|
||||
function updateModeUI() {
|
||||
const needsVideo = mode !== 'csi';
|
||||
|
||||
// Show/hide camera prompt
|
||||
if (needsVideo && !videoCapture.isActive) {
|
||||
cameraPrompt.style.display = 'flex';
|
||||
} else {
|
||||
cameraPrompt.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update mode label in both the overlay and the camera prompt
|
||||
const labelMap = { dual: 'DUAL FUSION', video: 'VIDEO ONLY', csi: 'CSI ONLY' };
|
||||
const modeLabel = document.getElementById('mode-label');
|
||||
const promptLabel = document.getElementById('prompt-mode-label');
|
||||
if (modeLabel) modeLabel.textContent = labelMap[mode] || mode;
|
||||
if (promptLabel) promptLabel.textContent = labelMap[mode] || mode;
|
||||
}
|
||||
|
||||
function resizeCanvases() {
|
||||
const videoPanel = document.querySelector('.video-panel');
|
||||
if (videoPanel) {
|
||||
const rect = videoPanel.getBoundingClientRect();
|
||||
skeletonCanvas.width = rect.width;
|
||||
skeletonCanvas.height = rect.height;
|
||||
}
|
||||
|
||||
// CSI canvas (min 200px width)
|
||||
csiCanvas.width = Math.max(200, csiCanvas.parentElement.clientWidth);
|
||||
csiCanvas.height = 120;
|
||||
|
||||
// Embedding canvas (min 200px width)
|
||||
embeddingCanvas.width = Math.max(200, embeddingCanvas.parentElement.clientWidth);
|
||||
embeddingCanvas.height = 140;
|
||||
}
|
||||
|
||||
// === Main Loop ===
|
||||
let _loopErrorShown = false;
|
||||
let _diagDone = false;
|
||||
function mainLoop(timestamp) {
|
||||
if (!isRunning) return;
|
||||
requestAnimationFrame(mainLoop);
|
||||
|
||||
if (isPaused) return;
|
||||
|
||||
try {
|
||||
const elapsed = performance.now() / 1000 - startTime;
|
||||
const totalStart = performance.now();
|
||||
|
||||
// --- Video Pipeline ---
|
||||
let videoEmb = null;
|
||||
let motionRegion = null;
|
||||
if (mode !== 'csi' && videoCapture.isActive) {
|
||||
const t0 = performance.now();
|
||||
const frame = videoCapture.captureFrame(56, 56);
|
||||
if (frame) {
|
||||
videoEmb = visualCnn.extract(frame.rgb, frame.width, frame.height);
|
||||
motionRegion = videoCapture.detectMotionRegion(56, 56);
|
||||
|
||||
// Feed motion to CSI simulator for correlated demo data
|
||||
// When detected=false, CSI simulator handles through-wall persistence
|
||||
csiSimulator.updatePersonState(
|
||||
motionRegion.detected ? 1.0 : 0,
|
||||
motionRegion.detected ? motionRegion.x + motionRegion.w / 2 : 0.5,
|
||||
motionRegion.detected ? motionRegion.y + motionRegion.h / 2 : 0.5,
|
||||
frame.motion
|
||||
);
|
||||
|
||||
fusionEngine.updateConfidence(
|
||||
frame.brightness, frame.motion,
|
||||
0, csiSimulator.isLive || mode === 'dual'
|
||||
);
|
||||
}
|
||||
latency.video = performance.now() - t0;
|
||||
}
|
||||
|
||||
// --- CSI Pipeline ---
|
||||
let csiEmb = null;
|
||||
if (mode !== 'video') {
|
||||
const t0 = performance.now();
|
||||
const csiFrame = csiSimulator.nextFrame(elapsed);
|
||||
const pseudoImage = csiSimulator.buildPseudoImage(56);
|
||||
csiEmb = csiCnn.extract(pseudoImage, 56, 56);
|
||||
|
||||
fusionEngine.updateConfidence(
|
||||
videoCapture.brightnessScore,
|
||||
videoCapture.motionScore,
|
||||
csiFrame.snr,
|
||||
true
|
||||
);
|
||||
|
||||
// Draw CSI heatmap
|
||||
const heatmap = csiSimulator.getHeatmapData();
|
||||
renderer.drawCsiHeatmap(csiCtx, heatmap, csiCanvas.width, csiCanvas.height);
|
||||
|
||||
latency.csi = performance.now() - t0;
|
||||
}
|
||||
|
||||
// --- Fusion ---
|
||||
const t0f = performance.now();
|
||||
const fusedEmb = fusionEngine.fuse(videoEmb, csiEmb, mode);
|
||||
latency.fusion = performance.now() - t0f;
|
||||
|
||||
// --- Pose Decode ---
|
||||
// For CSI-only mode, generate a synthetic motion region from CSI energy
|
||||
if (mode === 'csi' && (!motionRegion || !motionRegion.detected)) {
|
||||
const csiPresence = csiSimulator.personPresence;
|
||||
if (csiPresence > 0.1) {
|
||||
motionRegion = {
|
||||
detected: true,
|
||||
x: 0.25, y: 0.15, w: 0.5, h: 0.7,
|
||||
coverage: csiPresence,
|
||||
motionGrid: null,
|
||||
gridCols: 10,
|
||||
gridRows: 8
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// CSI state for through-wall tracking
|
||||
const csiState = {
|
||||
csiPresence: csiSimulator.personPresence,
|
||||
isLive: csiSimulator.isLive
|
||||
};
|
||||
|
||||
const keypoints = poseDecoder.decode(fusedEmb, motionRegion, elapsed, csiState);
|
||||
|
||||
// --- Render Skeleton ---
|
||||
const labelMap = { dual: 'DUAL FUSION', video: 'VIDEO ONLY', csi: 'CSI ONLY' };
|
||||
renderer.drawSkeleton(skeletonCtx, keypoints, skeletonCanvas.width, skeletonCanvas.height, {
|
||||
minConfidence: confidenceThreshold,
|
||||
color: mode === 'csi' ? 'amber' : 'green',
|
||||
label: labelMap[mode]
|
||||
});
|
||||
|
||||
// --- Render Embedding Space ---
|
||||
const embPoints = fusionEngine.getEmbeddingPoints();
|
||||
renderer.drawEmbeddingSpace(embeddingCtx, embPoints, embeddingCanvas.width, embeddingCanvas.height);
|
||||
|
||||
// --- Update UI ---
|
||||
latency.total = performance.now() - totalStart;
|
||||
|
||||
// FPS
|
||||
frameCount++;
|
||||
if (timestamp - lastFpsTime > 500) {
|
||||
fps = Math.round(frameCount * 1000 / (timestamp - lastFpsTime));
|
||||
lastFpsTime = timestamp;
|
||||
frameCount = 0;
|
||||
fpsDisplay.textContent = `${fps} FPS`;
|
||||
}
|
||||
|
||||
// Fusion bars
|
||||
const vc = fusionEngine.videoConfidence;
|
||||
const cc = fusionEngine.csiConfidence;
|
||||
const fc = fusionEngine.fusedConfidence;
|
||||
videoBar.style.width = `${vc * 100}%`;
|
||||
csiBar.style.width = `${cc * 100}%`;
|
||||
fusedBar.style.width = `${fc * 100}%`;
|
||||
videoBarVal.textContent = `${Math.round(vc * 100)}%`;
|
||||
csiBarVal.textContent = `${Math.round(cc * 100)}%`;
|
||||
fusedBarVal.textContent = `${Math.round(fc * 100)}%`;
|
||||
|
||||
// Latency
|
||||
latVideoEl.textContent = `${latency.video.toFixed(1)}ms`;
|
||||
latCsiEl.textContent = `${latency.csi.toFixed(1)}ms`;
|
||||
latFusionEl.textContent = `${latency.fusion.toFixed(1)}ms`;
|
||||
latTotalEl.textContent = `${latency.total.toFixed(1)}ms`;
|
||||
|
||||
// Cross-modal similarity
|
||||
const sim = fusionEngine.getCrossModalSimilarity();
|
||||
crossModalEl.textContent = sim.toFixed(3);
|
||||
|
||||
// RuVector attention pipeline stats
|
||||
const rvStats = poseDecoder.attentionStats;
|
||||
const rvEnergyEl = document.getElementById('rv-energy');
|
||||
const rvRefineEl = document.getElementById('rv-refine');
|
||||
const rvImpactEl = document.getElementById('rv-impact');
|
||||
if (rvEnergyEl) rvEnergyEl.textContent = (rvStats.energy || 0).toFixed(2);
|
||||
if (rvRefineEl) rvRefineEl.textContent = ((rvStats.refinementMag || 0) * 1000).toFixed(1) + 'px';
|
||||
if (rvImpactEl) {
|
||||
const impact = Math.min(100, (rvStats.refinementMag || 0) * 5000);
|
||||
rvImpactEl.textContent = impact.toFixed(0) + '%';
|
||||
}
|
||||
// Pulse the pipeline stages when active
|
||||
if (visualCnn.useRuVector && rvStats.energy > 0.1) {
|
||||
document.querySelectorAll('.rv-stage').forEach(el => el.classList.add('active'));
|
||||
}
|
||||
|
||||
// RSSI update
|
||||
updateRssi(csiSimulator.rssiDbm);
|
||||
|
||||
// One-time diagnostic
|
||||
if (!_diagDone) {
|
||||
_diagDone = true;
|
||||
console.log(`[PoseFusion] frame 1 OK — mode=${mode}, csi.bufLen=${csiSimulator.amplitudeBuffer.length}, embPts=${embPoints?.fused?.length ?? 0}, rssi=${(csiSimulator.rssiDbm ?? -99).toFixed(1)}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (!_loopErrorShown) {
|
||||
_loopErrorShown = true;
|
||||
console.error('[MainLoop]', err);
|
||||
// Show error visually on page
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.style.cssText = 'position:fixed;bottom:60px;left:24px;right:24px;background:rgba(255,48,64,0.95);color:#fff;padding:12px 16px;border-radius:8px;font:12px/1.4 "JetBrains Mono",monospace;z-index:9999;max-height:120px;overflow:auto';
|
||||
errDiv.textContent = `[MainLoop Error] ${err.message}\n${err.stack?.split('\n').slice(0,3).join('\n')}`;
|
||||
document.body.appendChild(errDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === RSSI Visualization ===
|
||||
function updateRssi(dbm) {
|
||||
if (!rssiBarEl) return;
|
||||
|
||||
// Clamp to typical WiFi range: -100 (worst) to -30 (best)
|
||||
const clamped = Math.max(-100, Math.min(-30, dbm));
|
||||
const pct = ((clamped + 100) / 70) * 100; // 0-100%
|
||||
|
||||
rssiBarEl.style.width = `${pct}%`;
|
||||
rssiValueEl.textContent = `${Math.round(clamped)} dBm`;
|
||||
|
||||
// Quality label
|
||||
let quality;
|
||||
if (clamped > -50) quality = 'Excellent';
|
||||
else if (clamped > -60) quality = 'Good';
|
||||
else if (clamped > -70) quality = 'Fair';
|
||||
else if (clamped > -80) quality = 'Weak';
|
||||
else quality = 'Poor';
|
||||
rssiQualityEl.textContent = quality;
|
||||
|
||||
// Color the dBm value based on quality
|
||||
if (clamped > -60) rssiValueEl.style.color = 'var(--green-glow)';
|
||||
else if (clamped > -75) rssiValueEl.style.color = 'var(--amber)';
|
||||
else rssiValueEl.style.color = 'var(--red-alert)';
|
||||
|
||||
// Sparkline history
|
||||
rssiHistory.push(clamped);
|
||||
if (rssiHistory.length > RSSI_HISTORY_MAX) rssiHistory.shift();
|
||||
drawRssiSparkline();
|
||||
}
|
||||
|
||||
function drawRssiSparkline() {
|
||||
if (!rssiSparkCtx || rssiHistory.length < 2) return;
|
||||
const w = rssiSparkCanvas.width;
|
||||
const h = rssiSparkCanvas.height;
|
||||
const ctx = rssiSparkCtx;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Draw signal strength line
|
||||
const len = rssiHistory.length;
|
||||
const step = w / (RSSI_HISTORY_MAX - 1);
|
||||
|
||||
// Gradient fill under line
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, 'rgba(0,210,120,0.3)');
|
||||
grad.addColorStop(1, 'rgba(0,210,120,0)');
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = (RSSI_HISTORY_MAX - len + i) * step;
|
||||
const y = h - ((rssiHistory[i] + 100) / 70) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
// Fill area
|
||||
const lastX = (RSSI_HISTORY_MAX - 1) * step;
|
||||
const firstX = (RSSI_HISTORY_MAX - len) * step;
|
||||
ctx.lineTo(lastX, h);
|
||||
ctx.lineTo(firstX, h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Draw line on top
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = (RSSI_HISTORY_MAX - len + i) * step;
|
||||
const y = h - ((rssiHistory[i] + 100) / 70) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = '#00d878';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Pulsing dot at latest value
|
||||
const latestX = lastX;
|
||||
const latestY = h - ((rssiHistory[len - 1] + 100) / 70) * h;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(performance.now() / 300);
|
||||
ctx.beginPath();
|
||||
ctx.arc(latestX, latestY, 2 + pulse, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#00d878';
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(latestX, latestY, 4 + pulse * 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(0,216,120,${0.3 + pulse * 0.3})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Boot
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* PoseDecoder — Maps motion detection grid → 17 COCO keypoints.
|
||||
*
|
||||
* Uses per-cell motion intensity to track actual body part positions:
|
||||
* - Head: top-center motion cluster
|
||||
* - Shoulders/Elbows/Wrists: lateral motion in upper body zone
|
||||
* - Hips/Knees/Ankles: lower body motion distribution
|
||||
*
|
||||
* When person exits frame, CSI data continues tracking (through-wall mode).
|
||||
*/
|
||||
|
||||
// Extended keypoint definitions: 17 COCO + 9 hand/fingertip approximations = 26 total
|
||||
export const KEYPOINT_NAMES = [
|
||||
'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',
|
||||
'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',
|
||||
'left_wrist', 'right_wrist', 'left_hip', 'right_hip',
|
||||
'left_knee', 'right_knee', 'left_ankle', 'right_ankle',
|
||||
// Extended: hand keypoints (17-25)
|
||||
'left_thumb', 'left_index', 'left_pinky', // 17, 18, 19
|
||||
'right_thumb', 'right_index', 'right_pinky', // 20, 21, 22
|
||||
'left_foot_index', 'right_foot_index', // 23, 24 (toe tips)
|
||||
'neck', // 25 (mid-shoulder)
|
||||
];
|
||||
|
||||
// Skeleton connections (pairs of keypoint indices)
|
||||
export const SKELETON_CONNECTIONS = [
|
||||
[0, 1], [0, 2], [1, 3], [2, 4], // Head
|
||||
[0, 25], // Nose → neck
|
||||
[25, 5], [25, 6], // Neck → shoulders
|
||||
[5, 7], [7, 9], // Left arm
|
||||
[6, 8], [8, 10], // Right arm
|
||||
[5, 11], [6, 12], // Torso
|
||||
[11, 12], // Hips
|
||||
[11, 13], [13, 15], // Left leg
|
||||
[12, 14], [14, 16], // Right leg
|
||||
// Hand connections
|
||||
[9, 17], [9, 18], [9, 19], // Left wrist → fingers
|
||||
[10, 20], [10, 21], [10, 22], // Right wrist → fingers
|
||||
// Foot connections
|
||||
[15, 23], [16, 24], // Ankles → toes
|
||||
];
|
||||
|
||||
// Standard body proportions (relative to body height)
|
||||
const PROPORTIONS = {
|
||||
headToShoulder: 0.15,
|
||||
shoulderWidth: 0.25,
|
||||
shoulderToElbow: 0.18,
|
||||
elbowToWrist: 0.16,
|
||||
shoulderToHip: 0.30,
|
||||
hipWidth: 0.18,
|
||||
hipToKnee: 0.24,
|
||||
kneeToAnkle: 0.24,
|
||||
eyeSpacing: 0.04,
|
||||
earSpacing: 0.07,
|
||||
// Hand proportions
|
||||
wristToFinger: 0.09,
|
||||
fingerSpread: 0.04,
|
||||
thumbAngle: 0.6, // radians from wrist-elbow axis
|
||||
// Foot proportions
|
||||
ankleToToe: 0.06,
|
||||
};
|
||||
|
||||
export class PoseDecoder {
|
||||
constructor(embeddingDim = 128) {
|
||||
this.embeddingDim = embeddingDim;
|
||||
this.smoothedKeypoints = null;
|
||||
this.smoothingFactor = 0.25; // Low = responsive to real movement
|
||||
this._time = 0;
|
||||
|
||||
// Through-wall tracking state
|
||||
this._lastBodyState = null;
|
||||
this._ghostState = null;
|
||||
this._ghostConfidence = 0;
|
||||
this._ghostVelocity = { x: 0, y: 0 };
|
||||
|
||||
// Zone centroid tracking (normalized 0-1 positions)
|
||||
this._headCx = 0.5;
|
||||
this._headCy = 0.15;
|
||||
this._leftArmCx = 0.3;
|
||||
this._leftArmCy = 0.35;
|
||||
this._rightArmCx = 0.7;
|
||||
this._rightArmCy = 0.35;
|
||||
this._leftLegCx = 0.4;
|
||||
this._leftLegCy = 0.8;
|
||||
this._rightLegCx = 0.6;
|
||||
this._rightLegCy = 0.8;
|
||||
this._torsoCx = 0.5;
|
||||
this._torsoCy = 0.45;
|
||||
|
||||
// RuVector embedding → joint mapping
|
||||
// Each joint gets 2 consecutive embedding dimensions (dx, dy offset)
|
||||
// and 1 dimension for confidence modulation. 26 joints × 3 = 78 dims used from 128.
|
||||
// Remaining 50 dims encode global pose features (body scale, rotation, lean).
|
||||
this._jointEmbMap = this._buildJointEmbeddingMap(embeddingDim);
|
||||
|
||||
// Attention contribution tracking (for UI overlay)
|
||||
this.attentionStats = { energy: 0, maxDim: 0, refinementMag: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mapping from embedding dimensions to joint refinement signals.
|
||||
* This maps the RuVector attention output to anatomically meaningful joint offsets.
|
||||
*/
|
||||
_buildJointEmbeddingMap(dim) {
|
||||
const map = [];
|
||||
// 26 joints × 3 dims each (dx, dy, confidence_mod) = 78 dims
|
||||
for (let j = 0; j < 26; j++) {
|
||||
const base = j * 3;
|
||||
if (base + 2 < dim) {
|
||||
map.push({ dxDim: base, dyDim: base + 1, confDim: base + 2 });
|
||||
} else {
|
||||
map.push({ dxDim: j % dim, dyDim: (j + 1) % dim, confDim: (j + 2) % dim });
|
||||
}
|
||||
}
|
||||
// Global pose features from dims 78-127
|
||||
return {
|
||||
joints: map,
|
||||
scaleDim: Math.min(78, dim - 1), // body scale factor
|
||||
rotDim: Math.min(79, dim - 1), // body rotation
|
||||
leanXDim: Math.min(80, dim - 1), // lateral lean
|
||||
leanYDim: Math.min(81, dim - 1), // forward/back lean
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode motion data into 17 keypoints
|
||||
* @param {Float32Array} embedding - Fused embedding vector
|
||||
* @param {{ detected, x, y, w, h, motionGrid, gridCols, gridRows, motionCx, motionCy, exitDirection }} motionRegion
|
||||
* @param {number} elapsed - Time in seconds
|
||||
* @param {{ csiPresence: number }} csiState - CSI sensing state for through-wall
|
||||
* @returns {Array<{x: number, y: number, confidence: number, name: string}>}
|
||||
*/
|
||||
decode(embedding, motionRegion, elapsed, csiState = {}) {
|
||||
this._time = elapsed;
|
||||
|
||||
const hasMotion = motionRegion && motionRegion.detected;
|
||||
const hasCsi = csiState && csiState.csiPresence > 0.1;
|
||||
|
||||
if (hasMotion) {
|
||||
// Active tracking from video motion grid
|
||||
this._ghostConfidence = 0;
|
||||
const rawKeypoints = this._trackFromMotionGrid(motionRegion, embedding, elapsed);
|
||||
this._lastBodyState = { keypoints: rawKeypoints.map(kp => ({...kp})), time: elapsed };
|
||||
|
||||
// Track exit velocity
|
||||
if (motionRegion.exitDirection) {
|
||||
const speed = 0.008;
|
||||
this._ghostVelocity = {
|
||||
x: motionRegion.exitDirection === 'left' ? -speed : motionRegion.exitDirection === 'right' ? speed : 0,
|
||||
y: motionRegion.exitDirection === 'up' ? -speed : motionRegion.exitDirection === 'down' ? speed : 0
|
||||
};
|
||||
}
|
||||
|
||||
// Apply temporal smoothing
|
||||
if (this.smoothedKeypoints && this.smoothedKeypoints.length === rawKeypoints.length) {
|
||||
const alpha = this.smoothingFactor;
|
||||
for (let i = 0; i < rawKeypoints.length; i++) {
|
||||
rawKeypoints[i].x = alpha * this.smoothedKeypoints[i].x + (1 - alpha) * rawKeypoints[i].x;
|
||||
rawKeypoints[i].y = alpha * this.smoothedKeypoints[i].y + (1 - alpha) * rawKeypoints[i].y;
|
||||
}
|
||||
}
|
||||
|
||||
this.smoothedKeypoints = rawKeypoints;
|
||||
return rawKeypoints;
|
||||
|
||||
} else if (this._lastBodyState && (hasCsi || this._ghostConfidence > 0.05)) {
|
||||
// Through-wall mode: person left frame but CSI still senses them
|
||||
return this._trackThroughWall(elapsed, csiState);
|
||||
|
||||
} else if (this.smoothedKeypoints) {
|
||||
// Fade out
|
||||
const faded = this.smoothedKeypoints.map(kp => ({
|
||||
...kp,
|
||||
confidence: kp.confidence * 0.88
|
||||
})).filter(kp => kp.confidence > 0.05);
|
||||
if (faded.length === 0) this.smoothedKeypoints = null;
|
||||
else this.smoothedKeypoints = faded;
|
||||
return faded;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Track body parts from the motion grid.
|
||||
* Finds the centroid of motion in each body zone and positions joints there.
|
||||
*/
|
||||
_trackFromMotionGrid(region, embedding, elapsed) {
|
||||
const grid = region.motionGrid;
|
||||
const cols = region.gridCols || 10;
|
||||
const rows = region.gridRows || 8;
|
||||
|
||||
// Body bounding box (in normalized 0-1 coords)
|
||||
const bx = region.x, by = region.y, bw = region.w, bh = region.h;
|
||||
const cx = bx + bw / 2;
|
||||
const cy = by + bh / 2;
|
||||
const bodyH = Math.max(bh, 0.3);
|
||||
const bodyW = Math.max(bw, 0.15);
|
||||
|
||||
// Find motion centroids per body zone from the grid
|
||||
if (grid) {
|
||||
const zones = this._findZoneCentroids(grid, cols, rows, bx, by, bw, bh);
|
||||
// Smooth with low alpha for responsiveness
|
||||
const a = 0.3; // 30% old, 70% new → responsive
|
||||
this._headCx = a * this._headCx + (1 - a) * zones.head.x;
|
||||
this._headCy = a * this._headCy + (1 - a) * zones.head.y;
|
||||
this._leftArmCx = a * this._leftArmCx + (1 - a) * zones.leftArm.x;
|
||||
this._leftArmCy = a * this._leftArmCy + (1 - a) * zones.leftArm.y;
|
||||
this._rightArmCx= a * this._rightArmCx+ (1 - a) * zones.rightArm.x;
|
||||
this._rightArmCy= a * this._rightArmCy+ (1 - a) * zones.rightArm.y;
|
||||
this._leftLegCx = a * this._leftLegCx + (1 - a) * zones.leftLeg.x;
|
||||
this._leftLegCy = a * this._leftLegCy + (1 - a) * zones.leftLeg.y;
|
||||
this._rightLegCx= a * this._rightLegCx+ (1 - a) * zones.rightLeg.x;
|
||||
this._rightLegCy= a * this._rightLegCy+ (1 - a) * zones.rightLeg.y;
|
||||
this._torsoCx = a * this._torsoCx + (1 - a) * zones.torso.x;
|
||||
this._torsoCy = a * this._torsoCy + (1 - a) * zones.torso.y;
|
||||
}
|
||||
|
||||
const P = PROPORTIONS;
|
||||
|
||||
// Breathing (subtle)
|
||||
const breathe = Math.sin(elapsed * 1.5) * 0.002;
|
||||
|
||||
// === Position joints using tracked centroids ===
|
||||
|
||||
// HEAD: tracked centroid (top zone)
|
||||
const headX = this._headCx;
|
||||
const headY = this._headCy;
|
||||
|
||||
// TORSO center drives shoulder/hip
|
||||
const torsoX = this._torsoCx;
|
||||
const shoulderY = this._torsoCy - bodyH * 0.08 + breathe;
|
||||
const halfW = P.shoulderWidth * bodyH / 2;
|
||||
const hipHalfW = P.hipWidth * bodyH / 2;
|
||||
const hipY = shoulderY + P.shoulderToHip * bodyH;
|
||||
|
||||
// ARMS: elbow + wrist driven toward arm zone centroids
|
||||
// Left arm: shoulder is fixed, elbow/wrist pulled toward left arm centroid
|
||||
const lShX = torsoX - halfW;
|
||||
const lShY = shoulderY;
|
||||
// Vector from shoulder toward arm centroid
|
||||
const lArmDx = this._leftArmCx - lShX;
|
||||
const lArmDy = this._leftArmCy - lShY;
|
||||
const lArmDist = Math.sqrt(lArmDx * lArmDx + lArmDy * lArmDy) || 0.01;
|
||||
const lArmNx = lArmDx / lArmDist;
|
||||
const lArmNy = lArmDy / lArmDist;
|
||||
// Elbow at shoulderToElbow distance along that direction
|
||||
const elbowLen = P.shoulderToElbow * bodyH;
|
||||
const lElbowX = lShX + lArmNx * elbowLen;
|
||||
const lElbowY = lShY + lArmNy * elbowLen;
|
||||
// Wrist continues further
|
||||
const wristLen = P.elbowToWrist * bodyH;
|
||||
const lWristX = lElbowX + lArmNx * wristLen;
|
||||
const lWristY = lElbowY + lArmNy * wristLen;
|
||||
|
||||
// Right arm: same approach
|
||||
const rShX = torsoX + halfW;
|
||||
const rShY = shoulderY;
|
||||
const rArmDx = this._rightArmCx - rShX;
|
||||
const rArmDy = this._rightArmCy - rShY;
|
||||
const rArmDist = Math.sqrt(rArmDx * rArmDx + rArmDy * rArmDy) || 0.01;
|
||||
const rArmNx = rArmDx / rArmDist;
|
||||
const rArmNy = rArmDy / rArmDist;
|
||||
const rElbowX = rShX + rArmNx * elbowLen;
|
||||
const rElbowY = rShY + rArmNy * elbowLen;
|
||||
const rWristX = rElbowX + rArmNx * wristLen;
|
||||
const rWristY = rElbowY + rArmNy * wristLen;
|
||||
|
||||
// LEGS: knees/ankles pulled toward leg zone centroids
|
||||
const lHipX = torsoX - hipHalfW;
|
||||
const rHipX = torsoX + hipHalfW;
|
||||
const lLegDx = this._leftLegCx - lHipX;
|
||||
const lLegDy = Math.max(0.05, this._leftLegCy - hipY); // always downward
|
||||
const lLegDist = Math.sqrt(lLegDx * lLegDx + lLegDy * lLegDy) || 0.01;
|
||||
const lLegNx = lLegDx / lLegDist;
|
||||
const lLegNy = lLegDy / lLegDist;
|
||||
const kneeLen = P.hipToKnee * bodyH;
|
||||
const ankleLen = P.kneeToAnkle * bodyH;
|
||||
const lKneeX = lHipX + lLegNx * kneeLen;
|
||||
const lKneeY = hipY + lLegNy * kneeLen;
|
||||
const lAnkleX = lKneeX + lLegNx * ankleLen;
|
||||
const lAnkleY = lKneeY + lLegNy * ankleLen;
|
||||
|
||||
const rLegDx = this._rightLegCx - rHipX;
|
||||
const rLegDy = Math.max(0.05, this._rightLegCy - hipY);
|
||||
const rLegDist = Math.sqrt(rLegDx * rLegDx + rLegDy * rLegDy) || 0.01;
|
||||
const rLegNx = rLegDx / rLegDist;
|
||||
const rLegNy = rLegDy / rLegDist;
|
||||
const rKneeX = rHipX + rLegNx * kneeLen;
|
||||
const rKneeY = hipY + rLegNy * kneeLen;
|
||||
const rAnkleX = rKneeX + rLegNx * ankleLen;
|
||||
const rAnkleY = rKneeY + rLegNy * ankleLen;
|
||||
|
||||
// Arm raise amount (for hand openness)
|
||||
const leftArmRaise = Math.max(0, Math.min(1, (shoulderY - this._leftArmCy) / (bodyH * 0.3)));
|
||||
const rightArmRaise = Math.max(0, Math.min(1, (shoulderY - this._rightArmCy) / (bodyH * 0.3)));
|
||||
|
||||
// Compute hand finger positions from wrist-elbow axis
|
||||
const lHandAngle = Math.atan2(lWristY - lElbowY, lWristX - lElbowX);
|
||||
const rHandAngle = Math.atan2(rWristY - rElbowY, rWristX - rElbowX);
|
||||
const fingerLen = P.wristToFinger * bodyH;
|
||||
const fingerSpr = P.fingerSpread * bodyH;
|
||||
|
||||
// Hand openness driven by arm raise + arm lateral spread
|
||||
const lArmSpread = Math.abs(this._leftArmCx - (bx + bw * 0.3)) / (bw * 0.3);
|
||||
const rArmSpread = Math.abs(this._rightArmCx - (bx + bw * 0.7)) / (bw * 0.3);
|
||||
const lHandOpen = Math.min(1, leftArmRaise * 0.5 + lArmSpread * 0.5);
|
||||
const rHandOpen = Math.min(1, rightArmRaise * 0.5 + rArmSpread * 0.5);
|
||||
|
||||
const keypoints = [
|
||||
// 0: nose
|
||||
{ x: headX, y: headY + 0.01, confidence: 0.92 },
|
||||
// 1: left_eye
|
||||
{ x: headX - P.eyeSpacing * bodyH, y: headY - 0.005, confidence: 0.88 },
|
||||
// 2: right_eye
|
||||
{ x: headX + P.eyeSpacing * bodyH, y: headY - 0.005, confidence: 0.88 },
|
||||
// 3: left_ear
|
||||
{ x: headX - P.earSpacing * bodyH, y: headY + 0.005, confidence: 0.72 },
|
||||
// 4: right_ear
|
||||
{ x: headX + P.earSpacing * bodyH, y: headY + 0.005, confidence: 0.72 },
|
||||
// 5: left_shoulder
|
||||
{ x: lShX, y: lShY, confidence: 0.94 },
|
||||
// 6: right_shoulder
|
||||
{ x: rShX, y: rShY, confidence: 0.94 },
|
||||
// 7: left_elbow
|
||||
{ x: lElbowX, y: lElbowY, confidence: 0.87 },
|
||||
// 8: right_elbow
|
||||
{ x: rElbowX, y: rElbowY, confidence: 0.87 },
|
||||
// 9: left_wrist
|
||||
{ x: lWristX, y: lWristY, confidence: 0.82 },
|
||||
// 10: right_wrist
|
||||
{ x: rWristX, y: rWristY, confidence: 0.82 },
|
||||
// 11: left_hip
|
||||
{ x: lHipX, y: hipY, confidence: 0.91 },
|
||||
// 12: right_hip
|
||||
{ x: rHipX, y: hipY, confidence: 0.91 },
|
||||
// 13: left_knee
|
||||
{ x: lKneeX, y: lKneeY, confidence: 0.88 },
|
||||
// 14: right_knee
|
||||
{ x: rKneeX, y: rKneeY, confidence: 0.88 },
|
||||
// 15: left_ankle
|
||||
{ x: lAnkleX, y: lAnkleY, confidence: 0.83 },
|
||||
// 16: right_ankle
|
||||
{ x: rAnkleX, y: rAnkleY, confidence: 0.83 },
|
||||
|
||||
// === Extended keypoints (17-25) ===
|
||||
|
||||
// 17: left_thumb — offset at thumb angle from wrist-elbow axis
|
||||
{ x: lWristX + fingerLen * Math.cos(lHandAngle + P.thumbAngle) * (0.6 + lHandOpen * 0.4),
|
||||
y: lWristY + fingerLen * Math.sin(lHandAngle + P.thumbAngle) * (0.6 + lHandOpen * 0.4),
|
||||
confidence: 0.68 * (0.5 + lHandOpen * 0.5) },
|
||||
// 18: left_index — extends along wrist-elbow axis
|
||||
{ x: lWristX + fingerLen * Math.cos(lHandAngle) + fingerSpr * lHandOpen * Math.cos(lHandAngle + 0.3),
|
||||
y: lWristY + fingerLen * Math.sin(lHandAngle) + fingerSpr * lHandOpen * Math.sin(lHandAngle + 0.3),
|
||||
confidence: 0.72 * (0.5 + lHandOpen * 0.5) },
|
||||
// 19: left_pinky — offset opposite thumb
|
||||
{ x: lWristX + fingerLen * 0.85 * Math.cos(lHandAngle - P.thumbAngle * 0.7),
|
||||
y: lWristY + fingerLen * 0.85 * Math.sin(lHandAngle - P.thumbAngle * 0.7),
|
||||
confidence: 0.60 * (0.5 + lHandOpen * 0.5) },
|
||||
|
||||
// 20: right_thumb
|
||||
{ x: rWristX + fingerLen * Math.cos(rHandAngle - P.thumbAngle) * (0.6 + rHandOpen * 0.4),
|
||||
y: rWristY + fingerLen * Math.sin(rHandAngle - P.thumbAngle) * (0.6 + rHandOpen * 0.4),
|
||||
confidence: 0.68 * (0.5 + rHandOpen * 0.5) },
|
||||
// 21: right_index
|
||||
{ x: rWristX + fingerLen * Math.cos(rHandAngle) + fingerSpr * rHandOpen * Math.cos(rHandAngle - 0.3),
|
||||
y: rWristY + fingerLen * Math.sin(rHandAngle) + fingerSpr * rHandOpen * Math.sin(rHandAngle - 0.3),
|
||||
confidence: 0.72 * (0.5 + rHandOpen * 0.5) },
|
||||
// 22: right_pinky
|
||||
{ x: rWristX + fingerLen * 0.85 * Math.cos(rHandAngle + P.thumbAngle * 0.7),
|
||||
y: rWristY + fingerLen * 0.85 * Math.sin(rHandAngle + P.thumbAngle * 0.7),
|
||||
confidence: 0.60 * (0.5 + rHandOpen * 0.5) },
|
||||
|
||||
// 23: left_foot_index (toe tip) — extends forward from ankle
|
||||
{ x: lAnkleX + P.ankleToToe * bodyH * 0.5,
|
||||
y: lAnkleY + P.ankleToToe * bodyH * 0.3,
|
||||
confidence: 0.65 },
|
||||
// 24: right_foot_index
|
||||
{ x: rAnkleX + P.ankleToToe * bodyH * 0.5,
|
||||
y: rAnkleY + P.ankleToToe * bodyH * 0.3,
|
||||
confidence: 0.65 },
|
||||
|
||||
// 25: neck (midpoint between shoulders, slightly above)
|
||||
{ x: (lShX + rShX) / 2, y: shoulderY - P.headToShoulder * bodyH * 0.35, confidence: 0.93 },
|
||||
];
|
||||
|
||||
for (let i = 0; i < keypoints.length; i++) {
|
||||
keypoints[i].name = KEYPOINT_NAMES[i];
|
||||
}
|
||||
|
||||
// === RuVector Attention Embedding Refinement ===
|
||||
// Compute attention stats for the UI pipeline display, but only apply
|
||||
// positional refinement when a trained model is loaded (random-weight
|
||||
// embeddings carry no meaningful spatial signal and distort the skeleton).
|
||||
if (embedding && embedding.length >= 26 * 3) {
|
||||
this._computeEmbeddingStats(keypoints, embedding, bodyH);
|
||||
}
|
||||
|
||||
return keypoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply RuVector attention embedding to refine joint positions and confidence.
|
||||
*
|
||||
* The 128-dim fused embedding is decoded as:
|
||||
* - Dims 0-77: Per-joint (dx, dy, confidence_mod) × 26 joints
|
||||
* - Dims 78-81: Global pose parameters (scale, rotation, lean)
|
||||
* - Dims 82-127: Reserved for cross-modal fusion features
|
||||
*
|
||||
* The attention mechanism determines HOW MUCH each spatial region contributes
|
||||
* to each joint's refinement. Multi-Head captures global relationships,
|
||||
* Hyperbolic captures hierarchical (torso→limb→hand) dependencies,
|
||||
* MoE routes different body regions to specialized experts,
|
||||
* Linear provides fast extremity refinement, Local-Global balances detail/context.
|
||||
*/
|
||||
/**
|
||||
* Compute embedding statistics for UI display without modifying joint positions.
|
||||
* The 6-stage attention pipeline stats are shown in the RuVector panel.
|
||||
* Position refinement is disabled until a trained model replaces random weights.
|
||||
*/
|
||||
_computeEmbeddingStats(keypoints, emb, bodyH) {
|
||||
const map = this._jointEmbMap;
|
||||
const tc = (v) => Math.tanh(Number(v) || 0);
|
||||
|
||||
// Embedding energy (L2 norm of the used dims)
|
||||
let energy = 0;
|
||||
for (let i = 0; i < Math.min(emb.length, 82); i++) {
|
||||
energy += emb[i] * emb[i];
|
||||
}
|
||||
energy = Math.sqrt(energy);
|
||||
|
||||
// Simulated per-joint refinement magnitude (what WOULD be applied)
|
||||
const scale = bodyH * 0.015;
|
||||
let totalRefinement = 0;
|
||||
let maxDimVal = 0;
|
||||
|
||||
for (let j = 0; j < Math.min(keypoints.length, 26); j++) {
|
||||
const jmap = map.joints[j];
|
||||
if (!jmap) continue;
|
||||
const dx = tc(emb[jmap.dxDim]) * scale;
|
||||
const dy = tc(emb[jmap.dyDim]) * scale;
|
||||
totalRefinement += Math.sqrt(dx * dx + dy * dy);
|
||||
maxDimVal = Math.max(maxDimVal, Math.abs(tc(emb[jmap.dxDim])), Math.abs(tc(emb[jmap.dyDim])));
|
||||
}
|
||||
|
||||
this.attentionStats.energy = energy;
|
||||
this.attentionStats.maxDim = maxDimVal;
|
||||
this.attentionStats.refinementMag = totalRefinement / 26;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find weighted motion centroids for each body zone.
|
||||
* Divides the bounding box into 6 zones: head, left arm, right arm, torso, left leg, right leg.
|
||||
* Returns the (x,y) centroid of motion intensity for each zone.
|
||||
*/
|
||||
_findZoneCentroids(grid, cols, rows, bx, by, bw, bh) {
|
||||
// Zone definitions (in grid-relative fractions)
|
||||
const zones = {
|
||||
head: { rMin: 0, rMax: 0.2, cMin: 0.25, cMax: 0.75, wx: 0, wy: 0, wt: 0 },
|
||||
leftArm: { rMin: 0.1, rMax: 0.6, cMin: 0, cMax: 0.35, wx: 0, wy: 0, wt: 0 },
|
||||
rightArm: { rMin: 0.1, rMax: 0.6, cMin: 0.65, cMax: 1.0, wx: 0, wy: 0, wt: 0 },
|
||||
torso: { rMin: 0.15, rMax: 0.55, cMin: 0.3, cMax: 0.7, wx: 0, wy: 0, wt: 0 },
|
||||
leftLeg: { rMin: 0.5, rMax: 1.0, cMin: 0.1, cMax: 0.5, wx: 0, wy: 0, wt: 0 },
|
||||
rightLeg: { rMin: 0.5, rMax: 1.0, cMin: 0.5, cMax: 0.9, wx: 0, wy: 0, wt: 0 },
|
||||
};
|
||||
|
||||
// Accumulate weighted centroids per zone
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const ry = r / rows; // 0-1 within grid
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cx_g = c / cols; // 0-1 within grid
|
||||
const val = grid[r][c];
|
||||
if (val < 0.005) continue; // skip near-zero motion
|
||||
|
||||
// Map grid position to body-space coordinates (0-1)
|
||||
const worldX = bx + cx_g * bw;
|
||||
const worldY = by + ry * bh;
|
||||
|
||||
// Assign to matching zones (a cell can contribute to multiple overlapping zones)
|
||||
for (const z of Object.values(zones)) {
|
||||
if (ry >= z.rMin && ry < z.rMax && cx_g >= z.cMin && cx_g < z.cMax) {
|
||||
z.wx += worldX * val;
|
||||
z.wy += worldY * val;
|
||||
z.wt += val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute centroids with fallback defaults
|
||||
const centroid = (z, defX, defY) => ({
|
||||
x: z.wt > 0.01 ? z.wx / z.wt : defX,
|
||||
y: z.wt > 0.01 ? z.wy / z.wt : defY,
|
||||
weight: z.wt
|
||||
});
|
||||
|
||||
const midX = bx + bw / 2;
|
||||
const midY = by + bh / 2;
|
||||
|
||||
return {
|
||||
head: centroid(zones.head, midX, by + bh * 0.1),
|
||||
leftArm: centroid(zones.leftArm, bx + bw * 0.2, midY - bh * 0.05),
|
||||
rightArm: centroid(zones.rightArm, bx + bw * 0.8, midY - bh * 0.05),
|
||||
torso: centroid(zones.torso, midX, midY),
|
||||
leftLeg: centroid(zones.leftLeg, bx + bw * 0.35,by + bh * 0.75),
|
||||
rightLeg: centroid(zones.rightLeg, bx + bw * 0.65,by + bh * 0.75),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Through-wall tracking: continue showing pose via CSI when person left video frame.
|
||||
* The skeleton drifts in the exit direction with decreasing confidence.
|
||||
*/
|
||||
_trackThroughWall(elapsed, csiState) {
|
||||
if (!this._lastBodyState) return [];
|
||||
|
||||
const dt = elapsed - this._lastBodyState.time;
|
||||
const csiPresence = csiState.csiPresence || 0;
|
||||
|
||||
// Initialize ghost on first call
|
||||
if (this._ghostConfidence <= 0.05) {
|
||||
this._ghostConfidence = 0.8;
|
||||
this._ghostState = this._lastBodyState.keypoints.map(kp => ({...kp}));
|
||||
}
|
||||
|
||||
// Ghost confidence decays, but CSI presence sustains it
|
||||
const csiBoost = Math.min(0.7, csiPresence * 0.8);
|
||||
this._ghostConfidence = Math.max(0.05, this._ghostConfidence * 0.995 - 0.001 + csiBoost * 0.002);
|
||||
|
||||
// Drift the ghost in exit direction
|
||||
const vx = this._ghostVelocity.x;
|
||||
const vy = this._ghostVelocity.y;
|
||||
|
||||
// Breathing continues via CSI
|
||||
const breathe = Math.sin(elapsed * 1.5) * 0.003 * csiPresence;
|
||||
|
||||
const keypoints = this._ghostState.map((kp, i) => {
|
||||
return {
|
||||
x: kp.x + vx * dt * 0.3,
|
||||
y: kp.y + vy * dt * 0.3 + (i >= 5 && i <= 6 ? breathe : 0),
|
||||
confidence: kp.confidence * this._ghostConfidence * (0.5 + csiPresence * 0.5),
|
||||
name: kp.name
|
||||
};
|
||||
});
|
||||
|
||||
// Slow down drift over time
|
||||
this._ghostVelocity.x *= 0.998;
|
||||
this._ghostVelocity.y *= 0.998;
|
||||
|
||||
this.smoothedKeypoints = keypoints;
|
||||
return keypoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* VideoCapture — getUserMedia webcam capture with frame extraction.
|
||||
* Provides quality metrics (brightness, motion) for fusion confidence gating.
|
||||
*/
|
||||
|
||||
export class VideoCapture {
|
||||
constructor(videoElement) {
|
||||
this.video = videoElement;
|
||||
this.stream = null;
|
||||
this.offscreen = document.createElement('canvas');
|
||||
this.offCtx = this.offscreen.getContext('2d', { willReadFrequently: true });
|
||||
this.prevFrame = null;
|
||||
this.motionScore = 0;
|
||||
this.brightnessScore = 0;
|
||||
}
|
||||
|
||||
async start(constraints = {}) {
|
||||
const defaultConstraints = {
|
||||
video: {
|
||||
width: { ideal: 640 },
|
||||
height: { ideal: 480 },
|
||||
facingMode: 'user',
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: false
|
||||
};
|
||||
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(
|
||||
Object.keys(constraints).length ? constraints : defaultConstraints
|
||||
);
|
||||
this.video.srcObject = this.stream;
|
||||
await this.video.play();
|
||||
|
||||
this.offscreen.width = this.video.videoWidth;
|
||||
this.offscreen.height = this.video.videoHeight;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Video] Camera access failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.stream !== null && this.video.readyState >= 2;
|
||||
}
|
||||
|
||||
get width() { return this.video.videoWidth || 640; }
|
||||
get height() { return this.video.videoHeight || 480; }
|
||||
|
||||
/**
|
||||
* Capture current frame as RGB Uint8Array + compute quality metrics.
|
||||
* @param {number} targetW - Target width for CNN input
|
||||
* @param {number} targetH - Target height for CNN input
|
||||
* @returns {{ rgb: Uint8Array, width: number, height: number, motion: number, brightness: number }}
|
||||
*/
|
||||
captureFrame(targetW = 56, targetH = 56) {
|
||||
if (!this.isActive) return null;
|
||||
|
||||
// Draw to offscreen at target resolution
|
||||
this.offscreen.width = targetW;
|
||||
this.offscreen.height = targetH;
|
||||
this.offCtx.drawImage(this.video, 0, 0, targetW, targetH);
|
||||
const imageData = this.offCtx.getImageData(0, 0, targetW, targetH);
|
||||
const rgba = imageData.data;
|
||||
|
||||
// Convert RGBA → RGB
|
||||
const pixels = targetW * targetH;
|
||||
const rgb = new Uint8Array(pixels * 3);
|
||||
let brightnessSum = 0;
|
||||
let motionSum = 0;
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const r = rgba[i * 4];
|
||||
const g = rgba[i * 4 + 1];
|
||||
const b = rgba[i * 4 + 2];
|
||||
rgb[i * 3] = r;
|
||||
rgb[i * 3 + 1] = g;
|
||||
rgb[i * 3 + 2] = b;
|
||||
|
||||
// Luminance for brightness
|
||||
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
brightnessSum += lum;
|
||||
|
||||
// Motion: diff from previous frame
|
||||
if (this.prevFrame) {
|
||||
const pr = this.prevFrame[i * 3];
|
||||
const pg = this.prevFrame[i * 3 + 1];
|
||||
const pb = this.prevFrame[i * 3 + 2];
|
||||
motionSum += Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb);
|
||||
}
|
||||
}
|
||||
|
||||
this.brightnessScore = brightnessSum / (pixels * 255);
|
||||
this.motionScore = this.prevFrame ? Math.min(1, motionSum / (pixels * 100)) : 0;
|
||||
this.prevFrame = new Uint8Array(rgb);
|
||||
|
||||
return {
|
||||
rgb,
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
motion: this.motionScore,
|
||||
brightness: this.brightnessScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture full-resolution RGBA for overlay rendering
|
||||
* @returns {ImageData|null}
|
||||
*/
|
||||
captureFullFrame() {
|
||||
if (!this.isActive) return null;
|
||||
this.offscreen.width = this.width;
|
||||
this.offscreen.height = this.height;
|
||||
this.offCtx.drawImage(this.video, 0, 0);
|
||||
return this.offCtx.getImageData(0, 0, this.width, this.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect motion region + detailed motion grid for body-part tracking.
|
||||
* Returns bounding box + a grid showing WHERE motion is concentrated.
|
||||
* @returns {{ x, y, w, h, detected: boolean, motionGrid: number[][], gridCols: number, gridRows: number, exitDirection: string|null }}
|
||||
*/
|
||||
detectMotionRegion(targetW = 56, targetH = 56) {
|
||||
if (!this.isActive || !this.prevFrame) return { detected: false, motionGrid: null };
|
||||
|
||||
this.offscreen.width = targetW;
|
||||
this.offscreen.height = targetH;
|
||||
this.offCtx.drawImage(this.video, 0, 0, targetW, targetH);
|
||||
const rgba = this.offCtx.getImageData(0, 0, targetW, targetH).data;
|
||||
|
||||
let minX = targetW, minY = targetH, maxX = 0, maxY = 0;
|
||||
let motionPixels = 0;
|
||||
const threshold = 25;
|
||||
|
||||
// Motion grid: divide frame into cells and track motion intensity per cell
|
||||
const gridCols = 10;
|
||||
const gridRows = 8;
|
||||
const cellW = targetW / gridCols;
|
||||
const cellH = targetH / gridRows;
|
||||
const motionGrid = Array.from({ length: gridRows }, () => new Float32Array(gridCols));
|
||||
const cellPixels = cellW * cellH;
|
||||
|
||||
// Also track motion centroid weighted by intensity
|
||||
let motionCxSum = 0, motionCySum = 0, motionWeightSum = 0;
|
||||
|
||||
for (let y = 0; y < targetH; y++) {
|
||||
for (let x = 0; x < targetW; x++) {
|
||||
const i = y * targetW + x;
|
||||
const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2];
|
||||
const pr = this.prevFrame[i * 3], pg = this.prevFrame[i * 3 + 1], pb = this.prevFrame[i * 3 + 2];
|
||||
const diff = Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb);
|
||||
|
||||
if (diff > threshold * 3) {
|
||||
motionPixels++;
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
// Accumulate per-cell motion intensity
|
||||
const gc = Math.min(Math.floor(x / cellW), gridCols - 1);
|
||||
const gr = Math.min(Math.floor(y / cellH), gridRows - 1);
|
||||
const intensity = diff / (3 * 255); // Normalize 0-1
|
||||
motionGrid[gr][gc] += intensity / cellPixels;
|
||||
|
||||
// Weighted centroid
|
||||
if (diff > threshold) {
|
||||
motionCxSum += x * diff;
|
||||
motionCySum += y * diff;
|
||||
motionWeightSum += diff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detected = motionPixels > (targetW * targetH * 0.02);
|
||||
|
||||
// Motion centroid (normalized 0-1)
|
||||
const motionCx = motionWeightSum > 0 ? motionCxSum / (motionWeightSum * targetW) : 0.5;
|
||||
const motionCy = motionWeightSum > 0 ? motionCySum / (motionWeightSum * targetH) : 0.5;
|
||||
|
||||
// Detect exit direction: if centroid is near edges
|
||||
let exitDirection = null;
|
||||
if (detected && motionCx < 0.1) exitDirection = 'left';
|
||||
else if (detected && motionCx > 0.9) exitDirection = 'right';
|
||||
else if (detected && motionCy < 0.1) exitDirection = 'up';
|
||||
else if (detected && motionCy > 0.9) exitDirection = 'down';
|
||||
|
||||
// Track last known position for through-wall persistence
|
||||
if (detected) {
|
||||
this._lastDetected = {
|
||||
x: minX / targetW,
|
||||
y: minY / targetH,
|
||||
w: (maxX - minX) / targetW,
|
||||
h: (maxY - minY) / targetH,
|
||||
cx: motionCx,
|
||||
cy: motionCy,
|
||||
exitDirection,
|
||||
time: performance.now()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
detected,
|
||||
x: minX / targetW,
|
||||
y: minY / targetH,
|
||||
w: (maxX - minX) / targetW,
|
||||
h: (maxY - minY) / targetH,
|
||||
coverage: motionPixels / (targetW * targetH),
|
||||
motionGrid,
|
||||
gridCols,
|
||||
gridRows,
|
||||
motionCx,
|
||||
motionCy,
|
||||
exitDirection
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last known detection info (for through-wall persistence)
|
||||
*/
|
||||
get lastDetection() {
|
||||
return this._lastDetected || null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 rUv
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,220 @@
|
||||
# ruvector-attention-wasm
|
||||
|
||||
WebAssembly bindings for the ruvector-attention package, providing high-performance attention mechanisms for browser and Node.js environments.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Attention Mechanisms**:
|
||||
- Scaled Dot-Product Attention
|
||||
- Multi-Head Attention
|
||||
- Hyperbolic Attention (for hierarchical data)
|
||||
- Linear Attention (Performer-style)
|
||||
- Flash Attention (memory-efficient)
|
||||
- Local-Global Attention
|
||||
- Mixture of Experts (MoE) Attention
|
||||
- **CGT Sheaf Attention** (coherence-gated via Prime-Radiant)
|
||||
|
||||
- **Training Utilities**:
|
||||
- InfoNCE contrastive loss
|
||||
- Adam optimizer
|
||||
- AdamW optimizer (with decoupled weight decay)
|
||||
- Learning rate scheduler (warmup + cosine decay)
|
||||
|
||||
- **TypeScript Support**: Full type definitions and modern API
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ruvector-attention-wasm
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### TypeScript/JavaScript
|
||||
|
||||
```typescript
|
||||
import { initialize, MultiHeadAttention, utils } from 'ruvector-attention-wasm';
|
||||
|
||||
// Initialize WASM module
|
||||
await initialize();
|
||||
|
||||
// Create multi-head attention
|
||||
const attention = new MultiHeadAttention({ dim: 64, numHeads: 8 });
|
||||
|
||||
// Prepare inputs
|
||||
const query = new Float32Array(64);
|
||||
const keys = [new Float32Array(64), new Float32Array(64)];
|
||||
const values = [new Float32Array(64), new Float32Array(64)];
|
||||
|
||||
// Compute attention
|
||||
const output = attention.compute(query, keys, values);
|
||||
|
||||
// Use utilities
|
||||
const similarity = utils.cosineSimilarity(query, keys[0]);
|
||||
```
|
||||
|
||||
### Advanced Examples
|
||||
|
||||
#### Hyperbolic Attention
|
||||
|
||||
```typescript
|
||||
import { HyperbolicAttention } from 'ruvector-attention-wasm';
|
||||
|
||||
const hyperbolic = new HyperbolicAttention({
|
||||
dim: 128,
|
||||
curvature: 1.0
|
||||
});
|
||||
|
||||
const output = hyperbolic.compute(query, keys, values);
|
||||
```
|
||||
|
||||
#### MoE Attention with Expert Stats
|
||||
|
||||
```typescript
|
||||
import { MoEAttention } from 'ruvector-attention-wasm';
|
||||
|
||||
const moe = new MoEAttention({
|
||||
dim: 64,
|
||||
numExperts: 4,
|
||||
topK: 2
|
||||
});
|
||||
|
||||
const output = moe.compute(query, keys, values);
|
||||
|
||||
// Get expert utilization
|
||||
const stats = moe.getExpertStats();
|
||||
console.log('Load balance:', stats.loadBalance);
|
||||
```
|
||||
|
||||
#### Training with InfoNCE Loss
|
||||
|
||||
```typescript
|
||||
import { InfoNCELoss, Adam } from 'ruvector-attention-wasm';
|
||||
|
||||
const loss = new InfoNCELoss(0.07);
|
||||
const optimizer = new Adam(paramCount, {
|
||||
learningRate: 0.001,
|
||||
beta1: 0.9,
|
||||
beta2: 0.999,
|
||||
});
|
||||
|
||||
// Training loop
|
||||
const lossValue = loss.compute(anchor, positive, negatives);
|
||||
optimizer.step(params, gradients);
|
||||
```
|
||||
|
||||
#### Learning Rate Scheduling
|
||||
|
||||
```typescript
|
||||
import { LRScheduler, AdamW } from 'ruvector-attention-wasm';
|
||||
|
||||
const scheduler = new LRScheduler({
|
||||
initialLR: 0.001,
|
||||
warmupSteps: 1000,
|
||||
totalSteps: 10000,
|
||||
});
|
||||
|
||||
const optimizer = new AdamW(paramCount, {
|
||||
learningRate: scheduler.getLR(),
|
||||
weightDecay: 0.01,
|
||||
});
|
||||
|
||||
// Training loop
|
||||
for (let step = 0; step < 10000; step++) {
|
||||
optimizer.learningRate = scheduler.getLR();
|
||||
optimizer.step(params, gradients);
|
||||
scheduler.step();
|
||||
}
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.70+
|
||||
- wasm-pack
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build for web (ES modules)
|
||||
wasm-pack build --target web --out-dir pkg
|
||||
|
||||
# Build for Node.js
|
||||
wasm-pack build --target nodejs --out-dir pkg-node
|
||||
|
||||
# Build for bundlers (webpack, vite, etc.)
|
||||
wasm-pack build --target bundler --out-dir pkg-bundler
|
||||
|
||||
# Run tests
|
||||
wasm-pack test --headless --firefox
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Attention Mechanisms
|
||||
|
||||
- `MultiHeadAttention` - Standard multi-head attention
|
||||
- `HyperbolicAttention` - Attention in hyperbolic space
|
||||
- `LinearAttention` - Linear complexity attention (Performer)
|
||||
- `FlashAttention` - Memory-efficient attention
|
||||
- `LocalGlobalAttention` - Combined local and global attention
|
||||
- `MoEAttention` - Mixture of Experts attention
|
||||
- `CGTSheafAttention` - Coherence-gated via Prime-Radiant energy
|
||||
- `scaledDotAttention()` - Functional API for basic attention
|
||||
|
||||
### CGT Sheaf Attention (Prime-Radiant Integration)
|
||||
|
||||
The CGT (Coherence-Gated Transformer) Sheaf Attention mechanism uses Prime-Radiant's sheaf Laplacian energy to gate attention based on mathematical consistency:
|
||||
|
||||
```typescript
|
||||
import { CGTSheafAttention } from 'ruvector-attention-wasm';
|
||||
|
||||
const cgtAttention = new CGTSheafAttention({
|
||||
dim: 128,
|
||||
numHeads: 8,
|
||||
coherenceThreshold: 0.3, // Block if energy > threshold
|
||||
});
|
||||
|
||||
// Attention is gated by coherence energy
|
||||
const result = cgtAttention.compute(query, keys, values);
|
||||
console.log('Coherence energy:', result.energy);
|
||||
console.log('Is coherent:', result.isCoherent);
|
||||
```
|
||||
|
||||
**Key features:**
|
||||
- Energy-weighted attention: Lower coherence energy → higher attention
|
||||
- Automatic hallucination detection via residual analysis
|
||||
- GPU-accelerated with wgpu WGSL shaders (vec4 optimized)
|
||||
- SIMD fallback (AVX-512/AVX2/NEON)
|
||||
|
||||
### Training
|
||||
|
||||
- `InfoNCELoss` - Contrastive loss function
|
||||
- `Adam` - Adam optimizer
|
||||
- `AdamW` - AdamW optimizer with weight decay
|
||||
- `LRScheduler` - Learning rate scheduler
|
||||
|
||||
### Utilities
|
||||
|
||||
- `utils.cosineSimilarity()` - Cosine similarity between vectors
|
||||
- `utils.l2Norm()` - L2 norm of a vector
|
||||
- `utils.normalize()` - Normalize vector to unit length
|
||||
- `utils.softmax()` - Apply softmax transformation
|
||||
- `utils.attentionWeights()` - Compute attention weights from scores
|
||||
- `utils.batchNormalize()` - Batch normalization
|
||||
- `utils.randomOrthogonalMatrix()` - Generate random orthogonal matrix
|
||||
- `utils.pairwiseDistances()` - Compute pairwise distances
|
||||
|
||||
## Performance
|
||||
|
||||
The WASM bindings provide near-native performance for attention computations:
|
||||
|
||||
- Optimized with `opt-level = "s"` and LTO
|
||||
- SIMD acceleration where available
|
||||
- Efficient memory management
|
||||
- Zero-copy data transfer where possible
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ruvector-attention-wasm",
|
||||
"collaborators": [
|
||||
"Ruvector Team"
|
||||
],
|
||||
"description": "High-performance WebAssembly attention mechanisms: Multi-Head, Flash, Hyperbolic, MoE, CGT Sheaf Attention with GPU acceleration for transformers and LLMs",
|
||||
"version": "2.0.5",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ruvnet/ruvector"
|
||||
},
|
||||
"files": [
|
||||
"ruvector_attention_wasm_bg.wasm",
|
||||
"ruvector_attention_wasm.js",
|
||||
"ruvector_attention_wasm.d.ts"
|
||||
],
|
||||
"main": "ruvector_attention_wasm.js",
|
||||
"homepage": "https://ruv.io/ruvector",
|
||||
"types": "ruvector_attention_wasm.d.ts",
|
||||
"keywords": [
|
||||
"wasm",
|
||||
"attention",
|
||||
"transformer",
|
||||
"flash-attention",
|
||||
"llm"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* Browser ESM wrapper for ruvector-attention-wasm v2.0.5
|
||||
*
|
||||
* The upstream pkg/ was built with wasm-pack --target nodejs (CJS + fs.readFileSync).
|
||||
* This wrapper loads the same WASM binary via fetch() for browser use.
|
||||
*
|
||||
* Usage:
|
||||
* import initWasm, { WasmMultiHeadAttention, ... } from './ruvector_attention_browser.js';
|
||||
* await initWasm();
|
||||
* const attn = new WasmMultiHeadAttention(dim, heads);
|
||||
*/
|
||||
|
||||
let _wasm;
|
||||
let _initialized = false;
|
||||
|
||||
// The entire CJS module runs inside this IIFE to avoid polluting global scope.
|
||||
// We capture all exports in _mod.
|
||||
const _mod = {};
|
||||
|
||||
(function(exports, wasm_getter) {
|
||||
|
||||
// ── wasm-bindgen heap management ──────────────────────────────────
|
||||
const heap = new Array(128).fill(undefined);
|
||||
heap.push(undefined, null, true, false);
|
||||
let heap_next = heap.length;
|
||||
|
||||
function addHeapObject(obj) {
|
||||
if (heap_next === heap.length) heap.push(heap.length + 1);
|
||||
const idx = heap_next;
|
||||
heap_next = heap[idx];
|
||||
heap[idx] = obj;
|
||||
return idx;
|
||||
}
|
||||
function getObject(idx) { return heap[idx]; }
|
||||
function dropObject(idx) {
|
||||
if (idx < 132) return;
|
||||
heap[idx] = heap_next;
|
||||
heap_next = idx;
|
||||
}
|
||||
function takeObject(idx) {
|
||||
const ret = getObject(idx);
|
||||
dropObject(idx);
|
||||
return ret;
|
||||
}
|
||||
function isLikeNone(x) { return x === undefined || x === null; }
|
||||
|
||||
// ── Memory views ──────────────────────────────────────────────────
|
||||
let cachedDataViewMemory0 = null;
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
let cachedFloat32ArrayMemory0 = null;
|
||||
|
||||
function wasm() { return wasm_getter(); }
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer !== wasm().memory.buffer)
|
||||
cachedDataViewMemory0 = new DataView(wasm().memory.buffer);
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.buffer !== wasm().memory.buffer)
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm().memory.buffer);
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
function getFloat32ArrayMemory0() {
|
||||
if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.buffer !== wasm().memory.buffer)
|
||||
cachedFloat32ArrayMemory0 = new Float32Array(wasm().memory.buffer);
|
||||
return cachedFloat32ArrayMemory0;
|
||||
}
|
||||
function getArrayF32FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
|
||||
}
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr, ptr + len);
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
function passArrayF32ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 4, 4) >>> 0;
|
||||
getFloat32ArrayMemory0().set(arg, ptr / 4);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
const cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function debugString(val) {
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) return `${val}`;
|
||||
if (type == 'string') return `"${val}"`;
|
||||
if (type == 'symbol') return val.description ? `Symbol(${val.description})` : 'Symbol';
|
||||
if (type == 'function') return 'Function';
|
||||
if (Array.isArray(val)) return `[${val.map(debugString).join(', ')}]`;
|
||||
try {
|
||||
const keys = Object.keys(val);
|
||||
return `{${keys.map(k => `${k}: ${debugString(val[k])}`).join(', ')}}`;
|
||||
} catch (_) { return Object.prototype.toString.call(val); }
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try { return f.apply(this, args); }
|
||||
catch (e) { wasm().__wbindgen_export3(addHeapObject(e)); }
|
||||
}
|
||||
|
||||
// ── FinalizationRegistry ──────────────────────────────────────────
|
||||
const FR = typeof FinalizationRegistry !== 'undefined'
|
||||
? FinalizationRegistry
|
||||
: class { register() {} unregister() {} };
|
||||
|
||||
const WasmMultiHeadAttentionFinalization = new FR(ptr => wasm().__wbg_wasmmultiheadattention_free(ptr >>> 0, 1));
|
||||
const WasmFlashAttentionFinalization = new FR(ptr => wasm().__wbg_wasmflashattention_free(ptr >>> 0, 1));
|
||||
const WasmHyperbolicAttentionFinalization = new FR(ptr => wasm().__wbg_wasmhyperbolicattention_free(ptr >>> 0, 1));
|
||||
const WasmMoEAttentionFinalization = new FR(ptr => wasm().__wbg_wasmmoeattention_free(ptr >>> 0, 1));
|
||||
const WasmLinearAttentionFinalization = new FR(ptr => wasm().__wbg_wasmlinearattention_free(ptr >>> 0, 1));
|
||||
const WasmLocalGlobalAttentionFinalization = new FR(ptr => wasm().__wbg_wasmlocalglobalattention_free(ptr >>> 0, 1));
|
||||
|
||||
// ── Classes ───────────────────────────────────────────────────────
|
||||
|
||||
class WasmMultiHeadAttention {
|
||||
constructor(dim, num_heads) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
wasm().wasmmultiheadattention_new(retptr, dim, num_heads);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
if (r2) throw takeObject(r1);
|
||||
this.__wbg_ptr = r0 >>> 0;
|
||||
WasmMultiHeadAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmMultiHeadAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmmultiheadattention_free(ptr, 0);
|
||||
}
|
||||
get dim() { return wasm().wasmmultiheadattention_dim(this.__wbg_ptr); }
|
||||
get num_heads() { return wasm().wasmmultiheadattention_num_heads(this.__wbg_ptr); }
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmmultiheadattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WasmFlashAttention {
|
||||
constructor(dim, block_size) {
|
||||
const ret = wasm().wasmflashattention_new(dim, block_size);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmFlashAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmFlashAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmflashattention_free(ptr, 0);
|
||||
}
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmflashattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WasmHyperbolicAttention {
|
||||
constructor(dim, curvature) {
|
||||
const ret = wasm().wasmhyperbolicattention_new(dim, curvature);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmHyperbolicAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmHyperbolicAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmhyperbolicattention_free(ptr, 0);
|
||||
}
|
||||
get curvature() { return wasm().wasmhyperbolicattention_curvature(this.__wbg_ptr); }
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmhyperbolicattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WasmMoEAttention {
|
||||
constructor(dim, num_experts, top_k) {
|
||||
const ret = wasm().wasmmoeattention_new(dim, num_experts, top_k);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmMoEAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmMoEAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmmoeattention_free(ptr, 0);
|
||||
}
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmmoeattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WasmLinearAttention {
|
||||
constructor(dim, num_features) {
|
||||
const ret = wasm().wasmlinearattention_new(dim, num_features || dim);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmLinearAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmLinearAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmlinearattention_free(ptr, 0);
|
||||
}
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmlinearattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WasmLocalGlobalAttention {
|
||||
constructor(dim, local_window, global_tokens) {
|
||||
const ret = wasm().wasmlocalglobalattention_new(dim, local_window || 4, global_tokens || 2);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmLocalGlobalAttentionFinalization.register(this, this.__wbg_ptr, this);
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__wbg_ptr; this.__wbg_ptr = 0;
|
||||
WasmLocalGlobalAttentionFinalization.unregister(this);
|
||||
wasm().__wbg_wasmlocalglobalattention_free(ptr, 0);
|
||||
}
|
||||
compute(query, keys, values) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().wasmlocalglobalattention_compute(retptr, this.__wbg_ptr, ptr0, len0, addHeapObject(keys), addHeapObject(values));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standalone functions ──────────────────────────────────────────
|
||||
|
||||
function cosine_similarity(a, b) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(a, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(b, wasm().__wbindgen_export);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
wasm().cosine_similarity(retptr, ptr0, len0, ptr1, len1);
|
||||
var r0 = getDataViewMemory0().getFloat64(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r2) throw takeObject(r1);
|
||||
return r0;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(vec) {
|
||||
const ptr0 = passArrayF32ToWasm0(vec, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().normalize(ptr0, len0, addHeapObject(vec));
|
||||
}
|
||||
|
||||
function l2_norm(vec) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(vec, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().l2_norm(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getFloat64(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r2) throw takeObject(r1);
|
||||
return r0;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function softmax(vec) {
|
||||
const ptr0 = passArrayF32ToWasm0(vec, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().softmax(ptr0, len0, addHeapObject(vec));
|
||||
}
|
||||
|
||||
function batch_normalize(vectors, epsilon) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
wasm().batch_normalize(retptr, addHeapObject(vectors), isLikeNone(epsilon) ? 0x100000001 : Math.fround(epsilon));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function pairwise_distances(vectors) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
wasm().pairwise_distances(retptr, addHeapObject(vectors));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function scaled_dot_attention(query, keys, values, scale) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
const ptr0 = passArrayF32ToWasm0(query, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().scaled_dot_attention(retptr, ptr0, len0, addHeapObject(keys), addHeapObject(values), isLikeNone(scale) ? 0x100000001 : Math.fround(scale));
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 8, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 12, true);
|
||||
if (r3) throw takeObject(r2);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function attention_weights(scores, temperature) {
|
||||
const ptr0 = passArrayF32ToWasm0(scores, wasm().__wbindgen_export);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm().attention_weights(ptr0, len0, addHeapObject(scores), isLikeNone(temperature) ? 0x100000001 : Math.fround(temperature));
|
||||
}
|
||||
|
||||
function available_mechanisms() {
|
||||
const ret = wasm().available_mechanisms();
|
||||
return takeObject(ret);
|
||||
}
|
||||
|
||||
function random_orthogonal_matrix(dim) {
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
wasm().random_orthogonal_matrix(retptr, dim);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
var v1 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm().__wbindgen_export4(r0, r1 * 4, 4);
|
||||
return v1;
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
|
||||
function rv_init() { wasm().init(); }
|
||||
|
||||
function rv_version() {
|
||||
let d0, d1;
|
||||
const retptr = wasm().__wbindgen_add_to_stack_pointer(-16);
|
||||
try {
|
||||
wasm().version(retptr);
|
||||
d0 = getDataViewMemory0().getInt32(retptr + 0, true);
|
||||
d1 = getDataViewMemory0().getInt32(retptr + 4, true);
|
||||
return getStringFromWasm0(d0, d1);
|
||||
} finally {
|
||||
wasm().__wbindgen_add_to_stack_pointer(16);
|
||||
if (d0 !== undefined) wasm().__wbindgen_export4(d0, d1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Collect exports ───────────────────────────────────────────────
|
||||
exports.WasmMultiHeadAttention = WasmMultiHeadAttention;
|
||||
exports.WasmFlashAttention = WasmFlashAttention;
|
||||
exports.WasmHyperbolicAttention = WasmHyperbolicAttention;
|
||||
exports.WasmMoEAttention = WasmMoEAttention;
|
||||
exports.WasmLinearAttention = WasmLinearAttention;
|
||||
exports.WasmLocalGlobalAttention = WasmLocalGlobalAttention;
|
||||
exports.cosine_similarity = cosine_similarity;
|
||||
exports.normalize = normalize;
|
||||
exports.l2_norm = l2_norm;
|
||||
exports.softmax = softmax;
|
||||
exports.batch_normalize = batch_normalize;
|
||||
exports.pairwise_distances = pairwise_distances;
|
||||
exports.scaled_dot_attention = scaled_dot_attention;
|
||||
exports.attention_weights = attention_weights;
|
||||
exports.available_mechanisms = available_mechanisms;
|
||||
exports.random_orthogonal_matrix = random_orthogonal_matrix;
|
||||
exports.init = rv_init;
|
||||
exports.version = rv_version;
|
||||
|
||||
// ── Build WASM import object ──────────────────────────────────────
|
||||
exports.__wbg_get_imports = function() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg_Error_4577686b3a6d9b3a: (arg0, arg1) => addHeapObject(Error(getStringFromWasm0(arg0, arg1))),
|
||||
__wbg_String_8564e559799eccda: (arg0, arg1) => {
|
||||
const ret = String(getObject(arg1));
|
||||
const ptr1 = passStringToWasm0(ret, wasm().__wbindgen_export, wasm().__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_boolean_get_18c4ed9422296fff: (arg0) => {
|
||||
const v = getObject(arg0);
|
||||
const ret = typeof v === 'boolean' ? v : undefined;
|
||||
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
|
||||
},
|
||||
__wbg___wbindgen_copy_to_typed_array_5294f8e46aecc086: (arg0, arg1, arg2) => {
|
||||
new Uint8Array(getObject(arg2).buffer, getObject(arg2).byteOffset, getObject(arg2).byteLength).set(getArrayU8FromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg___wbindgen_debug_string_ddde1867f49c2442: (arg0, arg1) => {
|
||||
const ret = debugString(getObject(arg1));
|
||||
const ptr1 = passStringToWasm0(ret, wasm().__wbindgen_export, wasm().__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_is_function_d633e708baf0d146: (arg0) => typeof getObject(arg0) === 'function',
|
||||
__wbg___wbindgen_is_object_4b3de556756ee8a8: (arg0) => {
|
||||
const val = getObject(arg0);
|
||||
return typeof val === 'object' && val !== null;
|
||||
},
|
||||
__wbg___wbindgen_jsval_loose_eq_1562ceb9af84e990: (arg0, arg1) => getObject(arg0) == getObject(arg1),
|
||||
__wbg___wbindgen_number_get_5854912275df1894: (arg0, arg1) => {
|
||||
const obj = getObject(arg1);
|
||||
const ret = typeof obj === 'number' ? obj : undefined;
|
||||
getDataViewMemory0().setFloat64(arg0 + 8, isLikeNone(ret) ? 0 : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0, !isLikeNone(ret), true);
|
||||
},
|
||||
__wbg___wbindgen_string_get_3e5751597f39a112: (arg0, arg1) => {
|
||||
const obj = getObject(arg1);
|
||||
const ret = typeof obj === 'string' ? obj : undefined;
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm().__wbindgen_export, wasm().__wbindgen_export2);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_throw_39bc967c0e5a9b58: (arg0, arg1) => { throw new Error(getStringFromWasm0(arg0, arg1)); },
|
||||
__wbg_call_73af281463ec8b58: function() { return handleError(function(arg0, arg1) {
|
||||
return addHeapObject(getObject(arg0).call(getObject(arg1)));
|
||||
}, arguments); },
|
||||
__wbg_done_5aad55ec6b1954b1: (arg0) => getObject(arg0).done,
|
||||
__wbg_error_a6fa202b58aa1cd3: (arg0, arg1) => {
|
||||
try { console.error(getStringFromWasm0(arg0, arg1)); }
|
||||
finally { wasm().__wbindgen_export4(arg0, arg1, 1); }
|
||||
},
|
||||
__wbg_error_ad28debb48b5c6bb: (arg0) => console.error(getObject(arg0)),
|
||||
__wbg_get_4920fefd3451364b: function() { return handleError(function(arg0, arg1) {
|
||||
return addHeapObject(Reflect.get(getObject(arg0), getObject(arg1)));
|
||||
}, arguments); },
|
||||
__wbg_get_unchecked_3d0f4b91c8eca4f0: (arg0, arg1) => addHeapObject(getObject(arg0)[arg1 >>> 0]),
|
||||
__wbg_instanceof_ArrayBuffer_15859862b80b732d: (arg0) => {
|
||||
try { return getObject(arg0) instanceof ArrayBuffer; } catch (_) { return false; }
|
||||
},
|
||||
__wbg_instanceof_Uint8Array_2240b7046ac16f05: (arg0) => {
|
||||
try { return getObject(arg0) instanceof Uint8Array; } catch (_) { return false; }
|
||||
},
|
||||
__wbg_isArray_fad08a0d12828686: (arg0) => Array.isArray(getObject(arg0)),
|
||||
__wbg_iterator_fc7ad8d33bab9e26: () => addHeapObject(Symbol.iterator),
|
||||
__wbg_length_5855c1f289dfffc1: (arg0) => getObject(arg0).length,
|
||||
__wbg_length_a31e05262e09b7f8: (arg0) => getObject(arg0).length,
|
||||
__wbg_log_3c5e4b64af29e724: (arg0) => console.log(getObject(arg0)),
|
||||
__wbg_new_09959f7b4c92c246: (arg0) => addHeapObject(new Uint8Array(getObject(arg0))),
|
||||
__wbg_new_227d7c05414eb861: () => addHeapObject(new Error()),
|
||||
__wbg_new_cbee8c0d5c479eac: () => addHeapObject(new Array()),
|
||||
__wbg_next_a5fe6f328f7affc2: (arg0) => addHeapObject(getObject(arg0).next),
|
||||
__wbg_next_e592122bb4ed4c67: function() { return handleError(function(arg0) {
|
||||
return addHeapObject(getObject(arg0).next());
|
||||
}, arguments); },
|
||||
__wbg_prototypesetcall_f034d444741426c3: (arg0, arg1, arg2) => {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2));
|
||||
},
|
||||
__wbg_random_2b7bed8995d680fb: () => Math.random(),
|
||||
__wbg_set_4c81cfb5dc3a333c: (arg0, arg1, arg2) => { getObject(arg0)[arg1 >>> 0] = takeObject(arg2); },
|
||||
__wbg_stack_3b0d974bbf31e44f: (arg0, arg1) => {
|
||||
const ret = getObject(arg1).stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm().__wbindgen_export, wasm().__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0, ptr1, true);
|
||||
},
|
||||
__wbg_value_667dcb90597486a6: (arg0) => addHeapObject(getObject(arg0).value),
|
||||
__wbindgen_cast_0000000000000001: (arg0, arg1) => addHeapObject(getStringFromWasm0(arg0, arg1)),
|
||||
__wbindgen_object_drop_ref: (arg0) => takeObject(arg0),
|
||||
};
|
||||
return { __proto__: null, "./ruvector_attention_wasm_bg.js": import0 };
|
||||
};
|
||||
|
||||
})(_mod, () => _wasm);
|
||||
|
||||
|
||||
// ── Async WASM init (fetch-based for browsers) ───────────────────
|
||||
|
||||
export default async function initWasm() {
|
||||
if (_initialized) return;
|
||||
const wasmUrl = new URL('ruvector_attention_wasm_bg.wasm', import.meta.url);
|
||||
const imports = _mod.__wbg_get_imports();
|
||||
let result;
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports);
|
||||
} catch (e) {
|
||||
// Fallback if streaming fails (e.g. wrong MIME type)
|
||||
const bytes = await (await fetch(wasmUrl)).arrayBuffer();
|
||||
result = await WebAssembly.instantiate(bytes, imports);
|
||||
}
|
||||
} else {
|
||||
const bytes = await (await fetch(wasmUrl)).arrayBuffer();
|
||||
result = await WebAssembly.instantiate(bytes, imports);
|
||||
}
|
||||
_wasm = result.instance.exports;
|
||||
_wasm.__wbindgen_start();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
// ── ESM re-exports ────────────────────────────────────────────────
|
||||
// Attention mechanism classes
|
||||
export const WasmMultiHeadAttention = _mod.WasmMultiHeadAttention;
|
||||
export const WasmFlashAttention = _mod.WasmFlashAttention;
|
||||
export const WasmHyperbolicAttention = _mod.WasmHyperbolicAttention;
|
||||
export const WasmMoEAttention = _mod.WasmMoEAttention;
|
||||
export const WasmLinearAttention = _mod.WasmLinearAttention;
|
||||
export const WasmLocalGlobalAttention = _mod.WasmLocalGlobalAttention;
|
||||
// Utility functions
|
||||
export const cosine_similarity = _mod.cosine_similarity;
|
||||
export const normalize = _mod.normalize;
|
||||
export const l2_norm = _mod.l2_norm;
|
||||
export const softmax = _mod.softmax;
|
||||
export const batch_normalize = _mod.batch_normalize;
|
||||
export const pairwise_distances = _mod.pairwise_distances;
|
||||
export const scaled_dot_attention = _mod.scaled_dot_attention;
|
||||
export const attention_weights = _mod.attention_weights;
|
||||
export const random_orthogonal_matrix = _mod.random_orthogonal_matrix;
|
||||
export const available_mechanisms = _mod.available_mechanisms;
|
||||
// Lifecycle
|
||||
export const init = _mod.init;
|
||||
export const version = _mod.version;
|
||||
@@ -0,0 +1,359 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Adam optimizer
|
||||
*/
|
||||
export class WasmAdam {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Create a new Adam optimizer
|
||||
*
|
||||
* # Arguments
|
||||
* * `param_count` - Number of parameters
|
||||
* * `learning_rate` - Learning rate
|
||||
*/
|
||||
constructor(param_count: number, learning_rate: number);
|
||||
/**
|
||||
* Reset optimizer state
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Perform optimization step
|
||||
*
|
||||
* # Arguments
|
||||
* * `params` - Current parameter values (will be updated in-place)
|
||||
* * `gradients` - Gradient values
|
||||
*/
|
||||
step(params: Float32Array, gradients: Float32Array): void;
|
||||
/**
|
||||
* Get current learning rate
|
||||
*/
|
||||
learning_rate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdamW optimizer (Adam with decoupled weight decay)
|
||||
*/
|
||||
export class WasmAdamW {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Create a new AdamW optimizer
|
||||
*
|
||||
* # Arguments
|
||||
* * `param_count` - Number of parameters
|
||||
* * `learning_rate` - Learning rate
|
||||
* * `weight_decay` - Weight decay coefficient
|
||||
*/
|
||||
constructor(param_count: number, learning_rate: number, weight_decay: number);
|
||||
/**
|
||||
* Reset optimizer state
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Perform optimization step with weight decay
|
||||
*/
|
||||
step(params: Float32Array, gradients: Float32Array): void;
|
||||
/**
|
||||
* Get current learning rate
|
||||
*/
|
||||
learning_rate: number;
|
||||
/**
|
||||
* Get weight decay
|
||||
*/
|
||||
readonly weight_decay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash attention mechanism
|
||||
*/
|
||||
export class WasmFlashAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute flash attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new flash attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `block_size` - Block size for tiling
|
||||
*/
|
||||
constructor(dim: number, block_size: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyperbolic attention mechanism
|
||||
*/
|
||||
export class WasmHyperbolicAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute hyperbolic attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new hyperbolic attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `curvature` - Hyperbolic curvature parameter
|
||||
*/
|
||||
constructor(dim: number, curvature: number);
|
||||
/**
|
||||
* Get the curvature
|
||||
*/
|
||||
readonly curvature: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* InfoNCE contrastive loss for training
|
||||
*/
|
||||
export class WasmInfoNCELoss {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute InfoNCE loss
|
||||
*
|
||||
* # Arguments
|
||||
* * `anchor` - Anchor embedding
|
||||
* * `positive` - Positive example embedding
|
||||
* * `negatives` - Array of negative example embeddings
|
||||
*/
|
||||
compute(anchor: Float32Array, positive: Float32Array, negatives: any): number;
|
||||
/**
|
||||
* Create a new InfoNCE loss instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `temperature` - Temperature parameter for softmax
|
||||
*/
|
||||
constructor(temperature: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Learning rate scheduler
|
||||
*/
|
||||
export class WasmLRScheduler {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Get learning rate for current step
|
||||
*/
|
||||
get_lr(): number;
|
||||
/**
|
||||
* Create a new learning rate scheduler with warmup and cosine decay
|
||||
*
|
||||
* # Arguments
|
||||
* * `initial_lr` - Initial learning rate
|
||||
* * `warmup_steps` - Number of warmup steps
|
||||
* * `total_steps` - Total training steps
|
||||
*/
|
||||
constructor(initial_lr: number, warmup_steps: number, total_steps: number);
|
||||
/**
|
||||
* Reset scheduler
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Advance to next step
|
||||
*/
|
||||
step(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear attention (Performer-style)
|
||||
*/
|
||||
export class WasmLinearAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute linear attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new linear attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `num_features` - Number of random features
|
||||
*/
|
||||
constructor(dim: number, num_features: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-global attention mechanism
|
||||
*/
|
||||
export class WasmLocalGlobalAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute local-global attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new local-global attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `local_window` - Size of local attention window
|
||||
* * `global_tokens` - Number of global attention tokens
|
||||
*/
|
||||
constructor(dim: number, local_window: number, global_tokens: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixture of Experts (MoE) attention
|
||||
*/
|
||||
export class WasmMoEAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute MoE attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new MoE attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `num_experts` - Number of expert attention mechanisms
|
||||
* * `top_k` - Number of experts to use per query
|
||||
*/
|
||||
constructor(dim: number, num_experts: number, top_k: number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-head attention mechanism
|
||||
*/
|
||||
export class WasmMultiHeadAttention {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Compute multi-head attention
|
||||
*/
|
||||
compute(query: Float32Array, keys: any, values: any): Float32Array;
|
||||
/**
|
||||
* Create a new multi-head attention instance
|
||||
*
|
||||
* # Arguments
|
||||
* * `dim` - Embedding dimension
|
||||
* * `num_heads` - Number of attention heads
|
||||
*/
|
||||
constructor(dim: number, num_heads: number);
|
||||
/**
|
||||
* Get the dimension
|
||||
*/
|
||||
readonly dim: number;
|
||||
/**
|
||||
* Get the number of heads
|
||||
*/
|
||||
readonly num_heads: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SGD optimizer with momentum
|
||||
*/
|
||||
export class WasmSGD {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Create a new SGD optimizer
|
||||
*
|
||||
* # Arguments
|
||||
* * `param_count` - Number of parameters
|
||||
* * `learning_rate` - Learning rate
|
||||
* * `momentum` - Momentum coefficient (default: 0)
|
||||
*/
|
||||
constructor(param_count: number, learning_rate: number, momentum?: number | null);
|
||||
/**
|
||||
* Reset optimizer state
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Perform optimization step
|
||||
*/
|
||||
step(params: Float32Array, gradients: Float32Array): void;
|
||||
/**
|
||||
* Get current learning rate
|
||||
*/
|
||||
learning_rate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute attention weights from scores
|
||||
*/
|
||||
export function attention_weights(scores: Float32Array, temperature?: number | null): void;
|
||||
|
||||
/**
|
||||
* Get information about available attention mechanisms
|
||||
*/
|
||||
export function available_mechanisms(): any;
|
||||
|
||||
/**
|
||||
* Batch normalize vectors
|
||||
*/
|
||||
export function batch_normalize(vectors: any, epsilon?: number | null): Float32Array;
|
||||
|
||||
/**
|
||||
* Compute cosine similarity between two vectors
|
||||
*/
|
||||
export function cosine_similarity(a: Float32Array, b: Float32Array): number;
|
||||
|
||||
/**
|
||||
* Initialize the WASM module with panic hook
|
||||
*/
|
||||
export function init(): void;
|
||||
|
||||
/**
|
||||
* Compute L2 norm of a vector
|
||||
*/
|
||||
export function l2_norm(vec: Float32Array): number;
|
||||
|
||||
/**
|
||||
* Log a message to the browser console
|
||||
*/
|
||||
export function log(message: string): void;
|
||||
|
||||
/**
|
||||
* Log an error to the browser console
|
||||
*/
|
||||
export function log_error(message: string): void;
|
||||
|
||||
/**
|
||||
* Normalize a vector to unit length
|
||||
*/
|
||||
export function normalize(vec: Float32Array): void;
|
||||
|
||||
/**
|
||||
* Compute pairwise distances between vectors
|
||||
*/
|
||||
export function pairwise_distances(vectors: any): Float32Array;
|
||||
|
||||
/**
|
||||
* Generate random orthogonal matrix (for initialization)
|
||||
*/
|
||||
export function random_orthogonal_matrix(dim: number): Float32Array;
|
||||
|
||||
/**
|
||||
* Compute scaled dot-product attention
|
||||
*
|
||||
* # Arguments
|
||||
* * `query` - Query vector as Float32Array
|
||||
* * `keys` - Array of key vectors
|
||||
* * `values` - Array of value vectors
|
||||
* * `scale` - Optional scaling factor (defaults to 1/sqrt(dim))
|
||||
*/
|
||||
export function scaled_dot_attention(query: Float32Array, keys: any, values: any, scale?: number | null): Float32Array;
|
||||
|
||||
/**
|
||||
* Compute softmax of a vector
|
||||
*/
|
||||
export function softmax(vec: Float32Array): void;
|
||||
|
||||
/**
|
||||
* Get the version of the ruvector-attention-wasm crate
|
||||
*/
|
||||
export function version(): string;
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const __wbg_wasmadam_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmadamw_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmflashattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmhyperbolicattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasminfonceloss_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmlinearattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmmoeattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmmultiheadattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmsgd_free: (a: number, b: number) => void;
|
||||
export const attention_weights: (a: number, b: number, c: number, d: number) => void;
|
||||
export const available_mechanisms: () => number;
|
||||
export const batch_normalize: (a: number, b: number, c: number) => void;
|
||||
export const cosine_similarity: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
export const l2_norm: (a: number, b: number) => number;
|
||||
export const log: (a: number, b: number) => void;
|
||||
export const log_error: (a: number, b: number) => void;
|
||||
export const normalize: (a: number, b: number, c: number, d: number) => void;
|
||||
export const pairwise_distances: (a: number, b: number) => void;
|
||||
export const random_orthogonal_matrix: (a: number, b: number) => void;
|
||||
export const scaled_dot_attention: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const softmax: (a: number, b: number, c: number) => void;
|
||||
export const version: (a: number) => void;
|
||||
export const wasmadam_learning_rate: (a: number) => number;
|
||||
export const wasmadam_new: (a: number, b: number) => number;
|
||||
export const wasmadam_reset: (a: number) => void;
|
||||
export const wasmadam_set_learning_rate: (a: number, b: number) => void;
|
||||
export const wasmadam_step: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmadamw_new: (a: number, b: number, c: number) => number;
|
||||
export const wasmadamw_reset: (a: number) => void;
|
||||
export const wasmadamw_step: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmadamw_weight_decay: (a: number) => number;
|
||||
export const wasmflashattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmflashattention_new: (a: number, b: number) => number;
|
||||
export const wasmhyperbolicattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmhyperbolicattention_curvature: (a: number) => number;
|
||||
export const wasmhyperbolicattention_new: (a: number, b: number) => number;
|
||||
export const wasminfonceloss_compute: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
|
||||
export const wasminfonceloss_new: (a: number) => number;
|
||||
export const wasmlinearattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmlinearattention_new: (a: number, b: number) => number;
|
||||
export const wasmlocalglobalattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmlocalglobalattention_new: (a: number, b: number, c: number) => number;
|
||||
export const wasmlrscheduler_get_lr: (a: number) => number;
|
||||
export const wasmlrscheduler_new: (a: number, b: number, c: number) => number;
|
||||
export const wasmlrscheduler_reset: (a: number) => void;
|
||||
export const wasmlrscheduler_step: (a: number) => void;
|
||||
export const wasmmoeattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmmoeattention_new: (a: number, b: number, c: number) => number;
|
||||
export const wasmmultiheadattention_compute: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const wasmmultiheadattention_dim: (a: number) => number;
|
||||
export const wasmmultiheadattention_new: (a: number, b: number, c: number) => void;
|
||||
export const wasmmultiheadattention_num_heads: (a: number) => number;
|
||||
export const wasmsgd_learning_rate: (a: number) => number;
|
||||
export const wasmsgd_new: (a: number, b: number, c: number) => number;
|
||||
export const wasmsgd_reset: (a: number) => void;
|
||||
export const wasmsgd_set_learning_rate: (a: number, b: number) => void;
|
||||
export const wasmsgd_step: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
export const init: () => void;
|
||||
export const wasmadamw_set_learning_rate: (a: number, b: number) => void;
|
||||
export const wasmadamw_learning_rate: (a: number) => number;
|
||||
export const __wbg_wasmlocalglobalattention_free: (a: number, b: number) => void;
|
||||
export const __wbg_wasmlrscheduler_free: (a: number, b: number) => void;
|
||||
export const __wbindgen_export: (a: number, b: number) => number;
|
||||
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_export3: (a: number) => void;
|
||||
export const __wbindgen_export4: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ruvector-cnn-wasm",
|
||||
"type": "module",
|
||||
"description": "WASM bindings for ruvector-cnn - CNN feature extraction for image embeddings",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ruvnet/ruvector"
|
||||
},
|
||||
"files": [
|
||||
"ruvector_cnn_wasm_bg.wasm",
|
||||
"ruvector_cnn_wasm.js"
|
||||
],
|
||||
"main": "ruvector_cnn_wasm.js",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
],
|
||||
"keywords": [
|
||||
"cnn",
|
||||
"embeddings",
|
||||
"wasm",
|
||||
"simd",
|
||||
"machine-learning"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
/**
|
||||
* Configuration for CNN embedder
|
||||
*/
|
||||
export class EmbedderConfig {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
EmbedderConfigFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_embedderconfig_free(ptr, 0);
|
||||
}
|
||||
constructor() {
|
||||
const ret = wasm.embedderconfig_new();
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
EmbedderConfigFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Output embedding dimension
|
||||
* @returns {number}
|
||||
*/
|
||||
get embedding_dim() {
|
||||
const ret = wasm.__wbg_get_embedderconfig_embedding_dim(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Input image size (square)
|
||||
* @returns {number}
|
||||
*/
|
||||
get input_size() {
|
||||
const ret = wasm.__wbg_get_embedderconfig_input_size(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Whether to L2 normalize embeddings
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get normalize() {
|
||||
const ret = wasm.__wbg_get_embedderconfig_normalize(this.__wbg_ptr);
|
||||
return ret !== 0;
|
||||
}
|
||||
/**
|
||||
* Output embedding dimension
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set embedding_dim(arg0) {
|
||||
wasm.__wbg_set_embedderconfig_embedding_dim(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* Input image size (square)
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set input_size(arg0) {
|
||||
wasm.__wbg_set_embedderconfig_input_size(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* Whether to L2 normalize embeddings
|
||||
* @param {boolean} arg0
|
||||
*/
|
||||
set normalize(arg0) {
|
||||
wasm.__wbg_set_embedderconfig_normalize(this.__wbg_ptr, arg0);
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) EmbedderConfig.prototype[Symbol.dispose] = EmbedderConfig.prototype.free;
|
||||
|
||||
/**
|
||||
* Layer operations for building custom networks
|
||||
*/
|
||||
export class LayerOps {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
LayerOpsFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_layerops_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Apply batch normalization (returns new array)
|
||||
* @param {Float32Array} input
|
||||
* @param {Float32Array} gamma
|
||||
* @param {Float32Array} beta
|
||||
* @param {Float32Array} mean
|
||||
* @param {Float32Array} _var
|
||||
* @param {number} epsilon
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static batch_norm(input, gamma, beta, mean, _var, epsilon) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(gamma, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passArrayF32ToWasm0(beta, wasm.__wbindgen_export2);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
const ptr3 = passArrayF32ToWasm0(mean, wasm.__wbindgen_export2);
|
||||
const len3 = WASM_VECTOR_LEN;
|
||||
const ptr4 = passArrayF32ToWasm0(_var, wasm.__wbindgen_export2);
|
||||
const len4 = WASM_VECTOR_LEN;
|
||||
wasm.layerops_batch_norm(retptr, ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, ptr4, len4, epsilon);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v6 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v6;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Apply global average pooling
|
||||
* Returns one value per channel
|
||||
* @param {Float32Array} input
|
||||
* @param {number} height
|
||||
* @param {number} width
|
||||
* @param {number} channels
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static global_avg_pool(input, height, width, channels) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.layerops_global_avg_pool(retptr, ptr0, len0, height, width, channels);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v2 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v2;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) LayerOps.prototype[Symbol.dispose] = LayerOps.prototype.free;
|
||||
|
||||
/**
|
||||
* SIMD-optimized operations
|
||||
*/
|
||||
export class SimdOps {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
SimdOpsFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_simdops_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Dot product of two vectors
|
||||
* @param {Float32Array} a
|
||||
* @param {Float32Array} b
|
||||
* @returns {number}
|
||||
*/
|
||||
static dot_product(a, b) {
|
||||
const ptr0 = passArrayF32ToWasm0(a, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(b, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.simdops_dot_product(ptr0, len0, ptr1, len1);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* L2 normalize a vector (returns new array)
|
||||
* @param {Float32Array} data
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static l2_normalize(data) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(data, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.simdops_l2_normalize(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v2 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v2;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ReLU activation (returns new array)
|
||||
* @param {Float32Array} data
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static relu(data) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(data, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.simdops_relu(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v2 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v2;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ReLU6 activation (returns new array)
|
||||
* @param {Float32Array} data
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
static relu6(data) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(data, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.simdops_relu6(retptr, ptr0, len0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var v2 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v2;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) SimdOps.prototype[Symbol.dispose] = SimdOps.prototype.free;
|
||||
|
||||
/**
|
||||
* WASM CNN Embedder for image feature extraction
|
||||
*/
|
||||
export class WasmCnnEmbedder {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WasmCnnEmbedderFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wasmcnnembedder_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Compute cosine similarity between two embeddings
|
||||
* @param {Float32Array} a
|
||||
* @param {Float32Array} b
|
||||
* @returns {number}
|
||||
*/
|
||||
cosine_similarity(a, b) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(a, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(b, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
wasm.wasmcnnembedder_cosine_similarity(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1);
|
||||
var r0 = getDataViewMemory0().getFloat32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return r0;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the embedding dimension
|
||||
* @returns {number}
|
||||
*/
|
||||
get embedding_dim() {
|
||||
const ret = wasm.wasmcnnembedder_embedding_dim(this.__wbg_ptr);
|
||||
return ret >>> 0;
|
||||
}
|
||||
/**
|
||||
* Extract embedding from image data (RGB format, row-major)
|
||||
* @param {Uint8Array} image_data
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @returns {Float32Array}
|
||||
*/
|
||||
extract(image_data, width, height) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArray8ToWasm0(image_data, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.wasmcnnembedder_extract(retptr, this.__wbg_ptr, ptr0, len0, width, height);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
|
||||
if (r3) {
|
||||
throw takeObject(r2);
|
||||
}
|
||||
var v2 = getArrayF32FromWasm0(r0, r1).slice();
|
||||
wasm.__wbindgen_export(r0, r1 * 4, 4);
|
||||
return v2;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a new CNN embedder
|
||||
* @param {EmbedderConfig | null} [config]
|
||||
*/
|
||||
constructor(config) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
let ptr0 = 0;
|
||||
if (!isLikeNone(config)) {
|
||||
_assertClass(config, EmbedderConfig);
|
||||
ptr0 = config.__destroy_into_raw();
|
||||
}
|
||||
wasm.wasmcnnembedder_new(retptr, ptr0);
|
||||
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
this.__wbg_ptr = r0 >>> 0;
|
||||
WasmCnnEmbedderFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WasmCnnEmbedder.prototype[Symbol.dispose] = WasmCnnEmbedder.prototype.free;
|
||||
|
||||
/**
|
||||
* InfoNCE loss for contrastive learning (SimCLR style)
|
||||
*/
|
||||
export class WasmInfoNCELoss {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WasmInfoNCELossFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wasminfonceloss_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Compute loss for a batch of embedding pairs
|
||||
* embeddings: [2N, D] flattened where (i, i+N) are positive pairs
|
||||
* @param {Float32Array} embeddings
|
||||
* @param {number} batch_size
|
||||
* @param {number} dim
|
||||
* @returns {number}
|
||||
*/
|
||||
forward(embeddings, batch_size, dim) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(embeddings, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
wasm.wasminfonceloss_forward(retptr, this.__wbg_ptr, ptr0, len0, batch_size, dim);
|
||||
var r0 = getDataViewMemory0().getFloat32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return r0;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create new InfoNCE loss with temperature parameter
|
||||
* @param {number} temperature
|
||||
*/
|
||||
constructor(temperature) {
|
||||
const ret = wasm.wasminfonceloss_new(temperature);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmInfoNCELossFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Get the temperature parameter
|
||||
* @returns {number}
|
||||
*/
|
||||
get temperature() {
|
||||
const ret = wasm.wasminfonceloss_temperature(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WasmInfoNCELoss.prototype[Symbol.dispose] = WasmInfoNCELoss.prototype.free;
|
||||
|
||||
/**
|
||||
* Triplet loss for metric learning
|
||||
*/
|
||||
export class WasmTripletLoss {
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
WasmTripletLossFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_wasmtripletloss_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* Compute loss for a batch of triplets
|
||||
* @param {Float32Array} anchors
|
||||
* @param {Float32Array} positives
|
||||
* @param {Float32Array} negatives
|
||||
* @param {number} dim
|
||||
* @returns {number}
|
||||
*/
|
||||
forward(anchors, positives, negatives, dim) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(anchors, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(positives, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passArrayF32ToWasm0(negatives, wasm.__wbindgen_export2);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
wasm.wasmtripletloss_forward(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2, dim);
|
||||
var r0 = getDataViewMemory0().getFloat32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return r0;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Compute loss for a single triplet
|
||||
* @param {Float32Array} anchor
|
||||
* @param {Float32Array} positive
|
||||
* @param {Float32Array} negative
|
||||
* @returns {number}
|
||||
*/
|
||||
forward_single(anchor, positive, negative) {
|
||||
try {
|
||||
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
||||
const ptr0 = passArrayF32ToWasm0(anchor, wasm.__wbindgen_export2);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArrayF32ToWasm0(positive, wasm.__wbindgen_export2);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passArrayF32ToWasm0(negative, wasm.__wbindgen_export2);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
wasm.wasmtripletloss_forward_single(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
||||
var r0 = getDataViewMemory0().getFloat32(retptr + 4 * 0, true);
|
||||
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
||||
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
||||
if (r2) {
|
||||
throw takeObject(r1);
|
||||
}
|
||||
return r0;
|
||||
} finally {
|
||||
wasm.__wbindgen_add_to_stack_pointer(16);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get the margin parameter
|
||||
* @returns {number}
|
||||
*/
|
||||
get margin() {
|
||||
const ret = wasm.wasmtripletloss_margin(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* Create new triplet loss with margin
|
||||
* @param {number} margin
|
||||
*/
|
||||
constructor(margin) {
|
||||
const ret = wasm.wasmtripletloss_new(margin);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
WasmTripletLossFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (Symbol.dispose) WasmTripletLoss.prototype[Symbol.dispose] = WasmTripletLoss.prototype.free;
|
||||
|
||||
/**
|
||||
* Initialize panic hook for better error messages
|
||||
*/
|
||||
export function init() {
|
||||
wasm.init();
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg___wbindgen_throw_39bc967c0e5a9b58: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_export(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_new_227d7c05414eb861: function() {
|
||||
const ret = new Error();
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||
const ret = getObject(arg1).stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_export2, wasm.__wbindgen_export3);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return addHeapObject(ret);
|
||||
},
|
||||
__wbindgen_object_drop_ref: function(arg0) {
|
||||
takeObject(arg0);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./ruvector_cnn_wasm_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
const EmbedderConfigFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_embedderconfig_free(ptr >>> 0, 1));
|
||||
const LayerOpsFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_layerops_free(ptr >>> 0, 1));
|
||||
const SimdOpsFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_simdops_free(ptr >>> 0, 1));
|
||||
const WasmCnnEmbedderFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wasmcnnembedder_free(ptr >>> 0, 1));
|
||||
const WasmInfoNCELossFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wasminfonceloss_free(ptr >>> 0, 1));
|
||||
const WasmTripletLossFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_wasmtripletloss_free(ptr >>> 0, 1));
|
||||
|
||||
function addHeapObject(obj) {
|
||||
if (heap_next === heap.length) heap.push(heap.length + 1);
|
||||
const idx = heap_next;
|
||||
heap_next = heap[idx];
|
||||
|
||||
heap[idx] = obj;
|
||||
return idx;
|
||||
}
|
||||
|
||||
function _assertClass(instance, klass) {
|
||||
if (!(instance instanceof klass)) {
|
||||
throw new Error(`expected instance of ${klass.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dropObject(idx) {
|
||||
if (idx < 1028) return;
|
||||
heap[idx] = heap_next;
|
||||
heap_next = idx;
|
||||
}
|
||||
|
||||
function getArrayF32FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
let cachedFloat32ArrayMemory0 = null;
|
||||
function getFloat32ArrayMemory0() {
|
||||
if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) {
|
||||
cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedFloat32ArrayMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return decodeText(ptr, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function getObject(idx) { return heap[idx]; }
|
||||
|
||||
let heap = new Array(1024).fill(undefined);
|
||||
heap.push(undefined, null, true, false);
|
||||
|
||||
let heap_next = heap.length;
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function passArrayF32ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 4, 4) >>> 0;
|
||||
getFloat32ArrayMemory0().set(arg, ptr / 4);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeObject(idx) {
|
||||
const ret = getObject(idx);
|
||||
dropObject(idx);
|
||||
return ret;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedFloat32ArrayMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('ruvector_cnn_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
Binary file not shown.
Reference in New Issue
Block a user