mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb996294fb | |||
| be4dad6ede | |||
| c965e3e6c0 | |||
| 833ac84059 | |||
| 0bffe27288 | |||
| 753f0a23b7 | |||
| 2365f0c31b | |||
| 29233db6d5 | |||
| be4efecbcd | |||
| 3833929dcb | |||
| 1e469aa336 | |||
| d4f0e12073 | |||
| 07b792715f | |||
| 34eced880f | |||
| bb154d4e78 | |||
| 1f5b7b48c9 | |||
| a3478ea3b5 | |||
| fe913b0ea7 | |||
| 35722529bf | |||
| c9f005c360 | |||
| 5723f505b7 | |||
| 56265023dc | |||
| f751740d3d |
@@ -0,0 +1,200 @@
|
||||
name: Cog HA-Matter Release
|
||||
|
||||
# ADR-116 P8 — Build + sign + bundle the cog-ha-matter cog on a
|
||||
# version tag. Upload to gs://cognitum-apps/ runs only when the
|
||||
# GCP_CREDENTIALS + COGNITUM_OWNER_SIGNING_KEY secrets are set, so
|
||||
# this workflow is safe to merge before the production credentials
|
||||
# land — it'll bundle release artifacts to the workflow run page
|
||||
# either way.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cog-ha-matter-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build + sign + bundle but skip GCS upload'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CRATE: cog-ha-matter
|
||||
|
||||
jobs:
|
||||
build-x86_64:
|
||||
name: Build x86_64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-x86_64-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make build-x86_64
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-x86_64
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-x86_64.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-x86_64.sig
|
||||
echo "Signed cog-ha-matter-x86_64 ($(wc -c < dist/cog-ha-matter-x86_64.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
build-arm:
|
||||
name: Build aarch64 (arm)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install cross-compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-arm-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
run: |
|
||||
cargo build -p cog-ha-matter --release --target aarch64-unknown-linux-gnu
|
||||
mkdir -p crates/cog-ha-matter/cog/dist
|
||||
cp target/aarch64-unknown-linux-gnu/release/cog-ha-matter \
|
||||
crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
# ^ matches Makefile's `dist/$(CRATE)-arm` so `make sign-arm` finds it
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-arm
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-arm.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-arm.sig
|
||||
echo "Signed cog-ha-matter-arm ($(wc -c < dist/cog-ha-matter-arm.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
publish-gcs:
|
||||
name: Upload to GCS (gated)
|
||||
needs: [build-x86_64, build-arm]
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on dry-run dispatch; skip on tags when GCP_CREDENTIALS unset.
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
vars.HAS_GCP_CREDENTIALS == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download x86_64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: dist/
|
||||
|
||||
- name: Download arm artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: dist/
|
||||
|
||||
- name: Auth to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
|
||||
|
||||
- name: Set up gcloud
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Upload binaries + sidecars
|
||||
run: |
|
||||
gsutil cp dist/cog-ha-matter-x86_64 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sha256 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sha256
|
||||
gsutil cp dist/cog-ha-matter-arm gs://cognitum-apps/cogs/arm/cog-ha-matter-arm
|
||||
gsutil cp dist/cog-ha-matter-arm.sha256 gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sha256
|
||||
if [ -f dist/cog-ha-matter-x86_64.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sig gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sig
|
||||
fi
|
||||
if [ -f dist/cog-ha-matter-arm.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-arm.sig gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sig
|
||||
fi
|
||||
|
||||
- name: Print app-registry.json snippet for the cognitum-one PR
|
||||
run: |
|
||||
for arch in arm x86_64; do
|
||||
sha=$(cat dist/cog-cog-ha-matter-$arch.sha256)
|
||||
sig=$([ -f dist/cog-cog-ha-matter-$arch.sig ] && cat dist/cog-cog-ha-matter-$arch.sig || echo "")
|
||||
cat <<EOF
|
||||
--- $arch ---
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"version": "${GITHUB_REF_NAME#cog-ha-matter-v}",
|
||||
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/$arch/cog-cog-ha-matter-$arch",
|
||||
"binary_sha256": "$sha",
|
||||
"binary_signature": "$sig",
|
||||
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
|
||||
"min_seed_version": "0.6.0",
|
||||
"installable_on": ["$arch"]
|
||||
}
|
||||
EOF
|
||||
done
|
||||
@@ -0,0 +1,286 @@
|
||||
# ADR-117 P5 — cibuildwheel + PyPI publish workflow for `wifi-densepose`
|
||||
#
|
||||
# This workflow is **explicitly NOT** triggered on every push. It runs only on:
|
||||
# - a maintainer-dispatched `workflow_dispatch`
|
||||
# - a pushed tag matching `v*-pip` (e.g. `v2.0.0-pip`)
|
||||
#
|
||||
# The reason for the `-pip` tag suffix is that the repo already cuts
|
||||
# `v0.X.Y-esp32` tags for firmware releases (see CLAUDE.md). The `-pip`
|
||||
# suffix keeps the pip release schedule independent of the firmware
|
||||
# release schedule.
|
||||
#
|
||||
# Sequencing on release day (per ADR-117 §7.3):
|
||||
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
|
||||
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
|
||||
#
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
|
||||
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
|
||||
# docs/integrations/pypi-release.md so KICS does not flag the
|
||||
# secret name as a generic-secret literal in the workflow.
|
||||
#
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
|
||||
name: pip-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Which package to release"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- v2-wheels
|
||||
- v1-99-tombstone
|
||||
publish_to:
|
||||
description: "Where to publish"
|
||||
required: true
|
||||
default: testpypi
|
||||
type: choice
|
||||
options:
|
||||
- testpypi # dry-run target
|
||||
- pypi # production
|
||||
push:
|
||||
tags:
|
||||
- "v*-pip"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v2.0.0 — cibuildwheel matrix (5 wheels + sdist)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-wheels:
|
||||
name: Build ${{ matrix.os }} ${{ matrix.arch }}
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-13 # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: AMD64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
|
||||
- name: Set up QEMU
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all
|
||||
# Python minor versions ≥ 3.10. Build only cp310 wheels.
|
||||
- name: Build wheels (cibuildwheel)
|
||||
uses: pypa/cibuildwheel@v2.21
|
||||
env:
|
||||
CIBW_BUILD: "cp310-*"
|
||||
CIBW_ARCHS_LINUX: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_MACOS: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_WINDOWS: ${{ matrix.arch }}
|
||||
CIBW_BUILD_FRONTEND: "build"
|
||||
CIBW_BEFORE_BUILD: "pip install maturin>=1.7"
|
||||
# The PyO3 sdist landing depends on the cargo/Rust toolchain
|
||||
# being present. cibuildwheel images carry rustup on Linux
|
||||
# but we also pin a known-good version for reproducibility.
|
||||
CIBW_BEFORE_ALL_LINUX: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.82"
|
||||
CIBW_ENVIRONMENT_LINUX: 'PATH="$HOME/.cargo/bin:$PATH"'
|
||||
# Smoke-test every built wheel before accepting it. Catches
|
||||
# the case where the wheel imports but the compiled symbols
|
||||
# are missing.
|
||||
CIBW_TEST_REQUIRES: "pytest>=8.0"
|
||||
CIBW_TEST_COMMAND: 'python -c "import wifi_densepose; assert wifi_densepose.hello() == \"ok\"; print(wifi_densepose.__build_features__)"'
|
||||
with:
|
||||
package-dir: python
|
||||
output-dir: wheelhouse
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: wheelhouse/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build-sdist:
|
||||
name: Build v2 sdist
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install maturin
|
||||
run: pip install maturin>=1.7
|
||||
- name: Build sdist
|
||||
working-directory: python
|
||||
run: maturin sdist --out ../sdist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: sdist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-tombstone:
|
||||
name: Build v1.99.0 tombstone
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install build backend
|
||||
run: python -m pip install --upgrade pip build>=1.2
|
||||
- name: Build sdist + wheel
|
||||
working-directory: python/tombstone
|
||||
run: python -m build --outdir ../../tombstone-dist
|
||||
# Inspect what was actually built — the previous v1.99.0-pip run
|
||||
# showed an `import wifi_densepose` that returned cleanly instead
|
||||
# of raising, even though build logs said `adding 'wifi_densepose/__init__.py'`.
|
||||
# Print the wheel manifest + the __init__.py content so any
|
||||
# future regression is debuggable from the run log alone.
|
||||
- name: Inspect wheel contents
|
||||
run: |
|
||||
set -e
|
||||
WHL=tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
echo "--- wheel listing ---"
|
||||
python -m zipfile -l "$WHL"
|
||||
echo "--- wifi_densepose/__init__.py inside the wheel ---"
|
||||
python -m zipfile -e "$WHL" /tmp/tomb-inspect
|
||||
cat /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
echo "--- size in bytes ---"
|
||||
wc -c /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
# Smoke-test in an ISOLATED venv. The previous run's failure
|
||||
# mode was that the ubuntu-latest runner's system `python` had
|
||||
# site-packages picking up something other than the user-installed
|
||||
# wheel, so the import resolved to a different module. A clean
|
||||
# venv removes any ambiguity about which wifi_densepose is loaded.
|
||||
- name: Smoke-test tombstone in isolated venv
|
||||
run: |
|
||||
set -e
|
||||
# Copy the wheel to /tmp BEFORE entering the venv — we must
|
||||
# cd OUT of the repo root because the repo contains a
|
||||
# `wifi_densepose/` directory left over from the legacy v1
|
||||
# source. Python puts cwd at sys.path[0], so an import from
|
||||
# the repo root would resolve to the legacy directory and
|
||||
# bypass the freshly-installed wheel entirely (this was the
|
||||
# silent failure mode of the previous two run attempts).
|
||||
cp tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl /tmp/
|
||||
python -m venv /tmp/smoke-venv
|
||||
/tmp/smoke-venv/bin/python -m pip install --upgrade pip
|
||||
/tmp/smoke-venv/bin/python -m pip install /tmp/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
cd /tmp # away from the repo root's stray wifi_densepose/
|
||||
/tmp/smoke-venv/bin/python -c "import importlib.util as u; s = u.find_spec('wifi_densepose'); print('Resolved to:', s.origin); print('--- file content ---'); print(open(s.origin).read())"
|
||||
set +e
|
||||
/tmp/smoke-venv/bin/python -c "import wifi_densepose" 2> import-output.txt
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "ERROR: tombstone import succeeded — should have raised ImportError"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "github.com/ruvnet/RuView" import-output.txt; then
|
||||
echo "ERROR: tombstone ImportError missing migration URL"
|
||||
cat import-output.txt
|
||||
exit 1
|
||||
fi
|
||||
echo "Tombstone wheel correctly raises ImportError with migration URL."
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: tombstone-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Publish — gated by manual dispatch OR by the tag form
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-wheels.result == 'success' &&
|
||||
needs.build-sdist.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Gather all artifacts into dist/
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist-staging
|
||||
- name: Flatten artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
|
||||
ls -lh dist/
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v2.') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
needs: [build-tombstone]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-tombstone.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: dist
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v1.99') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
@@ -111,8 +111,20 @@ idf.py -p COM6 flash
|
||||
node scripts/rf-scan.js --port 5006 # Live RF room scan
|
||||
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning
|
||||
node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
|
||||
# Option 4: Python — live on PyPI (ADR-117)
|
||||
pip install ruview # or: pip install wifi-densepose
|
||||
# Both ship the same compiled PyO3 wheel (~250 KB, abi3-py310, Linux/macOS/Windows).
|
||||
# Add [client] for the asyncio WebSocket + paho-mqtt clients:
|
||||
pip install "ruview[client]" # or: pip install "wifi-densepose[client]"
|
||||
|
||||
# from ruview import BreathingExtractor, HeartRateExtractor # equivalent to:
|
||||
# from wifi_densepose import BreathingExtractor, HeartRateExtractor
|
||||
# from ruview.client import SensingClient, RuViewMqttClient
|
||||
```
|
||||
|
||||
[](https://pypi.org/project/ruview/) [](https://pypi.org/project/wifi-densepose/)
|
||||
|
||||
> [!NOTE]
|
||||
> **CSI-capable hardware recommended.** Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# ADR-116: Home Assistant + Matter as a Cognitum Seed cog (`cog-ha-matter`)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — P1 research complete ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)). P2 cog scaffold compiles (`v2/crates/cog-ha-matter`, 2/2 unit tests green). |
|
||||
| **Date** | 2026-05-23 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HA-COG** — HA + Matter, packaged for the Seed |
|
||||
| **Relates to** | [ADR-110](ADR-110-esp32-c6-firmware-extension.md) (C6 firmware substrate), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO + HA-MIND + HA-FABRIC), [ADR-102](ADR-102-edge-module-registry.md) (cog catalog), [ADR-101](ADR-101-pose-estimation-cog.md) (cog packaging precedent) |
|
||||
| **Tracking issue** | TBD — file under RuView issue tracker once research dossier lands |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-115 shipped the Home Assistant + Matter integration as a **`--mqtt` flag on `wifi-densepose-sensing-server`** — a Rust binary that runs on a Pi / Linux box, consumes UDP frames from the ESP32 fleet, and publishes MQTT for any Home Assistant install to discover. That works, but it makes HA+Matter a *configuration of the aggregator*, not an *installable artifact* a Cognitum Seed user can drop into their existing fleet.
|
||||
|
||||
The Cognitum Seed already has a [105-cog catalog](https://seed.cognitum.one/store) — packaged Seed apps (`cog-pose-estimation`, `cog-quantum-vitals`, `cog-person-matching`, etc.) that anyone can install from `app-registry.json`. **There is no `cog-ha-matter` yet.** That's the gap this ADR closes.
|
||||
|
||||
The cog packaging precedent is ADR-101 (`cog-pose-estimation`) which ships signed aarch64 + x86_64 binaries on GCS with a `pose_v1.safetensors` weight blob — same shape we'd want for the HA cog.
|
||||
|
||||
### 1.1 Why a cog, not just the existing flag?
|
||||
|
||||
| Path | Distribution | Discovery | Update | Witness | Local AI |
|
||||
|---|---|---|---|---|---|
|
||||
| `--mqtt` on `sensing-server` | manual install of the Rust binary | none | manual | none | external |
|
||||
| **`cog-ha-matter` Seed cog** | `app-registry.json` listing, one-click install | mDNS / cog browser | OTA via cog runtime | Ed25519 witness chain | local ruvllm + RuVector |
|
||||
|
||||
The cog ships HA+Matter as a first-class Seed feature — same UX as installing a pose estimator or person matcher.
|
||||
|
||||
### 1.2 What this ADR is *not*
|
||||
|
||||
- Not a deprecation of the `--mqtt` flag on sensing-server. The flag stays for Pi / Linux deployments without a Seed; the cog is the Seed-native option.
|
||||
- Not a port of HA-MIND / HA-DISCO logic to a different language. The Rust crate already exists; the cog *wraps* it as a Seed-installable artifact + adds Seed-specific surfaces (witness, RuVector, ruvllm-driven thresholds).
|
||||
- Not a Matter SDK ship. ADR-115 §9.10 deferred the matter-rs SDK wiring to v0.7.1; this ADR continues that deferral and focuses on the *cog packaging* + *first-class Seed integration*, with Matter Bridge mode shipping in v0.8 once the SDK is ready.
|
||||
|
||||
## 2. Decision (provisional — to be refined by the research dossier)
|
||||
|
||||
Build **`cog-ha-matter`** as a Cognitum Seed cog with these surfaces:
|
||||
|
||||
### 2.1 Core entity surface (unchanged from ADR-115)
|
||||
|
||||
The cog republishes the same 21 entities per node (11 raw + 10 semantic primitives) over MQTT auto-discovery, so HA installations behave identically whether the source is a Seed cog or an external sensing-server.
|
||||
|
||||
### 2.2 Seed-native enhancements
|
||||
|
||||
- **Self-contained MQTT broker (optional)** — if the user doesn't already run mosquitto, the cog can host an embedded broker on `cognitum-seed.local:1883` and act as the HA endpoint directly.
|
||||
- **mDNS service advertisement** — `_ruview-ha._tcp` so HA's discovery integration finds the Seed without manual config.
|
||||
- **RuVector-backed semantic-primitive thresholds** — instead of static `semantic-thresholds.yaml`, the cog learns per-home thresholds via a SONA-adapted RuVector model (matches the Seed's local-first AI story).
|
||||
- **Ed25519 witness chain** — every state transition logged with a Seed signature so care-home / regulated deployments can audit decisions.
|
||||
- **OTA firmware coordination** — the cog manages C6 firmware updates for ESP32-C6 nodes in the mesh (ADR-110 substrate).
|
||||
|
||||
### 2.3 Matter dimensions (depend on research findings)
|
||||
|
||||
The research dossier covers (a) Matter Bridge vs Matter Device mode, (b) Thread Border Router on the Seed's ESP32-S3 (if feasible), (c) CSA certification path, (d) which Matter device classes map cleanly to which entities. **Decision deferred** until the dossier lands; this ADR will be updated in §3 with the specific Matter feature set.
|
||||
|
||||
### 2.4 Multi-Seed federation
|
||||
|
||||
Multiple Seeds in adjacent rooms coordinate via:
|
||||
- ESP-NOW mesh (ADR-110 substrate) for time alignment
|
||||
- mDNS for service discovery
|
||||
- Witness chain replication for cross-Seed event provenance
|
||||
|
||||
The federation model is the natural extension of ADR-110's mesh substrate into the application layer. Specifically: ADR-110 gives us ≤100 µs cross-board sync; this ADR uses that to deduplicate cross-Seed events (one fall, one alert) and reconstruct multi-room transitions (one occupant, room A → hallway → room B).
|
||||
|
||||
## 3. Research dossier findings (P1 complete)
|
||||
|
||||
Full dossier: [`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md). The eight research questions are now answered:
|
||||
|
||||
1. **Matter Bridge vs Matter Root** — Matter 1.4 introduced `OccupancySensor (0x0107)` with `RFSensing` feature flag on cluster `0x0406` (revision 5 in Matter 1.4). That's the correct device class for WiFi-CSI sensing — no health/vitals cluster exists in Matter 1.4.2 and won't soon. **Seed acts as Bridge** with N dynamic OccupancySensor endpoints, **not Commissioner** (the C6 sensing nodes stay Accessories only — 320 KB SRAM no PSRAM rules out commissioning).
|
||||
2. **Thread Border Router** — ESP32-C6 single-chip TBR confirmed working; `CONFIG_OPENTHREAD_BORDER_ROUTER=y` is the only config step. ADR-110's `c6_timesync.c` already initialises 802.15.4 — TBR is a Kconfig flag away. Real value: HA's Improv-style commissioning works without a separate Thread border router box.
|
||||
3. **HACS value-add** — config flow (UI setup wizard), Repairs API (structured error cards), re-authentication, diagnostics download, typed service actions (`set_privacy_mode`, `calibrate_zone`), i18n translations. **Bronze is the minimum bar; Gold (repairs + diagnostics + reconfiguration) is the target.** Start from `hacs.integration_blueprint` template.
|
||||
4. **CSA certification** — ~$30-42k first year ($22.5k membership + $10-19k ATL lab fees). **Skippable for v1** by publishing as "Works with HA" instead. CSA re-evaluate at v0.9+ after HACS adoption data lands.
|
||||
5. **Cog RAM budget** — 128 MB RAM / 15 % CPU on the Seed appliance (Pi 5 + Hailo-10 variant has more headroom). 10 KB INT8 semantic-primitive classifier fits without PSRAM. Long-lived supervised process with capability scopes `network.mqtt + network.matter + api.ruview_vitals`.
|
||||
6. **ruvllm + RuVector latency** — `ruvllm-esp32` v0.3.3 confirms SONA self-optimising adaptation under 100 µs per query. 8→10 INT8 classifier ~10 KB quantised. Per-home threshold tuning via HA thumbs-up/thumbs-down feedback as LoRA-style gradient steps — closes the top user complaint (false positives) without cloud round-trips.
|
||||
7. **HIPAA / FDA** — FDA January 2026 General Wellness guidance explicitly classifies HR / sleep / activity-anomaly alerts as **wellness devices** (outside FDA jurisdiction) when marketed without diagnostic claims. Frame fall detection as **"activity anomaly notification"** not "fall diagnosis". `--privacy-mode` audit-only tier (no MQTT state messages, only SHA-256 digests on-Seed) creates a technical PHI barrier. `OccupancySensor (0x0107)` device class keeps the product in the same regulatory category as a smart motion sensor.
|
||||
8. **Competitor moat** — Aqara FP300 (Nov 2025): 5 entities, no person count, no vitals, no fall detection. TOMMY: zones only, no vitals, closed-source, paywalled. ESPectre: motion only. **RuView's differentiation** — HR/BR + 17-keypoint pose + 10 semantic primitives + witness chain + SONA adaptation — has no competitor equivalent.
|
||||
|
||||
## 4. Recommended v1 scope (from dossier §8)
|
||||
|
||||
Ranked by build cost × user impact:
|
||||
|
||||
| # | Feature | Cost | Impact | Phase |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **`--privacy-mode` audit-only tier** (no MQTT state, SHA-256 digests on-Seed) | ~1 week | Closes care / GDPR deployments | P3 (this cog) |
|
||||
| 2 | **Seed cog manifest + Ed25519 signing + store listing** | ~1-2 weeks | Enables one-click distribution | P2 + P8 (this cog) |
|
||||
| 3 | **Local SONA fine-tuning loop** (HA feedback → LoRA gradient steps) | ~2-3 weeks | Reduces false positives, closes #1 user complaint | P5 (this cog) |
|
||||
| 4 | **HACS gold-tier integration** (config flow + repairs + diagnostics) | ~4-6 weeks | Removes MQTT prerequisite for mainstream users | P9 (separate repo `hass-wifi-densepose`) |
|
||||
| 5 | **Matter Bridge with OccupancySensor + dynamic endpoints** | ~6-8 weeks | Apple Home / Google Home / Alexa native | **v0.8** dedicated sprint (after HACS adoption data) |
|
||||
| 6 | **Embedded MQTT broker (rumqttd) inside the cog** | ~1 week | "Works without external broker" but every HA install already has mosquitto / built-in | **v0.7** deferred — adds ~2 MB binary + ACL config surface for marginal user benefit. Dossier ranking did not include this in the prioritised v1 scope. |
|
||||
|
||||
## 4. Implementation phases
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) | ✅ **done** — 8 sections, 30+ citations, v1 scope ranked |
|
||||
| **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests | ✅ **done** — `cargo check` + `cargo test` green |
|
||||
| **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point | ✅ **wiring done** — `main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender<VitalsSnapshot>`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. |
|
||||
| **P4** | Seed-native enhancements (mDNS, witness; embedded broker deferred) | ✅ **shipped** — mDNS half: record-builder + ServiceInfo conversion + live responder wired into `main.rs` (HA auto-discovery on `_ruview-ha._tcp` works out of the box, `--no-mdns` flag for restrictive networks). Witness half: hash-chain + JSONL + file persistence + chain-level verify + Ed25519 signing. **Embedded rumqttd broker deferred to v0.7** per dossier §8 ranking — not in the prioritised v1 scope; v1 ships with external-broker only (mosquitto or HA's built-in broker). See §4 v1 scope table. |
|
||||
| **P5** | RuVector-backed threshold learning (SONA adaptation) | pending |
|
||||
| **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending |
|
||||
| **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending |
|
||||
| **P8** | Cog signing + `app-registry.json` listing + Seed Store entry | pending |
|
||||
| **P9** | HACS integration repo (`hass-wifi-densepose`) for HA-side install path | pending |
|
||||
| **P10** | Witness bundle + CSA-style spec compliance check | pending |
|
||||
|
||||
## 5. References
|
||||
|
||||
- ADR-101 — `cog-pose-estimation` packaging precedent (signed binaries on GCS, .cog manifest)
|
||||
- ADR-102 — edge module registry (`app-registry.json` surfaces all cogs)
|
||||
- ADR-110 — ESP32-C6 firmware substrate (mesh time alignment that multi-Seed federation depends on)
|
||||
- ADR-115 — HA-DISCO + HA-MIND + HA-FABRIC (the Rust crate this cog wraps)
|
||||
- `docs/research/ADR-116-ha-matter-cog-research.md` — companion research dossier (deep-researcher agent in progress)
|
||||
- Cognitum Seed store: https://seed.cognitum.one/store
|
||||
- Matter spec: https://csa-iot.org/all-solutions/matter/
|
||||
- HACS integration target: https://github.com/ruvnet/hass-wifi-densepose (planned)
|
||||
@@ -0,0 +1,807 @@
|
||||
# ADR-117: pip `wifi-densepose` modernization via PyO3 + maturin bindings
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **PIP-PHOENIX** — rising from a pure-Python server to Rust-core Python bindings |
|
||||
| **Relates to** | [ADR-021](ADR-021-esp32-vitals.md) (ESP32 vitals), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit / witness), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO + HA-MIND MQTT semantics), [ADR-116](ADR-116-cog-ha-matter-seed.md) (HA-COG Seed packaging) |
|
||||
| **Tracking issue** | TBD — file under RuView issue tracker |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 What the pip package is today
|
||||
|
||||
`wifi-densepose` v1.1.0 was published to PyPI on **2025-06-07** (two releases the same
|
||||
day: 1.0.0 at 13:24 UTC, 1.1.0 at 17:02 UTC). Both wheels carry the tag
|
||||
`py3-none-any` — no compiled extension, no platform-specific code. The package is a
|
||||
**pure-Python server application** sourced entirely from `archive/v1/`.
|
||||
|
||||
The package installs a 40-dependency stack including FastAPI, PyTorch, SQLAlchemy,
|
||||
Redis, Celery, OpenCV, asyncpg, psycopg2, and Scapy (`archive/v1/setup.py:46–87`).
|
||||
The declared entry points are:
|
||||
|
||||
```
|
||||
wifi-densepose = src.cli:cli
|
||||
wdp = src.cli:cli
|
||||
```
|
||||
|
||||
(`archive/v1/setup.py:178–179`)
|
||||
|
||||
The public API surface is centred on a FastAPI HTTP server, a SQLAlchemy/postgres
|
||||
database layer, and a Redis/Celery task queue — none of which map to the current Rust
|
||||
architecture. The `__init__.py` exports `app` (FastAPI), `CSIProcessor`,
|
||||
`PhaseSanitizer`, `PoseEstimator`, `RouterInterface`, `ServiceOrchestrator`,
|
||||
`HealthCheckService`, and `MetricsService` (`archive/v1/src/__init__.py:54–68`).
|
||||
|
||||
### 1.2 Why this matters now
|
||||
|
||||
ADR-115 (PR #778, merged 2026-05-23) shipped 21 Home Assistant entities, 10 semantic
|
||||
primitives, mTLS, privacy mode, and a full witness bundle from the Rust crate
|
||||
`wifi-densepose-sensing-server`. ADR-116 is packaging this as a Cognitum Seed cog.
|
||||
Neither surface is reachable from `pip install wifi-densepose` — the pip package cannot
|
||||
import a CsiFrame, decode an edge-vitals packet, call a DSP stage, verify a witness
|
||||
bundle, or subscribe to the sensing server's MQTT or WebSocket endpoints. The ecosystem
|
||||
split is now wide enough that the pip package actively misleads new users about what
|
||||
the project does.
|
||||
|
||||
Three concrete customer pain points:
|
||||
|
||||
1. A Python user who `pip install wifi-densepose` expecting to consume live pose/vitals
|
||||
data gets a FastAPI server that requires postgres + redis, not a library they can
|
||||
script against.
|
||||
2. Integrators writing HA automations or Node-RED flows in Python have no idiomatic
|
||||
Python API for the v0.7 telemetry surface (ADR-115 entities, semantic primitives).
|
||||
3. The ADR-028 witness chain (deterministic pipeline proof) is Python-based and
|
||||
exercised via `archive/v1/data/proof/verify.py`, but it imports from the v1 stack —
|
||||
it cannot witness the Rust pipeline that is now the production implementation.
|
||||
|
||||
### 1.3 What this ADR is *not*
|
||||
|
||||
- Not a removal of `archive/v1/` from the repository. The v1 codebase stays as a
|
||||
research archive and its proof bundle stays in `archive/v1/data/proof/`.
|
||||
- Not a port of the Rust crates to Python. The Rust workspace (`v2/`) is authoritative
|
||||
and unmodified by this ADR.
|
||||
- Not a replacement of the `wifi-densepose-sensing-server` Rust binary. The pip
|
||||
package wraps or clients the binary; it does not reimplement it.
|
||||
- Not an overlap with ADR-116 (Seed cog packaging). ADR-116 ships a Seed-installable
|
||||
artifact; ADR-117 ships a Python developer library for scripting, automation, and
|
||||
prototyping against the Rust stack.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — evidence
|
||||
|
||||
| Artifact | Value | Source |
|
||||
|---|---|---|
|
||||
| Latest PyPI version | **1.1.0** | `pypi.org/pypi/wifi-densepose/json` |
|
||||
| First release date | 2025-06-07T13:24:53Z | PyPI JSON metadata |
|
||||
| Latest release date | 2025-06-07T17:02:40Z | PyPI JSON metadata |
|
||||
| Months since last release | **~11.5 months** | as of 2026-05-24 |
|
||||
| Wheel tag | `py3-none-any` | PyPI simple index |
|
||||
| Hard dependencies | 40 (torch, fastapi, sqlalchemy, redis, celery, …) | `setup.py:46–87` |
|
||||
| Entry point | `src.cli:cli` | `setup.py:178` |
|
||||
| Python requires | `>=3.9` | `setup.py:108` |
|
||||
| Classifiers Python versions | 3.9, 3.10, 3.11, 3.12 | PyPI JSON classifiers |
|
||||
| Classifiers status | Beta (4) | PyPI JSON classifiers |
|
||||
| Current Rust workspace version | **0.3.0** | `v2/Cargo.toml:version` |
|
||||
| Rust crates in workspace | 20+ | `v2/Cargo.toml` members |
|
||||
| ADR-115 shipped | 2026-05-23 | PR #778 |
|
||||
|
||||
The v1 source package (`archive/v1/setup.py:112–215`) was clearly designed as an
|
||||
all-in-one server application, not a reusable library. The `find_packages` call at
|
||||
line 134 searches from `"."` (the archive root), meaning the wheel ships `src.*` as the
|
||||
importable namespace. The proof bundle (`archive/v1/data/proof/verify.py:56–57`) imports
|
||||
`src.hardware.csi_extractor.CSIData` and `src.core.csi_processor.CSIProcessor` — v1 pure
|
||||
Python only.
|
||||
|
||||
**PyPI org presence check:** a search for other `ruvnet`-published PyPI packages
|
||||
(`ruvector`, `claude-flow`) returned no matches in the PyPI simple index as of this
|
||||
writing. The `wifi-densepose` package is currently the only Python entry point for this
|
||||
project's ecosystem.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis
|
||||
|
||||
| Capability | Rust crate(s) | pip v1.1.0 status | Gap severity |
|
||||
|---|---|---|---|
|
||||
| `CsiFrame` / `CsiMetadata` core types | `wifi-densepose-core` (`types.rs`) | Not present — v1 uses `CSIData` Python class | **Critical** |
|
||||
| HR/BR extraction from CSI buffer | `wifi-densepose-vitals` (4-stage pipeline: preprocessor → breathing → heartrate → anomaly) | Stub Python (`src/hardware/csi_extractor.py`) with no DSP | **Critical** |
|
||||
| Phase sanitization / noise removal | `wifi-densepose-signal` (`phase_sanitizer`, `csi_processor`, `hampel`) | Python stubs in `src/core/phase_sanitizer.py` | **Critical** |
|
||||
| Motion detection + presence scoring | `wifi-densepose-signal` (`motion.rs`, `MotionDetector`) | Not present | **Critical** |
|
||||
| RuvSense multistatic sensing (13 modules) | `wifi-densepose-signal/src/ruvsense/` | Not present — ADR-029 post-dates v1 | **Critical** |
|
||||
| 17-keypoint pose estimation | `wifi-densepose-nn`, `wifi-densepose-mat` | Stub `PoseEstimator` wrapping a `torch.nn.Module` that requires model weights | **High** |
|
||||
| MQTT publisher (21 HA entities) | `wifi-densepose-sensing-server/src/mqtt/` | Not present — ADR-115 post-dates v1 | **High** |
|
||||
| Semantic primitives (10 types) | `wifi-densepose-sensing-server/src/semantic/` | Not present | **High** |
|
||||
| Matter bridge | `wifi-densepose-sensing-server/src/matter/` | Not present | **High** |
|
||||
| WS/REST client for sensing-server | `wifi-densepose-sensing-server` (Axum) | v1 has a separate FastAPI server; no client | **High** |
|
||||
| Witness bundle verification | ADR-028 / `scripts/generate-witness-bundle.sh` | `archive/v1/data/proof/verify.py` — proves v1 pipeline only | **High** |
|
||||
| ESP32-C6 firmware telemetry (ADR-110) | `wifi-densepose-hardware` + `wifi-densepose-sensing-server` | Not present | **Medium** |
|
||||
| Cross-viewpoint fusion (RuVector) | `wifi-densepose-ruvector/src/viewpoint/` | Not present | **Medium** |
|
||||
| Semantic-primitive MQTT payload | `wifi-densepose-sensing-server/src/semantic/bus.rs` | Not present | **Medium** |
|
||||
| PostgreSQL + Redis server mode | `archive/v1/` | Present (v1 only) | Low (not SOTA) |
|
||||
| FastAPI HTTP REST server | `archive/v1/src/app.py` | Present (v1 only) | Low (not SOTA) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision
|
||||
|
||||
Adopt **PyO3 + maturin Python extension bindings** as the primary modernization path,
|
||||
shipping the pip package as a platform-native wheel (`manylinux`, `macosx`, `win-amd64`)
|
||||
with compiled Rust extension modules, plus a pure-Python WS/MQTT client layer that talks
|
||||
to a running `wifi-densepose-sensing-server` instance.
|
||||
|
||||
This path is called **PIP-PHOENIX**.
|
||||
|
||||
### 4.1 Why PyO3 + maturin over the three rejected alternatives
|
||||
|
||||
| Criterion | **PyO3 + maturin** (chosen) | Subprocess wrapper | REST/WS client only | Pure Python reimpl |
|
||||
|---|---|---|---|---|
|
||||
| Performance for DSP | Native Rust speed, zero copy | IPC overhead per call | N/A — no local DSP | Python bottleneck |
|
||||
| Binary size in wheel | Core + vitals + signal only: ~2 MB stripped | Full sensing-server binary: ~15–30 MB | Minimal (~50 kB) | Minimal (~100 kB) |
|
||||
| Works offline / no server | Yes | Yes (binary bundled) | No — server required | Partial |
|
||||
| Proof bundle can cover Rust pipeline | Yes — bindings call the same Rust code the server uses | Partial — server is a black box | No | No |
|
||||
| Install experience | `pip install wifi-densepose` — wheel has no system deps | `pip install` downloads 25 MB binary | `pip install` — pure Python | `pip install` — pure Python |
|
||||
| Maintenance surface | Python bindings + Rust workspace | Python thin shim | Python client | Python reimpl must track Rust |
|
||||
| Async / tokio support | PyO3 0.28 `pyo3-asyncio` or `pyo3-async-runtimes` for async export; sync entry points for the DSP hot path | N/A | Native asyncio on client | N/A |
|
||||
| GIL concern | DSP-heavy calls release GIL via `py.allow_threads`; tokio runtime per module | N/A | None | N/A |
|
||||
| Fits existing architecture | Core + vitals + signal already have clean public APIs (`lib.rs` re-exports) | Requires sensing-server to be running | Requires sensing-server | Forks the domain model |
|
||||
|
||||
**Subprocess wrapper** is rejected because shipping a 25 MB pre-built server binary
|
||||
inside every pip wheel is an unacceptably heavy install, and it makes offline scripting
|
||||
impossible without starting the server.
|
||||
|
||||
**REST/WS client only** is rejected because it provides zero DSP utility offline and
|
||||
cannot close the witness gap — the proof bundle must exercise the same pipeline code.
|
||||
|
||||
**Pure Python reimplementation** is the root cause of the current drift and is
|
||||
explicitly rejected.
|
||||
|
||||
The chosen path starts small: **bind only the three crates with the highest Python
|
||||
utility** (`wifi-densepose-core`, `wifi-densepose-vitals`, `wifi-densepose-signal`),
|
||||
ship a `py3-none-any` pure-Python WS/MQTT client layer as a separate sub-module, and
|
||||
grow from there.
|
||||
|
||||
---
|
||||
|
||||
## 5. Detailed design
|
||||
|
||||
### 5.1 Rust crates bound in v2.0 (first wheel)
|
||||
|
||||
Three crates are in scope for the initial binding. They were chosen because they have
|
||||
no heavy system dependencies (no libtorch, no ONNX runtime), have stable `pub` re-export
|
||||
surfaces in `lib.rs`, and directly address the three most-requested missing capabilities.
|
||||
|
||||
| Crate | Exported Python types / functions | Binding rationale |
|
||||
|---|---|---|
|
||||
| `wifi-densepose-core` | `CsiFrame`, `CsiMetadata`, `Keypoint`, `KeypointType`, `PersonPose`, `PoseEstimate`, `Confidence`, `BoundingBox` | Foundation types shared by all other crates; without these users can't even describe a frame |
|
||||
| `wifi-densepose-vitals` | `CsiVitalPreprocessor`, `BreathingExtractor`, `HeartRateExtractor`, `VitalAnomalyDetector`, `VitalSignStore`, `VitalReading`, `VitalEstimate`, `AnomalyAlert` | The most-asked-for surface: HR/BR from a CSI buffer in 4 lines of Python |
|
||||
| `wifi-densepose-signal` | `CsiProcessor`, `CsiProcessorConfig`, `PhaseSanitizer`, `MotionDetector`, `MotionScore`, `FeatureExtractor`, `HardwareNormalizer` | DSP pipeline that produces the features vitals and pose estimation consume |
|
||||
|
||||
Crates **deferred to P6+**: `wifi-densepose-nn` (requires libtorch or candle — wheel
|
||||
size risk), `wifi-densepose-mat` (depends on nn), `wifi-densepose-ruvector` (RuVector
|
||||
GNN types — high value but adds ruvector-gnn 2.0.5 link dependency),
|
||||
`wifi-densepose-hardware` (ESP32 HAL — not Python-scripting friendly).
|
||||
|
||||
### 5.2 New workspace member: `python/`
|
||||
|
||||
A new crate `python/` is added as a workspace member at `v2/crates/wifi-densepose-py/`.
|
||||
It is a `cdylib` that re-exports the three bound crates behind a single maturin module
|
||||
named `wifi_densepose._core`.
|
||||
|
||||
```toml
|
||||
# v2/crates/wifi-densepose-py/Cargo.toml (sketch)
|
||||
[package]
|
||||
name = "wifi-densepose-py"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "_core"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310"] }
|
||||
wifi-densepose-core = { path = "../wifi-densepose-core", features = ["serde"] }
|
||||
wifi-densepose-vitals = { path = "../wifi-densepose-vitals" }
|
||||
wifi-densepose-signal = { path = "../wifi-densepose-signal" }
|
||||
```
|
||||
|
||||
The `abi3-py310` feature locks the stable ABI to CPython 3.10+, so one wheel binary
|
||||
works across 3.10, 3.11, 3.12, and 3.13 without recompilation.
|
||||
|
||||
PyO3 bindings pattern (example for `CsiFrame`):
|
||||
|
||||
```rust
|
||||
// v2/crates/wifi-densepose-py/src/core_types.rs
|
||||
use pyo3::prelude::*;
|
||||
use wifi_densepose_core::CsiFrame as RustCsiFrame;
|
||||
|
||||
#[pyclass(name = "CsiFrame")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyCsiFrame {
|
||||
inner: RustCsiFrame,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCsiFrame {
|
||||
#[new]
|
||||
fn new(amplitudes: Vec<f32>, phases: Vec<f32>, n_subcarriers: usize,
|
||||
sample_index: u64, sample_rate_hz: f32) -> Self {
|
||||
Self { inner: RustCsiFrame { amplitudes, phases, n_subcarriers,
|
||||
sample_index, sample_rate_hz } }
|
||||
}
|
||||
|
||||
#[getter] fn amplitudes(&self) -> Vec<f32> { self.inner.amplitudes.clone() }
|
||||
#[getter] fn phases(&self) -> Vec<f32> { self.inner.phases.clone() }
|
||||
#[getter] fn n_subcarriers(&self) -> usize { self.inner.n_subcarriers }
|
||||
}
|
||||
```
|
||||
|
||||
DSP calls that execute >1 ms release the GIL:
|
||||
|
||||
```rust
|
||||
#[pymethods]
|
||||
impl PyCsiProcessor {
|
||||
fn process<'py>(&mut self, py: Python<'py>, frame: &PyCsiFrame)
|
||||
-> PyResult<Option<PyProcessedSignal>>
|
||||
{
|
||||
py.allow_threads(|| self.inner.process(&frame.inner))
|
||||
.map(|opt| opt.map(PyProcessedSignal::from))
|
||||
.map_err(|e| PyRuntimeError::new_err(e.to_string()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 pip package layout
|
||||
|
||||
```
|
||||
wifi-densepose/ ← PyPI package name (unchanged)
|
||||
wifi_densepose/ ← importable namespace
|
||||
__init__.py ← re-exports core types + version
|
||||
_core.pyd / _core.so ← compiled PyO3 extension (maturin build output)
|
||||
vitals.py ← thin Python wrapper + docstrings over _core vitals types
|
||||
signal.py ← thin Python wrapper over _core signal types
|
||||
client/
|
||||
__init__.py
|
||||
ws.py ← asyncio WebSocket client for sensing-server /ws/sensing
|
||||
mqtt.py ← paho-mqtt wrapper for ruview/<node_id>/raw/* topics
|
||||
ha.py ← helpers for HA-DISCO payloads (read-only, mirrors ADR-115 §3.2)
|
||||
witness/
|
||||
__init__.py
|
||||
verify.py ← Python-callable witness verifier (re-creates ADR-028 proof
|
||||
over the Rust pipeline via PyO3 bindings, not archive/v1/)
|
||||
compat/
|
||||
v1.py ← import shim that raises MigrationError (see §9)
|
||||
py.typed ← PEP 561 marker
|
||||
```
|
||||
|
||||
The import path intentionally maps to Rust crate names:
|
||||
|
||||
```python
|
||||
from wifi_densepose import CsiFrame # core types
|
||||
from wifi_densepose.vitals import BreathingExtractor, HeartRateExtractor
|
||||
from wifi_densepose.signal import CsiProcessor, MotionDetector
|
||||
from wifi_densepose.client.ws import SensingClient
|
||||
from wifi_densepose.witness import verify_bundle
|
||||
```
|
||||
|
||||
### 5.4 PyPI distribution — wheel matrix
|
||||
|
||||
Published as `wifi-densepose==2.0.0` using **cibuildwheel** driven by GitHub Actions.
|
||||
|
||||
| Platform | Arch | CPython | Tag (stable ABI) |
|
||||
|---|---|---|---|
|
||||
| `manylinux_2_28` | x86_64 | 3.10+ | `cp310-abi3-manylinux_2_28_x86_64` |
|
||||
| `manylinux_2_28` | aarch64 | 3.10+ | `cp310-abi3-manylinux_2_28_aarch64` |
|
||||
| `macosx_11_0` | x86_64 | 3.10+ | `cp310-abi3-macosx_11_0_x86_64` |
|
||||
| `macosx_11_0` | arm64 | 3.10+ | `cp310-abi3-macosx_11_0_arm64` |
|
||||
| `win` | amd64 | 3.10+ | `cp310-abi3-win_amd64` |
|
||||
| sdist | — | — | source fallback |
|
||||
|
||||
The `abi3-py310` flag means **one binary per OS/arch** covers all supported Python
|
||||
versions — 5 wheels total plus an sdist, compared to the 20-wheel matrix that would be
|
||||
needed without stable ABI.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/pip-release.yml (sketch)
|
||||
- uses: pypa/cibuildwheel@v2
|
||||
with:
|
||||
package-dir: v2/crates/wifi-densepose-py
|
||||
output-dir: dist
|
||||
env:
|
||||
CIBW_BUILD: "cp310-*"
|
||||
CIBW_ARCHS_LINUX: "x86_64 aarch64"
|
||||
CIBW_ARCHS_MACOS: "x86_64 arm64"
|
||||
CIBW_ARCHS_WINDOWS: "AMD64"
|
||||
CIBW_BEFORE_BUILD: "pip install maturin"
|
||||
CIBW_BUILD_FRONTEND: "build[uv]"
|
||||
```
|
||||
|
||||
### 5.5 CLI parity
|
||||
|
||||
The pip wheel installs a `wifi-densepose` console script. In v2 this script is a thin
|
||||
Python shim that:
|
||||
|
||||
1. Checks whether `wifi-densepose-sensing-server` binary is on `PATH` (installed
|
||||
separately via a platform-specific binary distribution or `cargo install`).
|
||||
2. If found: proxies `wifi-densepose serve`, `wifi-densepose stream`, etc. to the Rust
|
||||
binary via `subprocess.run`.
|
||||
3. If not found: falls back to the PyO3 module for offline DSP commands
|
||||
(`wifi-densepose vitals --file recording.jsonl`).
|
||||
|
||||
This is explicitly **not** a reimplementation of the CLI — the Rust binary
|
||||
(`wifi-densepose-cli/src/main.rs`, currently exposes `mat` and `version` subcommands)
|
||||
is the authoritative CLI. The pip shim is a discovery/convenience layer.
|
||||
|
||||
### 5.6 WS/MQTT client layer
|
||||
|
||||
`wifi_densepose.client.ws.SensingClient` is a pure-Python asyncio client wrapping the
|
||||
sensing-server WebSocket at `/ws/sensing`:
|
||||
|
||||
```python
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as client:
|
||||
async for msg in client.stream():
|
||||
if msg.type == "edge_vitals":
|
||||
print(msg.breathing_rate_bpm, msg.heartrate_bpm)
|
||||
```
|
||||
|
||||
`wifi_densepose.client.mqtt.RuViewMqttClient` wraps paho-mqtt and subscribes to
|
||||
`ruview/<node_id>/raw/+` as defined in ADR-115 §3.2.
|
||||
|
||||
Both clients are **pure Python** (no PyO3) and are optional dependencies (`pip install
|
||||
wifi-densepose[client]`). They depend on `websockets>=12` and `paho-mqtt>=2` respectively.
|
||||
|
||||
### 5.7a Beamforming Feedback Loop Data (BFLD) support — new binding target
|
||||
|
||||
**Added 2026-05-24 per maintainer feedback during P3 implementation.**
|
||||
|
||||
BFLD is the transmitter-side, AP-station-loop view of the WiFi channel
|
||||
— compressed beamforming feedback frames that 802.11ac/ax/be stations
|
||||
send to the AP per sounding cycle. From a sensing perspective it
|
||||
complements receiver-side CSI:
|
||||
|
||||
| | Receiver-side CSI (current) | BFLD (this addition) |
|
||||
|---|---|---|
|
||||
| Source | RX side of the radio (e.g. Nexmon CSI on Pi 5, ESP32 promisc cb) | Sniffed BFR frames in the air or `mac80211` ACK trace |
|
||||
| Subcarriers (HE20) | 52 (HT-LTF) or 242 (HE-LTF) | Up to 996 (HE160 compressed BFR) — denser |
|
||||
| Hardware requirements | Patched Broadcom/Cypress or ESP32 specifically | **Any** 802.11ac+ station-AP pair — no patched firmware |
|
||||
| Privacy model | Captures everyone in radio range | Same |
|
||||
| Maturity in repo | Production (ADR-014, ADR-018, ADR-039) | Research; no Rust crate yet |
|
||||
| Suitable use case | Through-wall pose + vitals | Dense subcarrier reflection profile for AETHER-class biometric (ADR-024) and the soul-signature spec (`docs/research/soul/`) |
|
||||
|
||||
#### Binding strategy
|
||||
|
||||
Because the Rust workspace has no `wifi-densepose-bfld` crate yet, P3
|
||||
ships a **forward-compatible Python trait surface** that the future
|
||||
Rust crate plugs into without changing the Python API:
|
||||
|
||||
```python
|
||||
from wifi_densepose import BfldFrame, BfldReport
|
||||
|
||||
# Today (P3): construct from a parsed BFR feedback matrix (the bring-
|
||||
# your-own-parser path). Users on Pi 5 + Wireshark BFR dissector
|
||||
# pipe frames in directly.
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=…,
|
||||
sounding_index=…,
|
||||
sta_mac="aa:bb:cc:…",
|
||||
bandwidth_mhz=80,
|
||||
n_subcarriers=996,
|
||||
feedback_matrix=…, # numpy ndarray complex64 [Nr × Nc × Nsc]
|
||||
)
|
||||
|
||||
# P3 also ships a stub `BfldReport` aggregator that mirrors how
|
||||
# `VitalEstimate` aggregates `VitalReading`s. Users who have BFR
|
||||
# pipelines feeding RuView can use this today via the
|
||||
# bring-your-own-parser path.
|
||||
|
||||
# Tomorrow (post-v2.0): the `wifi-densepose-bfld` Rust crate (TBD —
|
||||
# separate ADR-1xx) provides ingestion from Nexmon `nl80211` traces +
|
||||
# kernel `mac80211` debugfs hooks, and the pip wheel transparently
|
||||
# binds it without changing this Python surface.
|
||||
```
|
||||
|
||||
#### Why this matters
|
||||
|
||||
Three reasons BFLD belongs in v2.0 rather than waiting for the Rust
|
||||
core:
|
||||
|
||||
1. **Customer pull**. Several integrators reading the ADR-115 release
|
||||
notes asked about WiFi-6 dense-subcarrier capture; the answer is
|
||||
BFLD, and we want the API stable before they build pipelines.
|
||||
2. **Soul-signature dependency**. The soul-signature research spec
|
||||
(`docs/research/soul/specification.md`) lists "Subcarrier Reflection
|
||||
Profile" as one of seven biometric channels. At HE20/HE80 the
|
||||
dense BFR subcarriers are the right input — exposing `BfldFrame`
|
||||
now lets researchers prototype the channel without waiting on a
|
||||
Rust ingestion crate.
|
||||
3. **Cross-vendor portability**. CSI ingestion needs patched
|
||||
firmware. BFR ingestion works on stock 802.11ac/ax hardware
|
||||
(capture via `tcpdump`/Wireshark + a BFR dissector). Shipping the
|
||||
Python data structures first gives the community a way to feed
|
||||
RuView from gear we don't directly support.
|
||||
|
||||
#### Implementation surface in P3
|
||||
|
||||
Lands as a new module `bindings/bfld.rs` (~150 lines, three
|
||||
`#[pyclass]` types):
|
||||
|
||||
- `BfldFrame` (frozen) — one compressed feedback matrix snapshot.
|
||||
Constructors: `from_compressed_feedback(...)` and
|
||||
`from_uncompressed_v(...)` (the 802.11n V-matrix form).
|
||||
Properties: `timestamp_ms`, `sounding_index`, `sta_mac`,
|
||||
`bandwidth_mhz`, `n_subcarriers`, `n_rows` (Nr), `n_cols` (Nc),
|
||||
`feedback_matrix` (numpy ndarray complex64).
|
||||
- `BfldReport` (frozen) — aggregator over a window of `BfldFrame`s.
|
||||
Properties: `n_frames`, `timestamp_first`, `timestamp_last`,
|
||||
`mean_amplitude_per_subcarrier`, `coherence_score`. The Python
|
||||
side gives users a stable handle for "all BFR data in this 60-s
|
||||
scan" without leaking the storage representation.
|
||||
- `BfldKind` (`#[pyclass(eq, eq_int, hash, frozen)]`) — enum
|
||||
enumerating the BFR variants we support: `CompressedHE20`,
|
||||
`CompressedHE40`, `CompressedHE80`, `CompressedHE160`,
|
||||
`UncompressedHT20`, `UncompressedHT40`.
|
||||
|
||||
Stub Rust implementation lives in `python/src/bfld_stub.rs` until
|
||||
the proper Rust crate exists; it's intentionally not in v2/crates/.
|
||||
A new ADR-1xx will own the Rust ingestion crate when we commit to it.
|
||||
|
||||
#### Open questions added
|
||||
|
||||
- §9.11 — Should BFLD ingestion live in a new `wifi-densepose-bfld`
|
||||
crate or in `wifi-densepose-signal` extended?
|
||||
- §9.12 — Per-vendor BFR variant compatibility (Broadcom vs Intel vs
|
||||
Qualcomm encode the compressed angles slightly differently) — how
|
||||
much normalisation belongs in the Python binding vs. the future
|
||||
Rust crate?
|
||||
|
||||
### 5.7 Witness chain (re-rooted to the Rust pipeline)
|
||||
|
||||
`wifi_densepose.witness.verify_bundle(path)` replaces the v1 proof verification with a
|
||||
new chain that exercises the Rust pipeline via PyO3:
|
||||
|
||||
```python
|
||||
from wifi_densepose.witness import verify_bundle
|
||||
|
||||
result = verify_bundle("dist/witness-bundle-ADR028-*/")
|
||||
assert result.verdict == "PASS", result.detail
|
||||
```
|
||||
|
||||
Internally it:
|
||||
1. Loads the 1,000-frame reference JSON from the bundle.
|
||||
2. Feeds each frame through `PyCsiProcessor` (PyO3 binding of the Rust `CsiProcessor`).
|
||||
3. Hashes the output using the same SHA-256 scheme as `archive/v1/data/proof/verify.py`.
|
||||
4. Compares against the published hash in `expected_features.sha256`.
|
||||
|
||||
The v1 proof (`archive/v1/data/proof/verify.py`) is **preserved unchanged** — it
|
||||
continues to prove the v1 pipeline. The new `witness.py` proves the v2/Rust pipeline.
|
||||
Both can coexist; the ADR-028 witness bundle ships with both.
|
||||
|
||||
---
|
||||
|
||||
## 6. Migration path (phased)
|
||||
|
||||
```
|
||||
P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6+
|
||||
scaffold core vitals+ client publish deferred
|
||||
types signal layer v2.0.0
|
||||
```
|
||||
|
||||
### P1 — Scaffold (1 week)
|
||||
|
||||
- [ ] Add `v2/crates/wifi-densepose-py/` as workspace member.
|
||||
- [ ] `Cargo.toml`: `crate-type = ["cdylib"]`, pyo3 0.28 + `abi3-py310`, no
|
||||
workspace deps yet (empty module compiles and imports).
|
||||
- [ ] `pyproject.toml` at repo root `python/` with `[build-system] requires =
|
||||
["maturin>=1.8"]` and `[tool.maturin] features = ["pyo3/extension-module"]`.
|
||||
- [ ] CI job: `maturin develop` on ubuntu-latest in a Python 3.12 venv; import
|
||||
`wifi_densepose._core` succeeds.
|
||||
- [ ] Publish `wifi-densepose==1.99.0` to PyPI with a migration notice in the
|
||||
module body (see §9 — no new features, just the tombstone release).
|
||||
|
||||
### P2 — Core type bindings (1 week)
|
||||
|
||||
- [ ] Bind `CsiFrame`, `CsiMetadata`, `Confidence`, `Keypoint`, `KeypointType`,
|
||||
`BoundingBox`, `PoseEstimate`, `PersonPose` from `wifi-densepose-core`.
|
||||
- [ ] All types: `__repr__`, `__eq__`, `__hash__` where meaningful; serde JSON
|
||||
round-trip via `pyo3-serde` or manual `to_dict()` / `from_dict()`.
|
||||
- [ ] Add `py.typed` + stub `.pyi` file generated by `pyo3-stub-gen`.
|
||||
- [ ] Unit tests: `tests/test_core.py` — construct each type, round-trip JSON.
|
||||
|
||||
### P3 — Vitals + signal DSP bindings (2 weeks)
|
||||
|
||||
- [ ] Bind the full 4-stage vitals pipeline:
|
||||
`CsiVitalPreprocessor`, `BreathingExtractor`, `HeartRateExtractor`,
|
||||
`VitalAnomalyDetector`, `VitalSignStore`, `VitalReading`, `VitalEstimate`,
|
||||
`AnomalyAlert`.
|
||||
- [ ] Bind signal DSP entry points: `CsiProcessor`, `CsiProcessorConfig`,
|
||||
`PhaseSanitizer`, `MotionDetector`, `HardwareNormalizer`.
|
||||
- [ ] GIL release (`py.allow_threads`) on all calls >0.5 ms (measured in bench).
|
||||
- [ ] Integration test: feed 1,000 frames from `archive/v1/data/proof/sample_csi_data.json`
|
||||
through the PyO3 vitals pipeline; assert output is deterministic across runs.
|
||||
- [ ] Re-implement `witness/verify.py` using P3 bindings; compare SHA-256 against the
|
||||
v1 expected hash. **Note:** the hash will differ because the Rust and Python
|
||||
processors are not identical — generate and publish a new `expected_features_v2.sha256`.
|
||||
|
||||
### P4 — WS/MQTT client layer (1 week)
|
||||
|
||||
- [ ] Implement `wifi_densepose.client.ws.SensingClient` (asyncio, `websockets>=12`).
|
||||
- [ ] Implement `wifi_densepose.client.mqtt.RuViewMqttClient` (paho-mqtt 2.x).
|
||||
- [ ] Add `wifi_densepose.client.ha` helpers that parse ADR-115 MQTT discovery payloads
|
||||
into Python dataclasses.
|
||||
- [ ] Integration test: spin up `sensing-server` in Docker with `--mock-frames`;
|
||||
assert `SensingClient` receives `edge_vitals` messages.
|
||||
|
||||
### P5 — First cibuildwheel publish as v2.0.0 (1 week)
|
||||
|
||||
- [ ] `.github/workflows/pip-release.yml` — cibuildwheel matrix (5 wheels + sdist).
|
||||
- [ ] `python_requires = ">=3.10"` (stable ABI base).
|
||||
- [ ] Populate `pyproject.toml` with minimal `install_requires`: `pyo3` is a build dep,
|
||||
not a runtime dep. Runtime extras: `[client]` adds `websockets>=12,paho-mqtt>=2`.
|
||||
- [ ] `pip install wifi-densepose==2.0.0` and smoke-test on each CI platform.
|
||||
- [ ] PyPI publish via Trusted Publisher (OIDC, no API token in secrets).
|
||||
- [ ] Announce: `wifi-densepose==1.99.0` tombstone already on PyPI; `v2.0.0` replaces
|
||||
it in search results.
|
||||
|
||||
### P3.5 — BFLD binding surface (concurrent with P3)
|
||||
|
||||
**Added 2026-05-24 per maintainer feedback.** See §5.7a for the rationale.
|
||||
|
||||
- [ ] `python/src/bindings/bfld.rs` — `BfldFrame`, `BfldReport`,
|
||||
`BfldKind` `#[pyclass]` wrappers backed by a stub Rust impl
|
||||
pending the v3 `wifi-densepose-bfld` crate.
|
||||
- [ ] `python/src/bfld_stub.rs` — minimal in-crate stub storage
|
||||
(vec of compressed feedback matrices) so the Python API is
|
||||
fully usable today even before the Rust ingestion crate lands.
|
||||
- [ ] Numpy bridge for `feedback_matrix` (Complex64 ndarray) — same
|
||||
approach as `CsiFrame.amplitude` from P3.
|
||||
- [ ] Tests covering: per-bandwidth constructor paths
|
||||
(HE20/HE40/HE80/HE160 + HT20/HT40), n_subcarriers contract,
|
||||
coherence_score sanity, BfldKind hashability + equality.
|
||||
- [ ] Forward-compat contract test: `BfldFrame` constructed today
|
||||
from a numpy ndarray must round-trip through (de)serialisation
|
||||
identically once the Rust crate exists.
|
||||
- [ ] §9.11 + §9.12 open questions raised so the eventual Rust crate
|
||||
has clear decisions waiting for it.
|
||||
|
||||
P3.5 is concurrent with P3 (no new schedule cushion needed) because
|
||||
the Python surface is independent of the rest of the v2/ workspace.
|
||||
Land in the same wheel as P3.
|
||||
|
||||
### P6+ — Deferred
|
||||
|
||||
- [ ] `wifi-densepose-bfld` Rust crate — proper ingestion from
|
||||
Nexmon BFR pcaps + `mac80211` debugfs. Replaces the P3.5 stub
|
||||
storage without changing the Python API. Owns its own ADR-1xx.
|
||||
- [ ] `wifi-densepose-nn` bindings (libtorch / candle wheel size TBD — see Open
|
||||
Questions §13.3).
|
||||
- [ ] `wifi-densepose-ruvector` bindings (RuVector attention types).
|
||||
- [ ] MQTT/Matter integration helpers (`wifi_densepose.client.matter`).
|
||||
- [ ] Deprecation notice on `wifi-densepose==1.x` releases (PyPI yank — see §9).
|
||||
- [ ] `wifi-densepose-sensing-server` binary distribution via pip extra
|
||||
(`pip install wifi-densepose[server]` fetches pre-built binary for the platform).
|
||||
- [ ] HACS Python integration built on top of the pip client layer (follow-on to
|
||||
ADR-115 §6.A).
|
||||
|
||||
---
|
||||
|
||||
## 7. Compatibility and deprecation
|
||||
|
||||
### 7.1 Version bump strategy
|
||||
|
||||
`wifi-densepose==2.0.0` is a **hard major-version break**. The 1.x import namespace
|
||||
`src.*` is incompatible with the 2.x namespace `wifi_densepose.*`. There is no shim
|
||||
that can bridge them transparently.
|
||||
|
||||
### 7.2 Tombstone release: v1.99.0
|
||||
|
||||
Before publishing v2.0.0, publish `wifi-densepose==1.99.0` as a pure-Python sdist/wheel
|
||||
whose sole content is:
|
||||
|
||||
```python
|
||||
# wifi_densepose/__init__.py (v1.99.0)
|
||||
raise ImportError(
|
||||
"wifi-densepose 1.x has been superseded by v2.0.0 which wraps "
|
||||
"the Rust-based stack. Run:\n\n"
|
||||
" pip install wifi-densepose==2.0.0\n\n"
|
||||
"Migration guide: https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md\n"
|
||||
"Legacy v1 source: archive/v1/ in the repository"
|
||||
)
|
||||
```
|
||||
|
||||
This ensures any project pinned to `wifi-densepose>=1` that upgrades to 1.99.0 gets a
|
||||
clear error rather than a silent broken import.
|
||||
|
||||
### 7.3 PyPI yank strategy
|
||||
|
||||
After v2.0.0 is stable (90-day observation window):
|
||||
|
||||
- Yank `wifi-densepose==1.0.0` — never had a separate stable release period; was
|
||||
superseded 4 hours after publication.
|
||||
- Leave `wifi-densepose==1.1.0` un-yanked but deprecated in the description.
|
||||
- Publish `wifi-densepose==1.99.0` as the canonical 1.x landing page (raise error).
|
||||
|
||||
Yanked versions remain installable with `pip install wifi-densepose==1.1.0 --force`
|
||||
so users with reproducible builds pinned to exact versions are not broken silently.
|
||||
|
||||
### 7.4 Semver
|
||||
|
||||
| Version | Content |
|
||||
|---|---|
|
||||
| 1.0.0 – 1.1.0 | Legacy Python server (archive/v1/) |
|
||||
| **1.99.0** | Tombstone: ImportError migration notice |
|
||||
| **2.0.0** | PyO3 Rust bindings + WS/MQTT client |
|
||||
| 2.x.y | Additive bindings + client improvements |
|
||||
| 3.0.0 | If/when nn bindings added (libtorch wheel size may force a separate package) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives considered and rejected
|
||||
|
||||
### Alt-A: Subprocess wrapper
|
||||
|
||||
Package the pre-built `wifi-densepose-sensing-server` Rust binary inside the pip wheel.
|
||||
Python calls it via `subprocess`. **Rejected** because: the binary is 15–30 MB stripped;
|
||||
the install footprint is prohibitive; offline DSP scripting still requires the server to
|
||||
be running; the witness chain cannot exercise Rust code through a black-box binary.
|
||||
|
||||
### Alt-B: REST/WS client only
|
||||
|
||||
Ship a pure-Python package that is purely a client to a running `sensing-server`
|
||||
instance. **Rejected** because: it provides zero offline utility; it cannot host the
|
||||
witness chain over the Rust pipeline; it solves the "Python access to telemetry" problem
|
||||
but not the "Python DSP / prototyping" problem that academic and embedded users need.
|
||||
|
||||
### Alt-C: Pure Python reimplementation
|
||||
|
||||
Rewrite the DSP pipeline in pure Python/NumPy to reach parity with the Rust
|
||||
implementation. **Rejected explicitly** — this is the root cause of the current 11-month
|
||||
drift and the pattern this ADR is designed to exit. Any Python reimplementation will
|
||||
immediately begin drifting again as the Rust stack evolves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **Build matrix complexity** — 5 target triples × cibuildwheel setup; CI time; QEMU for aarch64 cross-compile | High | Medium | Use `abi3-py310` (5 wheels not 20); QEMU aarch64 emulation available in GitHub Actions; maturin handles auditwheel automatically |
|
||||
| **Binary size** — future nn/ONNX bindings may push wheel past 50 MB | Medium | High | Keep nn bindings in a separate `wifi-densepose-nn` PyPI package; keep core+vitals+signal wheel lean (~2 MB stripped) |
|
||||
| **GIL / async issues** — PyO3 wrapping tokio crates requires careful runtime management; `py.allow_threads` must be used around all blocking Rust calls | High | High | Restrict initial bindings to synchronous Rust APIs (vitals, signal, core are all sync); async sensing-server client stays in pure-Python `client/ws.py` |
|
||||
| **Maintainer overhead** — two languages, two build systems, one PyPI package | Medium | Medium | maturin unifies the build; CI handles publishing; start with 3 bound crates only |
|
||||
| **1.x user breakage** — users pinned to `wifi-densepose>=1,<2` will get the tombstone | Low | Medium | 1.99.0 tombstone gives a clear error; maintain 1.1.0 on PyPI un-yanked for 90 days post-v2 |
|
||||
| **Windows Rust toolchain in CI** — linking PyO3 on Windows requires MSVC or mingw; extra CI complexity | Medium | Medium | GitHub Actions `windows-latest` has MSVC; maturin + cibuildwheel handle this natively |
|
||||
| **Stable ABI limitations** — `abi3` precludes some advanced PyO3 features (e.g. `Buffer` protocol) | Low | Low | Core/vitals/signal types are scalar/Vec<f32> — no need for buffer protocol in P2–P3 |
|
||||
| **PyPI name ownership** — we own `wifi-densepose` on PyPI (confirmed via rUv author field) | Low | Low | Confirm with `pypi.org/user/ruvnet` before publishing |
|
||||
|
||||
---
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
The following checks must all pass before ADR-117 is considered Accepted:
|
||||
|
||||
- [ ] `pip install wifi-densepose==2.0.0` succeeds on Python 3.10, 3.11, 3.12, 3.13
|
||||
on linux/x86_64, macos/arm64, and windows/amd64 in a clean venv with no extra build tools.
|
||||
- [ ] `python -c "import wifi_densepose; print(wifi_densepose.__version__)"` prints `2.0.0`.
|
||||
- [ ] `python -c "from wifi_densepose import CsiFrame; f = CsiFrame([1.0]*56, [0.0]*56, 56, 0, 100.0); print(f)"` produces a non-error repr.
|
||||
- [ ] The 4-stage vitals pipeline processes 1,000 frames in under 500 ms on a
|
||||
reference machine (CPython 3.12, linux x86_64, no GPU).
|
||||
- [ ] `wifi_densepose.witness.verify_bundle(path)` returns `verdict="PASS"` for a
|
||||
freshly generated witness bundle from `scripts/generate-witness-bundle.sh`.
|
||||
- [ ] `wifi_densepose.client.ws.SensingClient` receives at least one `edge_vitals`
|
||||
message from a `sensing-server --mock-frames` instance within 5 seconds.
|
||||
- [ ] `pip install wifi-densepose==1.99.0` raises `ImportError` with the migration URL.
|
||||
- [ ] The compiled `_core` extension has no unresolved dynamic library dependencies
|
||||
beyond libc/msvcrt (verified by `auditwheel show` on Linux, `delocate-listdeps` on macOS).
|
||||
- [ ] Type stubs (`wifi_densepose/*.pyi`) are present; `mypy --strict` passes on the
|
||||
example code in `examples/vitals_from_buffer.py`.
|
||||
- [ ] Total wheel size for core+vitals+signal: `≤ 5 MB` per platform.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
1. **Stable ABI base version**: `abi3-py310` drops support for Python 3.9, which v1.1.0
|
||||
declared. Is Python 3.9 EOL-enough (EOL 2025-10-05) to drop cleanly? *Tentative: yes,
|
||||
drop 3.9. Use abi3-py310.*
|
||||
|
||||
2. **Package name for nn bindings**: if `wifi-densepose-nn` bindings require a 30 MB
|
||||
libtorch wheel, should they live at `wifi-densepose-nn` (separate PyPI package) or
|
||||
as an optional heavy extra of `wifi-densepose[nn]`? *Tentative: separate package to
|
||||
avoid polluting the lean wheel.*
|
||||
|
||||
3. **Witness hash continuity**: the Rust pipeline will produce a different SHA-256 than
|
||||
the v1 Python pipeline for the same input frames. The new `expected_features_v2.sha256`
|
||||
must be generated and committed before v2.0.0 ships. Who generates it, and how is
|
||||
the generation process itself witnessed? *Tentative: generate in CI, commit hash to
|
||||
`archive/v1/data/proof/`, include in ADR-028 matrix.*
|
||||
|
||||
4. **`ruv-neural` crate**: `v2/crates/ruv-neural/` exists in the workspace. Is it a
|
||||
candidate for early Python bindings (useful for training-loop scripting), or should
|
||||
it wait for the nn/train tier? *Tentative: defer — it depends on training backends.*
|
||||
|
||||
5. **Tokio runtime**: `wifi-densepose-sensing-server` is tokio-based, but the three
|
||||
crates bound in P2–P3 (`core`, `vitals`, `signal`) are synchronous. Are there any
|
||||
hidden tokio dependencies that would force a runtime into the extension module?
|
||||
*Tentative: inspect each crate's Cargo.toml for tokio deps before P1 scaffold.*
|
||||
|
||||
6. **`pyo3-stub-gen` vs manual stubs**: automated stub generation from PyO3 has rough
|
||||
edges for generics and newtype patterns. Should we hand-write `.pyi` stubs for the
|
||||
first release? *Tentative: use `pyo3-stub-gen` for scaffolding, hand-tune for public
|
||||
API.*
|
||||
|
||||
7. **`wifi_densepose` vs `wifi-densepose` namespace**: the pip package name uses a dash
|
||||
(`wifi-densepose`) but Python imports use underscores (`wifi_densepose`). The v1
|
||||
package shipped under `src.*`, not `wifi_densepose.*`. Is there any tooling that
|
||||
hardcodes the `src` namespace? *Tentative: the `src.*` namespace was specific to
|
||||
`archive/v1/` and is cleanly dropped.*
|
||||
|
||||
8. **cibuildwheel version**: the current stable is cibuildwheel v2.x. Does the
|
||||
project's existing GitHub Actions config need updates for maturin builds vs
|
||||
the current `cargo build` / `build.py` patterns? *Tentative: yes, add a separate
|
||||
`pip-release.yml` workflow; do not modify existing Rust CI.*
|
||||
|
||||
9. **RuVector bindings timeline**: the `wifi-densepose-ruvector` crate (`v2/crates/`)
|
||||
depends on `ruvector-gnn = "2.0.5"`. Does ruvector-gnn ship as a pre-built static
|
||||
lib or require linking at build time? This directly affects the P6+ wheel size.
|
||||
*Tentative: investigate ruvector-gnn link strategy before committing to a timeline.*
|
||||
|
||||
10. **`wifi_densepose.client.ha` conflict with ADR-115/116**: the `ha.py` helper module
|
||||
should not duplicate the ADR-115 MQTT discovery logic in Python. Should it be read-only
|
||||
(parse HA discovery JSON → Python dataclasses) or also write (publish discovery JSON)?
|
||||
*Tentative: read-only for v2.0. Write path deferred to the HACS integration follow-on
|
||||
(ADR-115 §6.A).*
|
||||
|
||||
11. **BFLD Rust crate ownership** (added 2026-05-24): the P3.5 BFLD bindings ship with a
|
||||
stub Rust impl in `python/src/bfld_stub.rs`. The proper Rust crate (Nexmon BFR pcap
|
||||
parser + `mac80211` debugfs ingestor) will land later. Should it be a new
|
||||
`wifi-densepose-bfld` workspace member, or should it extend `wifi-densepose-signal`?
|
||||
*Tentative: new dedicated crate. Reasons: (a) the BFR parser is significant code
|
||||
(Wireshark's dissector is ~2k lines) and bloats `-signal`; (b) BFLD ingestion is
|
||||
optional — many deployments will only use CSI; gating behind a separate crate keeps
|
||||
the default `-signal` lean. Decide before committing to the crate name in any
|
||||
`pyproject.toml` extras.*
|
||||
|
||||
12. **BFLD per-vendor compressed-angle variants** (added 2026-05-24): 802.11 standardizes
|
||||
the compressed beamforming feedback format but vendors (Broadcom, Intel, Qualcomm,
|
||||
MediaTek) differ in psi/phi quantization step + ordering of consecutive matrix
|
||||
entries. How much normalisation belongs in the Python `BfldFrame.from_compressed_feedback`
|
||||
binding vs. the future Rust crate? *Tentative: Python binding is dumb (numpy ndarray
|
||||
in, numpy ndarray out — no decoding); the future Rust crate owns per-vendor
|
||||
normalisation, exposed via a `Vendor` enum on the binding constructor. Confirm via
|
||||
a per-vendor test fixture before P3.5 ships.*
|
||||
|
||||
---
|
||||
|
||||
## 12. References
|
||||
|
||||
### BFLD references (added 2026-05-24 for §5.7a + §11.11 + §11.12)
|
||||
|
||||
- Hernandez & Bulut, *"Wi-Fi Sensing With Compressed Beamforming Feedback"*, ACM TOSN 2024 — first systematic survey of BFR-as-sensing
|
||||
- Yousefi, Soltanaghaei & Bharadia, *"Just-In-Time Wi-Fi Sensing Using Compressed Beamforming Feedback"*, MobiSys 2023 — practical pipeline for breath + heart-rate extraction from sniffed BFR
|
||||
- IEEE 802.11ax-2021 §27.3.10 — Compressed Beamforming Feedback frame format
|
||||
- Wireshark BFR dissector — `packet-ieee80211.c` reference implementation
|
||||
- AX210 Linux mac80211 debugfs BFR capture path (kernel 6.10+)
|
||||
- Sample BFR-vs-CSI parity dataset — TBD; we'll publish one alongside the
|
||||
`wifi-densepose-bfld` crate when it lands
|
||||
|
||||
### Original references
|
||||
|
||||
- **PyPI package (current)**: https://pypi.org/project/wifi-densepose/ — v1.1.0, released 2025-06-07
|
||||
- **PyPI JSON metadata**: https://pypi.org/pypi/wifi-densepose/json
|
||||
- **Local source**: `archive/v1/setup.py`, `archive/v1/src/__init__.py`, `archive/v1/data/proof/verify.py`
|
||||
- **Rust workspace**: `v2/Cargo.toml`, `v2/crates/wifi-densepose-core/src/lib.rs`,
|
||||
`v2/crates/wifi-densepose-vitals/src/lib.rs`, `v2/crates/wifi-densepose-signal/src/lib.rs`,
|
||||
`v2/crates/wifi-densepose-sensing-server/src/lib.rs`
|
||||
- **PyO3 docs**: https://pyo3.rs/ — v0.28.3 stable, Rust ≥1.83 required
|
||||
- **maturin docs**: https://maturin.rs/ — supports Python 3.8+ on Linux/macOS/Windows/FreeBSD
|
||||
- **cibuildwheel docs**: https://cibuildwheel.pypa.io/
|
||||
- **ADR-021**: ESP32 vitals — defines the HR/BR extraction pipeline this ADR exposes in Python
|
||||
- **ADR-028**: ESP32 capability audit — defines the witness bundle format `witness/verify.py` must re-verify
|
||||
- **ADR-115**: HA-DISCO + HA-MIND + HA-FABRIC — defines the MQTT topic structure the `client/mqtt.py` helper consumes
|
||||
- **ADR-116**: HA-COG cog packaging — parallel effort; ADR-117 pip library is the developer-facing Python surface; ADR-116 is the Seed-installable artifact
|
||||
@@ -0,0 +1,196 @@
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BFLD** — Beamforming Feedback Layer for Detection |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN), [ADR-028](ADR-028-esp32-capability-audit.md) (witness), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (multistatic), [ADR-030](ADR-030-ruvsense-persistent-field-model.md) (field model), [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-115](ADR-115-home-assistant-integration.md) (HA), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip) |
|
||||
| **Sub-ADRs** | [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) (frame), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy), [ADR-121](ADR-121-bfld-identity-risk-scoring.md) (risk), [ADR-122](ADR-122-bfld-ruview-ha-matter-exposure.md) (RuView), [ADR-123](ADR-123-bfld-capture-path-nexmon-and-esp32.md) (capture) |
|
||||
| **Research bundle** | [`docs/research/BFLD/`](../research/BFLD/) (11 files, 13,544 words) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature multi-modal biometric. BFLD is the policy-enforcement and compliance layer for Soul Signature; the two share the AETHER encoder (ADR-024), the witness chain (ADR-110/028), the RVF container, and `cross_room.rs` (ADR-030). |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The plaintext BFI problem
|
||||
|
||||
IEEE 802.11ac and 802.11ax beamforming feedback (BFI) is exchanged between client stations (STA) and access points (AP) in **unencrypted management-plane frames**. The STA compresses the channel response into a Givens-rotation angle matrix (Φ/ψ) and transmits it as a VHT/HE Compressed Beamforming Report (CBFR). Any device in WiFi monitor mode within range can passively sniff these frames without joining the network.
|
||||
|
||||
Two independent 2024–2025 research results establish the severity of this exposure:
|
||||
|
||||
1. **BFId** (KIT, ACM CCS 2025) — re-identifies 197 individuals from BFI alone with >90% accuracy from 5 s of capture. https://publikationen.bibliothek.kit.edu/1000185756
|
||||
2. **LeakyBeam** (NDSS 2025) — detects occupancy through walls at 20 m with 82.7% TPR / 96.7% TNR using only plaintext BFI. https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf
|
||||
|
||||
Capture tooling is freely available: **Wi-BFI** (pip-installable), **PicoScenes**, **Nexmon BFI patches** for BCM43455c0 (Raspberry Pi 5 / 4 / 3B+).
|
||||
|
||||
### 1.2 Gap in the existing RuView pipeline
|
||||
|
||||
The wifi-densepose / RuView pipeline processes CSI via the rvCSI runtime (ADR-095/096) and emits presence, pose, vitals, and zone-activity events. **No layer in the existing pipeline measures whether the data it is processing is capable of identifying individuals.** All CSI is treated as equivalent from a privacy standpoint regardless of operating regime.
|
||||
|
||||
This gap becomes a compliance and liability issue at deployment scale. An operator placing RuView in a care home, hotel, shared office, or rental property has no instrument to verify that the system is operating anonymously.
|
||||
|
||||
### 1.3 BFI as a sensing signal
|
||||
|
||||
BFI is not only a threat vector — its compressed angle matrices carry multipath geometry useful for presence and motion detection, particularly in single-AP deployments where MIMO CSI is unavailable. BFLD treats BFI as an **optional input alongside CSI**, not a replacement.
|
||||
|
||||
### 1.4 Relationship to the Soul Signature research
|
||||
|
||||
The Soul Signature research (`docs/research/soul/`) defines a 7-channel multi-modal biometric for **consent-based** passive re-identification of enrolled individuals. Where Soul Signature *intentionally produces* identity (with a 60-second enrollment protocol), BFLD *measures and gates* identity leakage from the same sensing substrate. The two systems are complementary by design:
|
||||
|
||||
| Concern | Soul Signature | BFLD |
|
||||
|---------|----------------|------|
|
||||
| Intent | Create a biometric for enrolled persons | Measure and gate identity leakage |
|
||||
| Consent model | Explicit enrollment, GDPR/HIPAA modes | Default-deny, all unenrolled persons |
|
||||
| Operating class | Must run at `privacy_class = 1` (derived) | Defaults to class 2 (anonymous) |
|
||||
| Shared assets | AETHER encoder (ADR-024), WitnessChain (ADR-110/028), RVF container, `cross_room.rs` (ADR-030) | Same |
|
||||
| ID space | Long-lived opaque `person_id` per enrolled subject | Rotating `rf_signature_hash` per day per unenrolled person |
|
||||
|
||||
BFLD becomes Soul Signature's enforcement layer: the `identity_risk_score` gates whether a zone is leaky enough to enroll, the witness bundle is the regulator-facing audit artifact, and the structural privacy invariants (I1/I2/I3) ensure unenrolled bystanders stay anonymous even in zones where Soul Signature is actively matching enrolled persons. See ADR-120 §2.7 and ADR-121 §2.7 for the integration points.
|
||||
|
||||
### 1.5 What this ADR is *not*
|
||||
|
||||
- Not a removal of the CSI pipeline. ADR-095/096 rvCSI stays authoritative for CSI.
|
||||
- Not a port of any external sniffer into the repo. The Nexmon capture path lives in a separate adapter (see ADR-123).
|
||||
- Not a Matter SDK ship — Matter exposure is filtered through the ADR-116 `cog-ha-matter` boundary.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Create a new Rust crate **`wifi-densepose-bfld`** in `v2/crates/` that:
|
||||
|
||||
1. **Ingests** BFI angle matrices (Φ/ψ) from CBFR frames, optionally fused with CSI.
|
||||
2. **Computes** nine named features and an `identity_risk_score` (separability × temporal_stability × cross_perspective_consistency × sample_confidence).
|
||||
3. **Gates** all output through a `privacy_class` byte that **structurally prevents** identity-correlated data from being published at classes 2 (anonymous) and 3 (restricted).
|
||||
4. **Emits** `BfldEvent` JSON over MQTT under `ruview/<node_id>/bfld/*` with per-class topic routing.
|
||||
5. **Enforces three invariants structurally, not by policy**:
|
||||
- **I1**: Raw BFI never exits the node.
|
||||
- **I2**: Identity embedding is in-RAM-only (no disk, no network).
|
||||
- **I3**: Cross-site identity correlation is cryptographically impossible via per-site keyed BLAKE3 hash rotation with a daily epoch.
|
||||
|
||||
The umbrella implementation is decomposed into five sub-ADRs:
|
||||
|
||||
| Sub-ADR | Scope |
|
||||
|---------|-------|
|
||||
| **ADR-119** | `BfldFrame` wire format, magic `0xBF1D_0001`, deterministic serialization, CRC32 |
|
||||
| **ADR-120** | `privacy_class` semantics, BLAKE3 hash rotation, default-deny field classification |
|
||||
| **ADR-121** | Identity risk scoring formula, coherence gate, leakage estimator |
|
||||
| **ADR-122** | RuView surface: HA entities, Matter cluster boundary, MQTT topic ACL |
|
||||
| **ADR-123** | Capture path: Pi 5 / Nexmon adapter + ESP32-S3 BFI feasibility |
|
||||
|
||||
### 2.1 Crate module layout
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-bfld/
|
||||
├── Cargo.toml
|
||||
└── src/
|
||||
├── lib.rs
|
||||
├── frame.rs # BfldFrame (ADR-119)
|
||||
├── extractor.rs # CBFR parser → BfiCapture
|
||||
├── features.rs # 9 features
|
||||
├── identity_risk.rs # risk score (ADR-121)
|
||||
├── privacy_gate.rs # privacy_class enforcement (ADR-120)
|
||||
├── hash_rotation.rs # BLAKE3 per-site rotation (ADR-120)
|
||||
├── emitter.rs # BfldEvent → MQTT
|
||||
├── mqtt.rs # topic routing (ADR-122)
|
||||
└── ffi.rs # PyO3 bindings (ADR-117 pattern)
|
||||
```
|
||||
|
||||
### 2.2 Reuse map
|
||||
|
||||
| BFLD module | Depends on |
|
||||
|---|---|
|
||||
| `features.rs` | `wifi-densepose-signal/src/ruvsense/coherence.rs`, `multistatic.rs` |
|
||||
| `identity_risk.rs` | `wifi-densepose-ruvector/src/viewpoint/attention.rs`, `coherence.rs` |
|
||||
| `privacy_gate.rs` | (new) — no upstream dependency |
|
||||
| `hash_rotation.rs` | `blake3 = "1.5"` (keyed mode) |
|
||||
| `extractor.rs` | `vendor/rvcsi/crates/rvcsi-adapter-nexmon` (ADR-095/096) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- First explicit, auditable RF-layer privacy primitive in the wifi-densepose ecosystem.
|
||||
- `identity_risk_score` doubles as an anomaly signal (sudden spike → new AP firmware / nearby attacker-grade sniffer / unusual propagation).
|
||||
- BFI fusion augments presence/motion in single-AP deployments.
|
||||
- Deterministic frame hashes extend the ADR-028 witness-bundle pattern to the new surface.
|
||||
- Cross-site isolation is **structural, not policy-dependent** — a stronger guarantee than ACLs.
|
||||
|
||||
### Negative
|
||||
|
||||
- ESP32-S3 cannot directly capture CBFR via the Espressif WiFi API. Full BFLD pipeline requires a Pi 5 / Nexmon host sniffer (cognitum-v0 available; see ADR-123).
|
||||
- `identity_risk_score` calibration requires the KIT BFId dataset (non-commercial research agreement).
|
||||
- Estimated effort: ~10.5 engineer-weeks across the six ADRs.
|
||||
|
||||
### Neutral
|
||||
|
||||
- BFLD does not prevent passive BFI capture by an external attacker (LeakyBeam-class). It only ensures the **node's own output** is non-identifying. Operators must understand this distinction.
|
||||
- Daily hash rotation prevents multi-day analytics correlating individual signatures across the day boundary. Acceptable for privacy goals; may surprise analytics use-cases.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Skip BFI entirely (CSI-only)
|
||||
|
||||
Rejected because: (a) leaves the identity-leakage gap open for the CSI pipeline; (b) as BFI tooling becomes ubiquitous (Wi-BFI, PicoScenes), the absence of a privacy layer becomes more conspicuous for operators.
|
||||
|
||||
### Alt 2: Publish `identity_risk_score` publicly by default
|
||||
|
||||
Rejected: the risk score itself is privacy-sensitive (reveals presence via timing correlation). Default is opt-in.
|
||||
|
||||
### Alt 3: Cloud ML on raw BFI
|
||||
|
||||
Rejected: violates I1. Cloud training creates an off-node store of angle matrices reconstructible into identity profiles.
|
||||
|
||||
### Alt 4: Differential privacy noise on BFI at ingress
|
||||
|
||||
Deferred to a follow-up ADR. DP sensitivity analysis and its interaction with `identity_risk_score` calibration are not yet complete. Current design achieves privacy through structural impossibility, not noise injection.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Extractor parses BFI from 802.11ac and 802.11ax captures, 20/40/80/160 MHz, 2×2 through 4×4 MIMO.
|
||||
- [ ] **AC2**: Presence detection latency ≤ 1 s p95 from first non-empty BFI frame.
|
||||
- [ ] **AC3**: Motion score published at ≥ 1 Hz on `ruview/<node_id>/bfld/motion/state`.
|
||||
- [ ] **AC4**: Raw BFI bytes never present in any serialized `BfldFrame` payload at any `privacy_class` value.
|
||||
- [ ] **AC5**: With `privacy_mode` enabled, all identity-derived fields are absent from outbound events.
|
||||
- [ ] **AC6**: Identical `BfiCapture` inputs produce bit-identical `BfldFrame` serialization (deterministic hash).
|
||||
- [ ] **AC7**: Pipeline produces valid `BfldEvent` outputs without `csi_matrix` (BFI-only mode).
|
||||
|
||||
Per-sub-ADR acceptance criteria are defined in ADR-119 through ADR-123.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phased Rollout
|
||||
|
||||
| Phase | ADR | Scope | Effort |
|
||||
|-------|-----|-------|--------|
|
||||
| **P1** | 119 | Frame format + extractor stub | 1.5 wk |
|
||||
| **P2** | 121 | Features + identity_risk_score | 2.0 wk |
|
||||
| **P3** | 120 | Privacy gate + hash rotation | 1.5 wk |
|
||||
| **P4** | 122 (a) | MQTT emitter + HA discovery | 1.5 wk |
|
||||
| **P5** | 122 (b) | Matter cluster boundary in `cog-ha-matter` | 1.5 wk |
|
||||
| **P6** | 123 | Pi 5 / Nexmon capture adapter | 2.5 wk |
|
||||
| **Total** | | | **10.5 wk** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Related ADRs
|
||||
|
||||
See header table. Cross-references in body cite the structural reuse of:
|
||||
- ADR-024 (AETHER embedding for identity_risk computation)
|
||||
- ADR-027 (MERIDIAN's no-cross-site assumption is now structurally enforced by I3)
|
||||
- ADR-028 (witness-bundle extends to BFLD surface)
|
||||
- ADR-029/030 (`multistatic.rs`, `cross_room.rs` reused)
|
||||
- ADR-095/096 (rvCSI Nexmon adapter for BFI capture)
|
||||
- ADR-115 (HA surface extension)
|
||||
- ADR-116 (`cog-ha-matter` boundary filter)
|
||||
- ADR-117 (PyO3 bindings pattern)
|
||||
@@ -0,0 +1,163 @@
|
||||
# ADR-119: BFLD Frame Format and Wire Protocol
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-028](ADR-028-esp32-capability-audit.md) (witness/deterministic proof), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI `CsiFrame` schema) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
The BFLD pipeline (ADR-118) emits an over-the-wire `BfldFrame` consumed by the RuView aggregator, HA bridge, and witness bundle. The frame must be:
|
||||
|
||||
1. **Deterministic** — identical input ⇒ bit-identical output, so witness hashes survive verification (ADR-028 pattern).
|
||||
2. **Self-describing** — magic + version so future BFLD revisions don't silently corrupt aggregator state.
|
||||
3. **Privacy-classified at the byte level** — the receiver must know the data class before it even parses the payload, so it can drop frames it isn't authorized to handle.
|
||||
4. **Compact** — BFLD nodes may emit at up to 10 Hz; the frame must be small enough for unsharded MQTT and ESP-NOW transport.
|
||||
5. **Endianness-stable** — captures from x86_64 (ruvultra), aarch64 (cognitum-v0, Pi 5 cluster), and Xtensa (ESP32-S3) must produce identical bytes.
|
||||
|
||||
The existing rvCSI `CsiFrame` (ADR-095) is the closest precedent. BFLD reuses the same little-endian convention and the same "validate-before-FFI" posture.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 `BfldFrame` header (40 bytes, little-endian, packed)
|
||||
|
||||
```rust
|
||||
#[repr(C, packed)]
|
||||
pub struct BfldFrameHeader {
|
||||
pub magic: u32, // 0xBF1D_0001
|
||||
pub version: u16, // 1
|
||||
pub flags: u16, // bit0=has_csi_delta, bit1=privacy_mode, bit2-15 reserved
|
||||
pub timestamp_ns: u64, // monotonic capture clock
|
||||
|
||||
pub ap_hash: [u8; 16], // BLAKE3-keyed(site_salt, ap_mac)[0..16]
|
||||
pub sta_hash: [u8; 16], // BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16]
|
||||
pub session_id: [u8; 16], // ephemeral, rotated on capture-session boundary
|
||||
|
||||
pub channel: u16, // 802.11 channel number
|
||||
pub bandwidth_mhz: u16, // 20 | 40 | 80 | 160
|
||||
pub rssi_dbm: i16,
|
||||
pub noise_floor_dbm: i16,
|
||||
|
||||
pub n_subcarriers: u16,
|
||||
pub n_tx: u8,
|
||||
pub n_rx: u8,
|
||||
pub quantization: u8, // 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles)
|
||||
pub privacy_class: u8, // 0=raw, 1=derived, 2=anonymous, 3=restricted (default 2)
|
||||
|
||||
pub payload_len: u32,
|
||||
pub payload_crc32: u32, // CRC-32/ISO-HDLC over payload bytes only
|
||||
}
|
||||
```
|
||||
|
||||
Total header size: **86 bytes packed** (validated by `static_assertions::const_assert_eq!` in `wifi-densepose-bfld/src/frame.rs`). Earlier drafts stated 40 bytes — that was a counting error caught during P1 scaffold; see AC1 below.
|
||||
|
||||
### 2.2 Payload structure
|
||||
|
||||
Payload is a length-prefixed sequence of typed sections in this exact order:
|
||||
|
||||
```
|
||||
payload = compressed_angle_matrix
|
||||
‖ amplitude_proxy
|
||||
‖ phase_proxy
|
||||
‖ snr_vector
|
||||
‖ optional_csi_delta (present iff flags.bit0 set)
|
||||
‖ optional_vendor_extension (length 0 allowed)
|
||||
```
|
||||
|
||||
Each section is `[u32 len_le][bytes...]`. The CRC32 covers all section bytes including length prefixes, but **not** the header.
|
||||
|
||||
### 2.3 Privacy-class gating at serialization
|
||||
|
||||
The serializer enforces these rules **before** writing any payload bytes:
|
||||
|
||||
| `privacy_class` | `compressed_angle_matrix` | Identity-derived fields | Notes |
|
||||
|-----------------|---------------------------|-------------------------|-------|
|
||||
| 0 (`raw`) | full | full | **Local-only**, never serialized to a network sink |
|
||||
| 1 (`derived`) | downsampled to 8-bit, top-k subcarriers | full | Operator-acknowledged research mode |
|
||||
| 2 (`anonymous`, **default**) | absent (zero-length section) | absent | Production default |
|
||||
| 3 (`restricted`) | absent | absent + diagnostic-only | Equivalent to class 2 + suppresses `identity_risk_score` on the bus |
|
||||
|
||||
The serializer returns `Err(BfldError::PrivacyViolation)` if the caller attempts to publish a class-0 frame through a network sink. This is enforced by a sink-type marker trait (`LocalSink` vs `NetworkSink`).
|
||||
|
||||
### 2.4 Deterministic serialization
|
||||
|
||||
Three guarantees:
|
||||
|
||||
1. **Field order is fixed** by `#[repr(C, packed)]`.
|
||||
2. **Float quantization is canonical** — `quantization` byte values 1/2/3 use specified round-half-to-even with documented saturation; f32 (value 0) is forbidden over the wire (local-only).
|
||||
3. **CRC32 is computed last**, after all section bytes are placed.
|
||||
|
||||
The witness test in `tests/determinism.rs` captures a 200-frame BFI fixture, serializes it 1,000 times across two threads, and verifies the BLAKE3 of the resulting byte stream is bit-identical.
|
||||
|
||||
### 2.5 Magic value rationale
|
||||
|
||||
`0xBF1D_0001` is chosen so that `bf1d` reads as "BFLD" in hex-dump output, easing wireshark / xxd debugging. The final `0001` is the major version; minor revisions bump `version` field.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- 40-byte header + compact payload fits comfortably in a 1500-byte MTU even at 4×4 MIMO with 256 subcarriers.
|
||||
- Serialization is `#[no_std]` compatible — same code can run on ESP32-S3 (when ESP-NOW transport is added under ADR-123 P2).
|
||||
- Witness-bundle integration is direct: the existing `archive/v1/data/proof/verify.py` pattern extends to a `bfld_verify.py` that consumes the same SHA-256 expected-hash file format.
|
||||
|
||||
### Negative
|
||||
|
||||
- `#[repr(C, packed)]` on the header means consumers must use `read_unaligned` — small ergonomic cost, mitigated by a `#[derive(BfldFrameAccess)]` proc-macro.
|
||||
- Reserved flag bits 2-15 lock in future-extension order; any new bit assignment is a version bump.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The vendor-extension section allows downstream RuView cogs (e.g., `cog-pose-estimation`) to attach metadata without a header change, at the cost of CRC scope creep. Vendor sections are explicitly outside the witness hash.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Protobuf / FlatBuffers
|
||||
|
||||
Rejected: schema evolution overhead, witness-hash instability across protoc versions, ~3× wire bloat for the small fixed-shape fields.
|
||||
|
||||
### Alt 2: CBOR
|
||||
|
||||
Rejected: deterministic CBOR (RFC 8949 §4.2) is achievable but the parser surface is large and tag handling is a footgun for the `no_std` ESP32 path.
|
||||
|
||||
### Alt 3: Variable-width magic / no magic
|
||||
|
||||
Rejected: receivers must distinguish BFLD frames from rvCSI `CsiFrame` and other RuView payloads on shared transports.
|
||||
|
||||
### Alt 4: Move CRC32 to header
|
||||
|
||||
Rejected: CRC must be computed after the payload, so its value would otherwise force a header rewrite; placing it last avoids a buffer-pass-back.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `BfldFrameHeader` size is exactly **86 bytes** (packed) on x86_64, aarch64, and xtensa-esp32s3. The size was initially documented as 40 bytes during ADR drafting — that was a counting error; the implementation in `wifi-densepose-bfld/src/frame.rs` enforces the correct value via `const_assert_eq!`.
|
||||
- [ ] **AC2**: 1,000 serializations of a fixed `BfiCapture` fixture produce a bit-identical BLAKE3 hash.
|
||||
- [ ] **AC3**: `privacy_class = 0` frame returned through `NetworkSink::publish()` returns `Err(BfldError::PrivacyViolation)`.
|
||||
- [ ] **AC4**: Payload CRC32 mismatch causes `BfldFrame::parse()` to return `Err(BfldError::Crc)` without exposing partial payload state.
|
||||
- [ ] **AC5**: Round-trip serialize/parse preserves all header fields exactly.
|
||||
- [ ] **AC6**: A frame with `flags.bit0 = 0` (no CSI delta) and an unexpected CSI-delta section is rejected.
|
||||
- [ ] **AC7**: Bench: serialization throughput ≥ 50k frames/sec on a 2025-era M1/M2 / Pi 5 core.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 §2 (umbrella decision)
|
||||
- ADR-095 `CsiFrame` (`vendor/rvcsi/crates/rvcsi-core/src/frame.rs`)
|
||||
- CRC-32/ISO-HDLC: `crc = "3"` crate
|
||||
- BLAKE3 keyed mode: `blake3 = "1.5"`
|
||||
- IEEE 802.11-2020 §19.3.12 (Compressed Beamforming Report)
|
||||
@@ -0,0 +1,192 @@
|
||||
# ADR-120: BFLD Privacy Class and Hash Rotation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN no-cross-site), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security), [ADR-106](ADR-106-dp-sgd-and-primitive-isolation.md) (primitive isolation), [ADR-115](ADR-115-home-assistant-integration.md) (privacy mode) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature operates at `privacy_class = 1` (derived). §2.7 defines the dual-ID-space contract. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-118 declares three structural invariants for BFLD:
|
||||
|
||||
- **I1**: Raw BFI never exits the node.
|
||||
- **I2**: Identity embedding is in-RAM-only.
|
||||
- **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
|
||||
I1/I2 are enforced by sink typing and module visibility (ADR-119 §2.3). I3 requires a hash-rotation scheme that makes the same physical person produce **different** `rf_signature_hash` values across sites and across day boundaries, without any out-of-band coordination between sites.
|
||||
|
||||
The existing `HA-PRIVACY` mode in ADR-115 already toggles between "full" and "anonymous" surfaces, but at a per-event granularity — not at a per-byte-field granularity. BFLD requires the latter because the `BfldFrame` payload mixes sensing data (publishable) and identity-derived data (non-publishable) in the same struct.
|
||||
|
||||
The BFId paper (KIT, ACM CCS 2025) demonstrates that even a few minutes of BFI capture across the same site is sufficient to build a persistent biometric. The mitigation must be **structural**, not policy-dependent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 The four privacy classes
|
||||
|
||||
A single `privacy_class: u8` byte in the `BfldFrame` header (ADR-119 §2.1) selects one of four classes. The crate enforces field availability statically through marker types.
|
||||
|
||||
| Class | Name | Use case | Available fields |
|
||||
|-------|------|----------|------------------|
|
||||
| **0** | `raw` | Local-only research, never networked | All fields, full-precision BFI matrix, identity embedding |
|
||||
| **1** | `derived` | Operator-acknowledged research over LAN | Downsampled angle matrix, full features, identity_risk_score, identity_embedding |
|
||||
| **2** | `anonymous` (**default**) | Production deployment | Aggregate sensing only: presence, motion, person_count, zone_id, confidence |
|
||||
| **3** | `restricted` | Care-home / regulated deployment | Class 2 minus `identity_risk_score` and `rf_signature_hash` |
|
||||
|
||||
Default for new RuView nodes is class **2**. Operators must explicitly opt-down to class 1 via the existing `--research-mode` flag (ADR-115 §7); class 0 is reserved for `cargo test` and is unreachable from `wifi-densepose-sensing-server`.
|
||||
|
||||
### 2.2 Enforcement via marker types
|
||||
|
||||
```rust
|
||||
pub trait Sink {}
|
||||
|
||||
pub trait LocalSink: Sink {} // Allowed: classes 0,1,2,3
|
||||
pub trait NetworkSink: Sink {} // Allowed: classes 1,2,3 (NOT class 0)
|
||||
pub trait MatterSink: NetworkSink {} // Allowed: class 2,3 + cluster-filter (ADR-122)
|
||||
|
||||
impl Emitter {
|
||||
pub fn publish<S: NetworkSink>(&self, sink: &S, frame: BfldFrame)
|
||||
-> Result<(), BfldError>
|
||||
{
|
||||
if frame.header.privacy_class == 0 {
|
||||
return Err(BfldError::PrivacyViolation {
|
||||
reason: "class 0 to NetworkSink",
|
||||
});
|
||||
}
|
||||
// ... serialize and write
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The compiler refuses to call `publish` on a sink that doesn't impl `NetworkSink` with a class-0 frame because the runtime check is paired with a sink-marker check. Cross-sink frame routing requires an explicit class transition (see §2.4).
|
||||
|
||||
### 2.3 BLAKE3 keyed hash rotation for `rf_signature_hash`
|
||||
|
||||
The signature hash is computed as:
|
||||
|
||||
```rust
|
||||
pub fn rf_signature_hash(
|
||||
site_salt: &[u8; 32], // generated on first boot, persisted in TPM/KMS
|
||||
day_epoch: u32, // floor(unix_time_utc / 86400)
|
||||
features: &IdentityFeatures,
|
||||
) -> Hash {
|
||||
let mut hasher = blake3::Hasher::new_keyed(site_salt);
|
||||
hasher.update(&day_epoch.to_le_bytes());
|
||||
hasher.update(&features.canonical_bytes());
|
||||
hasher.finalize()
|
||||
}
|
||||
```
|
||||
|
||||
**Structural cross-site isolation**: because `site_salt` is a 256-bit random secret unique to each node and never transmitted, two sites observing the same physical person produce uncorrelated hashes. There is no key the operator (or an attacker who compromises one node) can use to bridge sites. This is stronger than a policy-based "do not share" rule because the bridge **cannot be computed**.
|
||||
|
||||
**Daily rotation**: `day_epoch` flipping at UTC midnight forces the hash of the same person to change once per day. Multi-day correlation requires re-acquiring the biometric, which the rotation actively breaks.
|
||||
|
||||
### 2.4 Class-transition transformer
|
||||
|
||||
The only way a high-class frame becomes a lower-class frame is through `PrivacyGate::demote(frame, target_class)`. This function:
|
||||
|
||||
1. Asserts the target class is strictly higher number than (or equal to) the input class.
|
||||
2. Zeroes the disallowed fields with `subtle::Zeroize`.
|
||||
3. Re-computes `payload_crc32`.
|
||||
4. Returns the new frame.
|
||||
|
||||
There is no `promote` operation — a class-2 frame cannot be turned back into a class-1 frame, because the dropped fields were not retained anywhere reachable from the gate.
|
||||
|
||||
### 2.5 `identity_embedding` lifecycle
|
||||
|
||||
The embedding (output of the AETHER encoder, ADR-024) is held in a `subtle::Zeroizing<[f32; 128]>` ring buffer of 64 entries (≈30 KB). Entries are:
|
||||
|
||||
1. Written by the encoder on each capture window.
|
||||
2. Consumed by `identity_risk_score` computation (ADR-121).
|
||||
3. **Never** written to disk, MQTT, or any other I/O sink — there is no `Serialize` impl on the type.
|
||||
4. Overwritten by the ring (FIFO).
|
||||
|
||||
A compile-time `#[forbid(serde::Serialize)]` lint on `IdentityEmbedding` ensures a future PR cannot accidentally add a `Serialize` derive.
|
||||
|
||||
### 2.6 Default-deny field classification
|
||||
|
||||
Every new field added to `BfldFrame` or `BfldEvent` must be tagged with `#[must_classify]` (a custom attribute macro). The macro fails compilation if the field is not listed in the per-class allow-list table. This forces future contributors to make an explicit privacy decision on every new field.
|
||||
|
||||
### 2.7 Dual-ID-space contract for Soul Signature deployments
|
||||
|
||||
Soul Signature (`docs/research/soul/`) is a consent-based biometric system that *intentionally* produces long-lived per-person identity. It cannot operate at the default class 2 — the identity_embedding it needs is structurally absent there. The contract:
|
||||
|
||||
| Deployment mode | `privacy_class` | ID space for unenrolled bystanders | ID space for enrolled persons |
|
||||
|---|---|---|---|
|
||||
| Default BFLD-only | 2 (anonymous) | Daily-rotated `rf_signature_hash` | n/a — no enrollment |
|
||||
| Soul Signature opt-in | **1 (derived)** | Daily-rotated `rf_signature_hash` (unchanged) | Long-lived opaque `person_id` from Soul Signature graph |
|
||||
| Restricted / care-home | 3 (restricted) | Suppressed | n/a — Soul Signature **disabled** at class 3 |
|
||||
|
||||
Two ID spaces coexist with **no collision**: the rotating hash is the privacy-preserving identifier for everyone *not* on the consent roster; the stable `person_id` is reserved for enrolled subjects under their own GDPR/HIPAA mode. Soul Signature's `match_against_enrolled()` function consumes only the in-RAM `identity_embedding` (I2 still holds) and emits a `person_id` plus a calibrated similarity score; it never writes the embedding to disk or the wire. The class-1 requirement is enforced statically: the Soul Signature match API takes a `&IdentityEmbedding` parameter, which is only constructible when the BFLD crate is compiled with `--features soul-signature` against a class-1 frame.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Cross-site identity correlation is **computationally impossible**, not merely "prohibited by policy". This is the strongest form of privacy guarantee available without a TEE.
|
||||
- Default-deny via `#[must_classify]` prevents the common pattern of "a new field shipped, then six months later we noticed it was identity-leaky".
|
||||
- `identity_embedding` cannot be serialized by accident — the type system carries the constraint.
|
||||
- The class transition transformer makes the data lifecycle explicit and auditable.
|
||||
|
||||
### Negative
|
||||
|
||||
- `site_salt` storage requires either a TPM (ADR-095/096 rvCSI platform feature gap) or a secrets file with strict mode. Loss of `site_salt` makes historical witness comparisons impossible — by design, but a documentation hazard.
|
||||
- `#[must_classify]` is a custom proc-macro; another moving part in the build.
|
||||
- Operators wanting multi-day analytics must work in aggregates only, not on per-individual signatures.
|
||||
|
||||
### Neutral
|
||||
|
||||
- Class 0 is `cargo test`-only. Some CI runners may need an explicit feature flag to compile class-0 paths.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Single boolean `privacy_mode` flag (status quo from ADR-115)
|
||||
|
||||
Rejected: insufficient granularity. The frame mixes publishable sensing with non-publishable identity, so the gate must operate at field-level, not event-level.
|
||||
|
||||
### Alt 2: SHA-256 instead of BLAKE3
|
||||
|
||||
Rejected: BLAKE3 keyed-hash mode is ~5× faster on the ESP32-S3 / Cortex-M cores and the security margin is equivalent for this use case. SHA-256 has no keyed-hash mode (HMAC-SHA256 is the alternative; works but is slower).
|
||||
|
||||
### Alt 3: Hash rotation on the hour, not the day
|
||||
|
||||
Rejected: hourly rotation breaks legitimate "person was here in the morning, came back in the afternoon" use-cases that operators may want. Day boundary is the compromise.
|
||||
|
||||
### Alt 4: Per-event nonces instead of daily epoch
|
||||
|
||||
Rejected: per-event nonces would force the consumer to track which events came from the same person within a session, which leaks identity information by structure. The day epoch preserves a coarse temporal grouping without leaking finer-grained identity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Calling `Emitter::publish` with a `privacy_class = 0` frame on a `NetworkSink` returns `BfldError::PrivacyViolation`.
|
||||
- [ ] **AC2**: Two BFLD nodes with different `site_salt` values observing the same simulated person produce `rf_signature_hash` values whose Hamming distance is ≥ 120 bits over 100 trials (statistical isolation test).
|
||||
- [ ] **AC3**: A frame with `privacy_class = 3` has both `identity_risk_score` and `rf_signature_hash` absent from the serialized payload.
|
||||
- [ ] **AC4**: `PrivacyGate::demote(class_1_frame, target=0)` fails to compile (compile-fail test).
|
||||
- [ ] **AC5**: A PR adding a new field to `BfldEvent` without `#[must_classify]` fails the build.
|
||||
- [ ] **AC6**: `IdentityEmbedding` has no `Serialize` impl reachable from any public function.
|
||||
- [ ] **AC7**: Dropping an `IdentityEmbedding` value zeroizes its memory (verified by a debugger-readable test under `cargo test --features zeroize-validation`).
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 (umbrella)
|
||||
- ADR-119 (frame format; `privacy_class` byte location)
|
||||
- KIT BFId (ACM CCS 2025): https://publikationen.bibliothek.kit.edu/1000185756
|
||||
- NDSS LeakyBeam (2025): https://www.ndss-symposium.org/wp-content/uploads/2025-5-paper.pdf
|
||||
- BLAKE3 keyed-hash: https://github.com/BLAKE3-team/BLAKE3
|
||||
- `subtle::Zeroize` for memory hygiene
|
||||
@@ -0,0 +1,182 @@
|
||||
# ADR-121: BFLD Identity Risk Scoring and Coherence Gate
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (multistatic fusion), [ADR-086](ADR-086-edge-novelty-gate.md) (novelty gate precedent), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy class) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — risk score doubles as Soul Signature enrollment-quality signal; §2.7 defines the Recalibrate exemption. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
BFLD's distinguishing primitive is the `identity_risk_score` — a scalar that says **"is this capture window currently capable of identifying a specific person?"**. The score has two consumers:
|
||||
|
||||
1. **The operator** — exposed as an HA diagnostic sensor (ADR-122). A spike from the long-term baseline indicates the RF environment has shifted toward a higher-leakage regime (new AP firmware, denser MIMO, attacker-grade sniffer in range).
|
||||
2. **The privacy gate** (ADR-120) — when the score crosses a configurable threshold, the gate downgrades the active `privacy_class` automatically (e.g., 2 → 3) until the score recovers.
|
||||
|
||||
The score must be:
|
||||
- **Bounded** in `[0, 1]` for HA gauge entities.
|
||||
- **Calibrated** against actual re-ID success rate, ideally on the KIT BFId dataset.
|
||||
- **Computable on-device** at ≥ 1 Hz on a Pi 5 core or an aarch64 cognitum-v0.
|
||||
- **Stable** — small environmental changes should not produce wild swings; the score is for slow-moving regime detection, not per-frame chatter.
|
||||
|
||||
ADR-086 (edge novelty gate) establishes a precedent for an on-device gate primitive. BFLD's risk scoring borrows the gate-pattern but with identity leakage as the trigger condition.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Nine features (from BFLD spec §5)
|
||||
|
||||
The features are computed over a sliding window of `W = 32` BFI frames (≈3 s at 10 Hz):
|
||||
|
||||
| Feature | Definition | Source |
|
||||
|---------|------------|--------|
|
||||
| `mean_angle_delta` | mean( ‖ Φ_t − Φ_{t-1} ‖ over subcarriers ) | extractor |
|
||||
| `subcarrier_variance` | var( ‖ Φ ‖ over subcarrier axis ) | extractor |
|
||||
| `temporal_entropy` | Shannon entropy of angle-bin histogram over W | extractor |
|
||||
| `doppler_proxy` | FFT peak magnitude of mean-angle time series | features.rs |
|
||||
| `path_stability` | 1 − ‖ Φ_t − median(Φ_{t-W..t}) ‖ / scale | features.rs |
|
||||
| `cross_antenna_correlation` | mean Pearson correlation across n_tx × n_rx pairs | features.rs |
|
||||
| `burst_motion_score` | high-pass-filtered angular velocity, soft-thresholded | features.rs |
|
||||
| `stationarity_score` | 1 − rolling KL divergence over W/2 vs W | features.rs |
|
||||
| `identity_separability_score` | top-1 cosine to nearest AETHER cluster centroid | identity_risk.rs |
|
||||
|
||||
The first eight are sensing features (also used by the presence/motion pipeline). Only the ninth depends on the AETHER embedding and therefore on `identity_class >= 1`.
|
||||
|
||||
### 2.2 Identity risk formula
|
||||
|
||||
```rust
|
||||
pub fn identity_risk_score(
|
||||
sep: f32, // identity_separability_score, [0, 1]
|
||||
stab: f32, // temporal_stability, [0, 1] = ema(path_stability, alpha=0.1)
|
||||
consist: f32,// cross_perspective_consistency, [0, 1] = multistatic.rs
|
||||
conf: f32, // sample_confidence, [0, 1] = f(SNR, n_subcarriers, n_rx)
|
||||
) -> f32 {
|
||||
// Clamp inputs, then multiplicative combination — any factor near 0 dominates.
|
||||
let s = sep.clamp(0.0, 1.0);
|
||||
let t = stab.clamp(0.0, 1.0);
|
||||
let p = consist.clamp(0.0, 1.0);
|
||||
let c = conf.clamp(0.0, 1.0);
|
||||
(s * t * p * c).clamp(0.0, 1.0)
|
||||
}
|
||||
```
|
||||
|
||||
Multiplicative combination is chosen so that **any** weak factor (e.g., very low SNR ⇒ low `conf`) collapses the score toward 0. This matches the privacy intent: when the system is uncertain, the score should be low and the operator should not be alarmed.
|
||||
|
||||
### 2.3 Calibration target
|
||||
|
||||
The score is calibrated against re-ID success rate on a held-out test split of the KIT BFId dataset. A piecewise-linear isotonic regression maps raw scores into a calibrated `[0, 1]` band where `score ≥ 0.8` corresponds to `>80%` re-ID accuracy on a 5-second window in the calibration dataset.
|
||||
|
||||
Calibration parameters live in `v2/crates/wifi-densepose-bfld/data/risk_calibration.toml` and are versioned independently of the code. A regression update is a content-only PR.
|
||||
|
||||
### 2.4 Coherence gate
|
||||
|
||||
The coherence gate (per ADR-029 `coherence_gate.rs` pattern) consumes the risk score and emits one of four actions:
|
||||
|
||||
```rust
|
||||
pub enum GateAction {
|
||||
Accept, // score < 0.5, publish normally
|
||||
PredictOnly, // 0.5 <= score < 0.7, publish but flag confidence
|
||||
Reject, // 0.7 <= score < 0.9, drop the event
|
||||
Recalibrate, // score >= 0.9, drop AND rotate site_salt
|
||||
}
|
||||
```
|
||||
|
||||
The `Recalibrate` action triggers a forced site-salt rotation — an aggressive response to a sustained high-risk regime. It costs the operator continuity of long-term aggregate analytics but is the right answer to an attacker-grade sniffer arriving in range.
|
||||
|
||||
### 2.5 Hysteresis
|
||||
|
||||
To prevent oscillation around the gate thresholds, the gate uses ±0.05 hysteresis and a 5-second debounce. A score must cross the boundary by the hysteresis margin and persist for the debounce window before the gate action changes.
|
||||
|
||||
### 2.6 Soul Signature interaction — Recalibrate exemption and enrollment-quality gate
|
||||
|
||||
Soul Signature (`docs/research/soul/`) intentionally exists in a high-separability regime — the whole point of its 60-second enrollment protocol is to push `identity_separability_score` toward 1.0. The default coherence gate (§2.4) would therefore fire `Recalibrate` constantly inside Soul Signature zones, rotating `site_salt` every few seconds and breaking enrollment.
|
||||
|
||||
Two integrations resolve this:
|
||||
|
||||
1. **Recalibrate exemption.** When the gate is about to fire `Recalibrate`, it consults a `SoulMatchOracle` (provided by the Soul Signature crate when compiled with `--features soul-signature`). If the oracle reports that the current high-separability cluster matches an enrolled `person_id` above the Soul Signature acceptance threshold, the gate downgrades to `PredictOnly` instead. The high score is the *intended* outcome of a successful match, not an attack indicator. Without the `soul-signature` feature, the oracle is a no-op stub returning `MatchOutcome::NotEnrolled`, so the gate behaves exactly per §2.4.
|
||||
|
||||
2. **Enrollment-quality gate.** Soul Signature's enrollment protocol (`scanning-process.md` §3) requires that the sensing zone meet a minimum identity-leakage regime — too low, and the resulting signature is unreliable. The BFLD `identity_risk_score` is exactly the right signal. Soul Signature gates enrollment on `score >= ENROLL_MIN` (default `0.65`) sustained over the 60-second window. If the score drops below threshold mid-enrollment, the protocol aborts and the operator is prompted to re-attempt in better RF conditions.
|
||||
|
||||
The exemption is asymmetric: it suppresses `Recalibrate` only for known-enrolled matches. Unknown high-separability clusters (a real attacker-grade sniffer, or an unenrolled person whose identity is unexpectedly leaky) still trigger `Recalibrate` as designed.
|
||||
|
||||
### 2.7 Compute budget
|
||||
|
||||
| Stage | Target latency | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| Feature extraction (8 features) | < 3 ms per window | ndarray + nalgebra; vectorized over subcarriers |
|
||||
| Separability (cosine to centroids) | < 5 ms per window | RuVector RaBitQ index (ADR-085) over ≤ 1k centroids |
|
||||
| Risk score | < 0.1 ms | scalar multiplicative |
|
||||
| Gate decision + hysteresis | < 0.1 ms | scalar |
|
||||
|
||||
Total p95 ≤ 10 ms per window on a Pi 5 core (8 ms target). Headroom on cognitum-v0 (Pi 5 + Hailo) is ample; ESP32-S3 hosts only the extraction stage (features computed; risk score is host-side per ADR-123). The `SoulMatchOracle` lookup (§2.6) adds < 1 ms when the `soul-signature` feature is enabled (RaBitQ index over enrolled centroids).
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The risk score becomes a first-class diagnostic surface for operators and a structural input to the privacy gate — both consumers from a single computation.
|
||||
- Multiplicative combination is conservative under uncertainty; the system is biased toward "report low risk when unsure", which is the right default.
|
||||
- Calibration is a content-only update — no recompile needed when the calibration file changes.
|
||||
- The recalibration gate action gives the system a self-healing response to a sniffer arrival without operator intervention.
|
||||
|
||||
### Negative
|
||||
|
||||
- Calibration requires the KIT BFId dataset; without it the score is uncalibrated and serves only as an internal trigger, not a publishable signal.
|
||||
- Multiplicative scoring can be dominated by `sample_confidence`, which is sensitive to channel conditions. A persistent low-SNR environment will keep the published score near 0 even when the underlying separability is high — an under-reporting failure mode that the documentation must call out.
|
||||
- The recalibrate action breaks historical hash continuity by design; an operator who wants long-term aggregates needs to know they will see a discontinuity on recalibrate events.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The nine features overlap with the existing CSI pipeline. BFLD computes them on BFI; the CSI pipeline computes them on CSI. Both can be fused via `cross_perspective_consistency`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Additive scoring (`(s + t + p + c) / 4`)
|
||||
|
||||
Rejected: a sample with high separability but very low confidence would still produce a moderate score, which over-reports risk in degraded RF conditions.
|
||||
|
||||
### Alt 2: Maximum scoring (`max(s, t, p, c)`)
|
||||
|
||||
Rejected: over-reports risk because any single high factor pins the output, even if the others contradict it.
|
||||
|
||||
### Alt 3: Learned scoring (a small MLP)
|
||||
|
||||
Rejected for this ADR: introduces an opaque model whose output cannot be audited from first principles. The multiplicative formula is simple, conservative, and directly explainable to operators. A learned model is a future option once enough calibration data is in hand.
|
||||
|
||||
### Alt 4: Per-feature thresholds instead of a continuous score
|
||||
|
||||
Rejected: continuous score is needed for the HA gauge entity and for downstream calibration. Per-feature thresholds would force operators to interpret nine separate binaries.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: All nine features are computed in `< 8 ms` p95 per window on a Pi 5 core.
|
||||
- [ ] **AC2**: `identity_risk_score` is monotonic non-decreasing in any single input when the other three are held constant.
|
||||
- [ ] **AC3**: Calibration regression on the KIT BFId test split: `score ≥ 0.8` corresponds to ≥ 80% re-ID accuracy ± 5%.
|
||||
- [ ] **AC4**: The coherence gate emits `Recalibrate` if score is ≥ 0.9 for ≥ 5 seconds.
|
||||
- [ ] **AC5**: Hysteresis prevents action oscillation across ± 0.05 of a threshold within a 5-second window.
|
||||
- [ ] **AC6**: At `privacy_class = 3`, the risk score is computed but not published to MQTT (kept local for the gate only).
|
||||
- [ ] **AC7**: A reproducible 1,000-frame synthetic fixture produces a deterministic score sequence (bit-identical across runs).
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-118 (umbrella)
|
||||
- ADR-024 (AETHER encoder for separability)
|
||||
- ADR-029 (`coherence_gate.rs` precedent)
|
||||
- ADR-086 (edge novelty gate pattern)
|
||||
- ADR-120 §2.4 (class transition consumed by gate)
|
||||
- KIT BFId dataset: https://publikationen.bibliothek.kit.edu/1000185756
|
||||
@@ -0,0 +1,210 @@
|
||||
# ADR-122: BFLD RuView Surface — Home Assistant, Matter, MQTT Exposure
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first), [ADR-100](ADR-100-cog-packaging-specification.md) (cog packaging), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO + HA-MIND), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter cog), [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md) (privacy class) |
|
||||
| **Companion research** | [`docs/research/soul/`](../research/soul/) — Soul Signature deployments expose enrolled-match diagnostics only over HA, never Matter. See §2.7. |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-115 shipped the RuView Home Assistant surface (21 entities, MQTT auto-discovery, mTLS, privacy mode) on the `wifi-densepose-sensing-server` Rust binary. ADR-116 is packaging this as the `cog-ha-matter` Cognitum Seed cog. BFLD must integrate into this surface without expanding the privacy-sensitive footprint already in production.
|
||||
|
||||
The integration must:
|
||||
|
||||
1. **Extend HA-DISCO** to advertise BFLD entities via the existing MQTT-discovery scheme.
|
||||
2. **Reject identity fields at the Matter boundary** — Matter exposes occupancy/motion/people-count only, never `identity_risk_score` or `rf_signature_hash`.
|
||||
3. **Route MQTT topics by privacy class** — class-2/3 events on the public topic tree, class-1 events on a gated `research/` subtree, class-0 events nowhere.
|
||||
4. **Federate cleanly into cognitum-v0** — BFLD events from multiple nodes flow through `cognitum-rvf-agent` (port 9004 per CLAUDE.local.md) for cross-node analytics, but identity-derived fields are stripped at the **publishing-node boundary**, not at the federation hub.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 HA entity surface (six new entities per node)
|
||||
|
||||
The cog republishes the existing 21 ADR-115 entities and adds:
|
||||
|
||||
| Entity ID | Type | Source field | Class gate | Diagnostic |
|
||||
|-----------|------|--------------|------------|------------|
|
||||
| `binary_sensor.<node>_bfld_presence` | occupancy | `BfldEvent.presence` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_motion` | gauge `[0,1]` | `BfldEvent.motion` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_person_count` | int | `BfldEvent.person_count` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_zone_activity` | enum | `BfldEvent.zone_activity` | ≥ 2 | no |
|
||||
| `sensor.<node>_bfld_identity_risk` | gauge `[0,1]` | `BfldEvent.identity_risk_score` | == 2 only | **yes** |
|
||||
| `sensor.<node>_bfld_confidence` | gauge `[0,1]` | `BfldEvent.confidence` | ≥ 2 | yes |
|
||||
|
||||
The `identity_risk` entity is exposed only under privacy class 2 and is flagged `entity_category: diagnostic` so HA dashboards do not promote it to a main-card sensor by default. Under class 3 it is computed but not published (per ADR-121 §2.4).
|
||||
|
||||
MQTT discovery payload follows the ADR-115 schema, plus a `bfld_version` attribute matching the `BfldFrameHeader::version` field.
|
||||
|
||||
### 2.2 MQTT topic tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/presence/state # class >= 2
|
||||
ruview/<node_id>/bfld/motion/state # class >= 2
|
||||
ruview/<node_id>/bfld/person_count/state # class >= 2
|
||||
ruview/<node_id>/bfld/zone_activity/state # class >= 2
|
||||
ruview/<node_id>/bfld/confidence/state # class >= 2
|
||||
ruview/<node_id>/bfld/identity_risk/state # class == 2 only
|
||||
ruview/<node_id>/bfld/raw # class 1, OFF by default
|
||||
ruview/<node_id>/bfld/availability # online/offline marker
|
||||
```
|
||||
|
||||
`raw` (class-1 derived BFI) is **not present** in the discovery payload at all — operators must explicitly subscribe and acknowledge the research-mode caveat. The publishing crate emits `MQTT_RAW_DISABLED` to availability when `privacy_class < 1`.
|
||||
|
||||
### 2.3 Mosquitto ACL example
|
||||
|
||||
```
|
||||
# Default-deny everything not explicitly granted
|
||||
pattern read ruview/+/bfld/+/state
|
||||
pattern read ruview/+/bfld/availability
|
||||
|
||||
# Public roles cannot read identity_risk or raw
|
||||
user public
|
||||
deny read ruview/+/bfld/identity_risk/state
|
||||
deny read ruview/+/bfld/raw
|
||||
|
||||
# Operator role can read identity_risk for diagnostics
|
||||
user operator
|
||||
allow read ruview/+/bfld/identity_risk/state
|
||||
|
||||
# Research role can read raw (requires class-1 operation)
|
||||
user research
|
||||
allow read ruview/+/bfld/raw
|
||||
```
|
||||
|
||||
The cog ships a default ACL template under `cog-ha-matter/etc/mosquitto.acl.d/bfld.conf` for operators who use the embedded broker (ADR-116 §2.2).
|
||||
|
||||
### 2.4 Matter cluster boundary
|
||||
|
||||
`cog-ha-matter` exposes BFLD via **three Matter clusters** only:
|
||||
|
||||
| Matter cluster | Source entity | Notes |
|
||||
|---|---|---|
|
||||
| Occupancy Sensing (0x0406) | `binary_sensor.<node>_bfld_presence` | reports binary occupancy + uncertainty (mapped from `confidence`) |
|
||||
| Boolean State (0x0045) | `sensor.<node>_bfld_motion >= 0.3` | thresholded; raw motion not exposed |
|
||||
| Occupancy Sensing extension | `sensor.<node>_bfld_person_count` | uses occupancy-sensor count where Matter spec supports |
|
||||
|
||||
**Explicitly NOT exposed via Matter**:
|
||||
|
||||
- `identity_risk_score`
|
||||
- `rf_signature_hash`
|
||||
- `identity_embedding`
|
||||
- `raw` BFI
|
||||
- `zone_activity` (zone IDs are site-specific and Matter is a cross-site surface)
|
||||
- `confidence` (HA-only diagnostic)
|
||||
|
||||
The Matter filter is implemented in `cog-ha-matter/src/matter/bfld_filter.rs` as a `MatterSink` trait impl that rejects classes 0 and 1 at compile time (via ADR-120 §2.2 marker types).
|
||||
|
||||
### 2.5 Federation with cognitum-v0
|
||||
|
||||
`cognitum-rvf-agent` (port 9004) receives BFLD events from multiple nodes. The events arriving at the federation hub are **already class-2/3** — identity-derived fields were stripped at each publishing node. The hub does not see and cannot reconstruct raw BFI or identity embeddings.
|
||||
|
||||
The federation contract:
|
||||
|
||||
| At publishing node | At cognitum-rvf-agent |
|
||||
|---|---|
|
||||
| Strip class-0/1 fields per ADR-120 | Receive class-2/3 events only |
|
||||
| Rotate `rf_signature_hash` per ADR-120 §2.3 | Aggregate counts; **do not** correlate hashes across sites |
|
||||
| Sign event with node Ed25519 key | Verify signature; reject unsigned events |
|
||||
|
||||
A `federation-witness` script (extending ADR-028) runs nightly on the hub and proves that no class-0/1 fields appeared in any received event over the previous 24 h.
|
||||
|
||||
### 2.6 HA blueprints (shipped with the cog)
|
||||
|
||||
Three operator-ready blueprints under `cog-ha-matter/blueprints/`:
|
||||
|
||||
1. **Presence-driven lighting** — `binary_sensor.*_bfld_presence` ⇒ `light.turn_on/off` with configurable hold time.
|
||||
2. **Motion-aware HVAC** — `sensor.*_bfld_motion > 0.3` ⇒ raise HVAC setpoint by ΔT.
|
||||
3. **Identity-risk anomaly notification** — `sensor.*_bfld_identity_risk` exceeds rolling z-score threshold ⇒ HA `notify.*` to the operator with the originating node and the 7-day baseline.
|
||||
|
||||
### 2.7 Soul Signature deployment posture
|
||||
|
||||
When the cog is compiled with `--features soul-signature`, two additional HA entities are exposed **at class 1 only**, and **never** over Matter:
|
||||
|
||||
| Entity ID | Type | Source | Class gate | Matter |
|
||||
|-----------|------|--------|------------|--------|
|
||||
| `sensor.<node>_soul_match_id` | string (opaque `person_id`) | Soul Signature match oracle | == 1 only | **rejected** |
|
||||
| `sensor.<node>_soul_match_score` | gauge `[0,1]` | Match similarity | == 1 only | **rejected** |
|
||||
| `sensor.<node>_soul_enrollment_quality` | gauge `[0,1]` | Mirror of `identity_risk_score` during enrollment | == 1 only | **rejected** |
|
||||
|
||||
These entities are part of the consent-based diagnostic surface for operators running Soul Signature deployments (care homes with explicit GDPR Art. 9 basis, employment with consent, etc.). The Matter cluster boundary in §2.4 already rejects them by type — the `MatterSink` impl only accepts class-2/3 frames, so `soul_match_id` is structurally unreachable through Matter.
|
||||
|
||||
Class-3 deployments **disable Soul Signature** entirely: the `match_against_enrolled()` call returns `MatchOutcome::Suppressed` and no soul entities are published. This makes class 3 the correct setting for any deployment where consent is uncertain or where regulators require Soul Signature to be unavailable.
|
||||
|
||||
A fourth blueprint ships only when `--features soul-signature` is enabled:
|
||||
|
||||
4. **Enrolled-person arrival notification** — `sensor.*_soul_match_id` transitions to a non-null value ⇒ HA `notify.*` to the enrolled person's configured contact (typically themselves or a designated caregiver). Default off; operator must opt in per enrolled person.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Six new HA entities give operators a complete BFLD diagnostic dashboard without leaking identity.
|
||||
- Matter exposure is structurally narrow — the cluster-filter implementation cannot accidentally expose identity fields because the type system rejects them.
|
||||
- The default ACL template gives operators a working privacy posture out of the box.
|
||||
- The federation contract makes it explicit that the hub cannot reconstruct identity even from the union of all node events.
|
||||
|
||||
### Negative
|
||||
|
||||
- The `identity_risk` HA entity exists only under class 2. Operators who run class 3 deployments cannot see the score even in their own dashboard. This is correct but may surprise care-home installers; documentation must be clear.
|
||||
- Three Matter clusters is conservative — some HA users may want the count exposed as a percentage or rate, which Matter does not support natively.
|
||||
- HA-blueprint coverage is intentionally small; operators wanting custom automations must work through the YAML surface.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The federation witness script runs nightly. A short-duration leak between witnesses is possible but bounded — any successful exfiltration of class-1 fields would still need to be reconstructed into identity, which the daily hash rotation breaks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Expose `identity_risk` over Matter (Generic Sensor cluster)
|
||||
|
||||
Rejected: Matter is a cross-vendor surface; exposing identity-risk there leaks the score to every Matter controller in the home, including third-party hubs the operator may not control. Keep it HA-internal.
|
||||
|
||||
### Alt 2: One unified MQTT topic `ruview/<node>/bfld` with JSON payload
|
||||
|
||||
Rejected: per-entity topics are the HA-DISCO convention (ADR-115) and let ACLs be field-specific. A unified topic forces an all-or-nothing read policy.
|
||||
|
||||
### Alt 3: Federate raw BFI to cognitum-v0 for cross-node analytics
|
||||
|
||||
Rejected: violates ADR-120 I1 (raw never leaves the node). Aggregates are sufficient for cross-node analytics; raw centralization is a hard no.
|
||||
|
||||
### Alt 4: Default `entity_category: diagnostic = false` for `identity_risk`
|
||||
|
||||
Rejected: promoting `identity_risk` to a main-card sensor would surprise operators with an identity-adjacent gauge on their main dashboard. Diagnostic category is the right default.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: HA auto-discovery publishes six new entities per node on first connect; HA recognizes all six.
|
||||
- [ ] **AC2**: Under privacy class 3, `sensor.<node>_bfld_identity_risk` is absent from the MQTT discovery payload.
|
||||
- [ ] **AC3**: `MatterSink::publish` rejects any frame at compile time when the source has `privacy_class < 2`.
|
||||
- [ ] **AC4**: The default mosquitto ACL denies `read ruview/+/bfld/identity_risk/state` to the `public` user role.
|
||||
- [ ] **AC5**: Three HA blueprints install cleanly into a fresh HA install and trigger their configured actions against a mock BFLD event stream.
|
||||
- [ ] **AC6**: The federation-witness script detects an injected class-1 field in a synthetic event and exits non-zero.
|
||||
- [ ] **AC7**: Matter occupancy-sensing cluster reports presence within 1 s of an HA `binary_sensor.*_bfld_presence` state change.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-115 (HA-DISCO entity scheme)
|
||||
- ADR-116 (`cog-ha-matter` cog packaging)
|
||||
- ADR-120 (privacy class enforcement)
|
||||
- ADR-121 (identity risk source)
|
||||
- ADR-100 (cog packaging spec)
|
||||
- Mosquitto ACL reference: https://mosquitto.org/man/mosquitto-conf-5.html
|
||||
- Matter spec — Occupancy Sensing cluster (0x0406)
|
||||
- Cognitum V0 appliance dashboard: `http://cognitum-v0:9000/`
|
||||
@@ -0,0 +1,186 @@
|
||||
# ADR-123: BFLD Capture Path — Pi 5 / Nexmon Adapter and ESP32-S3 Feasibility
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Parent** | [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) |
|
||||
| **Relates to** | [ADR-022](ADR-022-multi-bssid-wifi-scanning.md) (multi-BSSID scan), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-096](ADR-096-rvcsi-ffi-crate-layout.md) (rvCSI FFI), [ADR-110](ADR-110-esp32-c6-firmware-extension.md) (C6 firmware), [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) (BfldFrame) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
ADR-118 declares that BFLD captures BFI from commodity WiFi 5/6 traffic. The question this sub-ADR answers is: **on which hardware, with which adapter, and against which firmware limitations**.
|
||||
|
||||
### 1.1 ESP32-S3 BFI capability gap
|
||||
|
||||
The ESP32 capability audit (ADR-028) and the ESP32-S3 / C6 firmware (`firmware/esp32-csi-node/`, ADR-110) confirm that the Espressif WiFi API exposes **CSI** capture (`esp_wifi_set_csi_*`) but does not expose **raw 802.11 management-frame capture** in monitor mode for non-self-addressed CBFR reports. The S3 sees the CBFR frames its own AP-link generates (when it acts as a beamformer), but it cannot promiscuously sniff CBFR frames between other STA/AP pairs in the neighborhood.
|
||||
|
||||
The C6 (ESP32-C6 with RISC-V + Wi-Fi 6) has a more flexible RF subsystem but the same software-API constraint at the time of writing.
|
||||
|
||||
### 1.2 Pi 5 / Nexmon as the production capture host
|
||||
|
||||
The rvCSI platform (ADR-095/096) already vendors a Nexmon-based adapter (`rvcsi-adapter-nexmon`) that captures CSI from BCM43455c0 chips (Pi 5 / Pi 4 / Pi 3B+). Nexmon patches the firmware to surface CSI to userspace and **also surface CBFR frames** — the BFI extension is the same code path with a different filter.
|
||||
|
||||
cognitum-v0 (Pi 5 in the fleet, per CLAUDE.local.md) is already running Nexmon + the rvCSI runtime. It is the natural BFLD capture host.
|
||||
|
||||
### 1.3 What we need from each hardware tier
|
||||
|
||||
| Tier | Role | BFI capture | CSI capture | Notes |
|
||||
|------|------|-------------|-------------|-------|
|
||||
| ESP32-S3 / C6 | Sensing leaf | **no** | yes | Continues providing CSI to the existing pipeline |
|
||||
| Pi 5 / Nexmon | BFLD host | **yes** | yes (via Nexmon) | Primary BFLD capture |
|
||||
| ruvultra (RTX 5080 + AX210) | Training / dev | yes (via AX210 monitor mode) | yes | Dev capture; not production |
|
||||
| cognitum-v0 (Pi 5) | Appliance | **yes** (production) | yes | Production BFLD host |
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 Production capture path: Pi 5 / Nexmon
|
||||
|
||||
The BFLD production capture path is implemented as a new module in the vendored rvCSI submodule:
|
||||
|
||||
```
|
||||
vendor/rvcsi/crates/rvcsi-adapter-nexmon/
|
||||
└── src/
|
||||
├── lib.rs
|
||||
├── csi.rs # existing CSI capture
|
||||
└── bfi.rs # NEW — CBFR capture, exports BfiCapture
|
||||
```
|
||||
|
||||
The new `bfi.rs` parses CBFR frames (VHT or HE) from the Nexmon-patched firmware's userspace stream, extracts Φ/ψ angle matrices, and emits a `BfiCapture` struct that feeds the BFLD crate's extractor (ADR-118 §2.1, ADR-119).
|
||||
|
||||
The patch lives in the rvcsi submodule (`github.com/ruvnet/rvcsi`) and is shipped as `rvcsi-adapter-nexmon ^0.3.5` to crates.io. The wifi-densepose workspace consumes the published crate (or the submodule path during development).
|
||||
|
||||
### 2.2 BFLD crate adapter trait
|
||||
|
||||
`wifi-densepose-bfld` defines a `BfiCaptureAdapter` trait:
|
||||
|
||||
```rust
|
||||
pub trait BfiCaptureAdapter: Send + 'static {
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
fn capture(&mut self) -> Result<Option<BfiCapture>, Self::Error>;
|
||||
fn capabilities(&self) -> AdapterCapabilities;
|
||||
}
|
||||
|
||||
pub struct AdapterCapabilities {
|
||||
pub supports_he: bool, // 802.11ax (Wi-Fi 6)
|
||||
pub supports_160mhz: bool,
|
||||
pub max_n_rx: u8,
|
||||
pub host_kind: HostKind, // Pi5Nexmon | Ax210Linux | EspS3Local | Mock
|
||||
}
|
||||
```
|
||||
|
||||
Three impls ship initially:
|
||||
|
||||
- `NexmonBfiAdapter` — Pi 5 / Nexmon (production)
|
||||
- `Ax210BfiAdapter` — Linux + AX210 in monitor mode (dev / training, ruvultra)
|
||||
- `MockBfiAdapter` — replay fixture for tests and CI
|
||||
|
||||
A future fourth impl (`EspS3LocalAdapter`) is reserved for the day Espressif exposes promiscuous CBFR — it captures only the S3's own AP-link BFI for local self-reporting.
|
||||
|
||||
### 2.3 Capture-side privacy boundary
|
||||
|
||||
Per ADR-120 I1, raw BFI never leaves the capturing host. The adapter must therefore live on **the same physical box** as the BFLD crate's extractor and privacy gate. The architecture pattern:
|
||||
|
||||
```
|
||||
[ Pi 5 / cognitum-v0 ]
|
||||
├── nexmon firmware (kernel)
|
||||
├── rvcsi-adapter-nexmon (userspace, captures BFI)
|
||||
├── wifi-densepose-bfld (extracts, scores, gates)
|
||||
│ └── privacy_gate → class-2/3 frames only
|
||||
└── wifi-densepose-sensing-server (publishes MQTT + Matter)
|
||||
```
|
||||
|
||||
A network-mode adapter that streams raw BFI from a remote capture host is **explicitly forbidden**. The adapter trait does not include any "remote URL" parameter.
|
||||
|
||||
### 2.4 Channel / bandwidth coverage
|
||||
|
||||
The Nexmon adapter is configured by the existing `rvcsi-adapter-nexmon` channel-hopping schedule (ADR-095 §3.2). For BFLD it adds:
|
||||
|
||||
- Filter for VHT CBFR (action frame, category 21, action 0) and HE CBFR (category 30, action 0).
|
||||
- Per-channel BFI session-tracking — the same beamformer/beamformee pair across a channel hop is reconciled by AP MAC + STA MAC.
|
||||
|
||||
### 2.5 ESP32-S3 local self-reporting (deferred)
|
||||
|
||||
For deployments without a Pi 5 / cognitum-v0 nearby, a degraded BFLD mode runs on the ESP32-S3 itself:
|
||||
|
||||
- Captures only its own AP-link CBFR (self-addressed).
|
||||
- Computes features over the limited window.
|
||||
- Reports a coarsened `presence` + `motion` only — no `identity_risk_score` (insufficient sample diversity).
|
||||
- Emits `BfldFrame` at `privacy_class = 2` with a `flags.bit3 = self_only` marker.
|
||||
|
||||
This path is implemented in firmware as part of P2 / P3 of the ADR-118 rollout, after the Pi 5 path is stable. Effort is small (firmware path reuses the existing CSI capture loop) but the value is also low until ESP32 firmware exposes promiscuous CBFR — which is a Espressif-IDF roadmap item, not under project control.
|
||||
|
||||
### 2.6 Dev path: ruvultra / AX210
|
||||
|
||||
For local dev iteration on the Windows / ruvultra box, the AX210 adapter provides a workable capture path on Linux (ruvultra is Ubuntu 6.17 per CLAUDE.local.md). The AX210 supports 802.11ax + monitor mode with the `iwlwifi` driver patches that have landed upstream. This path is for training-data collection and dev testing, not production.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- BFLD ships as a production-ready surface on cognitum-v0 day one — no new hardware procurement.
|
||||
- The adapter-trait design lets new capture paths (AX211, MediaTek Filogic, etc.) slot in without changes to the BFLD crate.
|
||||
- The capture-side privacy boundary is structural: there is no remote-capture code path, so a future PR cannot accidentally introduce one.
|
||||
- ruvultra's AX210 path unblocks training and dev iteration on Linux without depending on the Pi 5 fleet.
|
||||
|
||||
### Negative
|
||||
|
||||
- BFLD's full pipeline depends on cognitum-v0 (or another Pi 5 / Nexmon host) being present in the deployment. Operators without a Pi 5 get only the degraded ESP32-S3 self-reporting path (limited utility).
|
||||
- Nexmon is a third-party kernel module; tracking upstream patches is ongoing maintenance.
|
||||
- The CBFR frame format differs between VHT (802.11ac) and HE (802.11ax); the parser must support both, and any 802.11be (Wi-Fi 7) deployment will require an additional parser path.
|
||||
|
||||
### Neutral
|
||||
|
||||
- ruvultra dev path uses AX210; the AX210 is not the production NIC, so dev/prod parity is via the fixture replay + the Nexmon adapter on cognitum-v0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Centralized capture host streams raw BFI to RuView nodes
|
||||
|
||||
Rejected: violates ADR-120 I1 (raw never leaves the capture host). The capture host **is** the BFLD node; there is no separation.
|
||||
|
||||
### Alt 2: Wait for Espressif promiscuous CBFR support
|
||||
|
||||
Rejected: indefinite timeline outside project control. The Pi 5 / Nexmon path is shippable today.
|
||||
|
||||
### Alt 3: Custom Pi 5 firmware fork instead of Nexmon
|
||||
|
||||
Rejected: forking BCM firmware is a huge maintenance burden and Nexmon already does what we need.
|
||||
|
||||
### Alt 4: Only ship the ESP32-S3 self-reporting path
|
||||
|
||||
Rejected: insufficient sample diversity for `identity_risk_score`. The whole point of BFLD is to measure identity leakage; a self-only path cannot do that meaningfully.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `NexmonBfiAdapter` captures ≥ 100 valid CBFR frames per minute from a 2-AP-3-STA test bench on a Pi 5 (cognitum-v0).
|
||||
- [ ] **AC2**: VHT (802.11ac) and HE (802.11ax) CBFR frames are both parsed; mixed-PHY captures produce correctly-typed `BfiCapture` outputs.
|
||||
- [ ] **AC3**: 20/40/80/160 MHz channel widths are all supported (one fixture each in `tests/`).
|
||||
- [ ] **AC4**: `BfiCaptureAdapter` trait has no method accepting a remote URL or socket address.
|
||||
- [ ] **AC5**: ESP32-S3 self-only adapter compiles `#[no_std]` and produces a `BfldFrame` with `flags.bit3 = self_only` set, no `identity_risk_score` field.
|
||||
- [ ] **AC6**: AX210 adapter on ruvultra captures CBFR for at least one fixture-generating dev session.
|
||||
- [ ] **AC7**: Capture loop sustains 10 Hz BFI frame rate on cognitum-v0 without dropping frames over a 10-minute soak test.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-095 / ADR-096 (rvCSI Nexmon adapter)
|
||||
- ADR-028 (ESP32 capability audit)
|
||||
- ADR-110 (ESP32-C6 firmware)
|
||||
- Nexmon BCM43455c0 patches: https://github.com/seemoo-lab/nexmon
|
||||
- Wi-BFI: https://arxiv.org/abs/2309.04408
|
||||
- IEEE 802.11-2020 §19.3.12 (VHT CBFR), §27.3.11 (HE CBFR)
|
||||
- cognitum-v0 fleet entry: `CLAUDE.local.md` (Tailscale fleet table)
|
||||
@@ -0,0 +1,64 @@
|
||||
# PyPI release runbook — `wifi-densepose` + `ruview`
|
||||
|
||||
Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
|
||||
|
||||
## Auth
|
||||
|
||||
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`.
|
||||
It's a project-token issued by the rUv PyPI account with upload
|
||||
scope for both `wifi-densepose` and `ruview`.
|
||||
|
||||
## Refreshing the token
|
||||
|
||||
The canonical copy of the token lives in GCP Secret Manager,
|
||||
project `cognitum-20260110`, entry name `PYPI_TOKEN`. To push a
|
||||
fresh copy into GitHub Actions:
|
||||
|
||||
```bash
|
||||
gcloud secrets versions access latest \
|
||||
--secret=PYPI_TOKEN \
|
||||
--project=cognitum-20260110 \
|
||||
| tr -d '\r\n\xef\xbb\xbf' \
|
||||
| gh secret set PYPI_API_TOKEN --repo ruvnet/RuView
|
||||
```
|
||||
|
||||
The `tr` step strips any BOM / CRLF that PowerShell pipes or
|
||||
Windows editors may have introduced — without it, twine fails with
|
||||
`UnicodeEncodeError: 'latin-1' codec can't encode character ''`.
|
||||
|
||||
## Triggering a release
|
||||
|
||||
Two paths:
|
||||
|
||||
- **Tag push** — `git tag v2.X.Y-pip && git push origin v2.X.Y-pip` —
|
||||
publishes the v2 wheel matrix. `v1.99.0-pip` triggers the tombstone
|
||||
job instead.
|
||||
- **Manual dispatch** — `gh workflow run pip-release.yml --ref <branch>
|
||||
-f target=v2-wheels -f publish_to=pypi`. Use `publish_to=testpypi`
|
||||
for a dry-run target if a TestPyPI token is also set as
|
||||
`TESTPYPI_API_TOKEN`.
|
||||
|
||||
## Release-day sequence
|
||||
|
||||
Per ADR-117 §7.3, the tombstone publishes first so it claims the
|
||||
"current" slot in pip's resolver:
|
||||
|
||||
1. `git tag v1.99.0-pip && git push origin v1.99.0-pip` →
|
||||
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
|
||||
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
|
||||
wifi_densepose"` → ImportError with migration URL.
|
||||
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel
|
||||
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`.
|
||||
4. (Optional, in lock-step) build + publish a matching `ruview`
|
||||
release from `python/ruview-meta/` so the meta-package version
|
||||
stays pinned to the same wifi-densepose version.
|
||||
|
||||
## Off-loop manual gates
|
||||
|
||||
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
|
||||
from the v2 Rust pipeline before any v2 publish.
|
||||
- **OIDC Trusted Publisher** — not used. The workflow is token-based;
|
||||
this is a deliberate choice to keep the secret refresh entirely in
|
||||
GCP. If the project migrates to OIDC later, remove `password:`
|
||||
from `pypa/gh-action-pypi-publish` calls and add the publisher
|
||||
registration on pypi.org.
|
||||
@@ -0,0 +1,358 @@
|
||||
---
|
||||
title: "ADR-116 Research: Home Assistant + Matter Cognitum Seed Cog"
|
||||
date: 2026-05-23
|
||||
author: ruv
|
||||
status: research-complete
|
||||
relates-to: ADR-110, ADR-115
|
||||
sources:
|
||||
- https://csa-iot.org/newsroom/matter-1-4-enables-more-capable-smart-homes/
|
||||
- https://csa-iot.org/newsroom/matter-1-4-2-enhancing-security-and-scalability-for-smart-homes/
|
||||
- https://docs.espressif.com/projects/esp-matter/en/latest/esp32c6/certification.html
|
||||
- https://docs.espressif.com/projects/esp-matter/en/latest/esp32s3/optimizations.html
|
||||
- https://matter-survey.org/cluster/0x0406
|
||||
- https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/
|
||||
- https://www.hacs.xyz/docs/publish/integration/
|
||||
- https://www.derekseaman.com/2025/11/aqara-fp300-the-ultimate-presence-sensor-home-assistant-edition.html
|
||||
- https://www.tommysense.com/
|
||||
- https://github.com/francescopace/espectre
|
||||
- https://kendallpc.com/fdas-2026-guidance-on-general-wellness-devices-policy-for-low-risk-devices-key-compliance-and-regulatory-insights-for-digital-health-companies/
|
||||
- https://www.troutman.com/insights/fdas-2026-guidance-on-general-wellness-devices-policy-for-low-risk-devices/
|
||||
- https://community.st.com/t5/stm32-summit-q-a/what-is-the-usual-cost-for-a-matter-certification/td-p/652346
|
||||
- https://github.com/p01di/esp32c6-thread-border-router
|
||||
- https://libraries.io/npm/ruvllm-esp32
|
||||
- https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-069-cognitum-seed-csi-pipeline.md
|
||||
- https://www.matteralpha.com/news/home-assistant-2025-12-adds-enhancements-to-matter-sensor-doorlock-and-covering
|
||||
- https://docs.nordicsemi.com/bundle/ncs-3.1.0/page/nrf/protocols/matter/getting_started/testing/thread_one_otbr.html
|
||||
---
|
||||
|
||||
# ADR-116 Research Dossier: Home Assistant + Matter Integration as a Cognitum Seed Cog
|
||||
|
||||
**Research question**: How far can we take HA + Matter integration for WiFi-DensePose / RuView, specifically packaged as a Cognitum Seed cog running on the ESP32-S3 Seed appliance?
|
||||
|
||||
**Baseline**: ADR-110 (ESP32-C6 mesh firmware, v0.7.0-esp32) and ADR-115 (HA-DISCO MQTT + HA-FABRIC Matter scaffold, v0.7.0) are both merged to main. This research scopes ADR-116.
|
||||
|
||||
---
|
||||
|
||||
## 1. Matter / Thread Frontier
|
||||
|
||||
### 1.1 Current specification state (May 2026)
|
||||
|
||||
Matter 1.4 (released November 2024) added Solar Power, Battery Storage, Heat Pump, Water Heater, and Mounted Load Control device types — primarily energy-management expansion. It did NOT add health, wellness, vitals, or biometric device types. The cluster relevant to WiFi-DensePose is the **Occupancy Sensing cluster (0x0406)**, which has been present since Matter 1.1 and reached revision 5 in Matter 1.4.
|
||||
|
||||
Matter 1.4.2 (current patch release as of research date) focused on security: vendor-ID cryptographic verification of Fabric Admins, Access Restriction Lists (ARLs) for network infrastructure devices, Certificate Revocation Lists (CRLs), and Wi-Fi-only commissioning without BLE. The Wi-Fi-only commissioning path (no BLE requirement) is directly relevant to the Seed, which hosts its own AMOLED UI and can display QR codes natively.
|
||||
|
||||
**Occupancy Sensing cluster 0x0406 feature flags** (Matter 1.4 revision 5): PIR, Ultrasonic, PhysicalContact, ActiveInfrared, **Radar**, **RFSensing**, Vision, Prediction, OccupancyEvent. The `RFSensing` feature flag added in 1.3 is the correct semantic tag for CSI-based WiFi detection — we are not PIR or Radar in the classical sense. Home Assistant 2025.12 added configurable `HoldTime` for occupancy sensors and support for `CurrentSensitivityLevel`, both attributes our MQTT path already carries.
|
||||
|
||||
**Breathing rate and heart rate have no Matter cluster today.** The spec does not define a BiomedicalSensing or VitalSigns device type. Until the CSA adds one (no public work item found as of May 2026), vitals must stay on MQTT. This is a hard architectural constraint for the Matter path.
|
||||
|
||||
### 1.2 Thread Border Router on ESP32-C6
|
||||
|
||||
The ESP32-C6 carries 802.15.4 natively (the same radio used for Thread and Zigbee). Espressif ships a working single-chip Thread Border Router reference design for C6 in `esp-matter`, confirmed by community hardware tests (p01di/esp32c6-thread-border-router on GitHub). The C6 can operate as a Thread BR while simultaneously sensing on 2.4 GHz Wi-Fi — the two radios share the same front-end but schedule airtime independently under ESP-IDF. ADR-110 already initializes the 802.15.4 subsystem (`c6_timesync.c`) for cross-node time sync; adding TBR functionality is a matter of enabling `CONFIG_OPENTHREAD_BORDER_ROUTER=y` in the C6 sdkconfig overlay, adding the `esp_openthread_border_router_init()` call, and exposing the backbone interface (Wi-Fi STA).
|
||||
|
||||
**Thread 1.4 (TREL)**, shipped with Apple tvOS 26 in late 2025, adds Thread Radio Encapsulation Link — Thread traffic tunneled over Wi-Fi as a fallback backhaul. The C6's Wi-Fi 6 radio supports this. TREL removes the hard dependency on a BR for cross-subnet Thread commissioning, which means a C6-equipped Seed node could participate in a Thread fabric without a dedicated BR appliance.
|
||||
|
||||
### 1.3 Matter Commissioner / Root mode
|
||||
|
||||
In Matter terms, a Commissioner is a distinct role from an Accessory (end device) or Bridge. The Matter spec allows a device to be simultaneously a Fabric member (commissioned) and a Commissioner (able to commission other devices). The `chip-tool` in `connectedhomeip` is the canonical embeddable commissioner implementation. Running chip-tool on the S3 (512 KB SRAM + 8 MB PSRAM) is feasible but borderline — the commissioner stack requires Thread discovery, BLE central, and certificate-chain verification, adding approximately 400–600 KB RAM footprint on top of the application. On the S3 with 8 MB PSRAM mapped to heap this is tractable; on the C6 (320 KB SRAM, no PSRAM) it is not.
|
||||
|
||||
**Practical recommendation**: the Cognitum Seed (S3 + PSRAM + full appliance OS) is the right place to host a Matter commissioner, not the C6 sensing nodes. The Seed can use its existing bearer-token API surface and its cognitum-fleet process (port 9002) as the orchestration layer that opens commissioning windows and bootstraps C6 nodes into the Fabric. C6 nodes remain Accessories only.
|
||||
|
||||
### 1.4 CSA certification path
|
||||
|
||||
Certification requires: (1) CSA membership (~$22,500/year for full member; lower tiers exist), (2) an Authorized Test Laboratory (ATL) engagement (~$10,000–$19,540 per product for lab fees and certification application), (3) PICS/PIXIT XML submission, (4) hardware shipping to the ATL, and (5) registration on the Distributed Compliance Ledger (DCL). Espressif provides pre-certified radio modules (ESP32-C6-MINI-1, ESP32-S3-MINI-1) which can reduce retesting scope under CSA's Rapid Recertification program — only clusters/device-types added beyond the pre-certified baseline require full ATL re-test. Using `esp-matter` with a pre-certified Espressif module, the realistic total cost for bridge certification is **$30,000–$42,000 first year, $22,500/year thereafter** for a full CSA member, or less if using a pass-through arrangement via an ODM partner that already holds membership.
|
||||
|
||||
**Alternative**: publish as "Works with Home Assistant" (free, no CSA ATL, just integration tests) and defer CSA certification to v1.1 when commercial customers require it. The `RFSensing` device class and OccupancySensing cluster are already well-supported in the HA Matter integration without certification.
|
||||
|
||||
**Key sources**: [Espressif Matter certification guide](https://docs.espressif.com/projects/esp-matter/en/latest/esp32c6/certification.html), [CSA certification process overview](https://csa-iot.org/certification/), [ST community cost discussion](https://community.st.com/t5/stm32-summit-q-a/what-is-the-usual-cost-for-a-matter-certification/td-p/652346), [Nordic Rapid Recertification notes](https://devzone.nordicsemi.com/f/nordic-q-a/116005/csa-iot-rapid-recertification-program), [ESP32-C6 single-chip TBR](https://github.com/p01di/esp32c6-thread-border-router).
|
||||
|
||||
---
|
||||
|
||||
## 2. HACS Distribution
|
||||
|
||||
### 2.1 What HACS unlocks beyond MQTT auto-discovery
|
||||
|
||||
MQTT auto-discovery (HA-DISCO, shipped in ADR-115) creates entities automatically but the integration surface is constrained:
|
||||
|
||||
| Capability | MQTT auto-discovery | HACS Python integration |
|
||||
|---|---|---|
|
||||
| Config flow (UI setup without YAML) | no — user edits MQTT broker settings manually | yes — wizard walks user through seed URL, token, privacy options |
|
||||
| Repairs API | no | yes — surfaces structured error reasons ("node offline", "firmware mismatch") as HA repair cards |
|
||||
| Diagnostics download | no | yes — button in HA device page exports a JSON bundle for bug reports |
|
||||
| Re-authentication flow | no | yes — handles token expiry without user needing to touch YAML |
|
||||
| Device registry deep links | partial — via_device works | yes — full device info page, firmware version, last-seen, signal quality |
|
||||
| Service actions | no | yes — `wifi_densepose.set_privacy_mode`, `wifi_densepose.calibrate_zone` as typed HA services |
|
||||
| Config entry options | no | yes — change polling interval, privacy mode, zone layout from HA UI |
|
||||
| Translations (i18n) | no | yes — strings.json enables localized entity names and setup UI |
|
||||
| Integration quality scale tier | n/a | bronze is minimum; gold (diagnostics + repairs + discovery) is the target |
|
||||
| HACS listing | not applicable | yes — users install via HACS Store in one click |
|
||||
|
||||
### 2.2 Quality Scale targets
|
||||
|
||||
HA's quality scale has four tiers. **Bronze** (19 rules) is the minimum: config_flow, unique entity IDs, test coverage, basic documentation. **Silver** adds 95%+ test coverage and re-authentication. **Gold** adds repairs flows, diagnostics, reconfiguration flows, device categories and translations — this is the target for a v1 HACS integration because it meets the bar set by well-regarded third-party integrations like Z-Wave JS and ESPresense. **Platinum** adds strict typing, async dependency injection, and websession management — worth pursuing but not on the v1 critical path.
|
||||
|
||||
### 2.3 HACS submission requirements
|
||||
|
||||
HACS requires: public GitHub repo, repo description, topic tags, README, single custom component at `custom_components/wifi_densepose/`, `manifest.json` with `domain`, `documentation`, `issue_tracker`, `codeowners`, `name`, `version` fields, and a `brand/icon.png`. No formal approval process — listing is automatic once requirements are met via HACS default repositories submission. HA's `hassfest` CI tool validates the manifest structure and can be added to the repo's CI pipeline as a workflow step.
|
||||
|
||||
The `hacs.integration_blueprint` template (github.com/jpawlowski/hacs.integration_blueprint) provides a well-maintained starting point with all boilerplate including config flow, repairs, diagnostics, and translations scaffolding.
|
||||
|
||||
**Key sources**: [HA quality scale rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/), [HACS publish guide](https://www.hacs.xyz/docs/publish/integration/), [HACS 2.0 announcement](https://www.home-assistant.io/blog/2024/08/21/hacs-the-best-way-to-share-community-made-projects-just-got-better/), [integration blueprint](https://github.com/jpawlowski/hacs.integration_blueprint).
|
||||
|
||||
---
|
||||
|
||||
## 3. Cog Architecture for the Seed
|
||||
|
||||
### 3.1 Current cog packaging model
|
||||
|
||||
Based on ADR-069 and the cognitum-v0 appliance surface observed in the fleet:
|
||||
|
||||
- Cogs are signed binaries distributed via GCS buckets and cataloged at `GET /api/v1/edge/registry` (ADR-102).
|
||||
- Each binary is verified against an **Ed25519 signature** before installation (ADR-100). The device-bound keypair lives in NVS on the Seed.
|
||||
- Cog binaries are platform-specific: `aarch64` for Pi-based Seed appliances, `x86_64` for the desktop appliance, and (from ADR-069) the feature-vector packet format (`edge_feature_pkt_t`, magic `0xC5110003`) defines the ESP32 side of the protocol. The cog runs on the Seed appliance, not directly on the ESP32.
|
||||
- The registry catalog at `seed.cognitum.one/store` lists 105 cogs with capability declarations. The Seed's `cognitum-ota-registry` (port 9003) handles OTA delivery.
|
||||
- Capability declarations include dependency lists, required Seed version, permission scopes (network, storage, MCP tool invocations), and resource budgets (max RAM, max CPU).
|
||||
|
||||
### 3.2 Proposed HA+Matter cog architecture
|
||||
|
||||
The cog runs as a long-lived process on the Seed (aarch64 binary, supervised by `cognitum-agent`). It owns two surfaces:
|
||||
|
||||
**Surface A — MQTT bridge**: connects to a user-configured Mosquitto broker (or uses the Seed's internal broker), republishes telemetry from the Seed's `ruview-vitals-worker` (port 50054) as HA auto-discovery messages. This reuses the HA-DISCO logic already in `wifi-densepose-sensing-server` but runs as a Seed-native cog rather than requiring the user to run the sensing-server separately. The cog registers a `ha_mqtt` MCP tool (114-tool catalog) so automations running on other cogs can call `ha_mqtt.publish_state(entity_id, state)`.
|
||||
|
||||
**Surface B — Matter bridge**: wraps `esp-matter` / `matter-rs` as a Matter Accessory Bridge. The Seed acts as a WiFi-connected Matter Bridge — one Fabric node with N dynamic endpoints, one per sensing zone. Device types used: `OccupancySensor` (0x0107, clusters: `OccupancySensing 0x0406` with `RFSensing` feature flag + `BooleanState 0x0045`), `ContactSensor` for fall events, and a vendor-specific numeric attribute for person count on the Bridge root endpoint. The Seed's AMOLED display shows the Matter QR code for commissioning — no phone or scanning app required.
|
||||
|
||||
**Surface C — HA HACS integration (optional for users without MQTT)**: a Python package in `custom_components/wifi_densepose/` that speaks directly to the Seed's REST API (`/api/v1/`, bearer token from cognitum-agent on port 80) and bootstraps config flow, entities, repairs, and diagnostics as described in §2.
|
||||
|
||||
**Deployment topology**: Seed acts as hub for all sensing nodes (ESP32-S3 and C6). Nodes stream feature vectors to the Seed over UDP (ADR-069 protocol). The cog translates these into HA entities, Matter endpoints, and (via Surface C) HACS entity objects. One cog install covers an unlimited number of ESP32 nodes behind that Seed.
|
||||
|
||||
### 3.3 Should the cog speak MQTT or publish Matter directly?
|
||||
|
||||
**MQTT to local HA is the lower-risk, faster path**: it requires no Matter SDK linkage, no CSA certification, and reuses the existing HA-DISCO logic. Matter direct publishing requires the Seed to hold a valid Fabric certificate (obtained through the commissioning flow with the user's HA or Apple Home controller), manage operational credentials, and handle rekey events. The overhead is manageable on the Seed (S3 processor + Pi aarch64 appliance stack), but the development and QA cost is 3-4x higher. The recommended architecture is: **MQTT as primary, Matter as secondary** — matching ADR-115's dual-protocol decision but now native to the cog.
|
||||
|
||||
**Key sources**: [ADR-069 CSI pipeline](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-069-cognitum-seed-csi-pipeline.md), [ESP32 Matter Bridge example](https://project-chip.github.io/connectedhomeip-doc/examples/bridge-app/esp32/README.html), [Tasmota Matter internals](https://tasmota.github.io/docs/Matter-Internals/), [cognitum-v0 fleet stack].
|
||||
|
||||
---
|
||||
|
||||
## 4. Local-First AI: ruvllm + RuVector on the Seed
|
||||
|
||||
### 4.1 Hardware budget
|
||||
|
||||
The Cognitum Seed (ESP32-S3 variant: 8 MB PSRAM + 16 MB flash; Pi 5 variant: 8 GB RAM, Hailo AI hat) has two distinct execution environments. For on-Seed inference the numbers differ dramatically:
|
||||
|
||||
| Target | RAM headroom for inference | Flash/storage | Typical INT8 model ceiling |
|
||||
|---|---|---|---|
|
||||
| ESP32-S3 (8 MB PSRAM) | ~5 MB after OS + MQTT + Matter stack | 16 MB flash | 3–5 MB quantized model (e.g., MobileNetV2-class) |
|
||||
| Pi 5 Seed (8 GB RAM, Hailo-10) | ~6 GB free | NVMe | 40 TOPS hardware acceleration; 7B INT4 models feasible |
|
||||
| cognitum-v0 Pi 5 (Hailo via ruvector-hailo-worker) | 6 GB RAM + Hailo | NVMe | 40 TOPS; Hailo HEF deployment |
|
||||
|
||||
For a **semantic-primitives inference cog running on the ESP32-S3 Seed**, the target is an INT8-quantized classifier that takes the 8-dimensional feature vector (`edge_feature_pkt_t`) as input and outputs 10 semantic primitive probabilities. This is a trivially small model (8 → 64 hidden → 10 outputs, ~10 KB quantized) — it fits entirely in SRAM without needing PSRAM. The ruvllm-esp32 library (npm: `ruvllm-esp32 0.3.3`, cargo: `ruvllm-esp32 0.3.2`) confirms this path: INT8 quantization, HNSW vector search, and SONA self-optimizing adaptation in under 100 µs per query.
|
||||
|
||||
### 4.2 SONA fine-tuning loop
|
||||
|
||||
The ruvllm SONA (Self-Optimizing Neural Architecture) adapter performs online gradient descent on LoRA-style adapter weights in under 100 µs per query. For the 10-semantic-primitive classifier, this means the Seed can fine-tune its thresholds per-home using occupant feedback without any cloud round-trip:
|
||||
|
||||
1. User confirms a false positive via HA notification (e.g., "that was not a fall, I just sat down quickly").
|
||||
2. Feedback is recorded via the cog's `ha_mqtt.feedback` MCP tool.
|
||||
3. SONA runs one gradient step on the LoRA adapter weights for the `fall_risk_elevated` primitive.
|
||||
4. New weights are written to NVS on the ESP32-S3. The witness chain records the adaptation event with a timestamp.
|
||||
|
||||
For the Pi 5 Seed with Hailo-10 (40 TOPS), this extends to full 7B-class LoRA fine-tuning using the Hailo HEF pipeline already running at port 50051 (`ruvector-hailo-worker`). The `ruvllm-microlora-adapt` MCP tool in the cog catalog covers this path.
|
||||
|
||||
**Latency budget**: 8-dim → 10-output classifier: <1 ms on S3 SRAM (well within 20 Hz update cadence). SONA one-step gradient: <100 µs per adaptation event. Total per-inference overhead: negligible.
|
||||
|
||||
### 4.3 RuVector embeddings for room-level semantic context
|
||||
|
||||
The Seed's RuVector 2.0.4 integration (ADR-016) maintains HNSW embeddings of CSI feature vectors. The semantic primitives (sleeping, distress, meeting, etc.) can be implemented as HNSW nearest-neighbor lookups against a learned embedding space rather than threshold classifiers — this is more robust to room geometry variation. The `embeddings_rabitq_search` tool (RaBitQ approximate NN) supports sub-millisecond search on the ESP32-S3 PSRAM-hosted index. At 8 dimensions and 1,000 stored vectors, the HNSW index occupies approximately 200 KB — comfortably within PSRAM budget.
|
||||
|
||||
**Key sources**: [ruvllm-esp32 on libraries.io](https://libraries.io/npm/ruvllm-esp32), [ESP32-S3 TinyML optimization guide](https://zediot.com/blog/esp32-s3-tinyml-optimization/), [edge LLM deployment 2025](https://kodekx-solutions.medium.com/edge-llm-deployment-on-small-devices-the-2025-guide-2eafb7c59d07), [LoRA-Edge paper](https://arxiv.org/pdf/2511.03765).
|
||||
|
||||
---
|
||||
|
||||
## 5. Multi-Seed Federation
|
||||
|
||||
### 5.1 Discovery mechanisms
|
||||
|
||||
Three viable discovery layers for two Seeds in adjacent rooms:
|
||||
|
||||
**mDNS**: each Seed already advertises `_ruview._tcp` and `_matter._tcp` on the LAN. A second Seed can discover the first via `mdns-sd` query at startup and register it as a peer node. The cognitum-fleet service (port 9002) already implements fleet orchestration; adding peer-to-peer node registration is an extension of that model. **Caveat**: mDNS is link-local and does not cross VLANs. For multi-VLAN deployments (common in prosumer and commercial setups), a Tailscale overlay (the project already has a fleet on Tailscale — see CLAUDE.local.md) provides routable discovery at the cost of adding the Tailscale daemon to the cog's dependency list.
|
||||
|
||||
**Matter multi-admin**: once both Seeds are commissioned to the same Matter Fabric (e.g., via HA's Matter integration), the Fabric provides a shared namespace. However, Matter does not define a cross-device occupancy-handoff event — it only publishes per-device state. Handoff logic must live in HA automations or in the Seed cog's federation layer.
|
||||
|
||||
**Direct ESP-NOW mesh (ADR-110)**: the C6 nodes already run ESP-NOW with 99.56% RX reliability. Two Seeds each hosting C6 nodes can use ESP-NOW as the real-time cross-node synchronization bus — one C6 detects motion entering a room, broadcasts the event over ESP-NOW, the adjacent C6 primes its detector, and the Seed coordinator reconciles the two Occupancy states. This is the lowest-latency path (sub-millisecond over ESP-NOW vs. hundreds of milliseconds over MQTT → HA automation → MQTT).
|
||||
|
||||
### 5.2 Conflict resolution for simultaneous fall detection
|
||||
|
||||
When two sensing nodes both fire `fall_detected=true` within a short window, the cog applies a simple deduplication rule: the detection with the higher `presence_score` wins, and a 5-second exclusion window is applied on the lower-scoring node (matching the fall debounce logic from the firmware — 3-frame consecutive + 5 s cooldown). The winner's event is forwarded to HA as the canonical fall event. The loser is recorded in the witness chain with a `DEDUP_SUPPRESSED` tag for audit.
|
||||
|
||||
For cross-room occupancy, the cog maintains a **single-occupancy graph**: if node A detects person_count=1 and node B simultaneously detects person_count=1, and the two nodes are configured as adjacent rooms, the cog checks whether person_count in the home (sum of all node counts) is consistent with known occupant count (configurable, defaults to household size from HA's `persons` entity). Inconsistency triggers a `multi_room_transition` event published to HA rather than both nodes claiming simultaneous presence.
|
||||
|
||||
### 5.3 Witness chain for cross-Seed events
|
||||
|
||||
ADR-069 defines a SHA-256 tamper-evident witness chain per node. For cross-Seed events, the chain must include a cross-reference: each Seed's witness head at the time of the event is included in the other's chain entry. The cog implements this via a shared `witness_sync` MCP tool that both Seeds call before writing a cross-node event. This produces a bifurcated chain that any third party can verify for temporal consistency.
|
||||
|
||||
**Key sources**: [Matter multi-admin guide](https://mattercoder.com/codelabs/how-to-use-multi-admin/), [ESP-NOW mesh ADR-110 witness log](../WITNESS-LOG-110.md), [HA mDNS cross-VLAN thread](https://niksa.dev/posts/ha-vlan/), [home-assistant-matter-hub mDNS issue](https://github.com/t0bst4r/home-assistant-matter-hub/issues/237).
|
||||
|
||||
---
|
||||
|
||||
## 6. Competitor Analysis
|
||||
|
||||
### 6.1 Aqara FP2 and FP300
|
||||
|
||||
**FP2** (mmWave, Wi-Fi): presence, person count (up to 5), 30 zones with 320 detection areas, fall detection. HA integration via native Zigbee or Matter (Thread firmware). Matter mode is severely limited per user testing — configurable parameters are stripped and sensitivity settings are unavailable. Zigbee mode (via Zigbee2MQTT) is the recommended HA path. **No vitals (HR/BR), no pose.** Privacy story: local processing, no cloud required for automations.
|
||||
|
||||
**FP300** (5-in-1: mmWave + PIR + light + temperature + humidity, Matter-over-Thread): presence (binary only), temperature, humidity, light level. No person count, no fall detection, no vitals. Thread firmware gives 5 HA entities. Matter mode is functional but configuration-limited. Battery-powered (2× CR2450, ~2 years in Thread mode). **Verdict**: Aqara's Matter story is hardware-first but software-limited. Their Matter device class choice is `OccupancySensor` with standard PIR/Radar bitmap — no `RFSensing` flag.
|
||||
|
||||
### 6.2 TOMMY (tommysense.com)
|
||||
|
||||
Wi-Fi CSI sensing for HA. Uses ESP32 nodes. Exposes zones as binary sensors (MQTT, port 1886) and as Matter `OccupancySensor` endpoints (QR-based pairing). Motion and presence only — no vitals, no pose, no fall detection. Privacy: fully local, one periodic license-check outbound call. Closed-source algorithm and firmware; open-source HA integration. **Pricing**: free trial (1 zone, 2-min pause per 2 min of detection), Pro (unlimited zones, continuous). **Key gap vs RuView**: no HR/BR, no pose keypoints, no fall detection, no witness chain, no SONA adaptation.
|
||||
|
||||
### 6.3 ESPectre (github.com/francescopace/espectre)
|
||||
|
||||
Open-source CSI motion detection with HA integration (HACS). ESP32-only. Motion detection via RSSI phase variance analysis — no person counting, no vitals, no fall detection. Python-based HA custom component. No Matter support. **Verdict**: proof-of-concept quality; not a commercial competitor but demonstrates demand for the HACS distribution path.
|
||||
|
||||
### 6.4 Frigate NVR
|
||||
|
||||
Video-based local AI NVR. MQTT integration with HA creates binary sensors (`binary_sensor.frigate_<camera>_person_motion`), person count sensors, and clip/snapshot sensors per camera. All inference on-device (Coral EdgeTPU or Hailo). **Privacy**: fully local, no cloud. Frigate's MQTT entity catalog per camera: 1 camera stream entity, N object detection binary sensors (person, car, dog, etc.), N object count sensors. No vitals, no pose skeleton. Matter support: none in Frigate itself. **Key privacy contrast vs RuView**: Frigate requires cameras (video pixels), RuView uses RF only — privacy advantage in bedrooms, bathrooms, and care settings.
|
||||
|
||||
### 6.5 RoomMe (Intellithings)
|
||||
|
||||
Bluetooth LE room presence using smartphone proximity. Supports HomeKit and some smart-device ecosystems. No native HA integration, no MQTT, no Matter. High per-unit cost ($69). No vitals, no fall detection. Not a real competitor for the CSI/mmWave presence category.
|
||||
|
||||
### 6.6 Competitor entity catalog comparison
|
||||
|
||||
| Feature | RuView (ADR-115) | Aqara FP2 | Aqara FP300 | TOMMY | Frigate |
|
||||
|---|---|---|---|---|---|
|
||||
| Presence (binary) | yes | yes | yes | yes | yes (person class) |
|
||||
| Person count | yes | yes (5 max) | no | no | yes (per class) |
|
||||
| HR / BR | yes | no | no | no | no |
|
||||
| Pose keypoints | yes (17-pt) | no | no | no | no |
|
||||
| Fall detection | yes | yes | no | no | no |
|
||||
| Semantic primitives | yes (10) | no | no | no | no |
|
||||
| Multi-room handoff | yes (cog) | no | no | no | no |
|
||||
| Privacy mode | yes (wire-strip) | local only | local only | local only | local only |
|
||||
| HACS integration | roadmap | no | no | yes | yes |
|
||||
| Matter native | yes (bridge) | yes (limited) | yes | yes | no |
|
||||
| Witness chain | yes | no | no | no | no |
|
||||
|
||||
**Key sources**: [Aqara FP300 HA review](https://www.derekseaman.com/2025/11/aqara-fp300-the-ultimate-presence-sensor-home-assistant-edition.html), [TOMMY product page](https://www.tommysense.com/), [ESPectre GitHub](https://github.com/francescopace/espectre), [Frigate NVR docs](https://frigate.video/), [mmWave presence sensors 2026 comparison](https://www.linknlink.com/blogs/guides/best-mmwave-presence-sensors-home-assistant-2026).
|
||||
|
||||
---
|
||||
|
||||
## 7. Regulatory Frontier
|
||||
|
||||
### 7.1 FDA classification landscape (2026 update)
|
||||
|
||||
The FDA issued updated General Wellness Device guidance on January 6, 2026. Key clarifications relevant to WiFi-DensePose:
|
||||
|
||||
**Wellness device criteria** (functions that keep the product outside FDA jurisdiction): the device must (a) have low inherent risk to user safety, (b) make no reference to specific diseases or conditions, and (c) not provide diagnostic or treatment outputs. Examples in the guidance: heart rate monitoring, sleep tracking, activity/recovery metrics, oxygen saturation trends — all qualify as wellness when marketed without diagnostic claims.
|
||||
|
||||
**Claims that trigger medical device classification**: any output labeled as "abnormal, pathological, or diagnostic"; recommendations concerning clinical thresholds or treatment; ongoing clinical monitoring or alerts for medical management; substitution for an FDA-approved device. A fall detection feature framed as "alert a caregiver when you might have fallen" is materially different from one framed as "diagnose fall injury" — the former qualifies as wellness under the 2026 guidance; the latter does not.
|
||||
|
||||
**The defensible wellness-device position for RuView**: (a) market fall detection as an "activity anomaly notification" not a "medical fall diagnosis"; (b) include explicit disclaimers against diagnostic or clinical use in app-store descriptions, labeling, and HA integration documentation; (c) avoid "medical-grade" accuracy claims for HR/BR readings; (d) position the device as a "smart home occupancy and wellness assistant" rather than a "patient monitoring system."
|
||||
|
||||
### 7.2 HIPAA applicability
|
||||
|
||||
HIPAA applies only when an entity is a HIPAA "covered entity" (healthcare providers, health plans, clearinghouses) or their "business associate." A consumer smart home product sold direct-to-homeowners is not automatically a covered entity. However, HIPAA applicability is triggered if the Seed's data flows into a covered entity's system (e.g., a care facility's EHR). The privacy-mode flag in ADR-115 (stripping HR/BR/pose at the wire, publishing only semantic state digests) creates a technical barrier to PHI transmission that supports a "not a covered entity" position.
|
||||
|
||||
**All 50 US states** impose data breach notification requirements regardless of HIPAA status. The witness chain (SHA-256 tamper-evident audit log per node) satisfies most state-level data-integrity requirements.
|
||||
|
||||
### 7.3 Matter Health-Check device class
|
||||
|
||||
Matter currently has no "Health" or "Wellness" device class in the formal taxonomy. The closest is `OccupancySensor` with the `RFSensing` feature flag. The device type `0x0107` (OccupancySensor) in the DCL will not trigger any health-device regulatory scrutiny. Using this device type keeps the Seed in the same regulatory category as a smart motion sensor — well outside the medical device perimeter.
|
||||
|
||||
**Key sources**: [FDA 2026 General Wellness guidance (Kendall PC)](https://kendallpc.com/fdas-2026-guidance-on-general-wellness-devices-policy-for-low-risk-devices-key-compliance-and-regulatory-insights-for-digital-health-companies/), [Troutman Pepper Locke analysis](https://www.troutman.com/insights/fdas-2026-guidance-on-general-wellness-devices-policy-for-low-risk-devices/), [IEEE Spectrum FDA device rules](https://spectrum.ieee.org/fda-medical-device-rules), [FDA wellness tracker / cybersecurity interlock (Troutman)](https://www.troutman.com/insights/wellness-trackers-medical-status-and-cybersecurity-how-fda-ftc-and-state-laws-interlock/).
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontier Features Worth Shipping
|
||||
|
||||
### 8.1 HACS marketplace listing
|
||||
|
||||
**Build cost**: medium (4–6 weeks for a gold-tier integration). **User impact**: very high — one-click install removes the MQTT broker prerequisite for non-power-users.
|
||||
|
||||
Architecture: Python package at `custom_components/wifi_densepose/`, config flow that discovers Seeds via mDNS (`_ruview._tcp`) or manual IP, bearer token authentication against `GET /api/v1/status`, full entity catalog matching ADR-115 §3.1 (21 entities per node), repairs for offline nodes, diagnostics export, translations for EN/FR/DE/ES. Start from `hacs.integration_blueprint` template. Submit via HACS default repositories GitHub submission.
|
||||
|
||||
### 8.2 Matter Bridge with OccupancySensor / ContactSensor / BooleanState
|
||||
|
||||
**Build cost**: high (6–8 weeks including CI test harness with chip-tool simulator). **User impact**: high for Apple Home / Google Home users who don't run HA.
|
||||
|
||||
Device type mapping:
|
||||
- Presence → `OccupancySensor (0x0107)` with `OccupancySensing (0x0406)`, `RFSensing` feature flag set, `HoldTime` attribute wired to sensing-server's zone dwell time.
|
||||
- Fall detected → `ContactSensor (0x0015)` used as event source (state: `true` for 5 s after fall, then auto-reset) — closest available device type until a FallEvent device type exists in the spec.
|
||||
- Person count → vendor-specific attribute on the Bridge root endpoint (`VendorSpecificAttributeCount`, cluster 0xFFF1_xxxx namespace).
|
||||
|
||||
Memory on S3: baseline Matter stack ~1.5 MB flash, ~195 KB DRAM + PSRAM heap; BLE freed post-commissioning recovers ~100 KB. 16 dynamic endpoints (default maximum, configurable per `NUM_DYNAMIC_ENDPOINTS`) costs ~550 bytes DRAM each. For 8 zones: 8 × 550 = 4.4 KB additional DRAM — well within budget. Wi-Fi-only commissioning (Matter 1.4.2) eliminates BLE requirement, simplifying the Seed hardware path.
|
||||
|
||||
### 8.3 Cognitum Seed cog manifest + signing
|
||||
|
||||
**Build cost**: low (1–2 weeks). **User impact**: enables one-tap install from the Cognitum Seed store.
|
||||
|
||||
Manifest structure (based on ADR-069/ADR-100 patterns):
|
||||
```json
|
||||
{
|
||||
"id": "cog-ha-matter-v1",
|
||||
"version": "1.0.0",
|
||||
"platforms": ["aarch64", "x86_64"],
|
||||
"min_seed_version": "0.8.1",
|
||||
"capabilities": ["network.mqtt", "network.matter", "api.ruview_vitals"],
|
||||
"resource_budget": {"ram_mb": 128, "cpu_percent": 15},
|
||||
"signing_key_id": "ed25519:ruv-cog-signing-v1",
|
||||
"registry_url": "https://seed.cognitum.one/store/cog-ha-matter",
|
||||
"ha_integration_repo": "https://github.com/ruvnet/hass-wifi-densepose"
|
||||
}
|
||||
```
|
||||
Binary signing uses the existing Ed25519 keypair infrastructure from ADR-100. The `cognitum-ota-registry` (port 9003) handles delivery. The cog declaration includes the companion HACS integration GitHub URL so the Seed UI can prompt the user to install the HACS companion if they have HA detected on the LAN.
|
||||
|
||||
### 8.4 Local SONA fine-tuning loop for per-home thresholds
|
||||
|
||||
**Build cost**: low (2–3 weeks, given ruvllm-esp32 already provides the primitives). **User impact**: high — eliminates false positives that are the top complaint for presence/fall sensors in HA forums.
|
||||
|
||||
Implementation: HA sends feedback events via an MQTT command topic (`homeassistant/wifi_densepose/<node>/cmd/feedback`). The cog's SONA adapter processes the feedback as a labeled training example and runs one gradient step. After 20 feedback events, it triggers a witness-chain-attested weight checkpoint. The HACS integration surfaces this as a "Improve detection accuracy" button in the HA device page, pointing users to a simple thumbs-up/thumbs-down UI on the last 10 events.
|
||||
|
||||
### 8.5 Multi-room presence handoff
|
||||
|
||||
**Build cost**: medium (3–4 weeks). **User impact**: high — eliminates the "ghost occupancy" problem where HA thinks two rooms are occupied when a person walks from one to the other.
|
||||
|
||||
Implementation: the cog runs a presence graph across all Seeds in the fleet. Nodes declare themselves adjacent via the manifest or via HA area assignment. When person_count transitions (room A: 1→0, room B: 0→1) within a configurable window (default 3 s), the cog publishes a single `multi_room_transition` event to HA with `from_zone` and `to_zone` fields, and holds the `person_count=1` in the destination room rather than briefly showing 0 in both. This is a cog-side state machine, not an HA automation — it runs at 20 Hz loop cadence.
|
||||
|
||||
### 8.6 Energy disaggregation: pairing vitals with HA energy entities
|
||||
|
||||
**Build cost**: medium (3–4 weeks). **User impact**: medium-high for sustainability-focused users.
|
||||
|
||||
Non-Intrusive Load Monitoring (NILM) in HA already exists as a community blueprint (github.com/tronikos NILM blueprint). The opportunity for RuView is the inverse: rather than using energy to infer occupancy, use RuView's presence data to validate NILM's occupancy assumptions. When RuView reports presence_score < 0.1 (no one home) but the NILM model predicts an active appliance load inconsistent with unoccupied state (e.g., a TV left on), HA can surface a "phantom load detected" notification. The cog publishes a `phantom_load_candidate` event when this condition holds for more than 5 minutes. Pairs with HA's Energy dashboard (introduced in 2021, stable since 2023) and the `homeassistant/sensor/<node>/phantom_load/config` MQTT discovery topic.
|
||||
|
||||
### 8.7 Privacy-mode "audit logs only"
|
||||
|
||||
**Build cost**: low (1 week, extends existing `--privacy-mode` flag from ADR-115). **User impact**: high for HIPAA-adjacent deployments (care facilities, eldercare) and for GDPR-jurisdiction users.
|
||||
|
||||
Three privacy tiers:
|
||||
- `none`: full telemetry (HR, BR, pose, presence, count) published to MQTT and Matter.
|
||||
- `semantic` (default): HR/BR/pose stripped at wire; semantic primitives (10 states) published only.
|
||||
- `audit-only`: no MQTT state messages; only SHA-256 digests of events logged to the witness chain on the Seed. HA receives heartbeat-only availability messages. Suitable for deployments where the home network is untrusted or subject to external logging.
|
||||
|
||||
The audit-only mode is a defensible HIPAA/GDPR position for integrators deploying in care settings — the Seed holds the event record, the network carries nothing personally identifiable.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Scope for HA+Matter Cog v1
|
||||
|
||||
Ranked by **build cost × user impact** (low cost + high impact first):
|
||||
|
||||
| Priority | Feature | Build effort | User impact | Ships in |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Privacy-mode audit-only tier** (§8.7) | 1 week | High (care/GDPR deployments) | v0.7.1 |
|
||||
| 2 | **Seed cog manifest + signing** (§8.3) | 1–2 weeks | High (Seed store distribution) | v0.7.1 |
|
||||
| 3 | **Local SONA fine-tuning loop** (§8.4) | 2–3 weeks | High (false-positive reduction) | v0.7.1 |
|
||||
| 4 | **HACS integration (gold tier)** (§8.1) | 4–6 weeks | Very high (removes MQTT prereq) | v0.7.2 |
|
||||
| 5 | **Multi-room presence handoff** (§8.5) | 3–4 weeks | High (ghost occupancy fix) | v0.7.2 |
|
||||
| 6 | **Matter Bridge OccupancySensor + ContactSensor** (§8.2) | 6–8 weeks | High (Apple/Google Home reach) | v0.8.0 |
|
||||
| 7 | **Energy disaggregation phantom-load** (§8.6) | 3–4 weeks | Medium-high (sustainability niche) | v0.8.0 |
|
||||
| 8 | **Thread Border Router on C6** (§1.2) | 2–3 weeks (config only) | Medium (Thread-fabric users) | v0.8.0 |
|
||||
| 9 | **CSA Matter certification** (§1.4) | $30–42k + 3–6 months | Medium (commercial badge) | post-v1.0 |
|
||||
|
||||
**Deferred**: Seed-as-Matter-Commissioner (feasible on S3 appliance but requires full chip-tool port; defer to v1.0), full HA quality-scale platinum tier (gold is sufficient for v1 HACS listing), NILM phantom-load (ships as experimental blueprint first, then proper integration).
|
||||
|
||||
**Recommended v0.7.1 sprint**: privacy-mode audit tier + cog manifest + SONA fine-tuning = 4–5 weeks total, fully within the existing Rust + ESP32 codebase with no new dependencies. This sprint closes the most impactful gap (care deployments + per-home personalization) before the heavier HACS/Matter work begins.
|
||||
|
||||
---
|
||||
|
||||
*Research methodology: 8 parallel web search passes, 12 targeted page fetches, cross-referenced against ADR-115 and ADR-110 source files. Evidence grade: High for Matter cluster specifications, FDA guidance, HACS requirements, and ESP32-S3 memory numbers. Medium for CSA certification cost estimates (sourced from forum discussion, not official price list). Low for ruvllm SONA per-home fine-tuning feasibility (derived from library documentation, not benchmarked on Seed hardware). Open question: whether ESP32-S3 PSRAM heap is sufficient for the full Matter Bridge stack alongside the existing sensing-server runtime — a build-and-measure step is needed before committing to the v0.8.0 Matter bridge sprint.*
|
||||
@@ -0,0 +1,293 @@
|
||||
# BFLD SOTA Survey — Beamforming Feedback: State of the Art
|
||||
|
||||
## 1. BFI vs CSI: Physical-Layer Differences and Leakage Profiles
|
||||
|
||||
### 1.1 Channel State Information (CSI)
|
||||
|
||||
CSI is the raw complex channel frequency response (CFR) measured at the receiver across
|
||||
all subcarriers and antenna pairs. Extracting CSI requires either (a) firmware
|
||||
modifications on the receiving NIC (Atheros CSI Tool, Nexmon CSI patch for BCM43455c0
|
||||
on Raspberry Pi 4/5) or (b) a specialized radio (software-defined radio with 802.11
|
||||
decoders). The resulting matrix is typically Ntx × Nrx × Nsubcarrier complex floats —
|
||||
dense, high-dimensional, and not transmitted over the air in standard operation.
|
||||
|
||||
This project's existing rvCSI runtime (`vendor/rvcsi/`) captures CSI via the Nexmon
|
||||
firmware patch on Raspberry Pi hardware (ADR-095/096). The ESP32-S3 on COM9 cannot
|
||||
produce CSI in the format needed for the full pipeline — it lacks the antenna count
|
||||
and the firmware support for per-subcarrier phase extraction at the fidelity rvcsi
|
||||
expects.
|
||||
|
||||
### 1.2 Beamforming Feedback Information (BFI)
|
||||
|
||||
BFI is fundamentally different: it is the compressed representation of the channel that
|
||||
a STA (station/client) sends back to an AP (access point) so the AP can steer its beam
|
||||
toward the client. The standard (IEEE 802.11ac/ax, section 9.4.1.52) defines the
|
||||
compressed beamforming format as:
|
||||
|
||||
1. The AP transmits a Null Data Packet (NDP) sounding frame.
|
||||
2. The STA measures the channel from the NDP, computes the singular-value decomposition
|
||||
V = U Sigma V^H, then compresses the right singular vectors using a series of Givens
|
||||
rotations.
|
||||
3. The Givens rotation produces a set of angles: Phi (φ) angles in [0, 2π) and Psi (ψ)
|
||||
angles in [0, π/2). In 802.11ac these are quantized to 7 and 5 bits respectively; in
|
||||
802.11ax the default is 4 bits for φ and 2 bits for ψ.
|
||||
4. The STA transmits a VHT/HE Compressed Beamforming frame (CBFR) containing those
|
||||
quantized angles, one set per active subcarrier (or per compressed subcarrier group),
|
||||
plus an SNR field per stream.
|
||||
|
||||
The CBFR is a **management-plane 802.11 frame, not an 802.3 data frame**. It is
|
||||
transmitted before association encryption is negotiated; in WPA2/WPA3 deployments, the
|
||||
beamforming sounding and feedback exchange happens in the clear because WPA2/WPA3
|
||||
encrypt data frames only. Even 802.11ax (Wi-Fi 6/6E) with Protected Management Frames
|
||||
(PMF) enabled does NOT encrypt action frames in the beamforming exchange by default on
|
||||
commodity APs as of 2025 (NDSS 2025 finding, "Lend Me Your Beam",
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/).
|
||||
|
||||
**Key asymmetry**: extracting CSI requires physical access to a device and firmware
|
||||
modification; extracting BFI requires only a WiFi adapter in monitor mode and a parser
|
||||
for the CBFR frame format. Wi-BFI (Haque, Meneghello, Restuccia; ACM WiNTECH 2023,
|
||||
https://arxiv.org/abs/2309.04408) is an open-source pip-installable tool that does
|
||||
exactly this.
|
||||
|
||||
### 1.3 Why BFI Is Uniquely Dangerous
|
||||
|
||||
CSI is a research instrument — accessing it requires deliberate effort. BFI is a
|
||||
production protocol artifact that any 802.11ac/ax STA broadcasts periodically as a
|
||||
matter of course. The attack-surface implications:
|
||||
|
||||
- **No firmware modification needed** on the target device or AP.
|
||||
- **Passive capture** is sufficient. Frames are broadcast in all directions, not
|
||||
beamformed, so a nearby attacker receives them at essentially the same SNR as the AP.
|
||||
- **Structured leakage**: the Phi/Psi angle matrices encode a compressed but
|
||||
non-trivially-invertible representation of the spatial channel, which includes
|
||||
multipath geometry that is body-shaped — the human body is a dielectric obstacle whose
|
||||
shape and movement modulate the channel.
|
||||
- **Regularity**: sounding happens at the AP's request, typically at 5–40 Hz in modern
|
||||
802.11ax deployments. A 60-second capture at 10 Hz produces 600 CBFR frames —
|
||||
sufficient for the BFId classifier to achieve >90% re-identification accuracy (ACM CCS
|
||||
2025, https://dl.acm.org/doi/10.1145/3719027.3765062).
|
||||
|
||||
---
|
||||
|
||||
## 2. Compressed Angle Matrices: The Identity Surface
|
||||
|
||||
### 2.1 Givens Rotation Reconstruction
|
||||
|
||||
The Phi/Psi angles encode a unitary matrix via the Givens rotation decomposition:
|
||||
|
||||
V = G(N, N-1, φ_{N,N-1}, ψ_{N,N-1}) · G(N, N-2, ...) · ... · G(2,1, φ_{2,1}, ψ_{2,1}) · D
|
||||
|
||||
where D is a diagonal phase matrix. For a 2×2 MIMO system this is two angles; for a
|
||||
4×4 system this is 12 angles. Each "column" in the BFI payload corresponds to one
|
||||
subcarrier group (or every 4th subcarrier in 802.11ax, every 2nd in 802.11ac).
|
||||
|
||||
The resulting per-subcarrier angle sequence is a time-varying signature of the spatial
|
||||
channel. Because the human body modulates the multipath channel, this sequence encodes
|
||||
body-specific geometry. The BFId paper (https://dl.acm.org/doi/10.1145/3719027.3765062)
|
||||
demonstrates that a supervised classifier trained on these sequences achieves identity
|
||||
recognition on a 197-person dataset.
|
||||
|
||||
### 2.2 The AI/ML Compression Feedback Loop
|
||||
|
||||
IEEE 802.11 standardization is actively exploring AI/ML-based compression for
|
||||
beamforming feedback (IEEE 802.11bn / Wi-Fi 8 study group, "Toward AIML Enabled WiFi
|
||||
Beamforming CSI Feedback Compression", https://arxiv.org/html/2503.00412v1). This work
|
||||
proposes neural codebooks that reduce feedback overhead. An important side effect: the
|
||||
learned latent space of a neural BFI compressor may be *more* identity-discriminative
|
||||
than the raw angles, because neural compression tends to preserve class-discriminative
|
||||
variance. BFLD must be designed to handle compressed BFI encodings, not just the raw
|
||||
Phi/Psi format.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tooling Landscape
|
||||
|
||||
### 3.1 Wi-BFI
|
||||
|
||||
- **Source**: https://arxiv.org/abs/2309.04408 / https://github.com/kfoysalhaque/MU-MIMO-Beamforming-Feedback-Extraction-IEEE802.11ac
|
||||
- **Capabilities**: real-time and offline extraction of BFAs from 802.11ac and 802.11ax;
|
||||
20/40/80/160 MHz; SU-MIMO and MU-MIMO; pip-installable.
|
||||
- **Relevance to BFLD**: the BFLD extractor module (`extractor.rs`) must produce
|
||||
semantically equivalent output to Wi-BFI — i.e., per-subcarrier Phi/Psi angle arrays
|
||||
plus per-stream SNR — so that research results from the Wi-BFI ecosystem can be
|
||||
replicated on BFLD captures.
|
||||
|
||||
### 3.2 PicoScenes
|
||||
|
||||
- **Source**: https://www.semanticscholar.org/paper/Eliminating-the-Barriers-Demystifying-Wi-Fi-Baseband-Jiang-Zhou/...
|
||||
- **Capabilities**: cross-NIC CSI and CBFR measurement platform; supports Intel AX200,
|
||||
AX210, Atheros AR9300, QCA6174; runs on Linux with custom kernel modules.
|
||||
- **Relevance to BFLD**: PicoScenes can simultaneously capture CSI and BFI from the
|
||||
same frame sequence, enabling the CSI+BFI fusion path described in the BFLD spec
|
||||
(`csi_matrix` optional input). The rvcsi adapter layer (`vendor/rvcsi/`) already
|
||||
handles the Nexmon PCap format; a PicoScenes adapter is a future extension.
|
||||
|
||||
### 3.3 Nexmon CSI (BCM43455c0)
|
||||
|
||||
- **Source**: https://github.com/seemoo-lab/nexmon_csi
|
||||
- **Hardware**: Raspberry Pi 4/5 with BCM43455c0 chip — the same hardware used in
|
||||
`cognitum-v0` (Pi 5 appliance in this fleet, see CLAUDE.local.md).
|
||||
- **Capabilities**: per-subcarrier complex CSI in monitor mode; 4×4 MIMO on Pi 5 with
|
||||
BCM43456.
|
||||
- **Relevance to BFLD**: the rvcsi nexmon adapter already routes PCap frames from this
|
||||
hardware into the wifi-densepose pipeline. BFI extraction on the same hardware requires
|
||||
an additional sniffer for CBFR frames alongside the CSI sniffer.
|
||||
|
||||
### 3.4 Atheros CSI Tool / iwlwifi CSI
|
||||
|
||||
- Legacy tools for Intel and Atheros NICs; require kernel module injection. Not relevant
|
||||
to the current hardware fleet (ESP32-S3 + Raspberry Pi 5), but documented here for
|
||||
completeness and for future Intel AX210-based deployments.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity Inference Attacks
|
||||
|
||||
### 4.1 BFId (ACM CCS 2025)
|
||||
|
||||
**Reference**: Todt, Morsbach, Strufe; KIT. ACM CCS 2025.
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
https://publikationen.bibliothek.kit.edu/1000185756
|
||||
Dataset: https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
|
||||
BFId is the first published identity-inference attack that uses BFI exclusively (no
|
||||
CSI). The methodology:
|
||||
|
||||
1. **Dataset**: 197 individuals, multiple sessions, multiple AP angles. Each subject
|
||||
walked a defined path while their STA continuously triggered BFI exchanges. CSI
|
||||
was also recorded simultaneously for comparison.
|
||||
2. **Feature extraction**: temporal sequences of Phi/Psi angle matrices, windowed at
|
||||
varying lengths. Basic statistical features (mean, variance, cross-subcarrier
|
||||
correlation) fed a shallow classifier.
|
||||
3. **Results**: re-identification accuracy >90% with as little as 5 seconds of BFI.
|
||||
Performance was robust to different walking styles and viewing angles — consistent
|
||||
with the hypothesis that anthropometric body shape (torso width, stride, limb
|
||||
geometry) rather than gait phase is the primary discriminator.
|
||||
4. **Comparison to CSI**: BFI-only accuracy was comparable to CSI-only accuracy for
|
||||
identity tasks, despite BFI being a compressed representation. This confirms that
|
||||
the Givens angle compression preserves identity-discriminative variance.
|
||||
|
||||
### 4.2 LeakyBeam (NDSS 2025)
|
||||
|
||||
**Reference**: Xiao, Chen, He, Han, Han; Zhejiang U., NTU, KAIST. NDSS 2025.
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
|
||||
|
||||
LeakyBeam targets occupancy detection (is a person present?) rather than identity.
|
||||
Key findings:
|
||||
|
||||
- BFI is detectable through walls at 20 m range with commodity hardware.
|
||||
- True positive rate 82.7%, true negative rate 96.7% in real-world evaluation.
|
||||
- The attack works because BFI encodes motion-induced channel perturbations even through
|
||||
obstacles — the Phi/Psi angle variance changes measurably when a body enters the room.
|
||||
- The defense (obfuscating BFI before transmission) requires minimal hardware changes.
|
||||
|
||||
**Implication for BFLD**: if a passive attacker with no relationship to the AP can
|
||||
detect occupancy, then the BFLD node is implicitly broadcasting presence information
|
||||
unless active obfuscation is deployed at the STA firmware level. BFLD cannot prevent
|
||||
this passive attack — it can only ensure the *node's own output* does not additionally
|
||||
leak identity.
|
||||
|
||||
### 4.3 Prior RF-Based Gait and Biometric Inference
|
||||
|
||||
Before BFI-specific attacks, the threat landscape was already established through
|
||||
CSI-based attacks:
|
||||
|
||||
- **Gait from CSI**: WiGait (2017), Wi-Gait (ScienceDirect 2023,
|
||||
https://www.sciencedirect.com/science/article/abs/pii/S1389128623001962),
|
||||
Gait+Respiration ID (IEEE Xplore 2021,
|
||||
https://ieeexplore.ieee.org/document/9488277) all demonstrate >90% gait-based
|
||||
re-identification from standard WiFi.
|
||||
- **Breathing biometrics**: Respiration rate and depth are person-specific at a
|
||||
population level. IEEE 802.11 CSI captures breathing as amplitude oscillations at
|
||||
0.1–0.5 Hz.
|
||||
- **Anthropometric inference**: Hand size, torso width, and limb geometry modulate the
|
||||
channel; classifiers trained on activity data have been shown to leak anthropometrics
|
||||
as a side effect.
|
||||
|
||||
The BFId finding that BFI achieves comparable accuracy to CSI for identity is consistent
|
||||
with this prior body of work — it simply demonstrates the attack is achievable with a
|
||||
lower barrier to entry.
|
||||
|
||||
---
|
||||
|
||||
## 5. Privacy-Preserving Sensing: Current State of the Art
|
||||
|
||||
### 5.1 Differential Privacy on RF Embeddings
|
||||
|
||||
"Differentially Private Feature Release for Wireless Sensing: Adaptive Privacy Budget
|
||||
Allocation on CSI Spectrograms" (https://arxiv.org/pdf/2512.20323) applies Laplace/
|
||||
Gaussian mechanisms to CSI spectrograms, calibrating epsilon per subcarrier based on
|
||||
empirical sensitivity. Results show meaningful reduction in identity-inference accuracy
|
||||
while preserving activity-recognition utility at epsilon = 1.0–4.0.
|
||||
|
||||
BFLD's `identity_risk_score` could be used as an adaptive epsilon selector: high-risk
|
||||
frames receive a tighter privacy budget (more noise), low-risk frames pass unmodified.
|
||||
This is a forward-looking integration not in the current spec.
|
||||
|
||||
### 5.2 Federated / Local-Only Inference
|
||||
|
||||
The consensus across 2024–2025 literature on wireless federated learning
|
||||
(https://arxiv.org/pdf/2603.19040, https://arxiv.org/pdf/2109.09142) is that
|
||||
local differential privacy (LDP) with gradient perturbation is achievable on resource-
|
||||
constrained edge devices. For BFLD's use case the critical property is simpler: the
|
||||
identity embedding never needs to leave the node. There is no federated learning step
|
||||
for identity. The risk score is a local computation whose output is published; the
|
||||
embedding that produced it is not.
|
||||
|
||||
### 5.3 ZK Attestation for Sensing
|
||||
|
||||
ZK-SenseLM (https://arxiv.org/pdf/2510.25677) proposes zero-knowledge proofs that a
|
||||
sensing model's output derives from legitimate data. This is architecturally close to
|
||||
ADR-028's witness-bundle approach. Future BFLD work could use ZK proofs to attest that
|
||||
the identity_risk_score was computed from the claimed input without revealing the input.
|
||||
|
||||
### 5.4 "Protecting Human Activity Signatures in Compressed IEEE 802.11 CSI Feedback"
|
||||
|
||||
(https://arxiv.org/pdf/2512.18529) — This 2024 paper directly addresses activity-
|
||||
signature leakage in CBFR frames and proposes perturbation of Phi/Psi angles at the STA
|
||||
before transmission. The defense is the dual of BFLD's approach: BFLD detects leakage
|
||||
at the receiver; this paper proposes suppression at the transmitter. Both approaches
|
||||
are complementary.
|
||||
|
||||
---
|
||||
|
||||
## 6. Relationship to Existing Project ADRs
|
||||
|
||||
**ADR-027 (MERIDIAN cross-environment generalization)**: BFLD's cross-room hash
|
||||
rotation directly instantiates the "no cross-site correlation" invariant that MERIDIAN
|
||||
assumes for privacy-safe multi-room deployment.
|
||||
|
||||
**ADR-028 (ESP32 capability audit + witness verification)**: The deterministic-proof
|
||||
pattern (`verify.py` + SHA-256 expected hash) is the template for BFLD's own acceptance
|
||||
test. BFLD must produce a deterministic frame hash given the same input — acceptance
|
||||
criterion 6 in the spec.
|
||||
|
||||
**ADR-024 (AETHER contrastive CSI embedding)**: BFLD reuses the AETHER embedding
|
||||
infrastructure for its identity_risk measurement. The risk score is a function of how
|
||||
separable the current embedding is from the population of known embeddings.
|
||||
|
||||
**ADR-029/030 (RuvSense multistatic + field model)**: BFLD's `cross_perspective_
|
||||
consistency` component of the risk formula requires correlation across multiple sensor
|
||||
viewpoints — the multistatic infrastructure from ADR-029 provides this.
|
||||
|
||||
**ADR-032 (multistatic mesh security hardening)**: The BFLD threat model is a
|
||||
superset of the security model in ADR-032. ADR-032 covers mesh compromise; BFLD adds
|
||||
the passive sniffing threat at the management-plane layer.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Technical Questions
|
||||
|
||||
1. **BFI capture on ESP32-S3**: The ESP32-S3's `esp_wifi_csi_set_config` API provides
|
||||
CSI via the vendor-specific Espressif HT20 format. It does not expose VHT/HE CBFR
|
||||
frames. BFI capture on this hardware likely requires host-side sniffing (Pi 5 +
|
||||
Nexmon in monitor mode, already available on cognitum-v0).
|
||||
|
||||
2. **Quantization resolution degradation**: At 4 bits for φ and 2 bits for ψ (802.11ax
|
||||
defaults), the angle resolution is coarser than in 802.11ac (7/5 bits). The BFId
|
||||
paper used 802.11ac hardware. BFLD must validate that the identity_risk_score
|
||||
calibration remains valid at lower quantization.
|
||||
|
||||
3. **WiFi 7 (802.11be) changes**: 802.11be introduces multi-link operation (MLO) and
|
||||
may change the sounding/feedback cadence. BFLD's frame format (magic 0xBF1D_0001,
|
||||
version byte) is designed to accommodate future protocol versions.
|
||||
@@ -0,0 +1,141 @@
|
||||
# BFLD Soul — Architectural Intent and Ethical Stance
|
||||
|
||||
## 1. The Central Metaphor: Immune System, Not Surveillance Lens
|
||||
|
||||
An immune system does not catalog every pathogen it encounters. It classifies threats
|
||||
by type, responds proportionally, and keeps its detailed records local to the organism.
|
||||
When the immune system flags a cell as dangerous, it does not broadcast the cell's
|
||||
identity to the outside world — it takes local action.
|
||||
|
||||
BFLD is built around this same principle. Its job is to detect when RF data is crossing
|
||||
from the realm of "ambient sensing" into the realm of "identity record" — and to respond
|
||||
locally: raise the risk score, restrict what leaves the node, rotate identifiers. It does
|
||||
not produce identity; it guards against the accidental production of identity.
|
||||
|
||||
This distinction matters because the same physical signal that drives BFLD's presence
|
||||
detection is also the signal that academic attackers (BFId, LeakyBeam) exploit for
|
||||
re-identification. BFLD cannot suppress the underlying physics. What it can do is make
|
||||
the node's *output* non-identifying, even when the node's *input* is capable of
|
||||
supporting identification.
|
||||
|
||||
---
|
||||
|
||||
## 2. Distinguishing Identity from the Rest of WiFi Sensing
|
||||
|
||||
WiFi sensing produces a spectrum of information:
|
||||
|
||||
| Output | Privacy class | Reversibility |
|
||||
|--------|--------------|---------------|
|
||||
| Presence (yes/no) | 2 — anonymous | Not reversible to identity |
|
||||
| Motion magnitude (0..1) | 1 — derived | Not reversible to identity |
|
||||
| Person count (integer) | 1 — derived | Not reversible to identity |
|
||||
| Zone activity | 1 — derived | Not reversible to identity |
|
||||
| Identity risk score | 1 — derived | Risk score, not identity |
|
||||
| RF signature hash | 1 — derived | Hash rotates daily; not reversible |
|
||||
| Identity embedding | 0 — raw | Directly reversible to biometric |
|
||||
| Raw BFI matrix | 0 — raw | Directly reversible to biometric |
|
||||
|
||||
BFLD's design follows this table structurally: the outputs in privacy class 0 never
|
||||
leave the node. The outputs in class 1 leave the node only after explicit operator opt-in
|
||||
for the sensitive ones (identity_risk_score). The outputs in class 2 flow freely.
|
||||
|
||||
This table is not a policy list — it is wired into the frame format. The `privacy_class`
|
||||
byte in every `BfldFrame` is checked at the emitter boundary before any byte leaves the
|
||||
node. Code that wants to send class-0 data must positively bypass a compile-time safety
|
||||
check, not merely forget to set a flag.
|
||||
|
||||
---
|
||||
|
||||
## 3. Three Non-Negotiable Invariants
|
||||
|
||||
These are not configurable options. They are structural properties of BFLD that
|
||||
hold regardless of operator configuration:
|
||||
|
||||
### Invariant 1: Raw BFI Never Leaves the Node
|
||||
|
||||
The BFI matrix, once ingested by the BFLD extractor, is consumed locally and never
|
||||
serialized to any outbound channel. This is enforced in two ways:
|
||||
|
||||
1. The `BfldFrame` struct's `bfi_matrix` field is not part of the serializable payload
|
||||
— it exists only as a private field in `extractor.rs` and is dropped after
|
||||
feature extraction completes.
|
||||
2. The MQTT emitter (`mqtt.rs`) has no code path that serializes a BFI matrix.
|
||||
The `ruview/<node_id>/bfld/raw/state` topic is disabled by default and, when
|
||||
enabled, publishes only a metadata summary (subcarrier count, timestamp, SNR range),
|
||||
not the angle matrices.
|
||||
|
||||
### Invariant 2: Identity Embedding Is Local-Only
|
||||
|
||||
The embedding computed by the RuVector pipeline (used to calculate `identity_risk_score`)
|
||||
lives in an in-RAM ring buffer with a configurable retention window (default: 10 minutes).
|
||||
It is never written to disk. It is never serialized to any MQTT topic. It is never
|
||||
included in any `BfldFrame` payload even at `privacy_class = 0` — raw means raw angles,
|
||||
not the derived embedding.
|
||||
|
||||
The mathematical property that enables this: `identity_risk_score` can be computed as a
|
||||
scalar from the embedding (separability × temporal_stability × cross_perspective_
|
||||
consistency × sample_confidence) without revealing the embedding itself. The score is a
|
||||
projection onto a scalar; the full vector is not required by any downstream consumer.
|
||||
|
||||
### Invariant 3: Cross-Site Identity Matching Is Structurally Impossible
|
||||
|
||||
The `rf_signature_hash` is computed as:
|
||||
|
||||
blake3(site_salt ‖ day_epoch ‖ ephemeral_features)
|
||||
|
||||
where `site_salt` is a secret generated at first boot, stored in NVS, and never
|
||||
transmitted. Two BFLD nodes at two different sites will produce hashes in disjoint
|
||||
hash spaces by construction. Even an adversary who obtains the hash stream from
|
||||
both nodes cannot determine whether the same person visited both sites, because the
|
||||
site_salt is unknown and different.
|
||||
|
||||
The daily rotation (`day_epoch` = floor(timestamp_ns / 86400e9)) means that even within
|
||||
a single site, the hash of the same person changes each day. Hashes older than 24 hours
|
||||
have zero correlation with hashes produced today.
|
||||
|
||||
This is structural impossibility, not policy. The invariant holds even if the operator
|
||||
misconfigures the system, because it derives from the cryptographic property of blake3
|
||||
with a secret key, not from access-control rules.
|
||||
|
||||
---
|
||||
|
||||
## 4. Relationship to RuView's Ambient Intelligence Positioning
|
||||
|
||||
The project memory records RuView's positioning as "ambient intelligence platform, not
|
||||
sensor; packaging (HA, Docker, mDNS, blueprints) is the bottleneck." This framing is
|
||||
load-bearing for BFLD's design.
|
||||
|
||||
A "sensor" in the Home Assistant model is a device that reports measurements. A "sensor"
|
||||
is allowed to identify who is present — facial recognition cameras are sensors. BFLD
|
||||
explicitly rejects this model: the node is an ambient intelligence node that knows
|
||||
something about the environment (motion, occupancy, activity level) but structurally
|
||||
cannot know *who* is in the environment.
|
||||
|
||||
This positioning enables deployment in spaces where identity-tracking would be
|
||||
unacceptable: shared workspaces, guest accommodations, hotel rooms, care facilities.
|
||||
The argument to an operator at a care facility is not "trust us, we won't log who your
|
||||
patients are." It is: "the system is architecturally incapable of logging who your
|
||||
patients are, because the identifier rotates daily with a site-specific secret we don't
|
||||
hold."
|
||||
|
||||
---
|
||||
|
||||
## 5. Why This Layer Must Exist Before WiFi 7 Ships
|
||||
|
||||
802.11be (Wi-Fi 7) is entering mass market deployment in 2025–2026. It introduces
|
||||
multi-link operation (MLO), which dramatically increases the frequency of beamforming
|
||||
sounding exchanges. Where 802.11ax sonding might occur at 10–40 Hz, MLO sounding on
|
||||
multiple links simultaneously could produce 3–5× more CBFR frames per second.
|
||||
|
||||
More frames means more training data for identity classifiers. The BFId result at 5
|
||||
seconds of 802.11ac data will almost certainly improve with 5 seconds of 802.11be MLO
|
||||
data. The attack surface is not static.
|
||||
|
||||
BFLD's frame format (magic 0xBF1D_0001, version byte for extension) is designed to
|
||||
remain valid across protocol generations. The feature extraction modules are pluggable:
|
||||
a WiFi 7 BFI extractor can be added without changing the privacy gate, the hash rotation,
|
||||
or the MQTT emitter. The invariants remain invariant.
|
||||
|
||||
The window to establish safe defaults is now, before the installed base is hundreds of
|
||||
millions of unprotected nodes. BFLD is the layer that carries those safe defaults into
|
||||
every deployment from day one.
|
||||
@@ -0,0 +1,278 @@
|
||||
# BFLD Security Threat Model
|
||||
|
||||
## 1. Adversary Classes
|
||||
|
||||
### A1 — Passive Sniffer (Curious Neighbor)
|
||||
|
||||
**Capability**: WiFi adapter in monitor mode; consumer laptop running Wi-BFI or
|
||||
tcpdump with CBFR filter. No special access, no relationship to the target network.
|
||||
|
||||
**Goal**: Determine occupancy or identity of persons in an adjacent apartment/office.
|
||||
|
||||
**Effort**: Low. Wi-BFI is pip-installable. Monitor mode is available on commodity
|
||||
Linux laptops. No prior knowledge of the target network required — CBFR frames are
|
||||
broadcast in all directions.
|
||||
|
||||
**Relevance to BFLD**: A1 is the LeakyBeam threat (NDSS 2025). BFLD cannot prevent
|
||||
A1 from capturing BFI from the air. BFLD's job is to ensure its own output does not
|
||||
make A1's work easier by publishing identity-correlated data on reachable channels.
|
||||
|
||||
### A2 — Targeted Stalker
|
||||
|
||||
**Capability**: A1 capabilities plus knowledge of the target's device MAC address
|
||||
(obtainable from BSSID probe requests) and time correlation with known schedules.
|
||||
|
||||
**Goal**: Track a specific individual's presence across time or across locations.
|
||||
|
||||
**Effort**: Medium. Requires sustained monitoring (hours to days) and a correlation
|
||||
step.
|
||||
|
||||
**Relevance to BFLD**: If rf_signature_hash were stable over time, A2 could correlate
|
||||
hash sequences across sessions to confirm a specific person's schedule. The daily hash
|
||||
rotation (Invariant 3) severs this correlation.
|
||||
|
||||
### A3 — ISP / Operator
|
||||
|
||||
**Capability**: Access to MQTT broker, HA instance, or cloud integration receiving
|
||||
BFLD events.
|
||||
|
||||
**Goal**: Build behavioral profiles of occupants across many homes/installations.
|
||||
|
||||
**Effort**: Low if raw or identity-correlated fields are published to the broker.
|
||||
|
||||
**Relevance to BFLD**: BFLD restricts what reaches the broker. An operator cannot
|
||||
accidentally publish identity-correlated data because the privacy gate blocks it at
|
||||
the node boundary.
|
||||
|
||||
### A4 — Nation-State / Law Enforcement
|
||||
|
||||
**Capability**: Compelled access to cloud storage, MQTT broker logs, or HA history.
|
||||
Physical access to the BFLD node with forensic tools.
|
||||
|
||||
**Goal**: Retrospectively identify who was present at a location and when.
|
||||
|
||||
**Effort**: Depends on what data was logged. If BFLD's invariants hold, the broker
|
||||
holds only: presence events (boolean), motion scores (float), person counts (integer),
|
||||
and rotated hashes. None of these are individually re-identifiable.
|
||||
|
||||
**Relevant mitigation**: The daily hash rotation means that even log retention is
|
||||
privacy-preserving: a hash from Monday and a hash from Tuesday, even from the same
|
||||
person at the same node, are in disjoint hash spaces.
|
||||
|
||||
### A5 — Compromised AP Firmware
|
||||
|
||||
**Capability**: Malicious AP firmware that modifies the sounding schedule to extract
|
||||
more identity-discriminative BFI, or that responds to specially crafted packets with
|
||||
high-resolution channel feedback.
|
||||
|
||||
**Goal**: Improve passive capture quality from the node's BFI stream.
|
||||
|
||||
**Relevance to BFLD**: BFLD ingests BFI as captured from the air. If the AP is
|
||||
compromised to produce unusually high-resolution BFI, BFLD's identity_risk_score
|
||||
will correctly detect the elevated separability and flag the frames at higher risk.
|
||||
The system is self-normalizing to the quality of what is captured.
|
||||
|
||||
### A6 — Supply-Chain Compromise of RuView Node
|
||||
|
||||
**Capability**: Modified BFLD binary with the privacy gate removed or with an
|
||||
exfiltration path added.
|
||||
|
||||
**Goal**: Long-term silent collection of identity embeddings or raw BFI.
|
||||
|
||||
**Mitigation**: ADR-028's witness-bundle pattern — deterministic SHA-256 of the
|
||||
pipeline output. A compromised binary would produce different output for the same
|
||||
input, failing the verify.py check. The BFLD acceptance criterion 6 (deterministic
|
||||
frame hashes) is the direct countermeasure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Attack Trees
|
||||
|
||||
### AT-1: Passive BFI Capture → Identity Inference
|
||||
|
||||
```
|
||||
Attacker Goal: Re-identify a specific person via BFI
|
||||
|
|
||||
+-- Step 1: Place WiFi adapter in monitor mode (A1)
|
||||
| |
|
||||
| +-- CBFR frames arrive unencrypted (established by NDSS 2025 / BFId)
|
||||
|
|
||||
+-- Step 2: Parse Phi/Psi angles using Wi-BFI or equivalent
|
||||
| |
|
||||
| +-- No modification of target device required (Wi-BFI passive)
|
||||
|
|
||||
+-- Step 3: Collect 5-60 seconds of frames
|
||||
| |
|
||||
| +-- BFId: 5s sufficient at 10 Hz sounding rate for >90% accuracy
|
||||
|
|
||||
+-- Step 4: Run identity classifier (BFId architecture or similar)
|
||||
| |
|
||||
| +-- Requires enrollment (prior reference capture)
|
||||
| | |
|
||||
| | +-- OR: exploit BFLD's rf_signature_hash as a correlation anchor
|
||||
| | (mitigated by daily rotation — AT-2 below)
|
||||
|
|
||||
+-- Outcome: Identity label with >90% confidence
|
||||
```
|
||||
|
||||
BFLD mitigation: BFLD does not prevent AT-1 at the air interface. It ensures that
|
||||
BFLD's own output does not provide the "correlation anchor" in step 4.
|
||||
|
||||
### AT-2: Cross-Site Correlation via rf_signature_hash Leak
|
||||
|
||||
```
|
||||
Attacker Goal: Confirm person X visited site A and site B on the same day
|
||||
|
|
||||
+-- Prerequisite: Attacker has read access to MQTT broker at both sites
|
||||
|
|
||||
+-- Step 1: Collect rf_signature_hash sequences from site A and site B
|
||||
|
|
||||
+-- Step 2: Look for matching hashes within the same day_epoch
|
||||
| |
|
||||
| +-- BLOCKED: site_salt is site-specific and secret.
|
||||
| blake3(salt_A ‖ day ‖ features) != blake3(salt_B ‖ day ‖ features)
|
||||
| even if features are identical.
|
||||
| Two sites with the same person produce hashes in disjoint spaces.
|
||||
|
|
||||
+-- Outcome: No match possible. Attack fails structurally.
|
||||
```
|
||||
|
||||
### AT-3: Timing Side-Channel on identity_risk_score
|
||||
|
||||
```
|
||||
Attacker Goal: Infer when a known person is present by monitoring risk score changes
|
||||
|
|
||||
+-- Prerequisite: Read access to MQTT topic ruview/<node_id>/bfld/identity_risk/state
|
||||
|
|
||||
+-- Step 1: Baseline: collect identity_risk_score during known-empty periods
|
||||
|
|
||||
+-- Step 2: Monitor for anomalous spikes correlated with known schedules
|
||||
| |
|
||||
| +-- Partial mitigation: risk score is not published by default.
|
||||
| | Operator must explicitly enable it.
|
||||
| |
|
||||
| +-- Residual risk: even with publication enabled, the score measures risk of
|
||||
| identification, not identity itself. A high risk score means "this frame
|
||||
| is identity-discriminative" not "person X is present."
|
||||
|
|
||||
+-- Mitigation: MQTT ACL restricts identity_risk to local broker by default.
|
||||
+-- Mitigation: privacy_class=3 (restricted) zeros the risk score on output.
|
||||
```
|
||||
|
||||
### AT-4: MQTT Topic Enumeration
|
||||
|
||||
```
|
||||
Attacker Goal: Discover what BFLD data is published and harvest it
|
||||
|
|
||||
+-- Step 1: Connect to broker without TLS (if TLS not configured)
|
||||
|
|
||||
+-- Step 2: Subscribe to ruview/# wildcard
|
||||
|
|
||||
+-- Mitigation: Default mosquitto ACL denies wildcard subscription to anonymous clients.
|
||||
+-- Mitigation: TLS + client certificates recommended for all BFLD deployments.
|
||||
+-- Mitigation: ruview/<node_id>/bfld/raw/state is disabled by default.
|
||||
```
|
||||
|
||||
### AT-5: Matter Cluster Abuse
|
||||
|
||||
```
|
||||
Attacker Goal: Extract identity-correlated data via the Matter protocol integration
|
||||
|
|
||||
+-- Step 1: Join the Matter fabric as a legitimate controller
|
||||
|
|
||||
+-- Step 2: Read clusters exposed by the BFLD Matter endpoint
|
||||
| |
|
||||
| +-- Available: OccupancySensing (presence), MotionSensor (motion),
|
||||
| PeopleCount (person_count)
|
||||
| |
|
||||
| +-- NOT AVAILABLE: identity_risk_score, rf_signature_hash, raw_bfi,
|
||||
| identity_embedding — these are rejected at the Matter boundary.
|
||||
|
|
||||
+-- Outcome: Attacker gets presence/motion/count — same as any occupancy sensor.
|
||||
No identity-correlated data is accessible via Matter.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Trust Boundary Diagram
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ BFLD NODE (local) │
|
||||
│ │
|
||||
│ WiFi air interface │
|
||||
│ │ CBFR frames (unencrypted, passively sniffable by any A1) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ raw BFI ┌──────────────┐ │
|
||||
│ │ BFI │──────────────│ Feature │ │
|
||||
│ │ Extractor │ (local RAM) │ Extractor │ │
|
||||
│ └──────────────┘ └──────┬───────┘ │
|
||||
│ │ features (not BFI) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ embedding │
|
||||
│ │ Identity │──────────────┐ │
|
||||
│ │ Risk Engine │ (local RAM │ │
|
||||
│ └──────┬───────┘ ring buf) │ │
|
||||
│ │ risk_score │ │
|
||||
│ ▼ │ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ Privacy Gate │ │ │
|
||||
│ │ privacy_class check | hash rotation | field masking │ │ │
|
||||
│ └───────┬──────────────────────────────────────────────┘ │ │
|
||||
│ │ filtered BfldFrame [embedding │ │
|
||||
│ │ (no raw BFI, no embedding) NEVER exits │ │
|
||||
│ ▼ this box] │ │
|
||||
│ ┌──────────────┐ │ │
|
||||
│ │ MQTT │ presence/motion/person_count/risk(opt) │ │
|
||||
│ │ Emitter │────────────────────────────────────────► │ │
|
||||
│ └──────────────┘ [TLS recommended] │ │
|
||||
│ │ │
|
||||
└──────────────────────────────────────────────────────────────┘─────────┘
|
||||
│
|
||||
│ MQTT (TLS)
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Local Broker │ │ cognitum-v0 federation endpoint │
|
||||
│ (mosquitto) │──────► │ (identity fields STRIPPED at node │
|
||||
└────────┬────────────┘ │ boundary before federation) │
|
||||
│ └──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Home Assistant │──────► │ Matter Fabric │
|
||||
│ (presence/motion/ │ │ (OccupancySensing / MotionSensor / │
|
||||
│ person_count only)│ │ PeopleCount ONLY) │
|
||||
└─────────────────────┘ └──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Threat Profile per privacy_class Value
|
||||
|
||||
| privacy_class | Value | Data exposed outbound | Residual threats |
|
||||
|--------------|-------|----------------------|-----------------|
|
||||
| raw | 0 | Derived angles + amplitude proxy + phase proxy + SNR. Never BFI matrix. | Angle sequences are identity-discriminative; use only in controlled research environments. Never default. |
|
||||
| derived | 1 | All BFLD output fields including identity_risk_score and rf_signature_hash. | Risk score timing side-channel (AT-3). Hash must remain rotated. |
|
||||
| anonymous | 2 | presence, motion, person_count, zone_activity, confidence. No identity-correlated fields. | Temporal occupancy patterns may leak schedule information. Not identity. |
|
||||
| restricted | 3 | presence only (binary). All other fields zeroed or suppressed. | Minimal. On/off presence is equivalent to a passive IR sensor. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Witness / Attestation Strategy
|
||||
|
||||
Following ADR-028's pattern, BFLD should produce a deterministic proof bundle:
|
||||
|
||||
1. **Reference input**: a fixed seed synthetic BFI matrix (512 bytes, PRNG seed=117)
|
||||
stored alongside the test suite.
|
||||
2. **Expected output hash**: SHA-256 of the serialized `BfldFrame` produced from that
|
||||
input, committed to the repository.
|
||||
3. **CI check**: `verify_bfld.py` — same structure as `archive/v1/data/proof/verify.py`
|
||||
— runs in CI and locally. A compromised binary (A6 threat) would change the output
|
||||
hash and immediately fail this check.
|
||||
4. **Witness log**: extend `docs/WITNESS-LOG-028.md` with a BFLD section covering the
|
||||
privacy gate and hash rotation.
|
||||
|
||||
This attestation does not prevent a runtime compromise, but it raises the cost
|
||||
significantly: a supply-chain attacker must either (a) match the expected output hash
|
||||
while also exfiltrating data (computationally infeasible for a hash adversary), or
|
||||
(b) accept that the tampered binary will be detected on the next verify run.
|
||||
@@ -0,0 +1,279 @@
|
||||
# BFLD Privacy Gating — Mechanisms in Depth
|
||||
|
||||
## 1. The privacy_class Byte: Concrete Data Exposure Tables
|
||||
|
||||
The `privacy_class` byte is the single authoritative classifier for what a BFLD node
|
||||
is permitted to emit. It is set by the privacy gate module (`privacy_gate.rs`) on every
|
||||
outbound `BfldFrame` based on the computed `identity_risk_score` and operator configuration.
|
||||
|
||||
### Class 0 — raw
|
||||
|
||||
Intended exclusively for local research captures and red-team validation. Not a
|
||||
deployable configuration.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | Boolean |
|
||||
| motion | Yes | 0..1 float |
|
||||
| person_count | Yes | u8 |
|
||||
| identity_risk_score | Yes | f32 |
|
||||
| rf_signature_hash | Yes | Rotated blake3, 32 bytes hex |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| compressed_angle_matrix | Yes | Phi/Psi per subcarrier — the sensitive surface |
|
||||
| amplitude_proxy | Yes | |
|
||||
| phase_proxy | Yes | |
|
||||
| snr_vector | Yes | |
|
||||
| bfi_matrix (raw) | NEVER | Dropped before serialization; not in wire format |
|
||||
| identity_embedding | NEVER | Local RAM only; not in wire format |
|
||||
|
||||
### Class 1 — derived
|
||||
|
||||
Default for operator-opted-in diagnostics. Includes identity_risk_score and hash but
|
||||
no angle matrices.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | Yes | |
|
||||
| person_count | Yes | |
|
||||
| identity_risk_score | Yes | Diagnostic; not in HA default entities |
|
||||
| rf_signature_hash | Yes | Rotated hash only |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| compressed_angle_matrix | No | Zeroed |
|
||||
| amplitude_proxy | No | |
|
||||
| phase_proxy | No | |
|
||||
| snr_vector | Yes | Per-stream aggregate only |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
### Class 2 — anonymous
|
||||
|
||||
Default for all standard deployments. No identity-correlated fields.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | Yes | |
|
||||
| person_count | Yes | |
|
||||
| identity_risk_score | No | Suppressed |
|
||||
| rf_signature_hash | No | Suppressed |
|
||||
| zone_activity | Yes | |
|
||||
| confidence | Yes | |
|
||||
| All angle/amplitude/phase fields | No | Zeroed |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
### Class 3 — restricted
|
||||
|
||||
Maximum privacy. Suitable for care facilities, medical deployments, guest spaces.
|
||||
|
||||
| Field | Published | Notes |
|
||||
|-------|-----------|-------|
|
||||
| presence | Yes | |
|
||||
| motion | No | Suppressed |
|
||||
| person_count | No | Suppressed |
|
||||
| All other fields | No | |
|
||||
| bfi_matrix (raw) | NEVER | |
|
||||
| identity_embedding | NEVER | |
|
||||
|
||||
---
|
||||
|
||||
## 2. rf_signature_hash Rotation Algorithm
|
||||
|
||||
### Construction
|
||||
|
||||
```
|
||||
site_salt := blake3_keyed_hash(secret="bfld-site-seed", data=node_mac_address)
|
||||
# Generated once at first boot, stored in NVS, never transmitted
|
||||
# 32 bytes
|
||||
|
||||
day_epoch := floor(timestamp_ns / 86_400_000_000_000)
|
||||
# One new epoch per UTC day
|
||||
|
||||
ephemeral := mean_angle_delta ‖ subcarrier_variance ‖ burst_motion_score
|
||||
# A small fixed-length summary of the current window's features
|
||||
# Not identity-specific — any of several persons could produce
|
||||
# similar values
|
||||
|
||||
rf_signature_hash := BLAKE3(
|
||||
key = site_salt, // 32 bytes; site-specific secret key
|
||||
input = day_epoch_bytes(8) ‖ ephemeral_features(24)
|
||||
)
|
||||
```
|
||||
|
||||
### Why cross-site re-identification is structurally impossible
|
||||
|
||||
Two BFLD nodes at sites A and B produce:
|
||||
|
||||
```
|
||||
hash_A = BLAKE3(key=salt_A, input=day ‖ features)
|
||||
hash_B = BLAKE3(key=salt_B, input=day ‖ features)
|
||||
```
|
||||
|
||||
BLAKE3 is a PRF (pseudorandom function family) keyed on site_salt. Given identical
|
||||
`day ‖ features` inputs, hash_A and hash_B are pseudorandom and independent because
|
||||
salt_A != salt_B. An adversary who observes hash_A and hash_B cannot determine whether
|
||||
they correspond to the same person without knowing both salts.
|
||||
|
||||
This is not a security proof; it is a consequence of BLAKE3's PRF security assumption,
|
||||
which holds as long as the site_salt remains secret.
|
||||
|
||||
### Why within-site, within-day tracking is safe
|
||||
|
||||
Within a single day at a single site, two frames from the same person will produce
|
||||
similar ephemeral features, leading to similar (though not identical — ephemeral features
|
||||
have some frame-to-frame variation) hash values. This is intentional: it allows
|
||||
clustering of same-person events within a session without enabling identity recovery.
|
||||
|
||||
The hash is NOT the identity. It is a pseudonym within the scope of (site, day). A
|
||||
person who visits the same site on two different days gets different pseudonyms on each
|
||||
day.
|
||||
|
||||
### Daily rotation schedule
|
||||
|
||||
```
|
||||
epoch_0 = 0 # day 0 (unix epoch: 1970-01-01)
|
||||
epoch_k = k * 86_400_000_000_000 # day k in nanoseconds
|
||||
rotation_time = epoch_{k+1} # midnight UTC
|
||||
```
|
||||
|
||||
At rotation time, all existing rf_signature_hash values become cryptographically
|
||||
disconnected from future values. Logs from before rotation cannot be correlated with
|
||||
logs after rotation even by the node operator.
|
||||
|
||||
---
|
||||
|
||||
## 3. Identity Embedding Lifecycle
|
||||
|
||||
```
|
||||
BFI frame arrives
|
||||
|
|
||||
v
|
||||
Feature extraction (identity_risk.rs)
|
||||
|
|
||||
v
|
||||
RuVector embedding computed: Vec<f32, 128>
|
||||
|
|
||||
+-------> identity_risk_score (scalar projection)
|
||||
| Published (class 1) or suppressed (class 2/3)
|
||||
|
|
||||
v
|
||||
In-RAM ring buffer (EmbeddingRingBuf)
|
||||
- capacity: 600 frames (default 10 minutes at 1 Hz)
|
||||
- implemented as VecDeque<Embedding> in heap memory
|
||||
- NEVER written to disk (no serde, no file I/O in the type)
|
||||
- NEVER serialized to any MQTT or HTTP path
|
||||
- Cleared on node restart (RAM is volatile)
|
||||
|
|
||||
v [after retention window]
|
||||
Dropped from ring buffer
|
||||
```
|
||||
|
||||
The ring buffer serves two purposes: (1) temporal_stability calculation requires
|
||||
comparing the current embedding to recent embeddings; (2) the coherence gate
|
||||
(`coherence_gate.rs`, from `v2/crates/wifi-densepose-signal/src/ruvsense/`) uses
|
||||
recent frames to determine whether a new frame is a continuation of an existing
|
||||
trajectory or a new event.
|
||||
|
||||
Both purposes require only that the embeddings exist in RAM during the computation.
|
||||
Neither purpose requires persistence.
|
||||
|
||||
---
|
||||
|
||||
## 4. Privacy-Mode Wire-Format Diff
|
||||
|
||||
The following shows what changes in the serialized `BfldFrame` payload when the node
|
||||
transitions from class 1 (derived) to class 2 (anonymous), which is the transition
|
||||
that happens when `privacy_mode` is enabled by the operator.
|
||||
|
||||
```
|
||||
BfldFrame {
|
||||
magic: 0xBF1D_0001, // unchanged
|
||||
version: 1, // unchanged
|
||||
ap_id: blake3(node_mac ‖ "ap"), // unchanged (already hashed at ingress)
|
||||
sta_id: ephemeral_u64, // unchanged (already ephemeral)
|
||||
session_id: u64, // unchanged
|
||||
quantization: 0x02, // unchanged (i8 in class 1)
|
||||
privacy_class: 0x01 -> 0x02, // CHANGED
|
||||
|
||||
// Payload (compressed):
|
||||
compressed_angle_matrix: [...], // class 1: present; class 2: zeroed + omitted
|
||||
amplitude_proxy: [...], // class 1: present; class 2: omitted
|
||||
phase_proxy: [...], // class 1: present; class 2: omitted
|
||||
snr_vector: [...], // class 1: present; class 2: present (aggregate)
|
||||
|
||||
// Event (JSON within payload or outer envelope):
|
||||
presence: true, // unchanged
|
||||
motion: 0.42, // unchanged
|
||||
person_count: 1, // unchanged
|
||||
identity_risk_score: 0.71, // class 1: present; class 2: OMITTED
|
||||
rf_signature_hash: "a3f2...", // class 1: present; class 2: OMITTED
|
||||
zone_activity: "living_room", // unchanged
|
||||
confidence: 0.88, // unchanged
|
||||
payload_crc32: <recomputed> // recomputed after changes
|
||||
}
|
||||
```
|
||||
|
||||
The wire-format diff is verified by the acceptance test suite: the same input must
|
||||
produce a deterministic output for each privacy_class value.
|
||||
|
||||
---
|
||||
|
||||
## 5. Default-Deny Posture for Future Fields
|
||||
|
||||
Every new field added to `BfldFrame` or the BFLD event JSON in the future MUST be
|
||||
classified before it ships. The process:
|
||||
|
||||
1. New field is added to `BfldFrame` struct.
|
||||
2. A `#[privacy_class(minimum = N)]` attribute annotation (or equivalent runtime
|
||||
check in `privacy_gate.rs`) declares the minimum privacy class at which this
|
||||
field is suppressed.
|
||||
3. Unit test asserts that serializing at class < N includes the field and at class ≥ N
|
||||
omits it.
|
||||
4. The PR that adds the field cannot pass CI without the classification annotation.
|
||||
|
||||
This is enforced by a custom `#[must_classify]` lint in the crate — any public field
|
||||
on `BfldFrame` without a classification attribute produces a compile warning that
|
||||
becomes a CI error.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auditability: Verifying That Raw BFI Never Left the Network
|
||||
|
||||
An operator who wants to verify that no raw BFI or identity data has been transmitted
|
||||
from their BFLD node can use the following procedure:
|
||||
|
||||
### 6.1 Network-level audit (tcpdump)
|
||||
|
||||
```bash
|
||||
# On the node or a port-mirrored switch:
|
||||
tcpdump -i eth0 -w bfld_audit.pcap port 1883 or port 8883
|
||||
|
||||
# After capture, search for the BFI frame magic bytes in the PCAP:
|
||||
# Magic 0xBF1D_0001 in big-endian is bytes BF 1D 00 01
|
||||
# If these bytes appear in the MQTT payload, raw BFI may be present.
|
||||
# They should NOT appear — BFLD strips the angle matrix at privacy_class >= 2.
|
||||
strings bfld_audit.pcap | grep -v "presence\|motion\|person_count" | wc -l
|
||||
# Expected: only presence/motion/person_count keys in the MQTT payloads.
|
||||
```
|
||||
|
||||
### 6.2 Node self-check command
|
||||
|
||||
```bash
|
||||
# RuView CLI (planned for P3):
|
||||
wifi-densepose bfld audit --duration 60s
|
||||
# Output: "60 frames processed. 0 frames with raw_bfi in payload.
|
||||
# 0 frames with identity_embedding in payload.
|
||||
# privacy_class distribution: {2: 57, 3: 3}"
|
||||
```
|
||||
|
||||
### 6.3 CI deterministic hash check
|
||||
|
||||
```bash
|
||||
python python/wifi_densepose/verify_bfld.py
|
||||
# Must print: VERDICT: PASS
|
||||
# If a modified binary is exfiltrating raw BFI as part of the payload,
|
||||
# the output hash will differ from the committed expected hash.
|
||||
```
|
||||
@@ -0,0 +1,239 @@
|
||||
# BFLD Automation & Ecosystem Integration
|
||||
|
||||
## 1. Home Assistant Integration
|
||||
|
||||
### 1.1 Entities Exposed by BFLD
|
||||
|
||||
BFLD extends the sensing-server's existing HA entity set (ADR-115, 21 entities) with
|
||||
the following new entities:
|
||||
|
||||
| Entity | Type | HA Platform | privacy_class | Default |
|
||||
|--------|------|-------------|--------------|---------|
|
||||
| `binary_sensor.bfld_presence` | Boolean | binary_sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_motion` | Float 0..1 | sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_person_count` | Integer | sensor | 1 — derived | ON |
|
||||
| `sensor.bfld_confidence` | Float 0..1 | sensor | 2 — anonymous | ON |
|
||||
| `sensor.bfld_identity_risk` | Float 0..1 | sensor (diagnostic) | 1 — derived | OFF |
|
||||
| `sensor.bfld_zone_activity` | String | sensor | 2 — anonymous | ON |
|
||||
|
||||
`bfld_identity_risk` is classified as a diagnostic entity in the HA model — it is
|
||||
hidden by default in the UI and not included in recorder history unless explicitly
|
||||
enabled. This matches the operator opt-in posture for class-1 fields.
|
||||
|
||||
### 1.2 MQTT Discovery Payload (example for presence sensor)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "BFLD Presence",
|
||||
"unique_id": "bfld_presence_<node_id_hash>",
|
||||
"state_topic": "ruview/<node_id>/bfld/presence/state",
|
||||
"device_class": "occupancy",
|
||||
"payload_on": "true",
|
||||
"payload_off": "false",
|
||||
"device": {
|
||||
"identifiers": ["ruview_<node_id_hash>"],
|
||||
"name": "RuView BFLD Node",
|
||||
"model": "wifi-densepose-bfld",
|
||||
"manufacturer": "RuView"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Topic: `homeassistant/binary_sensor/bfld_<node_id_hash>/presence/config`
|
||||
|
||||
### 1.3 HA Blueprints
|
||||
|
||||
**Blueprint 1: Presence-driven lighting**
|
||||
|
||||
Trigger: `binary_sensor.bfld_presence` changes to `on`.
|
||||
Condition: Time is between sunset and sunrise.
|
||||
Action: Turn on `light.living_room` at 40% brightness.
|
||||
Exit: `binary_sensor.bfld_presence` off for 5 minutes → turn off light.
|
||||
|
||||
This blueprint uses only class-2 (anonymous) data. No identity information is required.
|
||||
|
||||
**Blueprint 2: Motion-aware HVAC**
|
||||
|
||||
Trigger: `sensor.bfld_motion` rises above 0.3 (active movement threshold).
|
||||
Action: Set `climate.living_room` to comfort mode.
|
||||
Trigger: `sensor.bfld_motion` stays below 0.1 for 20 minutes (room settled).
|
||||
Action: Set `climate.living_room` to eco mode.
|
||||
|
||||
**Blueprint 3: Identity-risk anomaly notification**
|
||||
|
||||
Trigger: `sensor.bfld_identity_risk` rises above 0.8 (high-risk threshold).
|
||||
Condition: privacy mode is NOT enabled.
|
||||
Action: Notify user via HA mobile app: "BFLD: High identity-leakage risk detected.
|
||||
Consider enabling privacy mode."
|
||||
|
||||
This blueprint is the only one that touches a class-1 field. The notification is
|
||||
a privacy-protective action — it alerts the operator that the sensing environment
|
||||
has changed (e.g., new router firmware, new AP nearby, changed room geometry) in
|
||||
a way that makes the RF channel more identity-discriminative.
|
||||
|
||||
---
|
||||
|
||||
## 2. Matter Exposure
|
||||
|
||||
Matter clusters expose the absolute minimum set of BFLD outputs. The constraint is
|
||||
intentional: Matter fabrics can include cloud bridges, and identity-correlated data
|
||||
must never reach cloud endpoints.
|
||||
|
||||
### 2.1 Permitted Matter Clusters
|
||||
|
||||
| Matter Cluster | Cluster ID | BFLD Source | Notes |
|
||||
|----------------|-----------|-------------|-------|
|
||||
| Occupancy Sensing | 0x0406 | `presence` | `OccupancySensing` attribute `Occupancy` bit 0 |
|
||||
| Motion Detection | 0x040E (proposed) | `motion` | Published as motion event cluster |
|
||||
| People Count | — (vendor extension) | `person_count` | No standard cluster yet; use vendor attribute |
|
||||
|
||||
### 2.2 Rejected Matter Fields
|
||||
|
||||
The following BFLD fields MUST NOT be exposed via Matter regardless of operator
|
||||
configuration:
|
||||
|
||||
- `identity_risk_score`
|
||||
- `rf_signature_hash`
|
||||
- `raw_bfi`
|
||||
- `identity_embedding`
|
||||
- `compressed_angle_matrix`
|
||||
- Any future field classified at privacy_class < 2
|
||||
|
||||
This rejection is enforced in the `cog-ha-matter` crate (`v2/crates/cog-ha-matter/`),
|
||||
which filters `BfldFrame` events before populating Matter attribute reports.
|
||||
|
||||
### 2.3 Matter Endpoint Configuration
|
||||
|
||||
```
|
||||
Endpoint 1: BFLD Occupancy
|
||||
- Cluster: Occupancy Sensing (0x0406)
|
||||
- Attribute 0x0000 Occupancy: 0x01 (bitmask, bit 0 = presence)
|
||||
- Attribute 0x0001 OccupancySensorType: 0x03 (Other = WiFi RF)
|
||||
- Cluster: Basic Information (0x0028)
|
||||
- NodeLabel: "BFLD-<node_id_short>"
|
||||
- ProductName: "wifi-densepose-bfld"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. MQTT Topic Structure and ACL Recommendations
|
||||
|
||||
### 3.1 Topic Tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/
|
||||
presence/state # "true" | "false" — class 2
|
||||
motion/state # "0.42" — class 2
|
||||
person_count/state # "1" — class 1
|
||||
identity_risk/state # "0.71" — class 1, disabled by default
|
||||
raw/state # disabled by default, class 0 metadata only
|
||||
zone_activity/state # "living_room" — class 2
|
||||
confidence/state # "0.88" — class 2
|
||||
events/bfld_update # Full JSON event payload — class 2 fields only by default
|
||||
```
|
||||
|
||||
### 3.2 Mosquitto ACL Recommendations
|
||||
|
||||
```
|
||||
# /etc/mosquitto/acl.conf (example)
|
||||
|
||||
# BFLD node publishes to its own subtree
|
||||
user bfld_node_<node_id>
|
||||
topic write ruview/<node_id>/bfld/#
|
||||
|
||||
# Home Assistant reads presence, motion, count, zone, confidence
|
||||
user homeassistant
|
||||
topic read ruview/+/bfld/presence/state
|
||||
topic read ruview/+/bfld/motion/state
|
||||
topic read ruview/+/bfld/person_count/state
|
||||
topic read ruview/+/bfld/zone_activity/state
|
||||
topic read ruview/+/bfld/confidence/state
|
||||
topic read ruview/+/bfld/events/bfld_update
|
||||
|
||||
# HA diagnostic access (operator opt-in required to add this rule):
|
||||
# topic read ruview/+/bfld/identity_risk/state
|
||||
|
||||
# DENY all wildcard subscriptions for anonymous clients:
|
||||
# (mosquitto default: anonymous clients get no access)
|
||||
|
||||
# DENY raw topic for all non-admin users:
|
||||
# raw/state is never written by default; no read ACL needed
|
||||
```
|
||||
|
||||
### 3.3 TLS Configuration
|
||||
|
||||
BFLD should use TLS for all MQTT connections. The BFLD node connects as a TLS client;
|
||||
the broker must present a certificate matching the expected CA. The sensing-server
|
||||
already supports mTLS (ADR-115). BFLD inherits this configuration.
|
||||
|
||||
---
|
||||
|
||||
## 4. Node-RED and OpenHAB Compatibility
|
||||
|
||||
BFLD publishes standard MQTT payloads with consistent topic structure. No Node-RED
|
||||
or OpenHAB plugin is required; standard MQTT input/output nodes work directly.
|
||||
|
||||
**Node-RED example flow**:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "bfld-in", "type": "mqtt in",
|
||||
"topic": "ruview/+/bfld/presence/state", "qos": "1"},
|
||||
{"id": "filter", "type": "switch",
|
||||
"property": "payload", "rules": [{"t": "eq", "v": "true"}]},
|
||||
{"id": "notify", "type": "http request",
|
||||
"url": "http://ha/api/events/bfld_presence_on"}
|
||||
]
|
||||
```
|
||||
|
||||
**OpenHAB MQTT binding** (items file):
|
||||
|
||||
```
|
||||
Switch BfldPresence "BFLD Presence" {mqtt="<[broker:ruview/node1/bfld/presence/state:state:default]"}
|
||||
Number BfldMotion "BFLD Motion" {mqtt="<[broker:ruview/node1/bfld/motion/state:state:default]"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. cognitum-v0 Federation
|
||||
|
||||
The cognitum-v0 appliance (Pi 5, running ruview-mcp-brain on port 9876,
|
||||
cognitum-rvf-agent on port 9004, ruvector-hailo-worker on port 50051 — see
|
||||
CLAUDE.local.md) is the fleet coordinator for multi-room correlation.
|
||||
|
||||
BFLD events from individual nodes flow to cognitum-v0 via the federation path.
|
||||
The critical constraint: **identity fields are stripped at the node boundary before
|
||||
federation**. The stripping happens in the local BFLD emitter (`mqtt.rs`), not in
|
||||
cognitum-v0. By the time a BFLD event reaches the broker that cognitum-v0 subscribes to,
|
||||
it contains only class-2 (anonymous) or class-3 (restricted) fields.
|
||||
|
||||
### 5.1 Federation Topics
|
||||
|
||||
```
|
||||
# Node-local (not federated):
|
||||
ruview/<node_id>/bfld/identity_risk/state
|
||||
ruview/<node_id>/bfld/raw/state
|
||||
|
||||
# Federated (forwarded to cognitum-v0 broker):
|
||||
ruview/<node_id>/bfld/presence/state
|
||||
ruview/<node_id>/bfld/motion/state
|
||||
ruview/<node_id>/bfld/person_count/state
|
||||
ruview/<node_id>/bfld/events/bfld_update
|
||||
```
|
||||
|
||||
### 5.2 cognitum-rvf-agent Role
|
||||
|
||||
The `cognitum-rvf-agent` (port 9004) handles cross-node RVF (RuView Frame) container
|
||||
events. For BFLD, it receives federated presence/motion/count events and can correlate
|
||||
them for multi-room occupancy (e.g., "person moved from living room node to kitchen
|
||||
node"). It does not receive or need identity information to perform this correlation —
|
||||
it uses temporal and spatial proximity, not identity.
|
||||
|
||||
### 5.3 Hailo Inference (Future)
|
||||
|
||||
The `ruvector-hailo-worker` (port 50051) on cognitum-v0 runs vector similarity on the
|
||||
Hailo-8 AI accelerator. A future extension could offload BFLD's identity_risk_score
|
||||
computation to the Hailo worker, keeping the identity embedding local to cognitum-v0
|
||||
while giving individual nodes the benefit of a larger enrollment pool for risk
|
||||
calibration. This is explicitly out of scope for the current BFLD spec — it is noted
|
||||
here as an integration-compatible extension point.
|
||||
@@ -0,0 +1,253 @@
|
||||
# BFLD Implementation Plan
|
||||
|
||||
## 1. New Crate: wifi-densepose-bfld
|
||||
|
||||
Location: `v2/crates/wifi-densepose-bfld/`
|
||||
|
||||
This crate slots between `wifi-densepose-signal` (BFI normalization, temporal windowing)
|
||||
and `wifi-densepose-sensing-server` (MQTT/HA integration). It does not depend on the
|
||||
training pipeline (`wifi-densepose-train`) or the neural-network inference crate
|
||||
(`wifi-densepose-nn`) in the default build — feature flags activate those paths.
|
||||
|
||||
### 1.1 Module Layout
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-bfld/
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs # Public API: BfldPipeline, BfldFrame, BfldEvent
|
||||
frame.rs # BfldFrame struct, serialization, CRC32, magic bytes
|
||||
extractor.rs # BFI packet capture interface, Phi/Psi parsing,
|
||||
# 802.11ac/ax CBFR format decoder
|
||||
features.rs # Feature computation: mean_angle_delta,
|
||||
# subcarrier_variance, temporal_entropy,
|
||||
# doppler_proxy, path_stability,
|
||||
# cross_antenna_correlation, burst_motion_score,
|
||||
# stationarity_score, identity_separability_score
|
||||
identity_risk.rs # identity_risk_score formula, EmbeddingRingBuf,
|
||||
# in-RAM-only lifecycle enforcement
|
||||
privacy_gate.rs # privacy_class assignment, field masking,
|
||||
# #[must_classify] lint check
|
||||
emitter.rs # BfldEvent construction, JSON serialization
|
||||
mqtt.rs # MQTT topic publishing, ACL, per-class topic routing
|
||||
tests/
|
||||
frame_roundtrip.rs # BfldFrame serialization + CRC32 determinism
|
||||
privacy_gate.rs # Per-class field suppression assertions
|
||||
hash_rotation.rs # Cross-site isolation + daily rotation proofs
|
||||
identity_risk.rs # Risk score bounded [0,1], local-only embedding
|
||||
acceptance.rs # All 7 acceptance criteria as named tests
|
||||
benches/
|
||||
pipeline_throughput.rs # Frame processing at 40 Hz
|
||||
```
|
||||
|
||||
### 1.2 Public API Sketch
|
||||
|
||||
```rust
|
||||
// lib.rs — primary entry points
|
||||
|
||||
pub struct BfldPipeline {
|
||||
config: BfldConfig,
|
||||
extractor: BfiExtractor,
|
||||
feature_engine: FeatureEngine,
|
||||
identity_risk: IdentityRiskEngine,
|
||||
privacy_gate: PrivacyGate,
|
||||
emitter: BfldEmitter,
|
||||
}
|
||||
|
||||
impl BfldPipeline {
|
||||
pub fn new(config: BfldConfig) -> Result<Self, BfldError>;
|
||||
pub fn process_frame(&mut self, raw: RawBfiCapture) -> Option<BfldEvent>;
|
||||
pub fn current_privacy_class(&self) -> PrivacyClass;
|
||||
pub fn enable_privacy_mode(&mut self); // forces class 3
|
||||
}
|
||||
|
||||
pub struct BfldEvent {
|
||||
pub timestamp_ns: u64,
|
||||
pub presence: bool,
|
||||
pub motion: f32, // 0.0..1.0
|
||||
pub person_count: u8,
|
||||
pub identity_risk_score: Option<f32>, // None if privacy_class >= 2
|
||||
pub rf_signature_hash: Option<[u8; 32]>, // None if privacy_class >= 2
|
||||
pub zone_id: Option<ZoneId>,
|
||||
pub confidence: f32,
|
||||
pub privacy_class: PrivacyClass,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
pub enum PrivacyClass {
|
||||
Raw = 0,
|
||||
Derived = 1,
|
||||
Anonymous = 2,
|
||||
Restricted = 3,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Reuse Map: Existing Crates and Modules
|
||||
|
||||
### 2.1 RuvSense Modules (wifi-densepose-signal)
|
||||
|
||||
Path: `v2/crates/wifi-densepose-signal/src/ruvsense/`
|
||||
|
||||
| Module | Used by BFLD | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `coherence_gate.rs` | `identity_risk.rs` | Accept/reject frame based on coherence score; gates embeddings fed into risk calculation |
|
||||
| `multistatic.rs` | `features.rs` | Attention-weighted fusion for cross_perspective_consistency component of risk score |
|
||||
| `cross_room.rs` | `privacy_gate.rs` | Environment fingerprinting — confirms that the site_salt corresponds to the current room geometry |
|
||||
| `longitudinal.rs` | `identity_risk.rs` | Welford stats for temporal_stability component |
|
||||
| `adversarial.rs` | `extractor.rs` | Physically-impossible signal detection — flags frames that may be from a compromised AP (A5 threat) |
|
||||
|
||||
Not used by BFLD: `pose_tracker.rs`, `intention.rs`, `gesture.rs`, `tomography.rs`,
|
||||
`field_model.rs` — these operate above the identity-risk layer.
|
||||
|
||||
### 2.2 RuVector v2.0.4 Crates
|
||||
|
||||
| Crate | BFLD Usage | Rationale |
|
||||
|-------|-----------|-----------|
|
||||
| `ruvector-attention` | `identity_risk.rs` | Spatial attention over subcarrier dimension for embedding computation |
|
||||
| `ruvector-mincut` | `features.rs` | Person separation score as input to person_count feature |
|
||||
| `ruvector-temporal-tensor` | `extractor.rs` | Temporal windowing + compression of BFI angle sequences |
|
||||
|
||||
Not used: `ruvector-attn-mincut`, `ruvector-solver` — spectrogram and sparse
|
||||
interpolation are not needed in the BFI pipeline.
|
||||
|
||||
### 2.3 Cross-Viewpoint Fusion (wifi-densepose-ruvector)
|
||||
|
||||
Path: `v2/crates/wifi-densepose-ruvector/src/viewpoint/`
|
||||
|
||||
| Module | BFLD Usage |
|
||||
|--------|-----------|
|
||||
| `coherence.rs` | Cross-viewpoint phase coherence for cross_perspective_consistency risk component |
|
||||
| `geometry.rs` | Fisher Information / Cramer-Rao bounds for confidence estimation |
|
||||
| `attention.rs` | GeometricBias-weighted attention for multi-AP BFI fusion |
|
||||
| `fusion.rs` | MultistaticArray aggregate root — BFLD subscribes to domain events here |
|
||||
|
||||
---
|
||||
|
||||
## 3. ESP32 Firmware Additions
|
||||
|
||||
### 3.1 ESP32-S3 BFI Capability Assessment
|
||||
|
||||
The ESP32-S3's WiFi driver (`csi_collector.c` in `firmware/esp32-csi-node/main/`)
|
||||
uses `esp_wifi_csi_set_config()` and the `wifi_csi_cb_t` callback. This produces
|
||||
Espressif HT20 CSI in a vendor-specific format — amplitude + phase per subcarrier,
|
||||
not the VHT/HE Compressed Beamforming frames (CBFR) that contain Phi/Psi angles.
|
||||
|
||||
The ESP32-S3 does NOT have a public API to generate or capture CBFR frames. Espressif's
|
||||
802.11 implementation does receive and process CBFR frames internally (for beamforming
|
||||
its own transmissions), but these are not exposed via the CSI callback.
|
||||
|
||||
**Consequence**: BFI capture for BFLD requires host-side sniffing, not ESP32 firmware
|
||||
modification.
|
||||
|
||||
### 3.2 Host-Side BFI Capture Path
|
||||
|
||||
Recommended capture hardware: Raspberry Pi 5 with BCM43456 chip running Nexmon CSI
|
||||
patch. This is already present in the fleet as `cognitum-v0` (Pi 5, Tailscale IP
|
||||
100.77.59.83 per CLAUDE.local.md).
|
||||
|
||||
Capture path:
|
||||
1. Nexmon monitor mode captures all 802.11 frames on the target channel.
|
||||
2. A filter pass extracts CBFR frames (frame type = Action, subtype = VHT/HE CBFR).
|
||||
3. The rvcsi adapter (`vendor/rvcsi/`) already handles Nexmon PCap format; add a
|
||||
BFI extractor alongside the existing CSI extractor.
|
||||
4. Frames are forwarded to the BFLD pipeline via the existing UDP stream path
|
||||
(`stream_sender.c` / sensing-server).
|
||||
|
||||
### 3.3 Firmware Changes Required (Minimal)
|
||||
|
||||
The only firmware change needed in `firmware/esp32-csi-node/main/` is to the
|
||||
`stream_sender.c` protocol: add a packet type byte to the stream header to distinguish
|
||||
CSI frames from BFI frames. The BFI frames originate on the Pi-side host, not the
|
||||
ESP32; the ESP32 stream is unchanged.
|
||||
|
||||
```c
|
||||
// stream_sender.h — add packet type
|
||||
#define STREAM_PKT_TYPE_CSI 0x01
|
||||
#define STREAM_PKT_TYPE_BFI 0x02 // new: BFI frames from host capture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Plan: 7 Acceptance Criteria Mapped to Rust Tests
|
||||
|
||||
| AC | Criterion | Test in `acceptance.rs` |
|
||||
|----|-----------|------------------------|
|
||||
| AC1 | Commodity WiFi 5/6 capture (80/160 MHz, 2×2 MIMO minimum) | `ac1_commodity_wifi_capture`: assert BfiExtractor parses 80 MHz VHT CBFR sample fixture |
|
||||
| AC2 | Presence detection latency ≤ 1s from first non-empty BFI frame | `ac2_presence_latency`: replay 10-frame window, assert first `BfldEvent` with `presence=true` within 1,000 ms wall time |
|
||||
| AC3 | Motion score published at ≥ 1 Hz on `motion/state` topic | `ac3_motion_hz`: mock MQTT sink, run at 5 Hz input, assert ≥ 1 motion event per second |
|
||||
| AC4 | Raw BFI bytes never appear in serialized output | `ac4_raw_bfi_absent`: fuzz 1,000 random BfiCaptures, assert no bfi_matrix bytes in serialized BfldFrame for any privacy_class |
|
||||
| AC5 | Privacy-mode suppresses all identity-derived fields | `ac5_privacy_mode`: enable privacy_mode, assert BfldEvent fields identity_risk_score and rf_signature_hash are None |
|
||||
| AC6 | Deterministic frame hash for identical inputs | `ac6_deterministic_hash`: run same BfiCapture 100 times, assert all output hashes identical |
|
||||
| AC7 | CSI-optional fusion: pipeline runs without csi_matrix | `ac7_csi_optional`: run BfldPipeline with None csi_matrix, assert no panic and presence event produced |
|
||||
|
||||
Additionally, `tests/hash_rotation.rs` must include:
|
||||
- `cross_site_isolation`: two BfldPipelines with different site_salts, identical inputs → hashes must differ
|
||||
- `daily_rotation`: same salt, frames 1 second before/after midnight → hashes must differ
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased Rollout
|
||||
|
||||
### P1 — Frame Format + Extractor Stub (2 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `frame.rs`: `BfldFrame` struct, serialization, CRC32, magic, version
|
||||
- `extractor.rs`: CBFR parser for 802.11ac VHT + 802.11ax HE formats
|
||||
- AC1, AC6 tests passing
|
||||
- `Cargo.toml` with workspace integration
|
||||
|
||||
Effort: 1 engineer, 2 weeks.
|
||||
|
||||
### P2 — Feature Extraction + Identity Risk (3 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `features.rs`: all 9 named features (mean_angle_delta through identity_separability_score)
|
||||
- `identity_risk.rs`: risk formula, EmbeddingRingBuf, coherence gate integration
|
||||
- AC4, AC7 tests passing (raw-absent, CSI-optional)
|
||||
- Integration with `ruvector-attention` and `ruvector-temporal-tensor`
|
||||
|
||||
Effort: 1 engineer, 3 weeks.
|
||||
|
||||
### P3 — Privacy Gate + MQTT (2 weeks)
|
||||
|
||||
Deliverables:
|
||||
- `privacy_gate.rs`: privacy_class assignment, field masking, `#[must_classify]` lint
|
||||
- `mqtt.rs`: per-class topic routing, discovery payloads, ACL documentation
|
||||
- AC2, AC3, AC5 tests passing (latency, Hz, privacy-mode)
|
||||
- Hash rotation: `hash_rotation.rs` tests passing
|
||||
- Deterministic proof bundle: `verify_bfld.py` equivalent
|
||||
|
||||
Effort: 1 engineer, 2 weeks.
|
||||
|
||||
### P4 — Home Assistant Integration (1 week)
|
||||
|
||||
Deliverables:
|
||||
- MQTT discovery payloads for all 6 entities
|
||||
- 3 HA blueprints
|
||||
- `sensor.bfld_identity_risk` marked diagnostic + hidden by default
|
||||
- Update `wifi-densepose-sensing-server` to include BFLD event routing
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
### P5 — Matter Exposure (1 week)
|
||||
|
||||
Deliverables:
|
||||
- `cog-ha-matter` crate updated to filter BfldFrame → Matter attribute reports
|
||||
- OccupancySensing cluster populated from `presence`
|
||||
- Rejection list for identity fields enforced at Matter boundary
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
### P6 — cognitum Federation (1 week)
|
||||
|
||||
Deliverables:
|
||||
- Topic routing in `mqtt.rs` for federated vs local topics
|
||||
- Documentation for cognitum-rvf-agent BFLD event subscription
|
||||
- End-to-end test: Pi 5 (cognitum-v0) receives federated events, identity fields absent
|
||||
|
||||
Effort: 0.5 engineer, 1 week.
|
||||
|
||||
**Total estimate**: ~10.5 engineer-weeks across 6 phases, approximately 3 calendar months
|
||||
with one engineer.
|
||||
@@ -0,0 +1,196 @@
|
||||
# BFLD Benchmarks and Evaluation Strategy
|
||||
|
||||
## 1. Datasets
|
||||
|
||||
### 1.1 BFId Dataset (Primary)
|
||||
|
||||
**Reference**: Todt, Morsbach, Strufe; KIT. ACM CCS 2025.
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
|
||||
197 individuals. BFI and CSI recorded simultaneously. Multiple sessions, multiple AP
|
||||
angles. Available to researchers for non-commercial use on request from KIT.
|
||||
|
||||
**Use in BFLD evaluation**: The BFId dataset provides the ground-truth identity labels
|
||||
needed to calibrate `identity_risk_score`. Specifically: given BFId's known re-ID
|
||||
accuracy as a function of time window, BFLD's identity_risk_score should correlate
|
||||
with BFId's success rate. High-risk frames (score > 0.7) should correspond to windows
|
||||
where BFId achieves > 80% accuracy; low-risk frames (score < 0.2) should correspond
|
||||
to windows where BFId accuracy approaches chance.
|
||||
|
||||
### 1.2 Wi-Pose and MM-Fi (Context)
|
||||
|
||||
**MM-Fi**: Multi-modal WiFi sensing dataset used by this project (ADR-015). Contains
|
||||
synchronized WiFi CSI, mmWave, and camera pose data. Does not contain BFI separately,
|
||||
but can be used to validate BFLD's CSI-optional path (AC7).
|
||||
|
||||
**Wi-Pose**: Academic benchmark for WiFi pose estimation. CSI only; used for
|
||||
person_count and motion accuracy baselines.
|
||||
|
||||
### 1.3 Proposed In-House Multi-Site Capture Protocol
|
||||
|
||||
**Purpose**: Validate cross-site isolation (Invariant 3) and daily rotation.
|
||||
|
||||
**Setup**:
|
||||
- Site A: ruvultra (RTX 5080 workstation, Tailscale 100.104.125.72) with USB WiFi
|
||||
adapter in monitor mode.
|
||||
- Site B: cognitum-v0 (Pi 5, Tailscale 100.77.59.83) with Nexmon monitor mode.
|
||||
- Subject pool: 5–10 volunteers.
|
||||
- Protocol: Each subject walks a fixed path at each site on 3 consecutive days.
|
||||
BFI captured simultaneously at both sites using Wi-BFI.
|
||||
|
||||
**Analysis**:
|
||||
1. Can the BFId classifier re-identify subjects within a site? (Baseline — should
|
||||
confirm BFId's published results.)
|
||||
2. Can any classifier re-identify subjects across sites using BFLD's
|
||||
rf_signature_hash? (Should fail — cross-site isolation test.)
|
||||
3. Can any classifier re-identify across days using BFLD's rf_signature_hash? (Should
|
||||
fail — daily rotation test.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Metrics
|
||||
|
||||
### 2.1 Presence Detection
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| Latency p50 | Time from first non-empty BFI frame to first `presence=true` event | < 500 ms |
|
||||
| Latency p95 | | < 1000 ms (AC2) |
|
||||
| False positive rate | Presence=true when room is confirmed empty | < 5% |
|
||||
| False negative rate | Presence=false when person confirmed present | < 2% |
|
||||
|
||||
Measurement method: camera ground-truth (ruvultra webcam via MediaPipe Pose, same
|
||||
as ADR-079 collection protocol) for empty/occupied labels.
|
||||
|
||||
### 2.2 Motion Score
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| MAE vs ground truth | Mean absolute error of motion score vs camera-derived motion magnitude | < 0.1 |
|
||||
| Hz at sustained operation | Events published per second on `motion/state` | >= 1 Hz (AC3) |
|
||||
| Latency p95 | Time from motion onset (camera) to motion event | < 750 ms |
|
||||
|
||||
### 2.3 Person Count
|
||||
|
||||
| Metric | Definition | Target |
|
||||
|--------|-----------|--------|
|
||||
| Count accuracy | Fraction of windows where BFLD person_count == camera count | > 85% for 1–3 persons |
|
||||
| Count MAE | | < 0.5 for counts 1–4 |
|
||||
|
||||
Person count is harder than presence. The target is achievable with MinCut separation
|
||||
(`ruvector-mincut`) but requires multi-AP coverage for 4+ persons.
|
||||
|
||||
### 2.4 Identity Risk Calibration
|
||||
|
||||
This is BFLD's novel evaluation dimension — no prior system has explicitly quantified
|
||||
this.
|
||||
|
||||
**Calibration definition**: Let `r(t)` = BFLD's identity_risk_score at time t.
|
||||
Let `acc(t)` = BFId classifier's re-identification accuracy when trained on frames
|
||||
around time t. The identity_risk_score is *calibrated* if:
|
||||
|
||||
E[acc(t) | r(t) = v] is monotonically increasing in v
|
||||
|
||||
In other words: higher risk scores should correspond to frames where identity inference
|
||||
is genuinely easier.
|
||||
|
||||
**Evaluation protocol**:
|
||||
1. Run BFId classifier in sliding 5-second windows on the BFId dataset.
|
||||
2. Record per-window BFId accuracy (using leave-one-out cross-validation).
|
||||
3. Run BFLD's identity_risk_score computation on the same windows.
|
||||
4. Compute Spearman correlation between risk scores and BFId accuracy.
|
||||
5. Target: Spearman rho > 0.5 (positive monotonic correlation).
|
||||
|
||||
### 2.5 Privacy-Mode False Positive Rate
|
||||
|
||||
When `privacy_mode` is enabled (privacy_class = 3), all identity-correlated fields
|
||||
should be suppressed. The false positive rate is the fraction of outbound events
|
||||
that inadvertently include an identity-correlated field despite privacy_mode being
|
||||
active.
|
||||
|
||||
**Target**: 0% (this is a hard correctness requirement, not a statistical target).
|
||||
Verified by the AC5 fuzz test in `acceptance.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Red-Team Protocol
|
||||
|
||||
### 3.1 Hash Re-identification Attack
|
||||
|
||||
**Question**: Can an attacker re-identify a person across rotated hashes?
|
||||
|
||||
**Setup**:
|
||||
- Run BFLD pipeline for person X across 3 days.
|
||||
- Collect `rf_signature_hash` values for each day: H_1, H_2, H_3.
|
||||
- Adversary has access to H_1, H_2, H_3 and knows they are from the same site.
|
||||
- Adversary attempts to confirm H_1, H_2, H_3 are from the same person.
|
||||
|
||||
**Success condition**: adversary achieves confirmation rate > chance (1/N for N subjects).
|
||||
|
||||
**Expected result**: FAIL (by construction of the hash rotation with site_salt).
|
||||
Since day_epoch changes daily and site_salt is fixed but unknown to the adversary,
|
||||
the hash function is a keyed PRF. The adversary has three random-looking 32-byte
|
||||
values with no structural relationship. Success rate should be indistinguishable from
|
||||
random guessing.
|
||||
|
||||
**Quantitative target**: success rate <= 1/N + 0.05 (within 5% of chance).
|
||||
|
||||
### 3.2 Cross-Site Re-identification Attack
|
||||
|
||||
**Question**: Can an attacker confirm person X visited both site A and site B?
|
||||
|
||||
**Setup**: Same as Section 1.3 in-house protocol. Adversary has BFLD event streams
|
||||
from both sites.
|
||||
|
||||
**Method**: Attempt to match rf_signature_hash values from site A and site B on the
|
||||
same day. Alternatively, train a classifier on BFI features (using the raw angle
|
||||
sequences from the captured data) and attempt cross-site re-ID.
|
||||
|
||||
**Expected result**: Hash-based matching fails by construction. Classifier-based
|
||||
re-ID may succeed if the adversary has raw angle data (which BFLD does not publish)
|
||||
but not using BFLD's published output.
|
||||
|
||||
**Success condition**: hash-based cross-site match rate <= 1/N + 0.05.
|
||||
|
||||
### 3.3 Timing Side-Channel Attack
|
||||
|
||||
**Question**: Can an attacker infer a person's schedule by monitoring
|
||||
identity_risk_score over time?
|
||||
|
||||
**Method**: Record identity_risk_score time series. Correlate with known schedule
|
||||
(person X leaves at 8am, returns at 6pm). Compute mutual information between
|
||||
schedule and risk score time series.
|
||||
|
||||
**Expected result**: Some correlation exists (risk score rises when person enters),
|
||||
but the attacker learns "someone is present" — equivalent to the presence sensor —
|
||||
not identity. This is acceptable: presence information is already published at
|
||||
class 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Comparison Baselines
|
||||
|
||||
| Baseline | Description | Presence F1 | Motion MAE | Identity leak |
|
||||
|----------|-------------|------------|-----------|--------------|
|
||||
| Raw CSI pipeline | Existing wifi-densepose pipeline (no BFLD) | ~0.95 (est.) | ~0.08 (est.) | Unquantified — no risk gating |
|
||||
| BFI-only (no BFLD) | Wi-BFI + threshold presence | ~0.82 (from LeakyBeam) | N/A | Angle matrices published |
|
||||
| BFI+CSI fusion (no BFLD) | Combined pipeline, ungated | ~0.97 (est.) | ~0.06 (est.) | Unquantified |
|
||||
| **BFLD (BFI+CSI, class 2)** | Full BFLD with anonymous privacy class | target 0.93 | target 0.10 | 0% (class 2 gate) |
|
||||
| BFLD (BFI-only, class 2) | BFLD without CSI input (AC7) | target 0.85 | target 0.12 | 0% (class 2 gate) |
|
||||
|
||||
The BFLD privacy-class guarantee reduces the raw sensing accuracy by a small margin
|
||||
versus an ungated BFI+CSI pipeline (target F1 0.93 vs estimated 0.97). This is the
|
||||
explicit trade-off: identity safety for a modest utility cost.
|
||||
|
||||
---
|
||||
|
||||
## 5. Continuous Evaluation in CI
|
||||
|
||||
Three tests run on every PR that touches the BFLD crate:
|
||||
|
||||
1. **Deterministic hash test** (AC6): same input → same output across platforms.
|
||||
2. **Privacy-mode field suppression fuzz** (AC5): 1,000 random inputs → no identity
|
||||
fields in class-2 output.
|
||||
3. **Latency smoke test** (AC2): 100-frame replay → first presence event < 200 ms
|
||||
(tighter than the 1s AC target, to keep CI fast).
|
||||
@@ -0,0 +1,214 @@
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection
|
||||
|
||||
> This file is a draft. When approved, copy to:
|
||||
> `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **BFLD** — Beamforming Feedback Layer for Detection |
|
||||
| **Relates to** | [ADR-024](ADR-024-contrastive-csi-embedding-model.md) (AETHER contrastive embedding), [ADR-027](ADR-027-cross-environment-domain-generalization.md) (MERIDIAN cross-environment), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit / witness), [ADR-029](ADR-029-ruvsense-multistatic-sensing-mode.md) (RuvSense multistatic), [ADR-030](ADR-030-ruvsense-persistent-field-model.md) (persistent field model), [ADR-031](ADR-031-ruview-sensing-first-rf-mode.md) (sensing-first RF mode), [ADR-032](ADR-032-multistatic-mesh-security-hardening.md) (mesh security hardening), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI platform), [ADR-115](ADR-115-home-assistant-integration.md) (HA integration), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Matter seed packaging), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (pip modernization) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The Plaintext BFI Problem
|
||||
|
||||
IEEE 802.11ac and 802.11ax beamforming feedback information (BFI) is exchanged between
|
||||
client stations (STA) and access points (AP) in unencrypted management-plane frames.
|
||||
The STA compresses the channel response into a matrix of Givens rotation angles (Phi/Psi)
|
||||
and transmits them in a VHT/HE Compressed Beamforming Report (CBFR) frame. These frames
|
||||
are passively sniffable by any device in WiFi monitor mode without any access to the
|
||||
target network.
|
||||
|
||||
Two independent 2024–2025 research papers establish the severity of this exposure:
|
||||
|
||||
1. **BFId** (Todt, Morsbach, Strufe; KIT; ACM CCS 2025,
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062): demonstrates re-identification of
|
||||
197 individuals using BFI alone, with >90% accuracy from 5 seconds of capture.
|
||||
2. **LeakyBeam** (Xiao et al.; Zhejiang U., NTU, KAIST; NDSS 2025,
|
||||
https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/):
|
||||
demonstrates occupancy detection through walls at 20 m range using BFI, with 82.7%
|
||||
TPR and 96.7% TNR.
|
||||
|
||||
Tooling for passive BFI capture is freely available. Wi-BFI
|
||||
(https://arxiv.org/abs/2309.04408) is pip-installable and supports 802.11ac/ax,
|
||||
SU/MU-MIMO, 20/40/80/160 MHz channels.
|
||||
|
||||
### 1.2 Gap in Existing Pipeline
|
||||
|
||||
The wifi-densepose sensing pipeline processes CSI via the rvCSI runtime (ADR-095/096)
|
||||
and produces presence, pose, vitals, and zone-activity events. No layer explicitly
|
||||
measures whether the data being processed is capable of identifying specific individuals.
|
||||
The pipeline treats all CSI as equivalent from a privacy standpoint, regardless of
|
||||
whether it is operating in a high-separability (identity-leaky) or low-separability
|
||||
(anonymous) regime.
|
||||
|
||||
This gap becomes a compliance and liability issue as WiFi sensing deployments scale.
|
||||
An operator deploying this system in a care facility, hotel, or shared office has no
|
||||
instrument to verify that the system is operating anonymously.
|
||||
|
||||
### 1.3 The BFI Opportunity
|
||||
|
||||
BFI is not only a threat vector — it is a complementary sensing signal. Because BFI
|
||||
encodes the channel response as a structured compressed matrix, it carries multipath
|
||||
geometry that can augment CSI-based presence and motion detection, particularly in
|
||||
scenarios where only one AP is available (fewer antenna pairs than a full MIMO CSI
|
||||
capture). The BFLD design treats BFI as an optional input alongside CSI, not as a
|
||||
replacement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
We will create a new crate `wifi-densepose-bfld` (to live in `v2/crates/`) that:
|
||||
|
||||
1. **Ingests** raw BFI (Phi/Psi angle matrices from CBFR frames) as input and optionally
|
||||
fuses CSI when available.
|
||||
2. **Computes** nine named features and derives an `identity_risk_score` using a
|
||||
separability × temporal_stability × cross_perspective_consistency × sample_confidence
|
||||
formula.
|
||||
3. **Gates** all output through a `privacy_class` mechanism that structurally prevents
|
||||
identity-correlated data from being published at privacy classes 2 and 3.
|
||||
4. **Emits** `BfldEvent` structs on MQTT topics under `ruview/<node_id>/bfld/` with
|
||||
per-class topic routing.
|
||||
5. **Enforces** three invariants structurally (not by policy):
|
||||
- Raw BFI never exits the node.
|
||||
- Identity embedding is in-RAM-only.
|
||||
- Cross-site identity correlation is made cryptographically impossible via per-site
|
||||
keyed BLAKE3 hash rotation with a daily epoch.
|
||||
|
||||
The `BfldFrame` wire format carries magic `0xBF1D_0001`, a version byte, hashed AP/STA
|
||||
identifiers, a quantization byte, a privacy_class byte, compressed feature payload, and
|
||||
a CRC32.
|
||||
|
||||
Matter exposure is limited to: OccupancySensing (presence), MotionSensor (motion),
|
||||
PeopleCount (person_count). Identity fields are rejected at the Matter boundary in the
|
||||
`cog-ha-matter` crate.
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Operators gain an explicit, auditable measure of privacy compliance at the RF layer —
|
||||
the first such primitive in the wifi-densepose ecosystem.
|
||||
- The identity_risk_score doubles as an anomaly signal: unexpected spikes indicate
|
||||
environmental changes (new AP firmware, nearby attacker-grade sniffer, unusual
|
||||
propagation geometry) that warrant investigation.
|
||||
- BFI fusion augments presence and motion accuracy in single-AP deployments, partially
|
||||
compensating for lower CSI antenna counts.
|
||||
- The crate's deterministic frame hashes enable the ADR-028 witness-bundle pattern to
|
||||
extend to the new sensing surface, preserving the existing audit trail model.
|
||||
- Cross-site identity isolation is structural, not policy-dependent. This is a stronger
|
||||
guarantee than access-control rules.
|
||||
|
||||
### Negative
|
||||
|
||||
- BFI capture on ESP32-S3 hardware is not directly possible via the Espressif WiFi API.
|
||||
The full BFLD pipeline requires a Pi 5 / Nexmon host-side sniffer (cognitum-v0 is
|
||||
available for this purpose, but it adds a fleet dependency for the BFI path).
|
||||
- The identity_risk_score calibration (correlation with actual re-ID success rate)
|
||||
requires the BFId dataset, which requires non-commercial research agreement with KIT.
|
||||
- ~10.5 engineer-weeks of implementation effort.
|
||||
|
||||
### Neutral
|
||||
|
||||
- BFLD does not prevent passive BFI capture by an external attacker (A1 / LeakyBeam
|
||||
threat). It only ensures the node's own output is non-identifying. Operators should
|
||||
be informed of this distinction.
|
||||
- The daily hash rotation means that occupant-counting analytics that span multiple
|
||||
days cannot correlate individual signatures across the day boundary. This is a privacy
|
||||
benefit that some analytics use-cases may find inconvenient.
|
||||
|
||||
---
|
||||
|
||||
## 4. Alternatives Considered
|
||||
|
||||
### Alt 1: Skip BFI entirely, CSI-only pipeline
|
||||
|
||||
The rvCSI pipeline (ADR-095/096) already handles CSI without BFI. This alternative
|
||||
requires no new crate and no change to the ESP32 firmware.
|
||||
|
||||
**Rejected because**: (a) it leaves the identity-leakage detection gap open for the
|
||||
existing CSI pipeline, and (b) as BFI capture tooling becomes more widespread (Wi-BFI,
|
||||
PicoScenes), the absence of a privacy layer becomes more conspicuous for operators.
|
||||
|
||||
### Alt 2: Publish identity_risk_score publicly (default-on)
|
||||
|
||||
Treat the risk score as a diagnostic metric that operators and the public can observe.
|
||||
|
||||
**Rejected because**: the risk score is itself a privacy-sensitive signal (it reveals
|
||||
when a specific person is present via timing correlation). The default should be
|
||||
opt-in, with the operator explicitly acknowledging the trade-off.
|
||||
|
||||
### Alt 3: Use raw BFI in cloud ML training
|
||||
|
||||
Send raw BFI angle matrices to a cloud training service to improve model quality.
|
||||
|
||||
**Rejected because**: this violates Invariant 1. Cloud training on raw BFI would
|
||||
create an off-node store of angle matrices that could be reconstructed into identity
|
||||
profiles. The on-device-only constraint is not negotiable.
|
||||
|
||||
### Alt 4: Differential privacy noise injection on BFI before any processing
|
||||
|
||||
Add calibrated Laplace/Gaussian noise to the angle matrices at ingress to provide
|
||||
epsilon-differential privacy on all downstream computations.
|
||||
|
||||
**Rejected for this ADR** (noted as future extension): DP noise calibration requires
|
||||
sensitivity analysis that is not yet complete, and the interaction between DP noise
|
||||
and the identity_risk_score formula requires separate validation. The current design
|
||||
achieves privacy through structural impossibility (local-only, hash rotation) rather
|
||||
than noise injection.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: The extractor parses BFI from commodity WiFi 5 (802.11ac) and WiFi 6
|
||||
(802.11ax) captures, supporting 20/40/80/160 MHz channel bandwidth and 2×2 through
|
||||
4×4 MIMO configurations.
|
||||
- [ ] **AC2**: Presence detection latency is ≤ 1s p95 from the first non-empty BFI
|
||||
frame in a new occupancy event.
|
||||
- [ ] **AC3**: Motion score is published at ≥ 1 Hz on the `ruview/<node_id>/bfld/motion/state`
|
||||
MQTT topic during sustained occupancy.
|
||||
- [ ] **AC4**: Raw BFI bytes (Phi/Psi angle matrices) are never present in any
|
||||
serialized `BfldFrame` payload at any `privacy_class` value.
|
||||
- [ ] **AC5**: When `privacy_mode` is enabled, all identity-derived fields
|
||||
(`identity_risk_score`, `rf_signature_hash`, `identity_embedding`) are absent from
|
||||
all outbound events.
|
||||
- [ ] **AC6**: Given identical `BfiCapture` inputs, the `BfldFrame` serialization
|
||||
produces bit-identical output (deterministic hash) across runs and across platforms.
|
||||
- [ ] **AC7**: The pipeline produces valid `BfldEvent` outputs when `csi_matrix` is
|
||||
absent (BFI-only mode), without panic or degraded presence/motion reporting beyond
|
||||
the documented accuracy bounds.
|
||||
|
||||
---
|
||||
|
||||
## 6. Related ADRs
|
||||
|
||||
- **ADR-024**: AETHER contrastive CSI embedding — BFLD reuses the AETHER embedding
|
||||
infrastructure for identity_risk computation.
|
||||
- **ADR-027**: MERIDIAN cross-environment — BFLD's cross-site isolation instantiates
|
||||
the "no cross-site correlation" assumption that MERIDIAN requires.
|
||||
- **ADR-028**: Witness verification — BFLD extends the deterministic proof pattern.
|
||||
- **ADR-029**: RuvSense multistatic — BFLD uses `multistatic.rs` for
|
||||
cross_perspective_consistency.
|
||||
- **ADR-030**: Persistent field model — BFLD uses `cross_room.rs` for
|
||||
environment fingerprinting in the hash rotation.
|
||||
- **ADR-031**: Sensing-first RF mode — BFLD is a new sensing primitive alongside
|
||||
the CSI-based sensing.
|
||||
- **ADR-032**: Mesh security hardening — BFLD's threat model is a superset.
|
||||
- **ADR-095/096**: rvCSI platform — BFLD shares the BFI capture path with rvCSI's
|
||||
Nexmon adapter.
|
||||
- **ADR-115**: HA integration — BFLD extends the 21-entity HA surface with 6 new
|
||||
entities.
|
||||
- **ADR-116**: Matter seed packaging — BFLD's Matter boundary filter is implemented
|
||||
in `cog-ha-matter`.
|
||||
- **ADR-117**: pip modernization — BFLD's Python bindings (PyO3) will follow the
|
||||
pattern established in ADR-117.
|
||||
@@ -0,0 +1,111 @@
|
||||
# GitHub Issue Draft
|
||||
|
||||
**Title**: feat: BFLD — Beamforming Feedback Layer for Detection (privacy-gated WiFi sensing)
|
||||
|
||||
**Labels**: `enhancement`, `privacy`, `security`, `area/signal`, `area/firmware`
|
||||
|
||||
**Milestone**: (TBD — suggest: v0.8.0)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Add a new crate `wifi-densepose-bfld` that turns raw 802.11 Beamforming Feedback
|
||||
Information (BFI) into bounded, privacy-gated sensing outputs. BFLD detects when RF
|
||||
data crosses from "ambient sensing" into "identity record" and structurally prevents
|
||||
identity-correlated data from leaving the node.
|
||||
|
||||
This is the safety layer that was missing from the CSI pipeline. As passive BFI sniffing
|
||||
tools (Wi-BFI, PicoScenes) become widely available and academic attacks (BFId at ACM CCS
|
||||
2025, LeakyBeam at NDSS 2025) demonstrate >90% re-identification from commodity WiFi,
|
||||
the wifi-densepose ecosystem needs an explicit privacy layer before scaling deployment.
|
||||
|
||||
## Motivation
|
||||
|
||||
1. **BFI is plaintext and passively sniffable.** IEEE 802.11ac/ax CBFR frames are
|
||||
transmitted before WPA2/WPA3 encryption is applied. Any nearby device in monitor mode
|
||||
can capture them (NDSS 2025: https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/).
|
||||
|
||||
2. **BFI enables re-identification.** The KIT BFId paper (ACM CCS 2025:
|
||||
https://dl.acm.org/doi/10.1145/3719027.3765062) demonstrates >90% identity
|
||||
recognition from 5 seconds of BFI, from a dataset of 197 individuals, using only
|
||||
the Phi/Psi Givens rotation angles.
|
||||
|
||||
3. **The existing pipeline has no identity-leakage measurement.** The rvCSI pipeline
|
||||
produces presence/motion/pose events without any indication of whether those outputs
|
||||
were derived from identity-discriminative data. An operator deploying in a care
|
||||
facility or shared office has no way to verify the system is behaving anonymously.
|
||||
|
||||
4. **WiFi 7 will make this worse.** 802.11be (Wi-Fi 7) multi-link operation increases
|
||||
sounding frequency 3–5×. The attack surface is not static.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
New crate at `v2/crates/wifi-densepose-bfld/` with the following pipeline:
|
||||
|
||||
```
|
||||
BFI capture (CBFR frames, Pi 5 / Nexmon monitor mode)
|
||||
→ BFI extractor (Phi/Psi parser, 802.11ac/ax)
|
||||
→ Normalization + temporal windowing
|
||||
→ Feature extraction (9 named features)
|
||||
→ Identity risk engine (in-RAM embeddings, coherence gate)
|
||||
→ Privacy gate (privacy_class byte, field masking)
|
||||
→ MQTT emitter (per-class topic routing)
|
||||
```
|
||||
|
||||
Three structural invariants (not configurable, not policy):
|
||||
1. Raw BFI never leaves the node.
|
||||
2. Identity embedding is in-RAM-only (VecDeque, never persisted).
|
||||
3. Cross-site identity matching is cryptographically impossible via per-site BLAKE3
|
||||
keyed hash with daily rotation.
|
||||
|
||||
Output events published on `ruview/<node_id>/bfld/{presence,motion,person_count,...}/state`.
|
||||
|
||||
Matter and HA expose only: presence, motion, person_count. Identity fields are rejected
|
||||
at both boundaries.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: Parser handles 802.11ac VHT and 802.11ax HE CBFR frames at 20/40/80/160 MHz,
|
||||
2×2 through 4×4 MIMO.
|
||||
- [ ] **AC2**: Presence detection latency ≤ 1s p95 from first non-empty BFI frame in
|
||||
a new occupancy event.
|
||||
- [ ] **AC3**: Motion score published at ≥ 1 Hz on `ruview/<node_id>/bfld/motion/state`
|
||||
during sustained occupancy.
|
||||
- [ ] **AC4**: Raw BFI bytes (Phi/Psi angle matrices) are never present in any
|
||||
serialized output at any `privacy_class` value.
|
||||
- [ ] **AC5**: Privacy mode suppresses all identity-derived fields (`identity_risk_score`,
|
||||
`rf_signature_hash`, `identity_embedding`) from all outbound events.
|
||||
- [ ] **AC6**: Identical `BfiCapture` input → bit-identical `BfldFrame` output
|
||||
(deterministic, cross-platform).
|
||||
- [ ] **AC7**: Pipeline produces valid `BfldEvent` with `csi_matrix = None` (BFI-only
|
||||
mode), without panic or significant accuracy degradation.
|
||||
|
||||
## References
|
||||
|
||||
- BFId paper: https://dl.acm.org/doi/10.1145/3719027.3765062
|
||||
- KIT BFId dataset: https://ps.tm.kit.edu/english/bfid-dataset/index.php
|
||||
- LeakyBeam (NDSS 2025): https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/
|
||||
- Wi-BFI tool: https://arxiv.org/abs/2309.04408
|
||||
- Protecting activity signatures in CSI feedback: https://arxiv.org/pdf/2512.18529
|
||||
- Research bundle: `docs/research/BFLD/` (this repo)
|
||||
- Draft ADR: `docs/research/BFLD/08-adr-draft.md` → ADR-118
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Preventing passive BFI capture by external attackers (hardware-level problem, not
|
||||
software).
|
||||
- Differential privacy noise injection (noted as future extension in ADR-118).
|
||||
- Federated identity learning (local-only is sufficient for the current use case).
|
||||
- BFI capture directly from ESP32-S3 firmware (Espressif API does not expose CBFR;
|
||||
host-side Pi 5 / Nexmon capture is the implementation path).
|
||||
- WiFi 7 / 802.11be multi-link BFI (frame format versioning accommodates it; not
|
||||
in scope for v1 implementation).
|
||||
|
||||
## Related Issues / PRs
|
||||
|
||||
- ADR-028 witness bundle (ref: this repo's `docs/WITNESS-LOG-028.md`)
|
||||
- ADR-115 HA integration (21 entities — BFLD adds 6 more)
|
||||
- ADR-116 Matter seed packaging (`cog-ha-matter` crate needs Matter boundary update)
|
||||
- ADR-117 pip modernization (PyO3 pattern reused for BFLD Python bindings)
|
||||
- rvCSI platform (ADR-095/096) — Nexmon adapter shared with BFLD BFI capture path
|
||||
@@ -0,0 +1,136 @@
|
||||
# BFLD: The Privacy Layer Your WiFi Sensing Stack Has Been Missing
|
||||
|
||||
Your WiFi router is broadcasting your identity in plaintext. Here is the layer that
|
||||
catches it.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Every time your phone or laptop connects to a WiFi 5 or WiFi 6 router, it periodically
|
||||
transmits a Beamforming Feedback Report (CBFR frame). This frame contains the compressed
|
||||
channel matrix the router needs to aim its antennas at your device. The compression uses
|
||||
Givens rotations — a pair of angles (Phi and Psi) per active subcarrier — that encode
|
||||
the spatial geometry of the wireless channel around your body.
|
||||
|
||||
Here is the catch: these frames are transmitted before WPA2/WPA3 encryption is applied.
|
||||
They are plaintext management frames, passively readable by any WiFi adapter in monitor
|
||||
mode within roughly 20 meters.
|
||||
|
||||
Two papers published in 2024–2025 confirm the threat is real:
|
||||
|
||||
- **BFId** (KIT, ACM CCS 2025): re-identifies 197 people from beamforming feedback alone,
|
||||
>90% accuracy from just 5 seconds of capture. Tools needed: a WiFi adapter, a pip
|
||||
install, and no access to the target network.
|
||||
(https://dl.acm.org/doi/10.1145/3719027.3765062)
|
||||
|
||||
- **LeakyBeam** (Zhejiang U. / NTU / KAIST, NDSS 2025): detects occupancy through walls
|
||||
at 20 m range using beamforming feedback with 82.7% accuracy.
|
||||
(https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/)
|
||||
|
||||
WiFi sensing systems — including this project — process these same signals to detect
|
||||
presence, count people, and track motion. Without a privacy layer, there is no way to
|
||||
know whether the sensing output is derived from anonymizable motion data or from
|
||||
identity-discriminative data.
|
||||
|
||||
---
|
||||
|
||||
## What BFLD Does
|
||||
|
||||
BFLD (Beamforming Feedback Layer for Detection) is a new Rust crate in the
|
||||
wifi-densepose workspace that adds one thing: an explicit, continuous measurement of
|
||||
whether the beamforming data currently being processed is capable of identifying
|
||||
individuals.
|
||||
|
||||
It outputs a small, structured event on every sensing cycle:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp_ns": 1748092800000000000,
|
||||
"presence": true,
|
||||
"motion": 0.42,
|
||||
"person_count": 1,
|
||||
"identity_risk_score": 0.71,
|
||||
"rf_signature_hash": "a3f2c1...e9b4",
|
||||
"zone_id": "living_room",
|
||||
"confidence": 0.88,
|
||||
"privacy_class": 1
|
||||
}
|
||||
```
|
||||
|
||||
High `identity_risk_score` (approaching 1.0) means the current sensing environment is
|
||||
producing data from which an attacker could re-identify individuals. Low score means
|
||||
the data is effectively anonymous.
|
||||
|
||||
The score is computed from four components: how separable the current RF embedding is
|
||||
from a population distribution, how stable that separability is over time, how
|
||||
consistent it is across multiple sensor viewpoints, and how confident the current sample
|
||||
is. Multiply them together, clamp to [0, 1].
|
||||
|
||||
---
|
||||
|
||||
## Three Invariants That Cannot Be Turned Off
|
||||
|
||||
BFLD enforces three properties structurally — not as settings, not as policies:
|
||||
|
||||
**1. Raw BFI never leaves the node.** The Phi/Psi angle matrices are consumed locally
|
||||
and dropped after feature extraction. They are not in the wire format. They are not in
|
||||
the MQTT payload. There is no code path to serialize them outbound.
|
||||
|
||||
**2. Identity embeddings are RAM-only.** The vector embedding used to compute the risk
|
||||
score lives in a fixed-size ring buffer (default: 10 minutes). It is never written to
|
||||
disk. When the node restarts, the buffer is gone.
|
||||
|
||||
**3. Cross-site re-identification is cryptographically impossible.** The
|
||||
`rf_signature_hash` is computed with a per-site secret key (generated at first boot,
|
||||
stored in local NVS, never transmitted) and a per-day epoch. Two nodes at two
|
||||
different sites, even receiving signals from the same person on the same day, produce
|
||||
hash values in completely disjoint hash spaces. No amount of hash-list comparison can
|
||||
reveal a cross-site visit.
|
||||
|
||||
---
|
||||
|
||||
## What Reaches Home Assistant and Matter
|
||||
|
||||
BFLD publishes to MQTT and HA. The following entities reach HA:
|
||||
|
||||
- `binary_sensor.bfld_presence`
|
||||
- `sensor.bfld_motion`
|
||||
- `sensor.bfld_person_count`
|
||||
- `sensor.bfld_confidence`
|
||||
|
||||
The Matter bridge exposes only OccupancySensing (presence) and motion. Identity risk
|
||||
score, rf_signature_hash, and all raw fields are rejected at both the HA and Matter
|
||||
boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Seven Acceptance Criteria
|
||||
|
||||
The implementation is done when these seven tests pass:
|
||||
|
||||
1. Parse 802.11ac and 802.11ax BFI at 20–160 MHz bandwidth, 2×2 to 4×4 MIMO.
|
||||
2. Presence latency ≤ 1 second p95.
|
||||
3. Motion published at ≥ 1 Hz.
|
||||
4. Raw BFI bytes absent from all output (verified by fuzz test).
|
||||
5. Privacy mode suppresses all identity fields.
|
||||
6. Identical input → identical output hash (cross-platform determinism).
|
||||
7. Pipeline runs without CSI input (BFI-only mode).
|
||||
|
||||
---
|
||||
|
||||
## BFLD Is an Immune System, Not a Surveillance Lens
|
||||
|
||||
The framing matters. BFLD does not produce identity — it measures identity risk and
|
||||
uses that measurement to gate what leaves the node. An immune system does not broadcast
|
||||
the identity of pathogens it encounters; it classifies, responds locally, and keeps
|
||||
detailed records inside the organism.
|
||||
|
||||
WiFi 7 / 802.11be is deploying now. Multi-link operation will increase beamforming
|
||||
sounding frequency 3–5x. The passive attack surface will grow. The time to establish
|
||||
safe defaults in WiFi sensing stacks is before that installed base is in place.
|
||||
|
||||
BFLD is that default.
|
||||
|
||||
Full research bundle: `docs/research/BFLD/` in the wifi-densepose repository.
|
||||
Draft ADR: `docs/research/BFLD/08-adr-draft.md` (ADR-118).
|
||||
@@ -0,0 +1,58 @@
|
||||
# BFLD Research Bundle — Beamforming Feedback Layer for Detection
|
||||
|
||||
BFLD is the safety layer that detects when RF data becomes identifying. It sits between
|
||||
raw 802.11 beamforming feedback (BFI) and every downstream consumer — home automation,
|
||||
MQTT, Matter, cloud — measuring the identity-leakage potential of each frame and gating
|
||||
what leaves the node. It does not produce identity; it guards against accidental or
|
||||
adversarial exposure of identity.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [01-sota-survey.md](01-sota-survey.md) | State-of-the-art literature: BFI vs CSI, attack tooling, identity-inference research, privacy-preserving techniques |
|
||||
| [02-soul.md](02-soul.md) | Architectural intent, ethical stance, three non-negotiable invariants |
|
||||
| [03-security-threat-model.md](03-security-threat-model.md) | Adversary classes, attack trees, mitigations, trust-boundary diagram, per-privacy-class analysis |
|
||||
| [04-privacy-gating.md](04-privacy-gating.md) | privacy_class byte semantics, hash rotation algorithm, embedding lifecycle, wire-format diffs |
|
||||
| [05-automation-integration.md](05-automation-integration.md) | Home Assistant entities, Matter clusters, MQTT ACLs, cognitum federation |
|
||||
| [06-implementation-plan.md](06-implementation-plan.md) | New crate layout, reuse map, ESP32 additions, test plan, phased rollout |
|
||||
| [07-benchmarks-and-evaluation.md](07-benchmarks-and-evaluation.md) | Datasets, metrics, red-team protocol, comparison baselines |
|
||||
| [08-adr-draft.md](08-adr-draft.md) | Draft ADR-118 for formal project adoption |
|
||||
| [09-github-issue.md](09-github-issue.md) | GitHub issue draft for tracking implementation |
|
||||
| [10-gist.md](10-gist.md) | Public-facing one-pager / blog summary |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
1. **Problem.** IEEE 802.11ac/ax beamforming feedback (BFI) — the compressed angle matrices
|
||||
(Phi/Psi, Givens rotation) exchanged between client and AP — is transmitted unencrypted
|
||||
on the management plane. Academic work (BFId at ACM CCS 2025, LeakyBeam at NDSS 2025)
|
||||
demonstrates that a passive sniffer with commodity hardware can re-identify individuals
|
||||
and infer occupancy through walls using only these frames. Existing CSI-based sensing
|
||||
pipelines have no explicit layer to detect when their output crosses from "motion event"
|
||||
into "identity record."
|
||||
|
||||
2. **Approach.** BFLD is a new crate (`wifi-densepose-bfld`) that wraps the BFI extraction
|
||||
and normalization path in an identity-leakage estimator. Every output frame carries a
|
||||
computed `identity_risk_score` and a `privacy_class` byte; downstream consumers decide
|
||||
whether to act based on those tags rather than on raw measurements.
|
||||
|
||||
3. **Novel contribution.** BFLD does not try to suppress identity inference — it tries to
|
||||
*measure* it continuously and make the measurement explicit in every event. This
|
||||
transforms a latent, silent risk into an observable, auditable signal. The combination
|
||||
of per-day per-site hash rotation and a local-only identity embedding creates structural
|
||||
impossibility of cross-site re-identification — not merely a policy promise.
|
||||
|
||||
4. **Security posture.** Raw BFI never leaves the node. Identity embeddings live only in
|
||||
an in-RAM ring buffer. The rf_signature_hash rotates daily using a per-site blake3
|
||||
keyed-hash that is never transmitted. Matter and HA expose only presence, motion, and
|
||||
person_count — never risk scores or embeddings.
|
||||
|
||||
5. **Integration plan.** Six phases: P1 frame format + extractor stub, P2 feature
|
||||
extraction + identity_risk, P3 privacy gate + MQTT, P4 HA integration, P5 Matter
|
||||
exposure, P6 cognitum federation. Each phase maps to a numbered acceptance criterion.
|
||||
The crate slots into the existing workspace between `wifi-densepose-signal` and
|
||||
`wifi-densepose-sensing-server`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Soul Signature — Research Specification
|
||||
|
||||
**Status:** Research Specification (Pre-Implementation)
|
||||
**Date:** 2026-05-24
|
||||
**Maintainer:** ruv
|
||||
|
||||
---
|
||||
|
||||
## What Is a Soul Signature
|
||||
|
||||
A Soul Signature is a fused multi-modal biometric identity vector derived entirely
|
||||
from passive electromagnetic measurement of a person inside a room equipped with
|
||||
WiFi-DensePose / RuView sensing nodes. No wearable, no camera, no explicit
|
||||
scan-time consent moment is required for recognition once a person has enrolled.
|
||||
|
||||
The word "soul" is deliberate product framing for a scientifically defensible concept:
|
||||
the same relationship a fingerprint bears to identity in forensic science, or FaceID
|
||||
to phone authentication, but extended to a new sensing dimension — passive RF at
|
||||
distance, through walls, at room scale. Seven orthogonal electromagnetic observables,
|
||||
fused into a single content-addressed RVF graph file, constitute the signature.
|
||||
|
||||
The claim is not mystical. Every channel is grounded in published physics and prior
|
||||
WiFi sensing literature. Every assertion about discriminative power either cites a
|
||||
peer-reviewed result or is explicitly marked "open research; baseline TBD."
|
||||
|
||||
---
|
||||
|
||||
## What a Soul Signature Is NOT
|
||||
|
||||
- It is NOT a replacement for fingerprint scanners, iris scanners, or FaceID on
|
||||
accuracy-per-attempt measures. Current RF biometrics are less mature than those
|
||||
modalities. See `security.md` for the honest error-rate picture.
|
||||
- It is NOT a single number, hash, or deterministic bit string. It is a
|
||||
probabilistic match against a stored graph with a calibrated false-accept rate.
|
||||
- It is NOT medically diagnostic. It detects biophysical proxies, not conditions.
|
||||
"Gait asymmetry increased 18% over 14 days" is the output, never "Parkinson's."
|
||||
- It is NOT equivalent to explicit-consent biometrics in regulated contexts. GDPR
|
||||
and HIPAA modes are defined and mandatory for healthcare deployments.
|
||||
- It is NOT currently deployable as a legal evidence instrument.
|
||||
- It is NOT snake oil, energy healing, or anything outside measurable electrophysics.
|
||||
|
||||
---
|
||||
|
||||
## Document Map
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `specification.md` | Typed RVF graph schema; all node types, edge types, serialization format; aggregator vs stored profile distinction |
|
||||
| `scanning-process.md` | Structured 60-second enrollment protocol; hardware requirements; quality gates; fast-scan and continuous modes; re-scan cadence |
|
||||
| `security.md` | Full threat model; five adversaries; mitigations; cryptographic primitive choices; GDPR/HIPAA mode; open research items |
|
||||
| `references.md` | All cited ADRs, papers, datasets, standards |
|
||||
|
||||
---
|
||||
|
||||
## Conceptual Graph (ASCII)
|
||||
|
||||
The following depicts one example soul signature as a graph stored in a single
|
||||
RVF container. Each box is an RVF node (a SEG_EMBED or SEG_META segment). Each
|
||||
arrow is a typed edge stored in the graph manifest.
|
||||
|
||||
```
|
||||
+-----------------------+
|
||||
| AETHER_Embedding | 128-dim f32, L2-normalized (ADR-024)
|
||||
| contrastive CSI | HNSW-searchable via ruvector-core
|
||||
| backbone embedding |
|
||||
+----------+------------+
|
||||
| derived_from
|
||||
v
|
||||
+-----------+-----------+ +------------------------+
|
||||
| FieldModel_Residual +---fuses--+ Subcarrier_Reflection |
|
||||
| ADR-030 perturbation | | per-angle multipath |
|
||||
| eigenmode projection | | amplitude + phase |
|
||||
+----------+------------+ +------------------------+
|
||||
| correlates_with
|
||||
v
|
||||
+----------+------------+ +------------------------+
|
||||
| Cardiac_HR_Profile +--links---+ Cardiac_Waveform_ |
|
||||
| baseline_bpm, HRV_LF | | Morphology (wavelet |
|
||||
| HRV_HF, rhythm_class | | coefficients) |
|
||||
+----------+------------+ +------------------------+
|
||||
| temporally_colocated
|
||||
v
|
||||
+----------+------------+
|
||||
| Respiratory_Pattern |
|
||||
| baseline_bpm, depth, |
|
||||
| apnea_index, HRV_RSA |
|
||||
+----------+------------+
|
||||
| temporally_colocated
|
||||
v
|
||||
+----------+------------+ +------------------------+
|
||||
| Gait_Timing +--links---+ Skeletal_Proportions |
|
||||
| cadence, stride_var, | | torso/limb ratios |
|
||||
| double_support_pct, | | from ADR-079 keypoints |
|
||||
| asymmetry_index | +------------------------+
|
||||
+----------+------------+
|
||||
| attested_by
|
||||
v
|
||||
+----------+------------+
|
||||
| WitnessChain | Ed25519 over (content_hash ||
|
||||
| ADR-110 attestation | timestamp || device_id) per ADR-110
|
||||
+-----------------------+
|
||||
```
|
||||
|
||||
File naming convention: `signature-<sha256-of-rvf-content>.rvf`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
This is a **research specification**. None of the soul-signature-specific graph
|
||||
container logic is implemented yet. The constituent ADRs (AETHER, MERIDIAN,
|
||||
RuvSense field model, ADR-039 vitals, ADR-110 witness chain) provide the substrate.
|
||||
The soul signature is the composition layer above them.
|
||||
|
||||
A future implementation ADR should reference this document and assign acceptance
|
||||
tests derived from the quality gates defined in `scanning-process.md`.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Soul Signature — References
|
||||
|
||||
**Status:** Research Specification (Pre-Implementation)
|
||||
**Date:** 2026-05-24
|
||||
**Author:** ruv
|
||||
|
||||
---
|
||||
|
||||
## 1. Internal Architecture Decision Records
|
||||
|
||||
All ADRs are located at `docs/adr/ADR-XXX-*.md` in this repository.
|
||||
|
||||
| ADR | Title | Relevance to soul signature |
|
||||
|---|---|---|
|
||||
| ADR-003 | RVF Cognitive Containers for CSI Data | RVF container format used by soul signature |
|
||||
| ADR-004 | HNSW Vector Search for Signal Fingerprinting | HNSW index for person_track embedding search |
|
||||
| ADR-005 | SONA Self-Learning Pose Estimation | LoRA adaptation, EWC regularization, environment profiles |
|
||||
| ADR-007 | Post-Quantum Cryptography Secure Sensing | PQC cryptographic context; foundation for ADR-108/109 |
|
||||
| ADR-010 | Witness Chains Audit Trail Integrity | Witness chain design; Ed25519 over frame bundles |
|
||||
| ADR-014 | SOTA Signal Processing Algorithms | RuvSense pipeline: conjugate multiplication, Hampel filter, spectrogram, BVP |
|
||||
| ADR-021 | Vital Sign Detection via rvdna Pipeline | Cardiac HR / respiratory extraction; bandpass filters; ADR-039 vitals packet |
|
||||
| ADR-023 | Trained DensePose Model with RuVector Pipeline | CsiToPoseTransformer backbone; MPJPE baseline 91.7 mm |
|
||||
| ADR-024 | Project AETHER — Contrastive CSI Embedding Model | Primary soul signature identity channel; 128-dim L2-normalized embedding; HNSW person_track index (>80% mAP target at 5 subjects) |
|
||||
| ADR-027 | Project MERIDIAN — Cross-Environment Domain Generalization | Environment-disentangled embeddings; HardwareNormalizer; multi-room portability |
|
||||
| ADR-029 | RuvSense Multistatic Sensing Mode | Multi-node mesh; 20 Hz DensePose; <30 mm jitter; person separation |
|
||||
| ADR-030 | RuvSense Persistent Field Model | Field normal modes; SVD eigenstructure; perturbation extraction; longitudinal drift; adversarial detection; cross-room continuity |
|
||||
| ADR-039 | ESP32-S3 Edge Intelligence Pipeline | Vitals packet wire format (magic `0xC511_0002`); HR/BR on-device extraction |
|
||||
| ADR-075 | MinCut Person Separation | ruvector-mincut for multi-person track assignment |
|
||||
| ADR-079 | Camera Ground-Truth Training | Paired camera + CSI training; skeletal proportions accuracy |
|
||||
| ADR-082 | Pose Tracker Confirmed Output Filter | Pose tracker output confidence filtering |
|
||||
| ADR-100 | Cog Packaging Specification | Ed25519 firmware signing; supply chain integrity |
|
||||
| ADR-105 | Federated CSI Training | Federated AETHER fine-tuning; secure aggregation |
|
||||
| ADR-106 | DP-SGD and Primitive Isolation | Differential privacy at training; biometric primitive isolation; (ε, δ)-DP budget |
|
||||
| ADR-107 | Cross-Installation Federation | Cross-installation secure aggregation; DH key exchange |
|
||||
| ADR-108 | Kyber Post-Quantum Key Exchange | Kyber-768 (NIST FIPS 203); hybrid X25519 + Kyber during migration |
|
||||
| ADR-109 | Dilithium PQC Signatures | Dilithium-3 (NIST FIPS 204); hybrid Ed25519 + Dilithium; cog signing |
|
||||
| ADR-110 | ESP32-C6 Firmware Extension | Wi-Fi 6 HE-LTF CSI (242 subcarriers); 802.15.4 time-sync; TWT; Ed25519 witness chain per-frame |
|
||||
| ADR-113 | Multistatic Placement Strategy | Node placement geometry; coverage analysis |
|
||||
| ADR-115 | Home Assistant Integration (HA-DISCO + HA-MIND) | Privacy mode; MQTT auto-discovery; semantic primitives layer under which soul signature operates |
|
||||
|
||||
---
|
||||
|
||||
## 2. AETHER and Contrastive Embedding Foundations
|
||||
|
||||
- Chen, T., Kornblith, S., Norouzi, M., & Hinton, G. (2020). **A Simple Framework for Contrastive Learning of Visual Representations** (SimCLR). *ICML 2020*. arXiv:2002.05709.
|
||||
- Chen, T., Kornblith, S., Sohl-Dickstein, J., & Hinton, G. (2020). **Big Self-Supervised Models are Strong Semi-Supervised Learners** (SimCLR v2). *NeurIPS 2020*. arXiv:2006.10029.
|
||||
- Bardes, A., Ponce, J., & LeCun, Y. (2022). **VICReg: Variance-Invariance-Covariance Regularization for Self-Supervised Learning**. *ICLR 2022*. arXiv:2105.04906.
|
||||
- Grill, J.-B., et al. (2020). **Bootstrap Your Own Latent: A New Approach to Self-Supervised Learning** (BYOL). *NeurIPS 2020*. arXiv:2006.07733.
|
||||
- Wang, T. & Isola, P. (2020). **Understanding Contrastive Representation Learning through Alignment and Uniformity on the Hypersphere**. *ICML 2020*. arXiv:2005.10242.
|
||||
|
||||
---
|
||||
|
||||
## 3. WiFi CSI Biometric Identification (Prior Art)
|
||||
|
||||
- **IdentiFi** (2025): Self-supervised WiFi-based identity recognition in multi-user smart environments. Contrastive pretraining in the signal domain produces identity-discriminative embeddings without spatial labels. *PMC:12115556*.
|
||||
- **WhoFi** (2025): Transformer-based WiFi CSI encoding for person re-identification. 95.5% accuracy on NTU-Fi (18 subjects). Validates transformer backbones for CSI re-ID. arXiv:2507.12869.
|
||||
- **Wi-PER81** (2025): Benchmark dataset of 162K wireless packets for WiFi-based person re-identification using Siamese networks. *Nature Scientific Data*, 2025. doi:10.1038/s41597-025-05804-0.
|
||||
- **CAPC** (Context-Aware Predictive Coding, 2024): CPC + Barlow Twins for WiFi sensing. 24.7% accuracy improvement on unseen environments. arXiv:2410.01825.
|
||||
- **SSL for WiFi HAR Survey** (2025): Comprehensive evaluation of SimCLR, VICReg, Barlow Twins, SimSiam on WiFi CSI. arXiv:2506.12052.
|
||||
|
||||
---
|
||||
|
||||
## 4. WiFi Sensing SOTA (Pose, Vitals, Gait)
|
||||
|
||||
- Geng, J., Huang, D., & De la Torre, F. (2022). **DensePose From WiFi**. *CMU*. arXiv:2301.00250.
|
||||
- Adib, F., Kabelac, Z., Katabi, D., & Miller, R.C. (2015). **3D Tracking via Body Radio Reflections** (WiTrack). *NSDI 2015*.
|
||||
- Wang, J., Gao, X., Zhang, K., & Liu, X. (2019). **Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition with Wi-Fi**. *MobiSys 2019*.
|
||||
- Zhao, M., Li, T., Abu Alsheikh, M., Tian, Y., Zhao, H., Torralba, A., & Katabi, D. (2018). **Through-Wall Human Pose Estimation Using Radio Signals**. *CVPR 2018*.
|
||||
- Zhao, M., Adib, F., & Katabi, D. (2016). **Emotion Recognition Using Wireless Signals** (EQ-Radio). *MobiCom 2016*. (HRV from WiFi; cardiac biometric baseline)
|
||||
- **PerceptAlign** (Chen et al., 2026): Geometry-conditioned cross-layout WiFi pose estimation. >60% cross-domain error reduction. Dataset: 21 subjects, 5 scenes, 18 actions. arXiv:2601.12252.
|
||||
- **Person-in-WiFi 3D** (Yan et al., 2024): Multi-person 3D pose from WiFi. 91.7 mm MPJPE (single-person). *CVPR 2024*.
|
||||
- **DGSense** (Zhou et al., 2025): Domain-invariant features for WiFi/mmWave/acoustic sensing. arXiv:2502.08155.
|
||||
- **X-Fi** (Chen & Yang, 2025): Modality-invariant foundation model for human sensing. 24.8% MPJPE improvement on MM-Fi. *ICLR 2025*. arXiv:2410.10167.
|
||||
- **AM-FM** (2026): First WiFi foundation model, pretrained on 9.2M CSI samples, 20 device types, 439 days. arXiv:2602.11200.
|
||||
- Ma, Y., Zhou, G., Wang, S., Zhao, H., & Jung, W. (2018). **SignFi: Sign Language Recognition Using WiFi**. *ACM IMWUT*. arXiv:1806.04583.
|
||||
|
||||
---
|
||||
|
||||
## 5. Training Datasets Referenced
|
||||
|
||||
- **MM-Fi** (2022): Multi-Modal Non-Intrusive 4D Human Dataset — WiFi CSI, mmWave, LiDAR, RGB-D. 27 subjects, 40 actions, 5 environments, 320K samples. 56-subcarrier CSI, 17 COCO keypoints. [github.com/ybhbingo/MMFi_dataset]
|
||||
- **Wi-Pose** (2022): WiFi-based 3D pose estimation dataset. Used in ADR-015.
|
||||
- **NTU-Fi** (2022): 56 activities, WiFi CSI, 75 Hz sampling. Used for WhoFi evaluation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Differential Privacy
|
||||
|
||||
- Abadi, M., Chu, A., Goodfellow, I., McMahan, H.B., Mironov, I., Talwar, K., & Zhang, L. (2016). **Deep Learning with Differential Privacy**. *CCS 2016*. [Moments Accountant; DP-SGD formulation used in ADR-106]
|
||||
- Mironov, I. (2017). **Rényi Differential Privacy**. *CSF 2017*. [Alternative DP accounting; referenced in ADR-106 as future enhancement]
|
||||
- Shokri, R., Stronati, M., Song, C., & Shmatikov, V. (2017). **Membership Inference Attacks Against Machine Learning Models**. *IEEE S&P 2017*. [Motivation for DP-SGD in ADR-106]
|
||||
|
||||
---
|
||||
|
||||
## 7. Cryptographic Standards
|
||||
|
||||
- **RFC 8032** (2017): Edwards-Curve Digital Signature Algorithm (EdDSA). [Ed25519; used in ADR-110 witness chain]
|
||||
- **RFC 8439** (2018): ChaCha20 and Poly1305 for IETF Protocols. [At-rest encryption primitive specified in security.md §5]
|
||||
- **RFC 9106** (2021): Argon2 Memory-Hard Function. [KDF for soul signature at-rest key derivation]
|
||||
- **NIST FIPS 203** (2024): Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM / Kyber). [ADR-108; post-quantum key exchange]
|
||||
- **NIST FIPS 204** (2024): Module-Lattice-Based Digital Signature Standard (ML-DSA / Dilithium). [ADR-109; post-quantum signatures]
|
||||
- **NIST SP 800-132 Draft** (2024): Recommendation for Password-Based Key Derivation. [Argon2id parameter guidance]
|
||||
|
||||
---
|
||||
|
||||
## 8. Biometric Standards (for Standards Awareness)
|
||||
|
||||
The soul signature is not currently certified to any of these standards but the
|
||||
specification is designed with awareness of the relevant frameworks.
|
||||
|
||||
- **ISO/IEC 19794-1:2011**: Biometric data interchange formats — Part 1: Framework.
|
||||
[Top-level; soul signature's node/edge schema follows the typed-attribute-record
|
||||
philosophy of this standard]
|
||||
- **ISO/IEC 19794-2:2011**: Biometric data interchange formats — Part 2: Finger
|
||||
minutiae data. [Structural analog for how the soul signature encodes per-channel
|
||||
discriminative features]
|
||||
- **ISO/IEC 19794-4:2011**: Biometric data interchange formats — Part 4: Finger image data.
|
||||
[Image-container analog; soul signature extends the concept to vector-valued
|
||||
multi-channel templates]
|
||||
- **ISO/IEC 29794-1:2016**: Biometric sample quality — Part 1: Framework.
|
||||
[Quality scoring framework; soul signature's per-node `confidence` field
|
||||
is conceptually analogous to ISO 29794 quality scores]
|
||||
- **ISO/IEC 30107-3:2023**: Biometric presentation attack detection — Part 3:
|
||||
Testing and reporting. [Presentation attack (anti-spoofing) framework;
|
||||
the adversarial.rs module is the soul signature's PAD implementation]
|
||||
|
||||
---
|
||||
|
||||
## 9. Reading List for RF Biometrics Newcomers
|
||||
|
||||
Ordered from most accessible to most technical.
|
||||
|
||||
1. Adib, F. (2017). **Using Radio Reflections to See the World**. MIT PhD thesis. [Most accessible introduction to using RF for human sensing; covers WiVi, WiTrack, EQ-Radio]
|
||||
2. Ma, Y., et al. (2019). **WiFi Sensing with Channel State Information: A Survey**. *ACM Computing Surveys*. doi:10.1145/3310194. [Comprehensive survey of CSI-based sensing approaches through 2019]
|
||||
3. Wang, X., et al. (2023). **A Survey on WiFi Sensing: From Signal to Action**. *IEEE Internet of Things Journal*. [Updated survey through 2023; covers contrastive learning approaches]
|
||||
4. Chen, T., et al. (2020). **A Simple Framework for Contrastive Learning** (SimCLR). arXiv:2002.05709. [Best starting point for understanding the contrastive learning approach used in AETHER]
|
||||
5. Geng, J., et al. (2022). **DensePose From WiFi**. arXiv:2301.00250. [Direct ancestor of this codebase; describes the cross-modal CSI → DensePose mapping]
|
||||
6. Abadi, M., et al. (2016). **Deep Learning with Differential Privacy**. CCS 2016. [Essential reading before any deployment collecting biometric data at training time]
|
||||
@@ -0,0 +1,306 @@
|
||||
# Soul Signature — Scanning Process
|
||||
|
||||
**Status:** Research Specification (Pre-Implementation)
|
||||
**Date:** 2026-05-24
|
||||
**Author:** ruv
|
||||
|
||||
---
|
||||
|
||||
## 1. Hardware Prerequisites
|
||||
|
||||
### 1.1 Full Protocol (N ≥ 3 Nodes)
|
||||
|
||||
| Component | Minimum | Recommended | Notes |
|
||||
|---|---|---|---|
|
||||
| Sensing nodes | 3 × ESP32-S3 (ADR-028) | 5+ nodes | Multi-node triangulation reduces angle-dependent blind spots; ADR-029 multistatic mesh |
|
||||
| Compute appliance | Cognitum Seed (Pi 5 + Hailo) | Same | Runs the field model, AETHER inference, vitals pipeline |
|
||||
| Network link | 2.4 GHz or 5 GHz AP | Dedicated sensing AP | Shared AP with user traffic degrades CSI frame rate |
|
||||
| Firmware version | ADR-110 v0.7.0+ | Same | Ed25519 witness chain required for attestation |
|
||||
| Clock sync | 802.15.4 time-sync (ESP32-C6) or NTP fallback | 802.15.4 preferred | ±100 µs alignment per ADR-110; NTP gives ±5 ms |
|
||||
|
||||
### 1.2 Degraded Mode (1 Node)
|
||||
|
||||
A single-node enrollment produces an incomplete signature:
|
||||
- Skeletal proportions: degraded (single-angle view)
|
||||
- Subcarrier reflection profile: single orientation only (3-orientation protocol collapses to 1)
|
||||
- AETHER embedding: usable but lower confidence
|
||||
- Cardiac / respiratory: unaffected (single-node sufficient)
|
||||
- Gait timing: usable if node placement allows bidirectional walk
|
||||
|
||||
Single-node signatures MUST be tagged `degraded_mode: true` in the manifest. The
|
||||
match score uses only the channels that met minimum confidence thresholds. The
|
||||
soul signature is technically valid but should be re-enrolled with multi-node
|
||||
hardware when possible.
|
||||
|
||||
### 1.3 ESP32-C6 Uplift (Wi-Fi 6 HE-LTF)
|
||||
|
||||
When at least one ESP32-C6 node is present (ADR-110), the subcarrier count
|
||||
expands from 52 (HT-LTF, S3) to up to 242 (HE-LTF, C6). The MERIDIAN
|
||||
HardwareNormalizer (ADR-027) maps all nodes to a canonical 56-subcarrier
|
||||
representation for the AETHER backbone. The full 242-subcarrier profile is
|
||||
preserved in the SubcarrierReflectionProfile node for higher-fidelity matching
|
||||
when available. The C6's 802.15.4 time-sync (±100 µs) also improves multistatic
|
||||
coherence relative to NTP-only S3 meshes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structured 60-Second Enrollment Protocol
|
||||
|
||||
The enrollment protocol produces exactly one `.rvf` soul signature file. The
|
||||
protocol is structured into five phases with exact timing. A human-readable
|
||||
prompt sequence should be delivered to the subject via audio or display.
|
||||
|
||||
### Phase 0 — Empty-Room Field Recalibration (T+0 to T+10)
|
||||
|
||||
Before the subject enters the sensing zone, the room must be empty and the
|
||||
ADR-030 field model must be current.
|
||||
|
||||
```
|
||||
T+0s : System checks field model age. Maximum age: 4 hours.
|
||||
If stale or absent → run field recalibration:
|
||||
Collect 1,200 CSI frames at 20 Hz (60 seconds of empty room)
|
||||
Compute per-link Welford mean and covariance
|
||||
Run SVD on covariance matrix → top-K=8 eigenmode vectors
|
||||
Store in field_model.rs::FieldNormalMode
|
||||
|
||||
T+0–10s: Quiet sampling of empty-room field state. No subject present.
|
||||
Operator prompt: "Please ensure the room is empty."
|
||||
System: verifies presence score < 0.1 (ADR-039 Tier 2 presence detection).
|
||||
Failure: if presence score ≥ 0.1, abort and report FAIL_ROOM_NOT_EMPTY.
|
||||
```
|
||||
|
||||
This phase is skipped (not aborted) if the field model was updated within the
|
||||
last 4 hours AND the current empty-room sampling confirms presence score < 0.05.
|
||||
|
||||
### Phase 1 — Deep Breathing Baseline (T+10 to T+25)
|
||||
|
||||
Subject enters the sensing zone and performs five deep breathing cycles.
|
||||
|
||||
```
|
||||
T+10s : Subject enters scan zone. System detects presence.
|
||||
Operator prompt: "Please stand still and breathe slowly and deeply."
|
||||
|
||||
T+10–25s: Subject stands at zone center, facing node cluster.
|
||||
Five complete breath cycles, each ≥ 4 seconds.
|
||||
System collects:
|
||||
- ADR-021 BreathingExtractor: baseline_bpm, depth_amplitude,
|
||||
inspiration_expiration_ratio, HRV_RSA
|
||||
- ADR-021 HeartRateExtractor: initial HR, HRV_SDNN (partial)
|
||||
- AETHER embedding: accumulates over 300 CSI frames (20 Hz × 15s)
|
||||
Quality gate: BreathingExtractor VitalCoherenceGate must emit
|
||||
PERMIT for ≥ 10 of the 15 seconds. Failure → FAIL_POOR_BREATHING_SIGNAL.
|
||||
```
|
||||
|
||||
### Phase 2 — Seated Rest (T+25 to T+35)
|
||||
|
||||
Subject sits to minimize motion and allow cardiac signal isolation.
|
||||
|
||||
```
|
||||
T+25s : Operator prompt: "Please sit down and rest quietly."
|
||||
|
||||
T+25–35s: Subject seated, minimal movement.
|
||||
System collects:
|
||||
- HeartRateExtractor: HR baseline, HRV_SDNN, HRV_RMSSD,
|
||||
LF/HF ratio, sinus rhythm classification
|
||||
- Cardiac_Waveform_Morphology: 64-coefficient wavelet decomposition
|
||||
of bandpass-filtered cardiac phase signal (0.8–2.0 Hz)
|
||||
Quality gate: HR confidence ≥ 0.6 for ≥ 7 of 10 seconds.
|
||||
Failure → FAIL_POOR_CARDIAC_SIGNAL (soft failure: cardiac nodes
|
||||
marked low-confidence; signature proceeds without them if AETHER
|
||||
and gait nodes pass their own thresholds).
|
||||
```
|
||||
|
||||
### Phase 3 — Gait Walk (T+35 to T+50)
|
||||
|
||||
Subject walks a 2-meter line twice in each direction.
|
||||
|
||||
```
|
||||
T+35s : Operator prompt: "Please walk a straight line of 2 meters back and
|
||||
forth twice at your natural pace."
|
||||
|
||||
T+35–50s: Subject walks: A→B, B→A, A→B, B→A (four transits, ≥ 8 strides total).
|
||||
System collects (via pose_tracker.rs, ADR-029 Sect 2.7):
|
||||
- GaitTimingNode: cadence, stride_period_variance,
|
||||
double_support_pct, asymmetry_index, step_width_m
|
||||
- SkeletalProportionsNode: torso/limb ratios from 17-keypoint
|
||||
trajectory accumulated over ≥ 8 strides
|
||||
- AETHER embedding: continues accumulating (300 more frames)
|
||||
Quality gate: ≥ 8 strides detected with confidence ≥ 0.7 per stride.
|
||||
Failure → FAIL_INSUFFICIENT_GAIT_DATA.
|
||||
Note: the ruvector-mincut DynamicPersonMatcher must confirm only one
|
||||
person is tracked. If two tracks are active → FAIL_MULTIPLE_SUBJECTS.
|
||||
```
|
||||
|
||||
### Phase 4 — Standing Orientation Scan (T+50 to T+60)
|
||||
|
||||
Subject stands at three orientations to capture the subcarrier reflection profile.
|
||||
|
||||
```
|
||||
T+50s : Operator prompt: "Please stand facing the wall. I will ask you to
|
||||
rotate in place twice."
|
||||
|
||||
T+50–53s: Orientation 0° (subject faces primary node cluster).
|
||||
System collects: SubcarrierReflectionProfile at 0°
|
||||
(ADR-030 field-subtracted, 56 subcarriers, amplitude + phase).
|
||||
|
||||
T+53s : Operator prompt: "Please turn 90 degrees to your right."
|
||||
|
||||
T+53–56s: Orientation 90°.
|
||||
System collects: SubcarrierReflectionProfile at 90°.
|
||||
|
||||
T+56s : Operator prompt: "Please turn 90 degrees to your right again."
|
||||
|
||||
T+56–60s: Orientation 180°.
|
||||
System collects: SubcarrierReflectionProfile at 180°.
|
||||
Body_Field_Coupling: computed from AETHER attention map weighted
|
||||
by ADR-030 top-K=8 eigenvectors (final computation at T=60s).
|
||||
|
||||
T+60s : Enrollment window closes.
|
||||
AETHER embedding finalized: mean pool over all ~1,200 accumulated frames.
|
||||
All node confidence values computed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Quality Gates
|
||||
|
||||
The enrollment FAILS and emits a structured error code if any of the following
|
||||
conditions are met. Failed enrollments do not produce a stored `.rvf` file.
|
||||
|
||||
| Gate | Condition for FAIL | Error code |
|
||||
|---|---|---|
|
||||
| Room occupied | Presence score ≥ 0.1 at Phase 0 end | `FAIL_ROOM_NOT_EMPTY` |
|
||||
| Multiple subjects | ≥ 2 active pose tracks during Phases 1–4 | `FAIL_MULTIPLE_SUBJECTS` |
|
||||
| Intermittent presence | Subject exits sensing zone for > 3 consecutive seconds | `FAIL_SUBJECT_LEFT_ZONE` |
|
||||
| AETHER confidence low | Final embedding confidence < 0.6 (HNSW search confidence) | `FAIL_AETHER_LOW_CONFIDENCE` |
|
||||
| Breathing signal absent | VitalCoherenceGate PERMIT rate < 67% during Phase 1 | `FAIL_POOR_BREATHING_SIGNAL` |
|
||||
| Gait data insufficient | Fewer than 8 strides detected with confidence ≥ 0.7 | `FAIL_INSUFFICIENT_GAIT_DATA` |
|
||||
| Field model dirty | Field model age > 4 hours and recalibration refused | `FAIL_STALE_FIELD_MODEL` |
|
||||
| Adversarial detection | RuvSense adversarial.rs flags physically impossible signal | `FAIL_ADVERSARIAL_SIGNAL` |
|
||||
| Node count below minimum | Fewer than 2 nodes online during Phases 3–4 | `WARN_DEGRADED_MODE` (not a hard fail; produces degraded signature) |
|
||||
|
||||
Soft failures (cardiac signal only) do not abort the enrollment; they mark those
|
||||
nodes as low-confidence and reduce the match weight for those channels at
|
||||
recognition time.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fast Scan (10-Second Degraded Identification)
|
||||
|
||||
A fast scan produces a partial query embedding, not a stored profile. It is used
|
||||
for recognition of already-enrolled subjects, not for new enrollment.
|
||||
|
||||
```
|
||||
T+0s : System checks whether field model is current (age < 4 hours).
|
||||
If stale: recognition accuracy degraded; warn operator.
|
||||
|
||||
T+0–10s: Subject stands still at zone center, natural breathing.
|
||||
System collects: AETHER embedding (200 frames, 10s at 20 Hz).
|
||||
Cardiac HR: partial (confidence typically < 0.5).
|
||||
Gait: not available.
|
||||
Subcarrier reflection: 1 orientation only.
|
||||
|
||||
T+10s : Query issued against all stored profiles in HNSW index.
|
||||
Match score computed using available channels only.
|
||||
Cardiac, gait, and skeletal proportions excluded from denominator
|
||||
(availability factor = 0 for absent channels).
|
||||
```
|
||||
|
||||
Fast scan is acceptable for:
|
||||
- Returning resident recognition (already enrolled, low-friction use case)
|
||||
- Home automation triggers (occupancy attribution per ADR-115 HA-MIND)
|
||||
|
||||
Fast scan is NOT acceptable for:
|
||||
- Initial enrollment
|
||||
- High-assurance access control
|
||||
- Healthcare identification
|
||||
|
||||
---
|
||||
|
||||
## 5. Continuous Mode — Implicit Signature Refinement
|
||||
|
||||
In continuous operating mode, the system incrementally updates the online
|
||||
aggregator for enrolled persons as they go about their normal activities. The
|
||||
stored profile is re-published from the aggregator every 90 days (or on the
|
||||
re-scan cadence, whichever comes first). This means a deployed system becomes
|
||||
more accurate over time, not less.
|
||||
|
||||
Convergence property: the Welford online statistics in the aggregator are
|
||||
numerically stable and converge to the true population mean/variance as
|
||||
observation count increases. The AETHER embedding accumulated over thousands
|
||||
of natural-activity windows is more representative than a single 60-second
|
||||
enrollment. The stored profile is replaced (not amended) on each re-publish; the
|
||||
old profile is archived (not deleted) per the forward-secrecy requirements in
|
||||
`security.md`.
|
||||
|
||||
The continuous mode raises a consent concern: a person is effectively being
|
||||
re-enrolled continuously without explicit action. This is addressed in
|
||||
`security.md §4` (Consent Architecture).
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-Room Enrollment
|
||||
|
||||
When a person moves across multiple sensing zones (e.g., living room and bedroom
|
||||
each with a Cognitum Seed node cluster), the cross-room signature works as follows:
|
||||
|
||||
1. Full 60-second enrollment is performed in the primary room. This produces the
|
||||
initial stored profile with `environment_normalized: false` in the manifest.
|
||||
|
||||
2. When the MERIDIAN domain generalization layer (ADR-027) is active, the
|
||||
HardwareNormalizer maps the enrollment embedding to the environment-invariant
|
||||
subspace. The stored profile is updated to `environment_normalized: true`.
|
||||
|
||||
3. In subsequent rooms, a fast scan (10s) is sufficient to attribute identity. The
|
||||
MERIDIAN-normalized AETHER embedding handles the room shift.
|
||||
|
||||
4. For healthcare deployments requiring room-by-room re-enrollment for regulatory
|
||||
reasons, a per-room enrollment protocol runs in each room and the signatures
|
||||
are linked by the opaque `person_id` field (never by raw PII).
|
||||
|
||||
---
|
||||
|
||||
## 7. Re-Scan Cadence
|
||||
|
||||
| Deployment context | Re-scan interval | Rationale |
|
||||
|---|---|---|
|
||||
| Healthy adult (residential) | 90 days | Anatomy stable; continuous mode refines continuously |
|
||||
| Child (growing skeleton) | 30 days | Skeletal proportions change; gait timing changes |
|
||||
| Healthcare / clinical | Per clinical event | Post-surgery, post-illness, post-significant weight change |
|
||||
| Post-exercise monitoring | 7 days during active programs | Body composition changes affect RF backscatter |
|
||||
| Any | On drift alert from longitudinal.rs (ADR-030 Tier 4) | System-initiated; shown to user as "calibration recommended" |
|
||||
|
||||
The `longitudinal.rs` module monitors five drift metrics (GaitSymmetry,
|
||||
StabilityIndex, BreathingRegularity, MicroTremor, ActivityLevel) using Welford
|
||||
statistics over daily observations. When any metric exceeds 2-sigma deviation
|
||||
sustained for 3 consecutive days, a `DriftAlert` is emitted. The system
|
||||
displays this as "signature drift detected — re-scan recommended," not as a
|
||||
health diagnosis.
|
||||
|
||||
---
|
||||
|
||||
## 8. Output Artifact
|
||||
|
||||
On successful completion, the enrollment pipeline produces:
|
||||
|
||||
1. `signature-<sha256>.rvf` — the binary soul signature container. Content-addressed.
|
||||
Encrypted with the person's key (see `security.md §5`) before writing to disk.
|
||||
|
||||
2. `signature-<sha256>.json` — the JSON-LD sidecar for human inspection and audit.
|
||||
Does not contain raw vector data. Safe to log.
|
||||
|
||||
3. A row in the local HNSW index (`ruvector-core::VectorIndex`, `person_track`
|
||||
subindex per ADR-024 §2.4) linking the person_id to the AETHER embedding.
|
||||
This index is used for O(log n) recognition queries.
|
||||
|
||||
4. An Ed25519 witness entry per ADR-110, signing
|
||||
`(rvf_sha256 || timestamp_ns || enrolled_by_device_id)`. Stored in the
|
||||
RVF SEG_WITNESS segment AND in the node's local audit log.
|
||||
|
||||
The enrollment process does NOT:
|
||||
- Transmit raw CSI or raw biometrics to any external server.
|
||||
- Publish the soul signature to MQTT or Matter unless explicitly configured with
|
||||
`--privacy-mode disabled` (see `security.md §6`).
|
||||
- Store PII (name, email, account linkage) in the `.rvf` file. The `person_id`
|
||||
field is an opaque u64. PII linkage, if any, lives in the application layer
|
||||
and is governed by separate access control.
|
||||
@@ -0,0 +1,367 @@
|
||||
# Soul Signature — Security, Privacy, and Threat Model
|
||||
|
||||
**Status:** Research Specification (Pre-Implementation)
|
||||
**Date:** 2026-05-24
|
||||
**Author:** ruv
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This document defines the threat model, mitigations, cryptographic primitive
|
||||
choices, privacy architecture, and open security research items for the Soul
|
||||
Signature system. It is intended to be reviewed by a security engineer or
|
||||
privacy counsel before any production deployment.
|
||||
|
||||
The soul signature is a passive biometric system. The security bar is:
|
||||
**attacker cost to achieve a false accept must exceed the value of the
|
||||
protected resource for the relevant threat model**. The soul signature does
|
||||
not claim to be unbreakable. It claims to be hard enough.
|
||||
|
||||
---
|
||||
|
||||
## 2. What We Explicitly Do NOT Claim
|
||||
|
||||
- Not equal to fingerprint scanners on FBI-tier datasets in EER terms. RF
|
||||
biometrics are a younger discipline. No independent benchmark with the soul
|
||||
signature's specific multi-channel fusion exists yet.
|
||||
- Not legal evidence. Passive RF biometric identification has no established
|
||||
legal precedent in any jurisdiction.
|
||||
- Not a replacement for explicit consent in regulated contexts (healthcare,
|
||||
employment, border control).
|
||||
- Not unbreakable under a nation-state adversary with full physical access to
|
||||
the sensing infrastructure.
|
||||
- Not validated at scale beyond the constituent ADR baselines. The AETHER
|
||||
channel (ADR-024) targets >80% mAP at 5 subjects; at 100+ subjects the
|
||||
false-accept rate is open research.
|
||||
|
||||
---
|
||||
|
||||
## 3. Threat Model
|
||||
|
||||
### 3.1 Attacker: Passive Eavesdropper on the WiFi Medium
|
||||
|
||||
**Capability:** An attacker near the WiFi sensing zone can observe CSI of any
|
||||
person who passes through. With enough CSI, the attacker could construct an
|
||||
unauthorized soul signature enrollment of an unconsenting bystander.
|
||||
|
||||
**Impact:** Unauthorized enrollment → unauthorized recognition → attribution of
|
||||
presence to a person who did not consent.
|
||||
|
||||
**Mitigation:**
|
||||
- Ambient CSI capture does NOT trigger enrollment. Enrollment requires the
|
||||
explicit 60-second structured protocol. Ambient bystander CSI produces
|
||||
`unauthenticated` pose tracks tagged as `person_id: NULL`.
|
||||
- Unauthenticated RVF nodes are pruned from the HNSW index after 24 hours.
|
||||
- The enrollment protocol requires presence confirmation from at least two
|
||||
sensing nodes simultaneously, making drive-by enrollment geometrically
|
||||
harder to achieve without physical proximity.
|
||||
|
||||
**Residual risk:** An attacker who can be physically present in the scanning
|
||||
zone for 60 seconds, under the observation of the scanning protocol, can cause
|
||||
enrollment of a fake person. This requires physical co-location and is
|
||||
equivalent to the threat model for any in-person biometric registration.
|
||||
|
||||
### 3.2 Attacker: Active Replay
|
||||
|
||||
**Capability:** An attacker records a CSI stream from a legitimate enrollment
|
||||
or recognition event and replays it to a sensing node to impersonate the
|
||||
enrolled person.
|
||||
|
||||
**Impact:** False positive recognition; unauthorized access or presence attribution.
|
||||
|
||||
**Mitigation:**
|
||||
- Each enrollment is bound to the room's ADR-030 field model eigenstate at
|
||||
enrollment time. The `environment_id` field in every vector node is a
|
||||
SHA-256 of the field model's eigenmode matrix. A replay in a different room
|
||||
produces a different `environment_id` and a dramatically different
|
||||
Subcarrier_Reflection_Profile — the cross-validation between these two
|
||||
signed fields fails.
|
||||
- The Ed25519 witness chain (ADR-110) includes a monotonic timestamp
|
||||
(`timestamp_ns`). A replay of an old signature is detected by the timestamp
|
||||
freshness check at recognition time (configurable; default: reject any
|
||||
signature older than 7 days for high-assurance contexts).
|
||||
- The ADR-030 field model continuously updates. Even if the replay is in the
|
||||
same room, the field model's eigenstate changes as furniture is moved or
|
||||
temperature shifts the propagation medium; cross-validation degrades over
|
||||
time.
|
||||
|
||||
**Residual risk:** Replay within the same room within a short time window
|
||||
(< 4 hours, before the field model rotates) by an attacker who has recorded the
|
||||
original CSI with high fidelity remains a plausible attack vector. This is not
|
||||
defended against by the current architecture. It requires a future ADR for
|
||||
challenge-response liveness detection.
|
||||
|
||||
### 3.3 Attacker: Phased-Array Vest / RF Body Emulator
|
||||
|
||||
**Capability:** An attacker wears a device capable of emitting RF signals that
|
||||
mimic another person's backscatter profile, allowing them to be recognized as
|
||||
the enrolled person.
|
||||
|
||||
**Impact:** The strongest impersonation attack; if successful, bypasses all
|
||||
electromagnetic biometric channels simultaneously.
|
||||
|
||||
**Mitigation:**
|
||||
- The RuvSense `adversarial.rs` module (ADR-030 Tier 7) enforces four
|
||||
physics-based consistency checks:
|
||||
1. Multi-link consistency: a real body perturbs all mesh links passing
|
||||
through its location. A vest emitting signals affects only the targeted
|
||||
link(s). Detection: at least 4 links must show correlated perturbation.
|
||||
2. Field model constraints: the perturbation must lie within the span of
|
||||
the room's eigenmode structure. Artificially injected signals produce
|
||||
perturbations inconsistent with room geometry.
|
||||
3. Temporal continuity: real movement is smooth in embedding space; injected
|
||||
signals can produce discontinuities flagged by the embedding velocity
|
||||
monitor.
|
||||
4. Energy conservation: total perturbation energy across all links must be
|
||||
consistent with the number and geometry of bodies present.
|
||||
- The adversarial detector fires `FAIL_ADVERSARIAL_SIGNAL` before the soul
|
||||
signature match is considered.
|
||||
|
||||
**Residual risk:** A sophisticated attacker with a calibrated phased-array
|
||||
system who also knows the room's eigenmode structure and the enrolled person's
|
||||
exact multi-link backscatter pattern could in principle construct a convincing
|
||||
emulation. This is a high-capability, high-cost attack. Practical countermeasure:
|
||||
require multi-node confirmation (ADR-029 multistatic) which raises the
|
||||
geometric complexity of the emulation exponentially with node count.
|
||||
|
||||
### 3.4 Attacker: Insider with Broker Access
|
||||
|
||||
**Capability:** A privileged operator or compromised service with read access
|
||||
to the stored `.rvf` files and the HNSW person_track index.
|
||||
|
||||
**Impact:** Exfiltration of biometric signatures; linkage of person_id to PII
|
||||
if linkage tables also accessible; replay or cross-site re-enrollment.
|
||||
|
||||
**Mitigation:**
|
||||
- At-rest encryption: all `.rvf` files are encrypted with ChaCha20-Poly1305
|
||||
using a key derived via Argon2id from a user-provided passphrase (or a FIDO2
|
||||
hardware token binding). The Cognitum Seed appliance NEVER stores the
|
||||
decryption key; it is re-derived from the passphrase on each access.
|
||||
- The opaque `person_id` (u64) in the `.rvf` file is not PII. PII linkage, if
|
||||
any, requires access to a separate application-layer database not stored on
|
||||
the sensing appliance.
|
||||
- The HNSW index stores only the 128-dim AETHER embedding, not raw CSI or full
|
||||
soul signatures. Exfiltration of the index exposes the embedding but not the
|
||||
full biometric record.
|
||||
- Differential privacy (ADR-106 DP-SGD) applies at training time when AETHER
|
||||
is fine-tuned on enrolled-person data, preventing membership inference attacks
|
||||
that could recover training samples from model weights.
|
||||
|
||||
**Residual risk:** If the passphrase is weak or the FIDO2 token is compromised,
|
||||
the at-rest encryption fails. Key management is a deployment responsibility.
|
||||
|
||||
### 3.5 Attacker: Manufacturer / Firmware Supply Chain
|
||||
|
||||
**Capability:** A malicious firmware update to the ESP32 node or Cognitum Seed
|
||||
appliance could silently exfiltrate soul signatures or CSI streams.
|
||||
|
||||
**Impact:** Large-scale passive surveillance; biometric data exfiltration across
|
||||
all installed appliances.
|
||||
|
||||
**Mitigation:**
|
||||
- All firmware releases are signed with Ed25519 (ADR-100 cog packaging) and
|
||||
verified by the appliance before installation. A Dilithium-3 post-quantum
|
||||
co-signature is added in the transition window (ADR-109).
|
||||
- The Ed25519 witness chain (ADR-110) signs each CSI frame bundle at the
|
||||
sensor level. A firmware change that alters the witness chain is detectable
|
||||
by downstream audit.
|
||||
- Network egress from the Cognitum Seed in `--privacy-mode` is blocked for
|
||||
raw CSI and soul signatures by default. Only MQTT auto-discovery messages
|
||||
(ADR-115) and OTA metadata are permitted outbound.
|
||||
- Open-source firmware. The ESP32 firmware and Cognitum Seed Rust crates are
|
||||
open source (this repository). Independent audit is possible.
|
||||
|
||||
**Residual risk:** A zero-day exploit in the ESP-IDF WiFi stack or the Rust
|
||||
codebase could bypass these controls. This is mitigated by regular security
|
||||
audits (run `npx @claude-flow/cli@latest security scan` per CLAUDE.md) but not
|
||||
eliminated.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consent Architecture
|
||||
|
||||
### 4.1 The Enrollment-vs-Recognition Distinction
|
||||
|
||||
The soul signature system enforces a hard distinction:
|
||||
|
||||
| Action | Consent required | Mechanism |
|
||||
|---|---|---|
|
||||
| Enrollment | Explicit, active | 60-second protocol with operator confirmation; produces signed `.rvf` |
|
||||
| Recognition of enrolled person | Implicit (enrollment = consent for recognition) | Continuous mode; HNSW match |
|
||||
| Ambient sensing of unenrolled person | No — but data is transient and pruned | Unauthenticated tracks; 24h TTL |
|
||||
| Updating stored profile from continuous mode | Implicit (set at enrollment time) | Aggregator auto-refresh; configurable |
|
||||
|
||||
The system operator is responsible for obtaining appropriate consent from
|
||||
persons before performing enrollment. The technical system enforces that
|
||||
enrollment cannot happen accidentally or from drive-by sensing.
|
||||
|
||||
### 4.2 Bystander Protection
|
||||
|
||||
Persons who pass through a sensing zone without being enrolled are sensed but
|
||||
not persistently identified. Their data flow:
|
||||
1. Pose tracker produces a track tagged `person_id: NULL`.
|
||||
2. AETHER embedding is computed for motion detection and occupancy counting
|
||||
(ADR-115 HA-MIND).
|
||||
3. The embedding is written to the `temporal_baseline` HNSW index with a 24-hour
|
||||
TTL and `authenticated: false`.
|
||||
4. After 24 hours, the entry is automatically pruned by the `EmbeddingIndex::prune()`
|
||||
method (ADR-024 §2.4).
|
||||
5. No `.rvf` file is created. No persistent record exists.
|
||||
|
||||
This architecture satisfies the GDPR principle of data minimization (Article 5(1)(c))
|
||||
for bystander data: the retention period is bounded, the data is not linked to
|
||||
an identity, and the storage is proportionate to the functional purpose
|
||||
(occupancy counting).
|
||||
|
||||
### 4.3 GDPR / HIPAA Mode
|
||||
|
||||
When `--privacy-mode enabled` (from ADR-115 HA-MIND §privacy):
|
||||
|
||||
1. Soul signatures are computed and stored locally only. They are NEVER
|
||||
published to MQTT topics, Matter clusters, or any external endpoint.
|
||||
2. The local REST API for accessing soul signatures requires a valid bearer
|
||||
token (ADR-028 bearer_auth.rs). No unauthenticated endpoint exposes
|
||||
biometric data.
|
||||
3. The JSON-LD sidecar is written to the local encrypted store only. It is not
|
||||
included in MQTT auto-discovery payloads.
|
||||
4. The longitudinal drift metrics (ADR-030 Tier 4) are published to MQTT in
|
||||
aggregated form only (e.g., `drift_detected: true`, never raw metric values
|
||||
that could be used for medical inference).
|
||||
5. A data deletion endpoint must be implemented: `DELETE /api/v1/persons/{id}`
|
||||
removes the `.rvf` file, the HNSW index entry, the JSON-LD sidecar, and all
|
||||
longitudinal Welford statistics for that person_id.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cryptographic Primitives
|
||||
|
||||
All primitives are chosen from NIST-approved or widely-audited standards.
|
||||
|
||||
| Purpose | Primitive | Rationale |
|
||||
|---|---|---|
|
||||
| Content integrity (per-segment) | CRC32 (IEEE 802.3) | Already implemented in `rvf_container.rs:line 70`. Corruption detection, not security. |
|
||||
| Content addressing | SHA-256 | File name derivation; pre-image resistance prevents name collisions |
|
||||
| Ed25519 signatures | Ed25519 (RFC 8032) | ADR-110 witness chain; 64-byte signatures; 128-bit security |
|
||||
| At-rest encryption | ChaCha20-Poly1305 (RFC 8439) | AEAD; software-friendly; no timing-attack surface like AES-CBC; 256-bit key |
|
||||
| Key derivation from passphrase | Argon2id (RFC 9106) | Memory-hard KDF; resistant to GPU/ASIC brute-force; recommended by NIST SP 800-132 draft (2024) |
|
||||
| DP-SGD noise | Gaussian N(0, σ²C²I) per ADR-106 | (ε, δ)-DP per Abadi et al. 2016 Moments Accountant |
|
||||
| Post-quantum key exchange (future) | Kyber-768 (NIST FIPS 203, 2024) | ADR-108; ~AES-192 security; NIST CNSA 2.0 recommended |
|
||||
| Post-quantum signatures (future) | Dilithium-3 (NIST FIPS 204, 2024) | ADR-109; hybrid mode with Ed25519 during transition window |
|
||||
|
||||
### 5.1 Argon2id Parameters
|
||||
|
||||
Default parameters for soul signature key derivation:
|
||||
|
||||
```
|
||||
m_cost = 65536 (64 MB memory)
|
||||
t_cost = 3 (3 iterations)
|
||||
p_cost = 4 (4 parallel lanes)
|
||||
output_len = 32 bytes (256-bit key for ChaCha20-Poly1305)
|
||||
salt = 16 random bytes stored alongside encrypted blob (NOT the person_id)
|
||||
```
|
||||
|
||||
These parameters provide ~100ms KDF time on a Pi 5, which is acceptable for
|
||||
enrollment (one-time) and recognition (HNSW match precedes decryption, so
|
||||
decryption is only triggered after a candidate match).
|
||||
|
||||
### 5.2 Forward Secrecy
|
||||
|
||||
Old soul signature files are NOT keys for new ones. Compromise of a 90-day-old
|
||||
`.rvf` file does not unlock the current profile. The key is derived from the
|
||||
user's passphrase each time, not derived from the previous file.
|
||||
|
||||
Archived files (kept for audit purposes) are re-encrypted on passphrase rotation
|
||||
if the operator elects to do so via the `soul-signature re-encrypt --all` CLI
|
||||
command (not yet implemented; specified here for future ADR).
|
||||
|
||||
---
|
||||
|
||||
## 6. Privacy Mode Integration (ADR-115)
|
||||
|
||||
The `--privacy-mode` flag defined in ADR-115 HA-MIND §9 is extended to cover
|
||||
soul signature data:
|
||||
|
||||
| Privacy mode | MQTT publish | REST API | Local storage | HNSW index |
|
||||
|---|---|---|---|---|
|
||||
| `disabled` (default for home users) | Aggregated presence/count only | Authenticated bearer required | Encrypted at rest | Local only |
|
||||
| `enabled` | Nothing biometric | Authenticated bearer required | Encrypted at rest | Local only |
|
||||
| `research` (explicit opt-in) | Full soul signature nodes (anonymized person_id) | Open (for research deployments only) | Encrypted at rest | Exportable |
|
||||
|
||||
The `research` mode requires a separate `--research-consent-token` flag and is
|
||||
intended for academic data collection under IRB approval. It must never be the
|
||||
default.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Research and Outstanding Security Work
|
||||
|
||||
The following items are known security gaps or open research questions. Each
|
||||
warrants a future ADR before production deployment at scale.
|
||||
|
||||
**7.1 Challenge-Response Liveness Detection**
|
||||
Replay attacks within a short time window (see §3.2 residual risk) are not
|
||||
defended against. A future mechanism should issue a random challenge (e.g.,
|
||||
"please raise your left hand") and verify the CSI response matches the challenge
|
||||
before accepting a recognition. This eliminates replay as a practical attack
|
||||
vector. Future ADR: ADR-120 (proposed).
|
||||
|
||||
**7.2 False-Accept Rate at Scale (N > 20 subjects)**
|
||||
The AETHER baseline (ADR-024) is tested at 5 subjects (>80% mAP). For household
|
||||
deployments this is sufficient. For building-scale deployments (50-500 subjects),
|
||||
the FAR is open research. Independent benchmarking on a dataset of 20+ subjects
|
||||
with the full 7-channel fusion is required before building-scale deployment can
|
||||
be recommended. Publication target: co-locate with ADR-027 MERIDIAN evaluation.
|
||||
|
||||
**7.3 Side-Channel Leakage from Encrypted RVF Files**
|
||||
The file size of an encrypted `.rvf` blob is observable by an attacker with
|
||||
filesystem access. File size is a function of the number of nodes present, which
|
||||
reveals whether the cardiac channel was captured (high-SNR enrollment vs
|
||||
low-SNR enrollment). This is a minor information leak. Mitigation: pad all
|
||||
`.rvf` files to a fixed 64 KB boundary. Future ADR: append to ADR-106.
|
||||
|
||||
**7.4 Membership Inference in Continuous Mode**
|
||||
In continuous mode, the AETHER model is fine-tuned on the enrolled person's
|
||||
data over months. An adversary with access to the model weights before and after
|
||||
a re-train cycle could infer that a specific enrollment occurred, even without
|
||||
the soul signature file, via membership inference (Shokri et al. 2017).
|
||||
ADR-106 DP-SGD mitigates this for federation round deltas but not for local
|
||||
single-device fine-tuning. Extension of DP-SGD to the local continuous-mode
|
||||
update is required. Future ADR: extend ADR-106.
|
||||
|
||||
**7.5 Physical Access to Sensing Nodes**
|
||||
An attacker with physical access to an ESP32 node can extract the firmware and
|
||||
attempt to reverse the Ed25519 signing key (if the key is stored in ESP32
|
||||
NVS without protection). ADR-110 uses NVS for key storage. A future ADR should
|
||||
mandate secure element storage (e.g., ATECC608A co-processor on the Cognitum
|
||||
Seed) for the signing key. Future ADR: ADR-121 (proposed).
|
||||
|
||||
**7.6 Federated Learning Linkability**
|
||||
When AETHER is retrained via federated learning (ADR-105), the LoRA weight
|
||||
deltas carry information about enrolled persons. ADR-106 applies DP-SGD to
|
||||
these deltas, but the post-quantum migration path (ADR-108 Kyber-768) is not
|
||||
yet integrated with the federation protocol. Until ADR-108 Phase 2 ships, the
|
||||
federation link is classically encrypted and vulnerable to harvest-now-decrypt-later
|
||||
attacks by quantum-capable adversaries. Assessed risk: low until 2027.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary Security Properties Table
|
||||
|
||||
| Property | Status | Evidence |
|
||||
|---|---|---|
|
||||
| At-rest encryption | Specified (ChaCha20-Poly1305 + Argon2id) | This document §5 |
|
||||
| Ed25519 attestation | Implemented | ADR-110 witness chain |
|
||||
| Replay resistance (cross-room) | Implemented | ADR-030 field model environment_id binding |
|
||||
| Replay resistance (same-room, short window) | Open gap | §7.1 |
|
||||
| Anti-spoofing (single-link injection) | Implemented | adversarial.rs multi-link consistency |
|
||||
| Anti-spoofing (phased-array vest) | Partial | adversarial.rs + energy conservation; residual risk documented |
|
||||
| Bystander protection | Specified | 24h TTL on unauthenticated tracks; §4.2 |
|
||||
| DP-SGD training privacy | Implemented (federation) | ADR-106 |
|
||||
| DP-SGD training privacy (local continuous mode) | Open gap | §7.4 |
|
||||
| GDPR data deletion | Specified | §4.3 `DELETE /api/v1/persons/{id}` |
|
||||
| Post-quantum migration path | Specified (Kyber-768, Dilithium-3) | ADR-108, ADR-109 |
|
||||
| Firmware supply chain integrity | Implemented (Ed25519 cog signing) | ADR-100, ADR-109 hybrid |
|
||||
| False-accept rate at scale | Open research | §7.2 |
|
||||
| Liveness detection | Open gap | §7.1 |
|
||||
| Secure element key storage | Open gap | §7.5 |
|
||||
@@ -0,0 +1,525 @@
|
||||
# Soul Signature — Technical Specification
|
||||
|
||||
**Status:** Research Specification (Pre-Implementation)
|
||||
**Date:** 2026-05-24
|
||||
**Author:** ruv
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A Soul Signature is a typed, content-addressed RVF graph encoding seven
|
||||
electromagnetic observables extracted from a person in a WiFi-DensePose sensing
|
||||
zone. The graph is stored as a single `.rvf` binary blob using the existing RVF
|
||||
container format (`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`)
|
||||
extended with two new segment types defined below. A human-readable JSON sidecar
|
||||
accompanies the blob for inspection and provenance.
|
||||
|
||||
The signature is probabilistic, not deterministic. Matching computes a weighted
|
||||
cosine similarity across graph dimensions, producing a score in [0, 1] with a
|
||||
calibrated false-accept rate (FAR). The FAR at a given threshold is an open
|
||||
research question; the AETHER person re-identification baseline (ADR-024 §2.8:
|
||||
>80% mAP at 5 subjects) is the lower bound for the primary embedding channel.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
### 2.1 Per-Individual
|
||||
|
||||
The signature encodes features that are structurally unique to one person at the
|
||||
sensing resolution of commodity WiFi hardware. Discriminative dimensions include:
|
||||
cardiac timing (R-R interval structure), respiratory mechanics (tidal depth,
|
||||
inspiration-to-expiration ratio), skeletal proportions (limb ratios from 17-keypoint
|
||||
pose, ADR-079), gait cadence variability, and the RF backscatter profile shaped by
|
||||
body mass distribution and geometry.
|
||||
|
||||
### 2.2 Passive at Enrollment Time
|
||||
|
||||
No explicit action from the subject is required at recognition time after
|
||||
enrollment. Recognition fires whenever an enrolled person is detected in a sensing
|
||||
zone. Enrollment itself requires a 60-second structured protocol (see
|
||||
`scanning-process.md`). This is a deliberate asymmetry: passive recognition +
|
||||
active enrollment — which is the same model used by FaceID (passive unlock after
|
||||
initial face setup).
|
||||
|
||||
The passivity of post-enrollment recognition is a privacy concern addressed in full
|
||||
in `security.md` §4.
|
||||
|
||||
### 2.3 Multi-Modal
|
||||
|
||||
Seven orthogonal channels contribute. Orthogonality matters: if one channel
|
||||
degrades (e.g., cardiac is masked by motion), the remaining six carry the match.
|
||||
No single channel is necessary for a positive identification above threshold;
|
||||
the fused score is a weighted aggregate.
|
||||
|
||||
### 2.4 Persistent Across Time
|
||||
|
||||
The stored signature is valid over weeks to months for adults with stable anatomy
|
||||
and health. Re-scan cadence is prescribed in `scanning-process.md`. The
|
||||
`longitudinal.rs` module (ADR-030 Tier 4) provides the drift detection that
|
||||
flags when a re-scan is necessary.
|
||||
|
||||
### 2.5 Defensible False-Accept Rate
|
||||
|
||||
The security model is not "unbreakable." It is "attacker cost exceeds value of
|
||||
attack for the threat model in §security." See `security.md` §3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Signature as a Typed RVF Graph
|
||||
|
||||
### 3.1 Container Format
|
||||
|
||||
The soul signature reuses the RVF binary container defined in
|
||||
`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` (lines 1–660).
|
||||
Existing segment types used:
|
||||
|
||||
| Segment type | Const | Purpose in soul signature |
|
||||
|---|---|---|
|
||||
| `SEG_MANIFEST` | `0x05` | Graph metadata: schema version, enroll timestamp, device ID, person_id (opaque u64) |
|
||||
| `SEG_VEC` | `0x01` | AETHER 128-dim embedding weights (backbone + projection head) |
|
||||
| `SEG_META` | `0x07` | JSON overlay: all non-vector node attributes |
|
||||
| `SEG_WITNESS` | `0x0A` | Ed25519 signature over `(content_hash_sha256 || timestamp_ns || enrolled_by_device_id)` |
|
||||
| `SEG_EMBED` | `0x0C` | AETHER embedding config + projection head weights (ADR-024 Phase 7) |
|
||||
| `SEG_LORA` | `0x0D` | Per-environment LoRA deltas for environment-adapted query |
|
||||
|
||||
Two new segment types are proposed for the soul signature extension:
|
||||
|
||||
| Segment type | Const | Purpose |
|
||||
|---|---|---|
|
||||
| `SEG_SOUL_GRAPH` | `0x10` | JSON-serialized graph: node list + edge list + attribute schemas |
|
||||
| `SEG_SOUL_INDEX` | `0x11` | Per-node HNSW index serialization for fast graph-level query |
|
||||
|
||||
The `SegmentHeader` structure is unchanged. Each segment is 64-byte aligned
|
||||
(field `alignment_pad` at offset `0x3C`). CRC32 content hash at offset `0x28`
|
||||
covers the payload, providing tamper detection per the existing implementation
|
||||
at `rvf_container.rs:line 70`.
|
||||
|
||||
### 3.2 Node Types
|
||||
|
||||
Each node is a typed struct. Serialized into SEG_META as a JSON object with a
|
||||
`node_type` discriminator string. Vector fields (f32 arrays) are co-located in
|
||||
a SEG_VEC segment indexed by the node's `vec_segment_id` field.
|
||||
|
||||
#### Node: AETHER_Embedding
|
||||
|
||||
Primary identity anchor. The contrastive CSI embedding from ADR-024.
|
||||
|
||||
```rust
|
||||
pub struct AetherEmbeddingNode {
|
||||
pub node_type: &'static str, // "AETHER_Embedding"
|
||||
pub vec_segment_id: u64, // references SEG_VEC containing 128 f32s
|
||||
pub embedding_dim: usize, // 128
|
||||
pub backbone: String, // "csi-to-pose-transformer"
|
||||
pub pretrain_method: String, // "simclr+vicreg"
|
||||
pub alignment_score: f32, // Lowman alignment metric at enrollment time
|
||||
pub uniformity_score: f32, // Hypersphere uniformity at enrollment time
|
||||
pub enrollment_frames: u32, // Number of CSI windows averaged into this node
|
||||
pub environment_id: String, // SHA-256 of field model eigenstate at enrollment
|
||||
pub confidence: f32, // HNSW search confidence against person_track index
|
||||
}
|
||||
```
|
||||
|
||||
Stored size: 128 × 4 = 512 bytes in SEG_VEC; JSON metadata ~200 bytes in SEG_META.
|
||||
Per ADR-024 §2.8, the person re-identification target is >80% mAP at 5 subjects.
|
||||
At 10+ subjects the accuracy is open research; baseline TBD.
|
||||
|
||||
#### Node: Cardiac_HR_Profile
|
||||
|
||||
Extracted from the ADR-039 vitals pipeline (magic `0xC511_0002`, fields offset 6-11:
|
||||
breathing_rate at `u16 LE` BPM×100, heart_rate at `u32 LE` BPM×10000).
|
||||
For the soul signature, cardiac extraction uses the ADR-021 bandpass pipeline
|
||||
(0.8–2.0 Hz) over a minimum 30-second rest window.
|
||||
|
||||
```rust
|
||||
pub struct CardiacHRProfileNode {
|
||||
pub node_type: &'static str, // "Cardiac_HR_Profile"
|
||||
pub baseline_bpm: f32, // mean HR over enrollment window (40–180 BPM range)
|
||||
pub hrv_sdnn_ms: f32, // SDNN: std dev of R-R intervals (ms)
|
||||
pub hrv_rmssd_ms: f32, // RMSSD: root mean square successive differences
|
||||
pub hrv_lf_power: f32, // LF band power (0.04–0.15 Hz), normalized
|
||||
pub hrv_hf_power: f32, // HF band power (0.15–0.4 Hz), normalized
|
||||
pub hrv_lf_hf_ratio: f32, // LF/HF ratio (autonomic balance marker)
|
||||
pub sinus_rhythm_class: u8, // 0=regular, 1=irregular, 2=indeterminate
|
||||
pub confidence: f32, // from ADR-021 VitalCoherenceGate PERMIT fraction
|
||||
pub window_seconds: u32, // duration of the measurement window
|
||||
}
|
||||
```
|
||||
|
||||
WiFi CSI-based HRV extraction is an active research area. The SDNN and RMSSD values
|
||||
are discriminative at group level (Zhao et al. 2017, Widar 3.0 2019) but per-person
|
||||
uniqueness has not been independently validated at scale. Status: open research.
|
||||
|
||||
#### Node: Cardiac_Waveform_Morphology
|
||||
|
||||
Wavelet decomposition of the bandpass-filtered cardiac phase signal. Captures the
|
||||
shape of the cardiac waveform, not just its rate. More discriminative than HR alone
|
||||
but requires higher SNR and longer measurement window.
|
||||
|
||||
```rust
|
||||
pub struct CardiacWaveformMorphologyNode {
|
||||
pub node_type: &'static str, // "Cardiac_Waveform_Morphology"
|
||||
pub vec_segment_id: u64, // references SEG_VEC: 64 f32 wavelet coefficients
|
||||
pub wavelet_family: String, // "db4" (Daubechies 4, standard for cardiac)
|
||||
pub decomposition_levels: u8, // 4 levels
|
||||
pub snr_db: f32, // measured SNR at enrollment; low-SNR nodes down-weighted
|
||||
pub confidence: f32,
|
||||
}
|
||||
```
|
||||
|
||||
Wavelet coefficient dimension: 64 floats = 256 bytes in SEG_VEC. Waveform
|
||||
morphology from CSI is highly environment-dependent; the ADR-030 field model
|
||||
subtraction must run before this measurement is taken to isolate body perturbation
|
||||
from room standing-wave artifacts.
|
||||
|
||||
#### Node: Respiratory_Pattern
|
||||
|
||||
Extracted by the ADR-021 BreathingExtractor (0.1–0.5 Hz bandpass) plus the
|
||||
ADR-030 persistence layer that accumulates statistics over the enrollment window.
|
||||
|
||||
```rust
|
||||
pub struct RespiratoryPatternNode {
|
||||
pub node_type: &'static str, // "Respiratory_Pattern"
|
||||
pub baseline_bpm: f32, // mean RR (normal adult: 12–20 BPM)
|
||||
pub depth_amplitude_normalized: f32, // tidal depth proxy from CSI variance
|
||||
pub inspiration_expiration_ratio: f32, // I:E ratio (1:1.5 to 1:3 typical)
|
||||
pub hrv_rsa_power: f32, // respiratory sinus arrhythmia spectral power
|
||||
pub apnea_index: f32, // events per hour of significant pauses
|
||||
pub waveform_regularity: f32, // coefficient of variation of breath intervals
|
||||
pub confidence: f32,
|
||||
pub window_seconds: u32,
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `apnea_index` field is a biophysical proxy signal (pause events in
|
||||
the signal), not a clinical AHI score. It is provided for signature
|
||||
discriminability, not diagnostic use.
|
||||
|
||||
#### Node: Gait_Timing
|
||||
|
||||
Extracted from the 17-keypoint Kalman pose tracker (`pose_tracker.rs`, ADR-029
|
||||
Sect 2.7) during the gait phase of the enrollment protocol. The tracker uses
|
||||
ruvector-mincut for person separation and AETHER re-ID for identity continuity.
|
||||
|
||||
```rust
|
||||
pub struct GaitTimingNode {
|
||||
pub node_type: &'static str, // "Gait_Timing"
|
||||
pub cadence_steps_per_min: f32, // steps per minute
|
||||
pub stride_period_variance: f32, // coefficient of variation of stride period
|
||||
pub double_support_pct: f32, // fraction of gait cycle in double support
|
||||
pub asymmetry_index: f32, // |left_stride - right_stride| / mean_stride
|
||||
pub step_width_m: f32, // lateral distance between foot strikes (proxy)
|
||||
pub velocity_variance: f32, // gait speed variability
|
||||
pub confidence: f32,
|
||||
pub stride_count: u32, // number of strides captured during enrollment
|
||||
}
|
||||
```
|
||||
|
||||
Gait biometrics from WiFi CSI are documented in WiGait (Adib et al., SIGCOMM
|
||||
2015) and WiDraw (Wang et al., MobiCom 2014). Discrimination across 10+ subjects
|
||||
in the same household is an open research question for the WiFi-only modality.
|
||||
|
||||
#### Node: Skeletal_Proportions
|
||||
|
||||
Derived from the ADR-079 camera + CSI paired keypoint pipeline when available,
|
||||
or from CSI-only pose estimation (ADR-023 CsiToPoseTransformer) in camera-free
|
||||
deployments. Encodes body geometry as ratios (not absolute values) for scale
|
||||
invariance.
|
||||
|
||||
```rust
|
||||
pub struct SkeletalProportionsNode {
|
||||
pub node_type: &'static str, // "Skeletal_Proportions"
|
||||
pub torso_to_leg_ratio: f32, // torso height / leg length
|
||||
pub shoulder_to_hip_ratio: f32, // shoulder width / hip width
|
||||
pub upper_to_lower_arm_ratio: f32, // upper arm / forearm
|
||||
pub upper_to_lower_leg_ratio: f32, // thigh / shin
|
||||
pub head_to_torso_ratio: f32, // head height / torso height
|
||||
pub arm_span_to_height_ratio: f32, // Vitruvian ratio (close to 1.0 for most adults)
|
||||
pub confidence: f32,
|
||||
pub keypoint_source: String, // "camera_paired" | "csi_only" | "fused"
|
||||
}
|
||||
```
|
||||
|
||||
CSI-only skeletal proportion estimation has ~15–25% error on individual ratio
|
||||
values (open research; baseline from ADR-023 MPJPE ~91.7 mm at best, per
|
||||
Person-in-WiFi 3D, CVPR 2024). Camera-paired values (ADR-079) are substantially
|
||||
more accurate. The node degrades gracefully when only CSI is available.
|
||||
|
||||
#### Node: Subcarrier_Reflection_Profile
|
||||
|
||||
The per-subcarrier amplitude attenuation and phase shift profile measured when
|
||||
the subject stands still at three orientations (0°, 90°, 180° rotation). This
|
||||
encodes the body's RF backscatter cross-section shape, which is determined by
|
||||
body mass distribution, limb geometry, and clothing/material factors.
|
||||
|
||||
```rust
|
||||
pub struct SubcarrierReflectionProfileNode {
|
||||
pub node_type: &'static str, // "Subcarrier_Reflection_Profile"
|
||||
pub vec_segment_id: u64, // SEG_VEC: 56 × 3 × 2 = 336 f32s
|
||||
// (56 subcarriers × 3 orientations ×
|
||||
// [amplitude_attenuation, phase_shift])
|
||||
pub n_subcarriers: u8, // 56 (HT-LTF) or up to 242 (HE-LTF, ADR-110 C6)
|
||||
pub n_orientations: u8, // 3
|
||||
pub frequency_mhz: u32, // center frequency at measurement time
|
||||
pub environment_id: String, // references field model used for subtraction
|
||||
pub confidence: f32,
|
||||
}
|
||||
```
|
||||
|
||||
This node directly exploits the ADR-030 field model: the empty-room baseline
|
||||
eigenstate is subtracted before computing the reflection profile, isolating the
|
||||
person's contribution. Without ADR-030 field subtraction, the profile is too
|
||||
environment-coupled to be transferable across rooms. With MERIDIAN (ADR-027),
|
||||
the hardware-normalizer layer maps ESP32-S3 (52 subcarriers HT-LTF) and
|
||||
ESP32-C6 (242 subcarriers HE-LTF per ADR-110) into a canonical 56-subcarrier
|
||||
representation before this measurement.
|
||||
|
||||
Stored: 336 × 4 = 1,344 bytes in SEG_VEC.
|
||||
|
||||
#### Node: Body_Field_Coupling
|
||||
|
||||
The AETHER attention map cells weighted by the ADR-030 room eigenmode structure.
|
||||
Encodes how strongly the person's body couples to each dominant electromagnetic
|
||||
mode of the room. This is the most physics-grounded node: it captures the
|
||||
person's interaction with the actual electromagnetic geometry of the space.
|
||||
|
||||
```rust
|
||||
pub struct BodyFieldCouplingNode {
|
||||
pub node_type: &'static str, // "Body_Field_Coupling"
|
||||
pub vec_segment_id: u64, // SEG_VEC: n_eigenmodes × n_keypoints f32s
|
||||
pub n_eigenmodes: u8, // top-K SVD modes from field_model.rs (default K=8)
|
||||
pub n_keypoints: u8, // 17 (COCO)
|
||||
pub eigenmode_energy_fractions: Vec<f32>, // fraction of total variance per mode
|
||||
pub environment_id: String, // must match SubcarrierReflectionProfile env
|
||||
pub confidence: f32,
|
||||
}
|
||||
```
|
||||
|
||||
This node is only valid when the same room's field model is available. For
|
||||
cross-room recognition, MERIDIAN's environment-disentangled embedding (ADR-027)
|
||||
is used instead. The BodyFieldCoupling node provides additional discriminative
|
||||
power in single-room deployments and degrades to optional in multi-room contexts.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Edge Types
|
||||
|
||||
Edges are stored in the SEG_SOUL_GRAPH JSON array. Each edge has a typed
|
||||
relationship that constrains how the nodes may be used in matching.
|
||||
|
||||
| Edge type | Source node(s) | Target node(s) | Semantics |
|
||||
|---|---|---|---|
|
||||
| `derived_from` | FieldModel_Residual (implicit) | AetherEmbedding | The embedding was computed after field model subtraction |
|
||||
| `correlates_with` | Cardiac_HR_Profile | Respiratory_Pattern | Cardiorespiratory coupling at measurement time; correlation coefficient stored as edge weight |
|
||||
| `temporally_colocated` | Any pair | Any pair | Both nodes were measured in the same time window; ensures consistency |
|
||||
| `temporally_after` | Post-gait node | Pre-gait node | Nodes acquired sequentially during enrollment protocol |
|
||||
| `requires_field_model` | SubcarrierReflectionProfile | BodyFieldCoupling | Matching this node requires the same room's ADR-030 field model |
|
||||
| `fuses` | AetherEmbedding | SubcarrierReflectionProfile | MERIDIAN-normalized fusion: both mapped to environment-invariant space |
|
||||
| `attested_by` | Any leaf node | WitnessChain | Ed25519 witness covers this node's content hash |
|
||||
| `derived_by_keypoint_tracker` | GaitTiming | SkeletalProportions | Both extracted from the same pose_tracker.rs output |
|
||||
| `environment_normalized` | Any node with `environment_id` | MERIDIAN manifest | MERIDIAN (ADR-027) was applied; signature is cross-room capable |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 The Aggregator vs. the Stored Profile
|
||||
|
||||
Two distinct graph instances exist in the runtime:
|
||||
|
||||
**Online Aggregator** — a mutable, in-memory graph that accumulates measurements
|
||||
across multiple sensing windows. Nodes are incrementally updated with Welford
|
||||
online statistics (`field_model.rs::WelfordStats`). Confidence fields grow toward
|
||||
1.0 as more frames accumulate. The aggregator never writes to disk during
|
||||
normal operation.
|
||||
|
||||
**Stored Profile** — an immutable, content-addressed `.rvf` file on disk. It is
|
||||
generated from the aggregator at the end of the enrollment protocol, when all node
|
||||
confidence fields exceed their minimum thresholds. The stored profile is the
|
||||
canonical soul signature.
|
||||
|
||||
```
|
||||
Online Aggregator (RAM) Stored Profile (disk / secure enclave)
|
||||
+----------------------+ +---------------------------+
|
||||
| AETHER_Embedding | enrollment | signature-<sha256>.rvf |
|
||||
| accumulated over | completion | SEG_MANIFEST |
|
||||
| 60-second protocol +-------------> | SEG_VEC (embedding + refl)|
|
||||
| Confidence: 0.0→1.0 | when all | SEG_META (all node attrs) |
|
||||
| | gates pass | SEG_EMBED (AETHER config) |
|
||||
| Cardiac_HR_Profile | | SEG_WITNESS (Ed25519) |
|
||||
| accumulated 30s rest | | SEG_SOUL_GRAPH (graph) |
|
||||
+----------------------+ +---------------------------+
|
||||
```
|
||||
|
||||
The aggregator pattern ensures that a partial scan (e.g., subject leaves after
|
||||
20 seconds) never produces a stored profile — the quality gates prevent premature
|
||||
commitment (see `scanning-process.md §5`).
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Serialization
|
||||
|
||||
**Binary container:** RVF blob, per `rvf_container.rs`. All numeric data is
|
||||
little-endian, f32 IEEE 754. Segment alignment: 64 bytes. CRC32 (IEEE 802.3
|
||||
polynomial) over each segment payload.
|
||||
|
||||
**Content addressing:** The file name is:
|
||||
```
|
||||
signature-<sha256-hex-of-rvf-bytes>.rvf
|
||||
```
|
||||
SHA-256 is computed over the complete concatenated RVF byte stream after
|
||||
`RvfBuilder::build()`. This is a different hash from the per-segment CRC32;
|
||||
the CRC32 provides corruption detection within segments, the SHA-256 provides
|
||||
content-based addressing and enables deduplication.
|
||||
|
||||
**JSON-LD sidecar:** An optional `signature-<sha256>.json` file with the same
|
||||
base name. Structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://ruv.net/soul-signature/v1",
|
||||
"schema_version": "0.1.0",
|
||||
"person_id": "<opaque_u64_hex>",
|
||||
"enrolled_at": "2026-05-24T00:00:00Z",
|
||||
"enrolled_by_device_id": "<mac_or_device_fingerprint>",
|
||||
"rvf_sha256": "<content_hash>",
|
||||
"nodes": [
|
||||
{ "node_type": "AETHER_Embedding", "confidence": 0.92, ... },
|
||||
{ "node_type": "Cardiac_HR_Profile", "confidence": 0.85, ... },
|
||||
...
|
||||
],
|
||||
"edges": [...],
|
||||
"witness": {
|
||||
"algorithm": "Ed25519",
|
||||
"public_key": "<hex>",
|
||||
"signature": "<hex>",
|
||||
"signed_fields": ["rvf_sha256", "enrolled_at", "enrolled_by_device_id"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The JSON-LD sidecar is human-readable and intended for audit and provenance.
|
||||
It does not contain raw biometric vectors; those stay in the RVF blob.
|
||||
|
||||
**ISO/IEC 19794-4 alignment:** The soul signature's graph-based vector template
|
||||
is conceptually analogous to the ISO/IEC 19794-4 finger image data format
|
||||
and ISO/IEC 19794-2 minutiae data. The node/edge schema is not binary-compatible
|
||||
with ISO 19794, but the design intent (typed attribute records, quality scores,
|
||||
creator provenance) follows the same standard's principles. Future work may
|
||||
include a conformance layer if regulatory certification is sought.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Matching Algorithm
|
||||
|
||||
Given a stored profile `P` and a query embedding `Q` derived from a live sensing
|
||||
window, the match score is computed as a weighted sum of per-channel cosine
|
||||
similarities:
|
||||
|
||||
```
|
||||
match_score = sum_i ( w_i * cosine_sim(P.channel_i, Q.channel_i) )
|
||||
/ sum_i ( w_i * availability(P.channel_i, Q.channel_i) )
|
||||
```
|
||||
|
||||
Where `availability` is 1.0 if both nodes are present and 0.0 if either is absent
|
||||
(graceful degradation when a channel cannot be measured in the query window).
|
||||
|
||||
Default weights (open research; these are design intent, not validated):
|
||||
|
||||
| Channel | Weight | Rationale |
|
||||
|---|---|---|
|
||||
| AETHER_Embedding | 0.35 | Primary identity anchor; best-studied channel |
|
||||
| Subcarrier_Reflection_Profile | 0.20 | Body geometry; angle-stable |
|
||||
| Cardiac_HR_Profile | 0.15 | Physiologically stable in healthy adults |
|
||||
| Gait_Timing | 0.15 | Well-studied biometric; discriminative |
|
||||
| Respiratory_Pattern | 0.10 | More variable than cardiac |
|
||||
| Skeletal_Proportions | 0.05 | Proxy for body shape; CSI-only is noisy |
|
||||
| Body_Field_Coupling | 0.00 (single-room) / 0.10 (cross-room disabled) | Valid only when room field model available |
|
||||
| Cardiac_Waveform_Morphology | 0.05 (supplementary) | High SNR requirement |
|
||||
|
||||
The threshold for a positive match is a deployment-specific parameter with a
|
||||
documented FAR/FRR trade-off. The AETHER channel alone achieves >80% mAP at 5
|
||||
subjects (ADR-024 §2.8 target). The fused multi-channel score is expected to
|
||||
exceed this; the exact improvement is open research, baseline TBD.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Rust Type Sketch
|
||||
|
||||
The following sketch shows how the soul signature types would integrate with
|
||||
the existing codebase. This is a design sketch, not implemented code.
|
||||
|
||||
```rust
|
||||
// In a future: v2/crates/wifi-densepose-sensing-server/src/soul_signature.rs
|
||||
|
||||
pub const SEG_SOUL_GRAPH: u8 = 0x10;
|
||||
pub const SEG_SOUL_INDEX: u8 = 0x11;
|
||||
|
||||
/// Complete soul signature as a graph container.
|
||||
pub struct SoulSignature {
|
||||
/// Content-addressed identifier: SHA-256 of the RVF blob bytes.
|
||||
pub content_hash: [u8; 32],
|
||||
/// Opaque person identifier (never PII directly).
|
||||
pub person_id: u64,
|
||||
/// Unix timestamp of enrollment completion (nanoseconds).
|
||||
pub enrolled_at_ns: u64,
|
||||
/// Device that performed enrollment.
|
||||
pub enrolled_by_device_id: String,
|
||||
/// All graph nodes, typed.
|
||||
pub nodes: SoulNodes,
|
||||
/// All graph edges.
|
||||
pub edges: Vec<SoulEdge>,
|
||||
/// Ed25519 witness chain (per ADR-110).
|
||||
pub witness: WitnessChain,
|
||||
}
|
||||
|
||||
pub struct SoulNodes {
|
||||
pub aether_embedding: Option<AetherEmbeddingNode>,
|
||||
pub cardiac_hr: Option<CardiacHRProfileNode>,
|
||||
pub cardiac_waveform: Option<CardiacWaveformMorphologyNode>,
|
||||
pub respiratory: Option<RespiratoryPatternNode>,
|
||||
pub gait_timing: Option<GaitTimingNode>,
|
||||
pub skeletal_proportions: Option<SkeletalProportionsNode>,
|
||||
pub subcarrier_reflection: Option<SubcarrierReflectionProfileNode>,
|
||||
pub body_field_coupling: Option<BodyFieldCouplingNode>,
|
||||
}
|
||||
|
||||
pub struct SoulEdge {
|
||||
pub edge_type: SoulEdgeType,
|
||||
pub source_node_type: String,
|
||||
pub target_node_type: String,
|
||||
pub weight: f32, // edge attribute (e.g., correlation coefficient)
|
||||
}
|
||||
|
||||
pub enum SoulEdgeType {
|
||||
DerivedFrom,
|
||||
CorrelatesWith,
|
||||
TemporallyColocated,
|
||||
TemporallyAfter,
|
||||
RequiresFieldModel,
|
||||
Fuses,
|
||||
AttestedBy,
|
||||
DerivedByKeypointTracker,
|
||||
EnvironmentNormalized,
|
||||
}
|
||||
|
||||
impl SoulSignature {
|
||||
/// Serialize to an RVF binary blob.
|
||||
pub fn to_rvf(&self) -> Vec<u8>;
|
||||
/// Deserialize from an RVF binary blob.
|
||||
pub fn from_rvf(data: &[u8]) -> Result<Self, SoulError>;
|
||||
/// Compute the weighted match score against a query.
|
||||
pub fn match_score(&self, query: &SoulQuery, weights: &MatchWeights) -> f32;
|
||||
/// Check whether all required nodes meet minimum confidence thresholds.
|
||||
pub fn is_complete(&self, policy: &CompletenessPolicy) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 What the Signature Is NOT
|
||||
|
||||
- Not a fingerprint of the room (that is the ADR-030 field model, a separate object).
|
||||
- Not a waveform recording (the enrolled vectors are statistics and embeddings, not raw CSI).
|
||||
- Not invertible to the original CSI stream (the AETHER projection head's information bottleneck prevents reconstruction; see ADR-024 §4 Negative consequences).
|
||||
- Not a single scalar. Reducing to one number for threshold comparison is a deployment decision; the underlying object is a 7-channel graph.
|
||||
- Not equal to a stored pose. The AETHER embedding captures body dynamics over many windows, not a single body pose at one instant.
|
||||
+55
-10
@@ -164,21 +164,66 @@ cargo add wifi-densepose-wasm-edge
|
||||
|
||||
See the full crate list and dependency order in [CLAUDE.md](../CLAUDE.md#crate-publishing-order).
|
||||
|
||||
### From Source (Python)
|
||||
### Python wheel (pip) — ADR-117
|
||||
|
||||
The Python API ships as **two interchangeable PyPI packages** — same
|
||||
compiled PyO3 wheel under both names; pick whichever import name
|
||||
reads better in your code:
|
||||
|
||||
| PyPI | Install | Latest | Import |
|
||||
|---|---|---|---|
|
||||
| [`ruview`](https://pypi.org/project/ruview/) | `pip install ruview` | `2.0.0a1` | `from ruview import ...` |
|
||||
| [`wifi-densepose`](https://pypi.org/project/wifi-densepose/) | `pip install wifi-densepose` | `2.0.0a1` | `from wifi_densepose import ...` |
|
||||
|
||||
```bash
|
||||
pip install ruview # core DSP (~250 KB compiled wheel)
|
||||
pip install "ruview[client]" # + asyncio WebSocket + paho-mqtt
|
||||
```
|
||||
|
||||
```python
|
||||
# vitals
|
||||
from ruview import BreathingExtractor, HeartRateExtractor
|
||||
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
|
||||
|
||||
# live sensing-server stream
|
||||
from ruview.client import SensingClient, EdgeVitalsMessage
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as c:
|
||||
async for msg in c.stream():
|
||||
if isinstance(msg, EdgeVitalsMessage):
|
||||
print(msg.breathing_rate_bpm, msg.heartrate_bpm)
|
||||
|
||||
# Home Assistant semantic primitives (ADR-115 HA-MIND)
|
||||
from ruview.client import (
|
||||
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener,
|
||||
)
|
||||
```
|
||||
|
||||
The wheels ship for Linux (x86_64, aarch64 via sdist), macOS (sdist),
|
||||
and Windows (amd64 wheel). Stable ABI (`abi3-py310`) — one binary
|
||||
covers Python 3.10+. Multi-arch native wheels are produced by the
|
||||
[pip-release.yml](../.github/workflows/pip-release.yml) cibuildwheel
|
||||
matrix on each `v*-pip` tag.
|
||||
|
||||
> **Migrating from v1.x?** The legacy `wifi-densepose==1.1.0` FastAPI
|
||||
> server is end-of-life. `wifi-densepose==1.99.0` is a tombstone that
|
||||
> raises `ImportError` with a migration URL; upgrade to `>=2.0.0a1`
|
||||
> (or switch to `ruview`).
|
||||
|
||||
To build the wheel from source (e.g. for a local change):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/RuView.git
|
||||
cd RuView
|
||||
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
|
||||
# Or via PyPI
|
||||
pip install wifi-densepose
|
||||
pip install wifi-densepose[gpu] # GPU acceleration
|
||||
pip install wifi-densepose[all] # All optional deps
|
||||
cd RuView/python
|
||||
pip install maturin>=1.7
|
||||
maturin develop --release
|
||||
pytest tests/ # 183 tests
|
||||
pytest bench/ --benchmark-only # 12 hot-path benchmarks
|
||||
```
|
||||
|
||||
Full API + tests breakdown is on the PyPI front page:
|
||||
[wifi-densepose on PyPI](https://pypi.org/project/wifi-densepose/) ·
|
||||
[ruview on PyPI](https://pypi.org/project/ruview/).
|
||||
|
||||
### Guided Installer
|
||||
|
||||
An interactive installer that detects your hardware and recommends a profile:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Python build/install artifacts
|
||||
target/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.so
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Maturin develop produces .pyd extensions in wifi_densepose/
|
||||
wifi_densepose/*.pyd
|
||||
wifi_densepose/*.so
|
||||
wifi_densepose/_native.abi3.*
|
||||
|
||||
# Local build wheels
|
||||
dist/
|
||||
wheelhouse/
|
||||
*.egg-info/
|
||||
Generated
+920
@@ -0,0 +1,920 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"ndarray 0.16.1",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pyo3",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.122"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-py"
|
||||
version = "2.0.0-alpha.1"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-vitals",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-vitals"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "wifi-densepose-py"
|
||||
version = "2.0.0-alpha.1"
|
||||
# The `python/` crate is intentionally OUTSIDE the `v2/` Cargo
|
||||
# workspace (ADR-117 §5.2) so maturin's `python-source` + `module-name`
|
||||
# config stays self-contained and `cargo test --workspace` in v2/
|
||||
# doesn't have to compile pyo3. Hence no `*.workspace = true`
|
||||
# inheritance here — every field is local.
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
|
||||
description = "PyO3 bindings for the WiFi-DensePose Rust core — ships as the `wifi-densepose` PyPI wheel (ADR-117)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
# ADR-117 §5.2: the Python wheel's compiled module name is
|
||||
# `wifi_densepose._native` (the leading underscore marks it as an internal
|
||||
# implementation detail re-exported by the pure-Python facade in
|
||||
# `wifi_densepose/__init__.py`). Keeping the name distinct from the crate
|
||||
# avoids the maturin gotcha where `wifi_densepose-py` would collide with
|
||||
# the user-facing `wifi_densepose` package on import.
|
||||
[lib]
|
||||
name = "wifi_densepose_native"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
|
||||
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
|
||||
# Without abi3 we'd need a separate wheel per Python minor version × OS
|
||||
# × arch, blowing up the cibuildwheel matrix.
|
||||
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] }
|
||||
|
||||
# Re-export the Rust core types through PyO3 #[pyclass] wrappers in P2.
|
||||
# Default-features-off keeps the wheel size below the 5 MB ADR-117 §5.4
|
||||
# budget by avoiding optional BLAS/openssl chains.
|
||||
wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-core" }
|
||||
|
||||
# P3 — vitals extraction (HR/BR via the 4-stage pipeline). Pure-sync;
|
||||
# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads.
|
||||
wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" }
|
||||
|
||||
# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for
|
||||
# the future P3 CsiFrame numpy round-trip.
|
||||
numpy = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
# Doc-test infrastructure for the Python-facing examples in the bound
|
||||
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.
|
||||
@@ -0,0 +1,143 @@
|
||||
# wifi-densepose
|
||||
|
||||
[](https://pypi.org/project/wifi-densepose/)
|
||||
[](https://pypi.org/project/wifi-densepose/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
**Detect human presence, count people, read breathing and heart rate, and
|
||||
estimate skeletal pose — using only the WiFi signal already in your home.**
|
||||
|
||||
No cameras. No wearables. Works through walls and in the dark.
|
||||
|
||||
`wifi-densepose` is the Python binding for the [RuView](https://github.com/ruvnet/RuView)
|
||||
sensing stack: a Rust core that turns the Channel State Information (CSI)
|
||||
emitted by ordinary WiFi chips into ambient-intelligence signals. The wheel
|
||||
ships compiled DSP for fast offline analysis, plus an opt-in Python client
|
||||
for talking to a live RuView sensing-server over WebSocket or MQTT.
|
||||
|
||||
## Features
|
||||
|
||||
- **17-keypoint pose** — full-body skeletal estimate from WiFi CSI, no camera
|
||||
- **Vital signs** — respiratory rate (6–30 BPM) and heart rate (40–120 BPM)
|
||||
with a confidence score and clinical-grade / degraded / unreliable status
|
||||
- **Presence, person count, fall detection, motion** — fused outputs from
|
||||
the same CSI stream
|
||||
- **10 semantic primitives** (HA-MIND) — someone-sleeping, possible-distress,
|
||||
room-active, bathroom-occupied, fall-risk-elevated, bed-exit, … — ready
|
||||
to wire into Home Assistant or Apple Home automations
|
||||
- **Beamforming Feedback (BFLD) support** — 802.11ac/ax/be compressed feedback
|
||||
matrices on top of the receiver-side CSI path
|
||||
- **GIL-releasing DSP** — extract loops run with the GIL released, so a
|
||||
tokio-backed web server can call into the pipeline without stalling its
|
||||
event loop
|
||||
- **Tiny wheel** — ~240 KB compiled (one binary per OS/arch covers Python
|
||||
3.10+ via the stable ABI)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install wifi-densepose # core DSP only
|
||||
pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
|
||||
```
|
||||
|
||||
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
|
||||
Windows (amd64).
|
||||
|
||||
## Usage
|
||||
|
||||
### Extract breathing rate from a CSI stream
|
||||
|
||||
```python
|
||||
from wifi_densepose import BreathingExtractor
|
||||
|
||||
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
|
||||
|
||||
for residuals, weights in your_csi_source: # one frame at a time
|
||||
est = br.extract(residuals=residuals, weights=weights)
|
||||
if est is not None:
|
||||
print(f"{est.value_bpm:.1f} BPM (confidence={est.confidence:.2f})")
|
||||
```
|
||||
|
||||
Heart rate is the same shape — `HeartRateExtractor.esp32_default()` with a
|
||||
0.8–2.0 Hz band-pass and a 15-second window.
|
||||
|
||||
### Subscribe to a live sensing-server
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from wifi_densepose.client import SensingClient, EdgeVitalsMessage
|
||||
|
||||
async def main():
|
||||
async with SensingClient("ws://your-ruview-node:8765/ws/sensing") as c:
|
||||
async for msg in c.stream():
|
||||
if isinstance(msg, EdgeVitalsMessage):
|
||||
print(msg.presence, msg.breathing_rate_bpm, msg.heartrate_bpm)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### React to Home Assistant semantic primitives
|
||||
|
||||
```python
|
||||
from wifi_densepose.client import (
|
||||
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener,
|
||||
)
|
||||
|
||||
listener = SemanticPrimitiveListener()
|
||||
listener.on(SemanticPrimitive.BedExit, lambda e: print("bed exit:", e.node_id))
|
||||
listener.on(SemanticPrimitive.PossibleDistress, lambda e: alert(e))
|
||||
|
||||
client = RuViewMqttClient(broker_host="homeassistant.local")
|
||||
client.on_message(
|
||||
"homeassistant/+/wifi_densepose_+/+/state",
|
||||
listener.handle_mqtt_message,
|
||||
)
|
||||
client.start()
|
||||
client.wait_connected()
|
||||
```
|
||||
|
||||
### Decode 802.11ax beamforming feedback
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from wifi_densepose import BfldFrame, BfldKind
|
||||
|
||||
# Parse compressed BFR from a Wireshark capture into a Complex64 ndarray ...
|
||||
fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2 Nc=1 Nsc=996 for HE80
|
||||
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=ts,
|
||||
sounding_index=seq,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
print(frame.n_subcarriers, frame.mean_amplitude)
|
||||
```
|
||||
|
||||
## Hardware
|
||||
|
||||
Works with any WiFi chip that exposes CSI. Reference setups (ESP-IDF firmware,
|
||||
build scripts, witness-verified test bundles) are in the
|
||||
[RuView repo](https://github.com/ruvnet/RuView):
|
||||
|
||||
| Device | Cost | Role |
|
||||
|---|---|---|
|
||||
| ESP32-S3 (8MB flash) | ~$9 | WiFi CSI sensing node |
|
||||
| ESP32-S3 SuperMini (4MB) | ~$6 | WiFi CSI (compact) |
|
||||
| ESP32-C6 + Seeed MR60BHA2 | ~$15 | mmWave HR/BR/presence add-on |
|
||||
|
||||
The legacy v1 line (Wi-Pose-style FastAPI server) is end-of-life;
|
||||
`wifi-densepose==1.99.0` is a tombstone that raises `ImportError` pointing
|
||||
to v2 with a migration URL.
|
||||
|
||||
## Links
|
||||
|
||||
- **Repository** — https://github.com/ruvnet/RuView
|
||||
- **Modernization plan** — [ADR-117](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md)
|
||||
- **Home Assistant integration** — [ADR-115](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-115-home-assistant-integration.md)
|
||||
- **Issues** — https://github.com/ruvnet/RuView/issues
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,111 @@
|
||||
"""ADR-117 hardening sweep — Benchmarks for the P3.5 numpy bridge
|
||||
and the P4 WS decoder.
|
||||
|
||||
The numpy bridge is the most-likely candidate for a hidden allocation
|
||||
hot-spot: every `BfldFrame.from_compressed_feedback()` call copies the
|
||||
ndarray into a Vec<Complex64>. Confirm the per-frame cost is
|
||||
acceptable for the BFR cadence the AP emits (typically a few
|
||||
hundred per second, not thousands).
|
||||
|
||||
The WS decoder runs once per frame the sensing-server emits. At
|
||||
worst-case ~100 Hz × number-of-subscribers, the decoder budget is
|
||||
tight; make sure dataclass construction doesn't dominate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import BfldFrame, BfldKind
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,shape", [
|
||||
(BfldKind.UncompressedHT20, (1, 1, 52)),
|
||||
(BfldKind.CompressedHE20, (2, 1, 242)),
|
||||
(BfldKind.CompressedHE80, (2, 1, 996)),
|
||||
(BfldKind.CompressedHE160, (2, 2, 1992)),
|
||||
])
|
||||
def test_bfld_from_compressed_feedback(benchmark, kind: BfldKind, shape: tuple[int, int, int]) -> None:
|
||||
rng = np.random.default_rng(seed=42)
|
||||
fb = (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)).astype(np.complex128)
|
||||
|
||||
def _build():
|
||||
return BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=kind,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
|
||||
benchmark(_build)
|
||||
|
||||
|
||||
def test_bfld_feedback_matrix_roundtrip(benchmark) -> None:
|
||||
"""How expensive is the numpy-out round-trip? Used by clients
|
||||
that want to do further analysis in numpy after constructing
|
||||
the frame."""
|
||||
rng = np.random.default_rng(seed=42)
|
||||
fb = (rng.standard_normal((2, 1, 996)) + 1j * rng.standard_normal((2, 1, 996))).astype(np.complex128)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
benchmark(frame.feedback_matrix)
|
||||
|
||||
|
||||
# ─── WS decoder ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_EDGE_VITALS_FRAME = json.dumps({
|
||||
"type": "edge_vitals",
|
||||
"node_id": "bench-node",
|
||||
"presence": True,
|
||||
"fall_detected": False,
|
||||
"motion": 0.34,
|
||||
"breathing_rate_bpm": 14.2,
|
||||
"heartrate_bpm": 72.5,
|
||||
"n_persons": 1,
|
||||
"motion_energy": 0.04,
|
||||
"presence_score": 0.91,
|
||||
"rssi": -42.0,
|
||||
})
|
||||
|
||||
|
||||
def test_ws_decoder_edge_vitals(benchmark) -> None:
|
||||
from wifi_densepose.client.ws import _decode
|
||||
|
||||
def _decode_one():
|
||||
return _decode(_EDGE_VITALS_FRAME)
|
||||
|
||||
benchmark(_decode_one)
|
||||
|
||||
|
||||
_POSE_FRAME = json.dumps({
|
||||
"type": "pose_data",
|
||||
"node_id": "bench-node",
|
||||
"timestamp": 1700000000.5,
|
||||
"persons": [
|
||||
{"id": i, "keypoints": [[0.5, 0.5, 0.9] for _ in range(17)]}
|
||||
for i in range(3)
|
||||
],
|
||||
"confidence": 0.85,
|
||||
})
|
||||
|
||||
|
||||
def test_ws_decoder_pose_data(benchmark) -> None:
|
||||
"""The pose_data frame is typically the largest one the server
|
||||
emits — bench it separately so a future blob-size regression
|
||||
in the persons array is visible."""
|
||||
from wifi_densepose.client.ws import _decode
|
||||
|
||||
def _decode_one():
|
||||
return _decode(_POSE_FRAME)
|
||||
|
||||
benchmark(_decode_one)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""ADR-117 hardening sweep — Benchmarks for the P3 vitals hot paths.
|
||||
|
||||
Targets the ESP32 production rate: 100 Hz × 56 subcarriers, which is
|
||||
what `BreathingExtractor.esp32_default()` is tuned for. The bench
|
||||
asserts the *per-extract* cost is comfortably below 10 ms — at 100 Hz
|
||||
that's the entire frame budget, so anything above 10 ms means the
|
||||
Python binding would be the bottleneck instead of the radio.
|
||||
|
||||
Run with:
|
||||
pytest python/bench/ --benchmark-only
|
||||
|
||||
The benchmarks are skipped by default (`addopts` in pyproject.toml
|
||||
doesn't include them) — they live in a sibling `bench/` directory
|
||||
so the main test run stays fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from random import Random
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import BreathingExtractor, HeartRateExtractor
|
||||
|
||||
|
||||
def _synth_frame(n_subcarriers: int, sample_rate: float, t: float, freq_hz: float, rng: Random) -> tuple[list[float], list[float]]:
|
||||
"""Build one ESP32-shape frame at time `t`: sine at `freq_hz` plus
|
||||
tiny per-subcarrier noise."""
|
||||
base = math.sin(2.0 * math.pi * freq_hz * t)
|
||||
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(n_subcarriers)]
|
||||
weights = [1.0] * n_subcarriers
|
||||
return residuals, weights
|
||||
|
||||
|
||||
def test_breathing_extract_per_frame_cost(benchmark) -> None:
|
||||
"""One BreathingExtractor.extract() at ESP32 defaults should
|
||||
finish well under 10 ms — that's the 100 Hz frame budget."""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
rng = Random(42)
|
||||
# Pre-fill ~25 seconds of history so the bench measures the
|
||||
# steady-state cost, not the cold-start cost.
|
||||
for i in range(2500):
|
||||
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 0.25, rng)
|
||||
br.extract(residuals=residuals, weights=weights)
|
||||
|
||||
def _one_frame():
|
||||
residuals, weights = _synth_frame(56, 100.0, 30.0, 0.25, rng)
|
||||
return br.extract(residuals=residuals, weights=weights)
|
||||
|
||||
benchmark(_one_frame)
|
||||
|
||||
|
||||
def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
|
||||
"""One HeartRateExtractor.extract() at ESP32 defaults — same 10 ms
|
||||
target."""
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
rng = Random(43)
|
||||
for i in range(1500):
|
||||
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
|
||||
hr.extract(residuals=residuals, weights=weights)
|
||||
|
||||
def _one_frame():
|
||||
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
|
||||
return hr.extract(residuals=residuals, weights=weights)
|
||||
|
||||
benchmark(_one_frame)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_subcarriers", [56, 114, 242])
|
||||
def test_breathing_extract_scaling(benchmark, n_subcarriers: int) -> None:
|
||||
"""Sanity check: cost should scale roughly linearly with the
|
||||
subcarrier count. Catches accidental O(n^2) regressions."""
|
||||
sample_rate = 100.0
|
||||
br = BreathingExtractor(n_subcarriers, sample_rate, 30.0)
|
||||
rng = Random(n_subcarriers)
|
||||
for i in range(2500):
|
||||
residuals, weights = _synth_frame(n_subcarriers, sample_rate, i / sample_rate, 0.25, rng)
|
||||
br.extract(residuals=residuals, weights=weights)
|
||||
|
||||
def _one_frame():
|
||||
residuals, weights = _synth_frame(n_subcarriers, sample_rate, 30.0, 0.25, rng)
|
||||
return br.extract(residuals=residuals, weights=weights)
|
||||
|
||||
benchmark(_one_frame)
|
||||
@@ -0,0 +1,99 @@
|
||||
# ADR-117 — `wifi-densepose` v2.x PyPI wheel
|
||||
#
|
||||
# This is the PyO3+maturin replacement for the legacy pure-Python
|
||||
# `wifi-densepose==1.1.0` (last release 2025-06-07). One compiled
|
||||
# extension module per OS/arch covers Python 3.10–3.13 via abi3.
|
||||
|
||||
[build-system]
|
||||
requires = ["maturin>=1.7,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wifi-densepose"
|
||||
version = "2.0.0a1"
|
||||
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "rUv", email = "ruv@ruv.net" },
|
||||
]
|
||||
keywords = [
|
||||
"wifi", "csi", "pose-estimation", "vital-signs",
|
||||
"biometric", "ambient-intelligence", "home-assistant", "matter",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Rust",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
"Topic :: System :: Hardware",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
# ADR-117 §5.6 — pure-Python WS/MQTT client. Lands in P4.
|
||||
client = [
|
||||
"websockets>=12.0",
|
||||
"paho-mqtt>=2.1",
|
||||
]
|
||||
# Developer dependencies for running the test suite + lint.
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.7",
|
||||
"mypy>=1.13",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
Repository = "https://github.com/ruvnet/RuView"
|
||||
Issues = "https://github.com/ruvnet/RuView/issues"
|
||||
Documentation = "https://github.com/ruvnet/RuView/tree/main/docs"
|
||||
"ADR-117 (modernization plan)" = "https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md"
|
||||
"Release notes (v0.7.0)" = "https://github.com/ruvnet/RuView/blob/main/docs/releases/v0.7.0-mqtt-matter.md"
|
||||
|
||||
# Console-script entry points wired up in P5 once the CLI shim exists.
|
||||
# [project.scripts]
|
||||
# wifi-densepose = "wifi_densepose.cli:main"
|
||||
|
||||
[tool.maturin]
|
||||
# Layout: pyproject.toml + Cargo.toml live at `python/`; the
|
||||
# python-source directory `wifi_densepose/` is a sibling (i.e. at
|
||||
# `python/wifi_densepose/`). `python-source = "."` tells maturin to
|
||||
# look for packages directly under the project root.
|
||||
python-source = "."
|
||||
module-name = "wifi_densepose._native"
|
||||
features = ["pyo3/extension-module"]
|
||||
# Strip debug symbols for smaller release wheels (ADR-117 §5.4 5 MB budget).
|
||||
strip = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --strict-markers"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
@@ -0,0 +1,58 @@
|
||||
# ruview
|
||||
|
||||
**Ambient intelligence from WiFi CSI.** Detect human presence, count
|
||||
people, read breathing and heart rate, and estimate skeletal pose —
|
||||
using only the WiFi signal already in your home. No cameras. No
|
||||
wearables. Works through walls and in the dark.
|
||||
|
||||
`ruview` is the brand-facing meta-package for the
|
||||
[RuView](https://github.com/ruvnet/RuView) sensing stack. It installs
|
||||
the compiled PyO3 wheel published as
|
||||
[`wifi-densepose`](https://pypi.org/project/wifi-densepose/) and
|
||||
re-exports its full API under the `ruview` namespace — so you can
|
||||
write either of these and they do the same thing:
|
||||
|
||||
```python
|
||||
from ruview import BreathingExtractor, SensingClient
|
||||
from wifi_densepose import BreathingExtractor, SensingClient
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install ruview # core DSP
|
||||
pip install "ruview[client]" # + WebSocket/MQTT clients
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from ruview import BreathingExtractor
|
||||
|
||||
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
|
||||
for residuals, weights in csi_source:
|
||||
est = br.extract(residuals=residuals, weights=weights)
|
||||
if est is not None:
|
||||
print(f"{est.value_bpm:.1f} BPM (confidence={est.confidence:.2f})")
|
||||
```
|
||||
|
||||
Full API + WebSocket / MQTT / Home Assistant integration docs:
|
||||
[wifi-densepose on PyPI](https://pypi.org/project/wifi-densepose/).
|
||||
|
||||
## Why two PyPI names?
|
||||
|
||||
Historic: `wifi-densepose` is the technical / academic name (the
|
||||
project started as a WiFi-based DensePose implementation).
|
||||
`ruview` is the brand the v2 ambient-intelligence platform ships
|
||||
under. Both are the same code. You pick the import that reads
|
||||
better in your project.
|
||||
|
||||
## Links
|
||||
|
||||
- **Repository** — https://github.com/ruvnet/RuView
|
||||
- **Modernization plan** — [ADR-117](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md)
|
||||
- **Issues** — https://github.com/ruvnet/RuView/issues
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-117 sibling release — `ruview` meta-package.
|
||||
#
|
||||
# Pure-Python wheel that re-exports everything from `wifi-densepose`
|
||||
# under the alias `ruview`. They're the same code, distributed under
|
||||
# two PyPI names so users can `pip install ruview` (the brand) or
|
||||
# `pip install wifi-densepose` (the technical name) — both end up
|
||||
# with the same compiled DSP available.
|
||||
#
|
||||
# Build:
|
||||
# cd python/ruview-meta
|
||||
# python -m build
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ruview"
|
||||
version = "2.0.0a1"
|
||||
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "rUv", email = "ruv@ruv.net" }]
|
||||
keywords = [
|
||||
"wifi", "csi", "pose-estimation", "vital-signs",
|
||||
"biometric", "ambient-intelligence", "home-assistant", "matter",
|
||||
"ruview",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
|
||||
# always gets a compatible wifi-densepose.
|
||||
"wifi-densepose==2.0.0a1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
client = ["wifi-densepose[client]==2.0.0a1"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
Repository = "https://github.com/ruvnet/RuView"
|
||||
Issues = "https://github.com/ruvnet/RuView/issues"
|
||||
Documentation = "https://github.com/ruvnet/RuView/tree/main/docs"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["ruview"]
|
||||
package-dir = { "" = "src" }
|
||||
@@ -0,0 +1,50 @@
|
||||
"""RuView — ambient intelligence from WiFi CSI.
|
||||
|
||||
This package is a thin alias around `wifi-densepose`. Both PyPI names
|
||||
ship the same code and the same compiled Rust core; `ruview` is the
|
||||
brand-facing name and `wifi-densepose` is the technical name. Pick
|
||||
whichever you prefer:
|
||||
|
||||
pip install ruview
|
||||
pip install wifi-densepose
|
||||
|
||||
Both make this work:
|
||||
|
||||
from ruview import BreathingExtractor, hello
|
||||
# or equivalently:
|
||||
from wifi_densepose import BreathingExtractor, hello
|
||||
|
||||
The actual compiled DSP, the Python facade, and every public class
|
||||
live in `wifi_densepose` — `ruview` just re-exports the surface so the
|
||||
two names are interchangeable in application code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import wifi_densepose as _wdp
|
||||
|
||||
# Re-export everything `wifi_densepose.__all__` declares.
|
||||
for _name in _wdp.__all__:
|
||||
globals()[_name] = getattr(_wdp, _name)
|
||||
|
||||
# Version + diagnostic fields — surface them under the ruview name
|
||||
# too so users can `print(ruview.__rust_version__)` without reaching
|
||||
# into the wifi_densepose module.
|
||||
__version__: str = _wdp.__version__
|
||||
__rust_version__: str = _wdp.__rust_version__
|
||||
__rust_build_tag__: str = _wdp.__rust_build_tag__
|
||||
__build_features__ = list(_wdp.__build_features__)
|
||||
|
||||
# The client sub-package is also aliased for symmetry.
|
||||
try:
|
||||
from wifi_densepose import client # type: ignore[import-not-found] # noqa: F401
|
||||
except ImportError:
|
||||
# client extras not installed — that's fine for the core import.
|
||||
pass
|
||||
|
||||
__all__ = list(_wdp.__all__) + [
|
||||
"__version__",
|
||||
"__rust_version__",
|
||||
"__rust_build_tag__",
|
||||
"__build_features__",
|
||||
]
|
||||
@@ -0,0 +1,344 @@
|
||||
//! ADR-117 P3.5 — Beamforming Feedback Loop Data (BFLD) bindings.
|
||||
//!
|
||||
//! BFLD is the transmitter-side, AP-station-loop view of the WiFi
|
||||
//! channel — compressed beamforming feedback frames that 802.11ac/ax/be
|
||||
//! stations send to the AP per sounding cycle. See ADR-117 §5.7a for
|
||||
//! the design rationale and ADR-117 §11.11/12 for open questions.
|
||||
//!
|
||||
//! **Important**: there is NO Rust ingestion crate for BFLD yet. The
|
||||
//! Python types in this module ship with a **stub Rust impl** that
|
||||
//! accepts pre-parsed feedback matrices via numpy. When the future
|
||||
//! `wifi-densepose-bfld` crate lands, it plugs in here without changing
|
||||
//! the Python API.
|
||||
//!
|
||||
//! Today's user path:
|
||||
//!
|
||||
//! 1. Capture BFR frames with `tcpdump` / Wireshark + the BFR dissector
|
||||
//! (or via `mac80211` debugfs on Linux 6.10+)
|
||||
//! 2. Parse the compressed feedback into a numpy Complex64 ndarray
|
||||
//! `[Nr × Nc × Nsc]` using your favourite Python BFR parser
|
||||
//! 3. Construct `BfldFrame.from_compressed_feedback(...)` to hand the
|
||||
//! matrix to RuView
|
||||
//!
|
||||
//! Tomorrow (post-v2.0): `wifi-densepose-bfld` does steps 1+2 for you.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use numpy::{Complex64, PyArray3, PyUntypedArrayMethods, PyReadonlyArray3};
|
||||
|
||||
// ─── BfldKind ────────────────────────────────────────────────────────
|
||||
|
||||
/// 802.11 PHY variant of the captured BFR frame. Determines the
|
||||
/// expected matrix dimensions + the quantization step of the
|
||||
/// compressed angles.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import BfldKind
|
||||
/// BfldKind.CompressedHE80 # 802.11ax 80 MHz compressed BFR
|
||||
/// ```
|
||||
#[pyclass(eq, eq_int, hash, frozen, name = "BfldKind")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum PyBfldKind {
|
||||
CompressedHE20 = 0,
|
||||
CompressedHE40 = 1,
|
||||
CompressedHE80 = 2,
|
||||
CompressedHE160 = 3,
|
||||
UncompressedHT20 = 4,
|
||||
UncompressedHT40 = 5,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBfldKind {
|
||||
/// Expected number of subcarriers for this BFLD variant.
|
||||
#[getter]
|
||||
fn n_subcarriers(&self) -> usize {
|
||||
match self {
|
||||
Self::CompressedHE20 => 242,
|
||||
Self::CompressedHE40 => 484,
|
||||
Self::CompressedHE80 => 996,
|
||||
Self::CompressedHE160 => 1992,
|
||||
Self::UncompressedHT20 => 52,
|
||||
Self::UncompressedHT40 => 108,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bandwidth in MHz for this BFLD variant.
|
||||
#[getter]
|
||||
fn bandwidth_mhz(&self) -> u16 {
|
||||
match self {
|
||||
Self::CompressedHE20 | Self::UncompressedHT20 => 20,
|
||||
Self::CompressedHE40 | Self::UncompressedHT40 => 40,
|
||||
Self::CompressedHE80 => 80,
|
||||
Self::CompressedHE160 => 160,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for 802.11ax (HE) variants, false for legacy HT.
|
||||
#[getter]
|
||||
fn is_he(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::CompressedHE20
|
||||
| Self::CompressedHE40
|
||||
| Self::CompressedHE80
|
||||
| Self::CompressedHE160
|
||||
)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
let name = match self {
|
||||
Self::CompressedHE20 => "CompressedHE20",
|
||||
Self::CompressedHE40 => "CompressedHE40",
|
||||
Self::CompressedHE80 => "CompressedHE80",
|
||||
Self::CompressedHE160 => "CompressedHE160",
|
||||
Self::UncompressedHT20 => "UncompressedHT20",
|
||||
Self::UncompressedHT40 => "UncompressedHT40",
|
||||
};
|
||||
format!("BfldKind.{}", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BfldFrame ───────────────────────────────────────────────────────
|
||||
|
||||
/// One BFR snapshot: a compressed beamforming feedback matrix tagged
|
||||
/// with metadata (timestamp, sounding sequence, source MAC, kind).
|
||||
///
|
||||
/// Backing storage: a numpy Complex64 ndarray `[Nr × Nc × Nsc]`. The
|
||||
/// Python constructor accepts the ndarray directly; under the hood we
|
||||
/// hold a `Vec<Complex64>` in row-major order.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// import numpy as np
|
||||
/// from wifi_densepose import BfldFrame, BfldKind
|
||||
///
|
||||
/// fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2, Nc=1, Nsc=996
|
||||
/// frame = BfldFrame.from_compressed_feedback(
|
||||
/// timestamp_ms=1234,
|
||||
/// sounding_index=42,
|
||||
/// sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
/// kind=BfldKind.CompressedHE80,
|
||||
/// feedback_matrix=fb,
|
||||
/// )
|
||||
/// print(frame.n_subcarriers, frame.kind, frame.n_rows, frame.n_cols)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "BfldFrame")]
|
||||
pub struct PyBfldFrame {
|
||||
timestamp_ms: i64,
|
||||
sounding_index: u32,
|
||||
sta_mac: String,
|
||||
kind: PyBfldKind,
|
||||
n_rows: usize,
|
||||
n_cols: usize,
|
||||
n_subcarriers: usize,
|
||||
// Row-major storage of the [Nr × Nc × Nsc] complex matrix.
|
||||
// Length = n_rows * n_cols * n_subcarriers.
|
||||
matrix: Vec<Complex64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBfldFrame {
|
||||
/// Construct from a pre-parsed Complex64 ndarray of shape
|
||||
/// `[n_rows, n_cols, n_subcarriers]`. The last dimension MUST
|
||||
/// match `kind.n_subcarriers`.
|
||||
#[staticmethod]
|
||||
fn from_compressed_feedback<'py>(
|
||||
timestamp_ms: i64,
|
||||
sounding_index: u32,
|
||||
sta_mac: &str,
|
||||
kind: PyBfldKind,
|
||||
feedback_matrix: PyReadonlyArray3<'py, Complex64>,
|
||||
) -> PyResult<Self> {
|
||||
let shape = feedback_matrix.shape();
|
||||
let n_rows = shape[0];
|
||||
let n_cols = shape[1];
|
||||
let n_subcarriers = shape[2];
|
||||
let expected = kind.n_subcarriers();
|
||||
if n_subcarriers != expected {
|
||||
return Err(pyo3::exceptions::PyValueError::new_err(format!(
|
||||
"feedback_matrix subcarrier dim {} does not match {:?}.n_subcarriers={}",
|
||||
n_subcarriers, kind, expected
|
||||
)));
|
||||
}
|
||||
// Copy into row-major Vec. This is the safe path; PyArray3 is
|
||||
// also row-major by default.
|
||||
let matrix: Vec<Complex64> = feedback_matrix
|
||||
.as_array()
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
Ok(Self {
|
||||
timestamp_ms,
|
||||
sounding_index,
|
||||
sta_mac: sta_mac.to_string(),
|
||||
kind,
|
||||
n_rows,
|
||||
n_cols,
|
||||
n_subcarriers,
|
||||
matrix,
|
||||
})
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn timestamp_ms(&self) -> i64 { self.timestamp_ms }
|
||||
|
||||
#[getter]
|
||||
fn sounding_index(&self) -> u32 { self.sounding_index }
|
||||
|
||||
#[getter]
|
||||
fn sta_mac(&self) -> &str { &self.sta_mac }
|
||||
|
||||
#[getter]
|
||||
fn kind(&self) -> PyBfldKind { self.kind }
|
||||
|
||||
#[getter]
|
||||
fn n_rows(&self) -> usize { self.n_rows }
|
||||
|
||||
#[getter]
|
||||
fn n_cols(&self) -> usize { self.n_cols }
|
||||
|
||||
#[getter]
|
||||
fn n_subcarriers(&self) -> usize { self.n_subcarriers }
|
||||
|
||||
/// Mean amplitude across the entire matrix (sanity-check metric;
|
||||
/// production-grade sensing pipelines look at per-subcarrier or
|
||||
/// per-row stats instead).
|
||||
#[getter]
|
||||
fn mean_amplitude(&self) -> f64 {
|
||||
if self.matrix.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f64 = self.matrix.iter().map(|c| c.norm()).sum();
|
||||
sum / self.matrix.len() as f64
|
||||
}
|
||||
|
||||
/// Return the feedback matrix as a numpy Complex64 ndarray of
|
||||
/// shape `[n_rows, n_cols, n_subcarriers]`. Allocates a fresh
|
||||
/// Python-owned array; the BfldFrame keeps its own copy.
|
||||
fn feedback_matrix<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray3<Complex64>> {
|
||||
PyArray3::from_vec3_bound(
|
||||
py,
|
||||
&self.reshape_to_vec3(),
|
||||
)
|
||||
.expect("Vec dimensions match the matrix shape — invariant of from_compressed_feedback")
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"BfldFrame(kind={:?}, nr={}, nc={}, nsc={}, sta={}, idx={}, mean_amp={:.4})",
|
||||
self.kind, self.n_rows, self.n_cols, self.n_subcarriers,
|
||||
self.sta_mac, self.sounding_index, self.mean_amplitude(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyBfldFrame {
|
||||
fn reshape_to_vec3(&self) -> Vec<Vec<Vec<Complex64>>> {
|
||||
let mut out = Vec::with_capacity(self.n_rows);
|
||||
for r in 0..self.n_rows {
|
||||
let mut row = Vec::with_capacity(self.n_cols);
|
||||
for c in 0..self.n_cols {
|
||||
let start = (r * self.n_cols + c) * self.n_subcarriers;
|
||||
let end = start + self.n_subcarriers;
|
||||
row.push(self.matrix[start..end].to_vec());
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BfldReport ──────────────────────────────────────────────────────
|
||||
|
||||
/// Aggregator over a window of `BfldFrame`s — the natural "all BFR
|
||||
/// data in this 60-second scan" container. Mirrors how `VitalReading`
|
||||
/// aggregates `VitalEstimate`s in the vitals pipeline.
|
||||
#[pyclass(name = "BfldReport")]
|
||||
pub struct PyBfldReport {
|
||||
frames: Vec<u32>, // sounding indices we hold (don't deep-copy the matrices)
|
||||
timestamp_first: Option<i64>,
|
||||
timestamp_last: Option<i64>,
|
||||
kind: Option<PyBfldKind>,
|
||||
mean_amplitudes: Vec<f64>, // one per frame
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBfldReport {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frames: Vec::new(),
|
||||
timestamp_first: None,
|
||||
timestamp_last: None,
|
||||
kind: None,
|
||||
mean_amplitudes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a frame to the report. All frames must share the same
|
||||
/// `kind`; the call errors if they don't.
|
||||
fn add_frame(&mut self, frame: &PyBfldFrame) -> PyResult<()> {
|
||||
if let Some(k) = self.kind {
|
||||
if k != frame.kind {
|
||||
return Err(pyo3::exceptions::PyValueError::new_err(format!(
|
||||
"frame kind {:?} does not match report kind {:?}",
|
||||
frame.kind, k
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
self.kind = Some(frame.kind);
|
||||
}
|
||||
self.frames.push(frame.sounding_index);
|
||||
self.timestamp_first = Some(self.timestamp_first.unwrap_or(frame.timestamp_ms).min(frame.timestamp_ms));
|
||||
self.timestamp_last = Some(self.timestamp_last.unwrap_or(frame.timestamp_ms).max(frame.timestamp_ms));
|
||||
self.mean_amplitudes.push(frame.mean_amplitude());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn n_frames(&self) -> usize { self.frames.len() }
|
||||
|
||||
#[getter]
|
||||
fn timestamp_first(&self) -> Option<i64> { self.timestamp_first }
|
||||
|
||||
#[getter]
|
||||
fn timestamp_last(&self) -> Option<i64> { self.timestamp_last }
|
||||
|
||||
#[getter]
|
||||
fn kind(&self) -> Option<PyBfldKind> { self.kind }
|
||||
|
||||
/// Mean of the per-frame mean amplitudes — coarse sanity metric
|
||||
/// for "the scan captured a stable signal over the window".
|
||||
#[getter]
|
||||
fn coherence_score(&self) -> f64 {
|
||||
if self.mean_amplitudes.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = self.mean_amplitudes.iter().sum::<f64>()
|
||||
/ self.mean_amplitudes.len() as f64;
|
||||
if mean == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
// Inverse coefficient of variation, clamped to [0, 1].
|
||||
let var = self.mean_amplitudes.iter()
|
||||
.map(|m| (m - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ self.mean_amplitudes.len() as f64;
|
||||
let cv = var.sqrt() / mean;
|
||||
(1.0 - cv.min(1.0)).max(0.0)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"BfldReport(n_frames={}, kind={:?}, coherence={:.3})",
|
||||
self.frames.len(), self.kind, self.coherence_score(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyBfldKind>()?;
|
||||
m.add_class::<PyBfldFrame>()?;
|
||||
m.add_class::<PyBfldReport>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! ADR-117 P2 — PyO3 bindings for `wifi_densepose_core::Keypoint` +
|
||||
//! `KeypointType` + `Confidence`.
|
||||
//!
|
||||
//! Design notes (consequential for the Python API surface):
|
||||
//!
|
||||
//! 1. **`Confidence` is NOT bound as a separate Python class.** End
|
||||
//! users hate having to construct a wrapper just to pass a float.
|
||||
//! Python-side, confidence is just an `f32` in `[0.0, 1.0]`; the
|
||||
//! binding validates on the way in.
|
||||
//!
|
||||
//! 2. **`KeypointType` is bound as a `#[pyclass]` enum** (PyO3 0.22
|
||||
//! supports `#[pyclass(eq, eq_int)]` for C-like enums). Python-side
|
||||
//! it surfaces as `wifi_densepose.KeypointType.Nose`, etc.
|
||||
//!
|
||||
//! 3. **`Keypoint` constructor accepts `z` as `Optional[float]`** so
|
||||
//! Python users can pass `Keypoint(KeypointType.Nose, 0.5, 0.3,
|
||||
//! 0.95)` for 2D or `Keypoint(..., z=0.1)` for 3D.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_core::{Confidence, Keypoint, KeypointType};
|
||||
|
||||
// ─── KeypointType ────────────────────────────────────────────────────
|
||||
|
||||
/// COCO-17 keypoint identifier — re-export of the Rust core enum.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import KeypointType
|
||||
/// kp = KeypointType.Nose
|
||||
/// print(kp.name) # "Nose"
|
||||
/// ```
|
||||
// `hash` makes the enum hashable in Python (usable as dict keys + set
|
||||
// members) — derived from `Hash` on the Rust side. `frozen` is a
|
||||
// hard requirement for `hash` per pyo3 contract.
|
||||
#[pyclass(eq, eq_int, hash, frozen, name = "KeypointType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyKeypointType {
|
||||
Nose = 0,
|
||||
LeftEye = 1,
|
||||
RightEye = 2,
|
||||
LeftEar = 3,
|
||||
RightEar = 4,
|
||||
LeftShoulder = 5,
|
||||
RightShoulder = 6,
|
||||
LeftElbow = 7,
|
||||
RightElbow = 8,
|
||||
LeftWrist = 9,
|
||||
RightWrist = 10,
|
||||
LeftHip = 11,
|
||||
RightHip = 12,
|
||||
LeftKnee = 13,
|
||||
RightKnee = 14,
|
||||
LeftAnkle = 15,
|
||||
RightAnkle = 16,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKeypointType {
|
||||
/// Lowercase snake_case name (matches the COCO standard).
|
||||
#[getter]
|
||||
fn snake_name(&self) -> &'static str {
|
||||
self.as_rust().name()
|
||||
}
|
||||
|
||||
/// Integer index 0–16 (COCO ordering).
|
||||
#[getter]
|
||||
fn index(&self) -> u8 {
|
||||
(*self).into()
|
||||
}
|
||||
|
||||
/// True if this keypoint is on the face (nose, eyes, ears).
|
||||
fn is_face(&self) -> bool {
|
||||
self.as_rust().is_face()
|
||||
}
|
||||
|
||||
/// True if this keypoint is in the upper body (shoulders, elbows, wrists).
|
||||
fn is_upper_body(&self) -> bool {
|
||||
self.as_rust().is_upper_body()
|
||||
}
|
||||
|
||||
/// All 17 keypoint types in COCO order. Useful for Jupyter
|
||||
/// enumeration: `for kp in KeypointType.all(): ...`.
|
||||
#[staticmethod]
|
||||
fn all() -> Vec<Self> {
|
||||
KeypointType::all().iter().map(|k| PyKeypointType::from_rust(*k)).collect()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("KeypointType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
impl PyKeypointType {
|
||||
pub(crate) fn as_rust(&self) -> KeypointType {
|
||||
// SAFETY equivalent: the enum variants line up 1:1 with the
|
||||
// Rust enum's `#[repr(u8)]` discriminants. The match below is
|
||||
// exhaustive on both sides so a future addition to either side
|
||||
// fails to compile until the other is updated.
|
||||
match self {
|
||||
Self::Nose => KeypointType::Nose,
|
||||
Self::LeftEye => KeypointType::LeftEye,
|
||||
Self::RightEye => KeypointType::RightEye,
|
||||
Self::LeftEar => KeypointType::LeftEar,
|
||||
Self::RightEar => KeypointType::RightEar,
|
||||
Self::LeftShoulder => KeypointType::LeftShoulder,
|
||||
Self::RightShoulder => KeypointType::RightShoulder,
|
||||
Self::LeftElbow => KeypointType::LeftElbow,
|
||||
Self::RightElbow => KeypointType::RightElbow,
|
||||
Self::LeftWrist => KeypointType::LeftWrist,
|
||||
Self::RightWrist => KeypointType::RightWrist,
|
||||
Self::LeftHip => KeypointType::LeftHip,
|
||||
Self::RightHip => KeypointType::RightHip,
|
||||
Self::LeftKnee => KeypointType::LeftKnee,
|
||||
Self::RightKnee => KeypointType::RightKnee,
|
||||
Self::LeftAnkle => KeypointType::LeftAnkle,
|
||||
Self::RightAnkle => KeypointType::RightAnkle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_rust(k: KeypointType) -> Self {
|
||||
match k {
|
||||
KeypointType::Nose => Self::Nose,
|
||||
KeypointType::LeftEye => Self::LeftEye,
|
||||
KeypointType::RightEye => Self::RightEye,
|
||||
KeypointType::LeftEar => Self::LeftEar,
|
||||
KeypointType::RightEar => Self::RightEar,
|
||||
KeypointType::LeftShoulder => Self::LeftShoulder,
|
||||
KeypointType::RightShoulder => Self::RightShoulder,
|
||||
KeypointType::LeftElbow => Self::LeftElbow,
|
||||
KeypointType::RightElbow => Self::RightElbow,
|
||||
KeypointType::LeftWrist => Self::LeftWrist,
|
||||
KeypointType::RightWrist => Self::RightWrist,
|
||||
KeypointType::LeftHip => Self::LeftHip,
|
||||
KeypointType::RightHip => Self::RightHip,
|
||||
KeypointType::LeftKnee => Self::LeftKnee,
|
||||
KeypointType::RightKnee => Self::RightKnee,
|
||||
KeypointType::LeftAnkle => Self::LeftAnkle,
|
||||
KeypointType::RightAnkle => Self::RightAnkle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PyKeypointType> for u8 {
|
||||
fn from(k: PyKeypointType) -> u8 {
|
||||
k as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl PyKeypoint {
|
||||
/// Rust-side accessor for the inner Keypoint (used by pose.rs).
|
||||
/// Not exposed to Python — Python users go through the
|
||||
/// #[pymethods] getters above.
|
||||
pub(crate) fn inner(&self) -> &Keypoint {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Rust-side constructor from a core Keypoint (used by pose.rs
|
||||
/// when re-wrapping outputs of PersonPose methods).
|
||||
pub(crate) fn from_rust(k: Keypoint) -> Self {
|
||||
Self { inner: k }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keypoint ────────────────────────────────────────────────────────
|
||||
|
||||
/// Single skeletal joint with COCO type, 2D-or-3D position, and a
|
||||
/// confidence score in [0.0, 1.0].
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import Keypoint, KeypointType
|
||||
///
|
||||
/// kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
/// print(kp.x, kp.y, kp.confidence, kp.is_visible)
|
||||
///
|
||||
/// kp_3d = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
|
||||
/// print(kp_3d.position_3d) # (0.2, 0.4, 0.1)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "Keypoint")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyKeypoint {
|
||||
inner: Keypoint,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKeypoint {
|
||||
/// Construct a new keypoint. Confidence must be in [0.0, 1.0].
|
||||
/// `z` is optional — omit for a 2D keypoint, supply for 3D.
|
||||
#[new]
|
||||
#[pyo3(signature = (keypoint_type, x, y, confidence, *, z=None))]
|
||||
fn new(
|
||||
keypoint_type: PyKeypointType,
|
||||
x: f32,
|
||||
y: f32,
|
||||
confidence: f32,
|
||||
z: Option<f32>,
|
||||
) -> PyResult<Self> {
|
||||
let conf = Confidence::new(confidence).map_err(|e| {
|
||||
pyo3::exceptions::PyValueError::new_err(e.to_string())
|
||||
})?;
|
||||
let inner = match z {
|
||||
Some(zv) => Keypoint::new_3d(keypoint_type.as_rust(), x, y, zv, conf),
|
||||
None => Keypoint::new(keypoint_type.as_rust(), x, y, conf),
|
||||
};
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// COCO keypoint type.
|
||||
#[getter]
|
||||
fn keypoint_type(&self) -> PyKeypointType {
|
||||
PyKeypointType::from_rust(self.inner.keypoint_type)
|
||||
}
|
||||
|
||||
/// X coordinate.
|
||||
#[getter]
|
||||
fn x(&self) -> f32 {
|
||||
self.inner.x
|
||||
}
|
||||
|
||||
/// Y coordinate.
|
||||
#[getter]
|
||||
fn y(&self) -> f32 {
|
||||
self.inner.y
|
||||
}
|
||||
|
||||
/// Z coordinate, or None for 2D keypoints.
|
||||
#[getter]
|
||||
fn z(&self) -> Option<f32> {
|
||||
self.inner.z
|
||||
}
|
||||
|
||||
/// Detection confidence in [0.0, 1.0].
|
||||
#[getter]
|
||||
fn confidence(&self) -> f32 {
|
||||
self.inner.confidence.value()
|
||||
}
|
||||
|
||||
/// True if this keypoint clears the default visibility threshold
|
||||
/// (`confidence >= 0.5`).
|
||||
#[getter]
|
||||
fn is_visible(&self) -> bool {
|
||||
self.inner.is_visible()
|
||||
}
|
||||
|
||||
/// 2D position as a tuple `(x, y)`.
|
||||
#[getter]
|
||||
fn position_2d(&self) -> (f32, f32) {
|
||||
self.inner.position_2d()
|
||||
}
|
||||
|
||||
/// 3D position as a tuple `(x, y, z)`, or None for 2D keypoints.
|
||||
#[getter]
|
||||
fn position_3d(&self) -> Option<(f32, f32, f32)> {
|
||||
self.inner.position_3d()
|
||||
}
|
||||
|
||||
/// Euclidean distance to another keypoint. If both are 3D the
|
||||
/// distance includes the z-axis; otherwise it's 2D only.
|
||||
fn distance_to(&self, other: &PyKeypoint) -> f32 {
|
||||
self.inner.distance_to(&other.inner)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
match self.inner.z {
|
||||
Some(z) => format!(
|
||||
"Keypoint(KeypointType.{:?}, x={}, y={}, z={}, confidence={:.4})",
|
||||
self.inner.keypoint_type, self.inner.x, self.inner.y, z, self.inner.confidence.value()
|
||||
),
|
||||
None => format!(
|
||||
"Keypoint(KeypointType.{:?}, x={}, y={}, confidence={:.4})",
|
||||
self.inner.keypoint_type, self.inner.x, self.inner.y, self.inner.confidence.value()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyKeypoint) -> bool {
|
||||
self.inner.keypoint_type == other.inner.keypoint_type
|
||||
&& self.inner.x == other.inner.x
|
||||
&& self.inner.y == other.inner.y
|
||||
&& self.inner.z == other.inner.z
|
||||
&& (self.inner.confidence.value() - other.inner.confidence.value()).abs() < f32::EPSILON
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the binding types with the `_native` PyModule.
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyKeypointType>()?;
|
||||
m.add_class::<PyKeypoint>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! ADR-117 P2 — PyO3 bindings for `BoundingBox`, `PersonPose`,
|
||||
//! `PoseEstimate`.
|
||||
//!
|
||||
//! Design notes:
|
||||
//!
|
||||
//! 1. **`PersonPose` exposes the 17-keypoint array as a Python dict
|
||||
//! keyed by `KeypointType`**, not as a fixed-length list with
|
||||
//! `None` slots. Pythonistas don't want to know that the underlying
|
||||
//! storage is `[Option<Keypoint>; 17]`.
|
||||
//!
|
||||
//! 2. **`PoseEstimate` metadata `id` and `timestamp` are exposed as
|
||||
//! strings** (UUID + RFC 3339) rather than as bound types. Users
|
||||
//! in notebooks rarely need to compare UUIDs structurally; strings
|
||||
//! are good enough and don't require binding `FrameId` /
|
||||
//! `Timestamp` as separate classes.
|
||||
//!
|
||||
//! 3. **`PersonPose` is mutable** via `set_keypoint` / `set_bbox` /
|
||||
//! `set_id` — it's a builder-style type users construct
|
||||
//! incrementally. Hence NOT `#[pyclass(frozen)]`.
|
||||
//!
|
||||
//! 4. **`PoseEstimate` is frozen** — once constructed, the list of
|
||||
//! persons + the metadata don't change.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::PyDict;
|
||||
|
||||
use wifi_densepose_core::{
|
||||
BoundingBox, Confidence, KeypointType, PersonPose, PoseEstimate,
|
||||
};
|
||||
|
||||
use super::keypoint::{PyKeypoint, PyKeypointType};
|
||||
|
||||
// ─── BoundingBox ─────────────────────────────────────────────────────
|
||||
|
||||
/// Axis-aligned bounding box around a detected person.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import BoundingBox
|
||||
///
|
||||
/// bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
|
||||
/// print(bb.width, bb.height, bb.area, bb.center)
|
||||
/// bb2 = BoundingBox.from_center(0.3, 0.45, 0.4, 0.5)
|
||||
/// print(bb.iou(bb2))
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "BoundingBox")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyBoundingBox {
|
||||
inner: BoundingBox,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBoundingBox {
|
||||
#[new]
|
||||
fn new(x_min: f32, y_min: f32, x_max: f32, y_max: f32) -> Self {
|
||||
Self { inner: BoundingBox::new(x_min, y_min, x_max, y_max) }
|
||||
}
|
||||
|
||||
/// Construct from center point + width + height.
|
||||
#[staticmethod]
|
||||
fn from_center(cx: f32, cy: f32, width: f32, height: f32) -> Self {
|
||||
Self { inner: BoundingBox::from_center(cx, cy, width, height) }
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn x_min(&self) -> f32 { self.inner.x_min }
|
||||
#[getter]
|
||||
fn y_min(&self) -> f32 { self.inner.y_min }
|
||||
#[getter]
|
||||
fn x_max(&self) -> f32 { self.inner.x_max }
|
||||
#[getter]
|
||||
fn y_max(&self) -> f32 { self.inner.y_max }
|
||||
#[getter]
|
||||
fn width(&self) -> f32 { self.inner.width() }
|
||||
#[getter]
|
||||
fn height(&self) -> f32 { self.inner.height() }
|
||||
#[getter]
|
||||
fn area(&self) -> f32 { self.inner.area() }
|
||||
#[getter]
|
||||
fn center(&self) -> (f32, f32) { self.inner.center() }
|
||||
|
||||
/// Intersection over Union (IoU) with another box. Range [0.0, 1.0].
|
||||
fn iou(&self, other: &PyBoundingBox) -> f32 {
|
||||
self.inner.iou(&other.inner)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"BoundingBox(x_min={}, y_min={}, x_max={}, y_max={})",
|
||||
self.inner.x_min, self.inner.y_min, self.inner.x_max, self.inner.y_max,
|
||||
)
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyBoundingBox) -> bool {
|
||||
self.inner == other.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl PyBoundingBox {
|
||||
pub(crate) fn from_rust(bb: BoundingBox) -> Self {
|
||||
Self { inner: bb }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PersonPose ──────────────────────────────────────────────────────
|
||||
|
||||
/// A single detected person with optional ID, up to 17 keypoints, and
|
||||
/// an optional bounding box.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import PersonPose, Keypoint, KeypointType, BoundingBox
|
||||
///
|
||||
/// pose = PersonPose()
|
||||
/// pose.set_keypoint(Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95))
|
||||
/// pose.set_keypoint(Keypoint(KeypointType.LeftShoulder, 0.4, 0.5, 0.92))
|
||||
/// pose.set_id(7)
|
||||
/// print(pose.visible_keypoint_count) # 2
|
||||
/// print(pose.get_keypoint(KeypointType.Nose).confidence) # 0.95
|
||||
/// print(pose.compute_bounding_box()) # auto-derived from visible kp
|
||||
/// ```
|
||||
#[pyclass(name = "PersonPose")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPersonPose {
|
||||
inner: PersonPose,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPersonPose {
|
||||
/// Construct an empty person pose. Set keypoints + bbox + id with
|
||||
/// the dedicated methods.
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self { inner: PersonPose::new() }
|
||||
}
|
||||
|
||||
/// Per-person track ID. None until set.
|
||||
#[getter]
|
||||
fn id(&self) -> Option<u32> {
|
||||
self.inner.id
|
||||
}
|
||||
|
||||
fn set_id(&mut self, id: u32) {
|
||||
self.inner.id = Some(id);
|
||||
}
|
||||
|
||||
/// Set or replace a keypoint. The keypoint's type determines its
|
||||
/// slot in the internal 17-element array.
|
||||
fn set_keypoint(&mut self, keypoint: PyKeypoint) {
|
||||
self.inner.set_keypoint(*keypoint.inner());
|
||||
}
|
||||
|
||||
/// Get a keypoint by type, or None if not set.
|
||||
fn get_keypoint(&self, keypoint_type: PyKeypointType) -> Option<PyKeypoint> {
|
||||
let kp = self.inner.get_keypoint(keypoint_type.as_rust())?;
|
||||
// Re-wrap the inner Rust Keypoint for Python.
|
||||
Some(PyKeypoint::from_rust(*kp))
|
||||
}
|
||||
|
||||
/// All keypoints as a dict keyed by KeypointType. Missing
|
||||
/// keypoints are omitted (NOT included with None values).
|
||||
fn keypoints<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
|
||||
// PyO3 0.22 — PyDict::new_bound returns a Bound, the legacy
|
||||
// PyDict::new (returning &PyDict) was removed in 0.21.
|
||||
let dict = PyDict::new_bound(py);
|
||||
for (i, kp_opt) in self.inner.keypoints.iter().enumerate() {
|
||||
if let Some(kp) = kp_opt {
|
||||
let kpt = match KeypointType::all().get(i) {
|
||||
Some(t) => *t,
|
||||
None => continue,
|
||||
};
|
||||
// Convert through IntoPy to satisfy ToPyObject bound
|
||||
// for dict.set_item — #[pyclass] types impl IntoPy but
|
||||
// not ToPyObject directly in PyO3 0.22.
|
||||
use pyo3::IntoPy;
|
||||
let k_obj: PyObject = PyKeypointType::from_rust(kpt).into_py(py);
|
||||
let v_obj: PyObject = PyKeypoint::from_rust(*kp).into_py(py);
|
||||
dict.set_item(k_obj, v_obj)?;
|
||||
}
|
||||
}
|
||||
Ok(dict)
|
||||
}
|
||||
|
||||
/// Number of visible keypoints (confidence >= 0.5).
|
||||
#[getter]
|
||||
fn visible_keypoint_count(&self) -> usize {
|
||||
self.inner.visible_keypoint_count()
|
||||
}
|
||||
|
||||
/// List of visible keypoints (subset of the dict from
|
||||
/// `keypoints()`).
|
||||
fn visible_keypoints(&self) -> Vec<PyKeypoint> {
|
||||
self.inner
|
||||
.visible_keypoints()
|
||||
.into_iter()
|
||||
.map(|k| PyKeypoint::from_rust(*k))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bounding box, if previously set or computed.
|
||||
#[getter]
|
||||
fn bounding_box(&self) -> Option<PyBoundingBox> {
|
||||
self.inner.bounding_box.map(PyBoundingBox::from_rust)
|
||||
}
|
||||
|
||||
fn set_bounding_box(&mut self, bb: PyBoundingBox) {
|
||||
self.inner.bounding_box = Some(bb.inner);
|
||||
}
|
||||
|
||||
/// Auto-compute bounding box from visible keypoints, set it
|
||||
/// internally, and return it. Returns None if no keypoints visible.
|
||||
fn compute_bounding_box(&mut self) -> Option<PyBoundingBox> {
|
||||
let bb = self.inner.compute_bounding_box()?;
|
||||
self.inner.bounding_box = Some(bb);
|
||||
Some(PyBoundingBox::from_rust(bb))
|
||||
}
|
||||
|
||||
/// Overall confidence in [0.0, 1.0].
|
||||
#[getter]
|
||||
fn confidence(&self) -> f32 {
|
||||
self.inner.confidence.value()
|
||||
}
|
||||
|
||||
fn set_confidence(&mut self, c: f32) -> PyResult<()> {
|
||||
self.inner.confidence = Confidence::new(c).map_err(|e| {
|
||||
pyo3::exceptions::PyValueError::new_err(e.to_string())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"PersonPose(id={:?}, visible_keypoints={}, confidence={:.4})",
|
||||
self.inner.id,
|
||||
self.inner.visible_keypoint_count(),
|
||||
self.inner.confidence.value(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyPersonPose {
|
||||
pub(crate) fn from_rust(pose: PersonPose) -> Self {
|
||||
Self { inner: pose }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PoseEstimate ────────────────────────────────────────────────────
|
||||
|
||||
/// Top-level result of a pose-estimation pass — a list of detected
|
||||
/// persons plus metadata about the inference run.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import PoseEstimate, PersonPose
|
||||
///
|
||||
/// est = PoseEstimate([pose1, pose2], confidence=0.87, latency_ms=8.4,
|
||||
/// model_version="v0.1.0")
|
||||
/// print(est.person_count, est.has_detections)
|
||||
/// best = est.highest_confidence_person()
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "PoseEstimate")]
|
||||
pub struct PyPoseEstimate {
|
||||
inner: PoseEstimate,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPoseEstimate {
|
||||
/// Construct a pose estimate from a list of detected persons,
|
||||
/// an overall confidence, inference latency, and model version
|
||||
/// string.
|
||||
#[new]
|
||||
fn new(
|
||||
persons: Vec<PyPersonPose>,
|
||||
confidence: f32,
|
||||
latency_ms: f32,
|
||||
model_version: String,
|
||||
) -> PyResult<Self> {
|
||||
let conf = Confidence::new(confidence).map_err(|e| {
|
||||
pyo3::exceptions::PyValueError::new_err(e.to_string())
|
||||
})?;
|
||||
let rust_persons: Vec<PersonPose> =
|
||||
persons.into_iter().map(|p| p.inner).collect();
|
||||
Ok(Self {
|
||||
inner: PoseEstimate::new(
|
||||
Vec::new(),
|
||||
rust_persons,
|
||||
conf,
|
||||
latency_ms,
|
||||
model_version,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Unique frame identifier as a UUID string.
|
||||
#[getter]
|
||||
fn id(&self) -> String {
|
||||
format!("{:?}", self.inner.id)
|
||||
.trim_start_matches("FrameId(")
|
||||
.trim_end_matches(')')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Frame timestamp as an RFC 3339 / ISO 8601 string in UTC.
|
||||
#[getter]
|
||||
fn timestamp(&self) -> String {
|
||||
// Timestamp's Debug impl is usable; for a fully spec-compliant
|
||||
// ISO format, a future refactor binds chrono. P2 string-form
|
||||
// is "good enough" for diagnostics.
|
||||
format!("{:?}", self.inner.timestamp)
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn persons(&self) -> Vec<PyPersonPose> {
|
||||
self.inner.persons.iter().cloned().map(PyPersonPose::from_rust).collect()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn confidence(&self) -> f32 {
|
||||
self.inner.confidence.value()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn latency_ms(&self) -> f32 {
|
||||
self.inner.latency_ms
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn model_version(&self) -> &str {
|
||||
&self.inner.model_version
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn person_count(&self) -> usize {
|
||||
self.inner.person_count()
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn has_detections(&self) -> bool {
|
||||
self.inner.has_detections()
|
||||
}
|
||||
|
||||
/// Get the person with the highest individual confidence, or None
|
||||
/// if no persons detected.
|
||||
fn highest_confidence_person(&self) -> Option<PyPersonPose> {
|
||||
self.inner
|
||||
.highest_confidence_person()
|
||||
.cloned()
|
||||
.map(PyPersonPose::from_rust)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"PoseEstimate(persons={}, confidence={:.4}, latency_ms={:.2}, model_version={:?})",
|
||||
self.inner.person_count(),
|
||||
self.inner.confidence.value(),
|
||||
self.inner.latency_ms,
|
||||
self.inner.model_version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppress unused-import warnings for HashMap (held for future
|
||||
/// keypoint-map helpers in P3).
|
||||
#[allow(dead_code)]
|
||||
fn _hashmap_kept_for_future_use() -> HashMap<u8, u8> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyBoundingBox>()?;
|
||||
m.add_class::<PyPersonPose>()?;
|
||||
m.add_class::<PyPoseEstimate>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! ADR-117 P3 — PyO3 bindings for `wifi_densepose_vitals`.
|
||||
//!
|
||||
//! Surfaces:
|
||||
//!
|
||||
//! - `VitalStatus` enum — clinical-grade / degraded / unreliable / unavailable
|
||||
//! - `VitalEstimate` — single BPM estimate + confidence + status
|
||||
//! - `VitalReading` — combined HR + BR + signal quality snapshot
|
||||
//! - `BreathingExtractor` — bandpass 0.1–0.5 Hz → respiratory rate
|
||||
//! - `HeartRateExtractor` — bandpass 0.8–2.0 Hz + autocorrelation → HR
|
||||
//!
|
||||
//! ## GIL release strategy (per ADR-117 §7 and the Q5 audit on
|
||||
//! 2026-05-24)
|
||||
//!
|
||||
//! `wifi-densepose-vitals` has zero tokio deps and the extract loops
|
||||
//! are pure-sync DSP. Wrap the `.extract(...)` calls in
|
||||
//! `py.allow_threads(|| ...)` so Python users can run inference in a
|
||||
//! tokio-backed web server without GIL contention starving the
|
||||
//! event loop.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_vitals::{
|
||||
BreathingExtractor, HeartRateExtractor, VitalEstimate, VitalReading, VitalStatus,
|
||||
};
|
||||
|
||||
// ─── VitalStatus enum ────────────────────────────────────────────────
|
||||
|
||||
/// Status of a vital sign measurement.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import VitalStatus
|
||||
/// VitalStatus.Valid # clinical-grade
|
||||
/// VitalStatus.Degraded # reduced confidence
|
||||
/// VitalStatus.Unreliable # single RSSI source / low quality
|
||||
/// VitalStatus.Unavailable # no measurement possible
|
||||
/// ```
|
||||
#[pyclass(eq, eq_int, hash, frozen, name = "VitalStatus")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyVitalStatus {
|
||||
Valid = 0,
|
||||
Degraded = 1,
|
||||
Unreliable = 2,
|
||||
Unavailable = 3,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVitalStatus {
|
||||
fn __repr__(&self) -> String {
|
||||
format!("VitalStatus.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
impl PyVitalStatus {
|
||||
fn as_rust(&self) -> VitalStatus {
|
||||
match self {
|
||||
Self::Valid => VitalStatus::Valid,
|
||||
Self::Degraded => VitalStatus::Degraded,
|
||||
Self::Unreliable => VitalStatus::Unreliable,
|
||||
Self::Unavailable => VitalStatus::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_rust(s: VitalStatus) -> Self {
|
||||
match s {
|
||||
VitalStatus::Valid => Self::Valid,
|
||||
VitalStatus::Degraded => Self::Degraded,
|
||||
VitalStatus::Unreliable => Self::Unreliable,
|
||||
VitalStatus::Unavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VitalEstimate ───────────────────────────────────────────────────
|
||||
|
||||
/// A single vital-sign estimate (BPM + confidence + status).
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import VitalEstimate, VitalStatus
|
||||
/// est = VitalEstimate(72.4, confidence=0.9, status=VitalStatus.Valid)
|
||||
/// print(est.value_bpm, est.confidence, est.status)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "VitalEstimate")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyVitalEstimate {
|
||||
inner: VitalEstimate,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVitalEstimate {
|
||||
#[new]
|
||||
fn new(value_bpm: f64, confidence: f64, status: PyVitalStatus) -> Self {
|
||||
Self {
|
||||
inner: VitalEstimate {
|
||||
value_bpm,
|
||||
confidence,
|
||||
status: status.as_rust(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn value_bpm(&self) -> f64 { self.inner.value_bpm }
|
||||
|
||||
#[getter]
|
||||
fn confidence(&self) -> f64 { self.inner.confidence }
|
||||
|
||||
#[getter]
|
||||
fn status(&self) -> PyVitalStatus { PyVitalStatus::from_rust(self.inner.status) }
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"VitalEstimate(value_bpm={:.2}, confidence={:.3}, status={:?})",
|
||||
self.inner.value_bpm, self.inner.confidence, self.inner.status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyVitalEstimate {
|
||||
fn from_rust(e: VitalEstimate) -> Self {
|
||||
Self { inner: e }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VitalReading ────────────────────────────────────────────────────
|
||||
|
||||
/// Combined HR + BR snapshot from one window of CSI data.
|
||||
#[pyclass(frozen, name = "VitalReading")]
|
||||
pub struct PyVitalReading {
|
||||
inner: VitalReading,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVitalReading {
|
||||
#[new]
|
||||
fn new(
|
||||
respiratory_rate: PyVitalEstimate,
|
||||
heart_rate: PyVitalEstimate,
|
||||
subcarrier_count: usize,
|
||||
signal_quality: f64,
|
||||
timestamp_secs: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: VitalReading {
|
||||
respiratory_rate: respiratory_rate.inner,
|
||||
heart_rate: heart_rate.inner,
|
||||
subcarrier_count,
|
||||
signal_quality,
|
||||
timestamp_secs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn respiratory_rate(&self) -> PyVitalEstimate {
|
||||
PyVitalEstimate::from_rust(self.inner.respiratory_rate.clone())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn heart_rate(&self) -> PyVitalEstimate {
|
||||
PyVitalEstimate::from_rust(self.inner.heart_rate.clone())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn subcarrier_count(&self) -> usize { self.inner.subcarrier_count }
|
||||
|
||||
#[getter]
|
||||
fn signal_quality(&self) -> f64 { self.inner.signal_quality }
|
||||
|
||||
#[getter]
|
||||
fn timestamp_secs(&self) -> f64 { self.inner.timestamp_secs }
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"VitalReading(br={:.1}, hr={:.1}, subcarriers={}, quality={:.3})",
|
||||
self.inner.respiratory_rate.value_bpm,
|
||||
self.inner.heart_rate.value_bpm,
|
||||
self.inner.subcarrier_count,
|
||||
self.inner.signal_quality,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BreathingExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Extracts respiratory rate (6–30 BPM) from per-subcarrier amplitude
|
||||
/// residuals via 0.1–0.5 Hz bandpass + zero-crossing analysis.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import BreathingExtractor
|
||||
///
|
||||
/// br = BreathingExtractor.esp32_default() # 56 subcarriers, 100 Hz, 30s window
|
||||
/// # or: BreathingExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=30.0)
|
||||
///
|
||||
/// # Feed residuals from your preprocessor (one frame at a time)
|
||||
/// est = br.extract(residuals=[0.01, -0.02, …], weights=[]) # equal weights
|
||||
/// if est is not None:
|
||||
/// print(est.value_bpm, est.confidence)
|
||||
/// ```
|
||||
#[pyclass(name = "BreathingExtractor")]
|
||||
pub struct PyBreathingExtractor {
|
||||
inner: BreathingExtractor,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBreathingExtractor {
|
||||
/// Construct with explicit parameters.
|
||||
#[new]
|
||||
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=30.0))]
|
||||
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
|
||||
Self {
|
||||
inner: BreathingExtractor::new(n_subcarriers, sample_rate, window_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// ESP32 defaults: 56 subcarriers, 100 Hz, 30-second window.
|
||||
#[staticmethod]
|
||||
fn esp32_default() -> Self {
|
||||
Self { inner: BreathingExtractor::esp32_default() }
|
||||
}
|
||||
|
||||
/// Extract respiratory rate from a vector of per-subcarrier
|
||||
/// residuals + per-subcarrier weights. GIL is released during the
|
||||
/// DSP loop so Python threads can do other work concurrently.
|
||||
///
|
||||
/// Returns `None` if insufficient history has been accumulated.
|
||||
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
|
||||
// GIL release: see ADR-117 §7 and the Q5 tokio audit. The DSP
|
||||
// loop is pure sync, no Python objects touched, safe to run
|
||||
// without the GIL.
|
||||
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
|
||||
est.map(PyVitalEstimate::from_rust)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("BreathingExtractor(0.1–0.5 Hz bandpass)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
|
||||
/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection.
|
||||
#[pyclass(name = "HeartRateExtractor")]
|
||||
pub struct PyHeartRateExtractor {
|
||||
inner: HeartRateExtractor,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyHeartRateExtractor {
|
||||
/// Construct with explicit parameters.
|
||||
#[new]
|
||||
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=15.0))]
|
||||
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
|
||||
Self {
|
||||
inner: HeartRateExtractor::new(n_subcarriers, sample_rate, window_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// ESP32 defaults: 56 subcarriers, 100 Hz, 15-second window.
|
||||
#[staticmethod]
|
||||
fn esp32_default() -> Self {
|
||||
Self { inner: HeartRateExtractor::esp32_default() }
|
||||
}
|
||||
|
||||
/// Extract heart rate from per-subcarrier residuals. GIL released
|
||||
/// during DSP.
|
||||
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
|
||||
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
|
||||
est.map(PyVitalEstimate::from_rust)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("HeartRateExtractor(0.8–2.0 Hz bandpass)")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyVitalStatus>()?;
|
||||
m.add_class::<PyVitalEstimate>()?;
|
||||
m.add_class::<PyVitalReading>()?;
|
||||
m.add_class::<PyBreathingExtractor>()?;
|
||||
m.add_class::<PyHeartRateExtractor>()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! ADR-117 — PyO3 bindings for the WiFi-DensePose Rust core.
|
||||
//!
|
||||
//! This crate is the compiled half of the `wifi-densepose` v2.x PyPI
|
||||
//! wheel. The Python-facing facade lives in `python/wifi_densepose/`
|
||||
//! and re-exports symbols from this module under their stable names.
|
||||
//!
|
||||
//! ## Phase status (per ADR-117 §6)
|
||||
//!
|
||||
//! - **P1 (scaffold) — this commit**: module loads, version constant
|
||||
//! exposed, smoke test passes via maturin develop.
|
||||
//! - **P2**: bind `CsiFrame`, `Keypoint`, `PoseEstimate` (next).
|
||||
//! - **P3**: bind 4-stage vitals + signal DSP.
|
||||
//! - **P4**: pure-Python `wifi_densepose.client` (WS/MQTT) — no Rust
|
||||
//! surface needed; lives outside this crate.
|
||||
//! - **P5**: cibuildwheel + PyPI publish.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
mod bindings {
|
||||
pub mod bfld;
|
||||
pub mod keypoint;
|
||||
pub mod pose;
|
||||
pub mod vitals;
|
||||
}
|
||||
|
||||
/// Version of the bound Rust core. Surfaced to Python as
|
||||
/// `wifi_densepose.__rust_version__` so users can correlate wheel
|
||||
/// behaviour with the exact `v2/crates/` HEAD it was built from.
|
||||
const RUST_CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Compile-time identifier for the Rust commit that produced this
|
||||
/// wheel. Surfaced for diagnostics. Set via `CARGO_PKG_VERSION` for
|
||||
/// now; P5 wires in the git SHA via `vergen`.
|
||||
const RUST_BUILD_TAG: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// One-line description of which feature flags were enabled at build
|
||||
/// time. Helps users debug "is my wheel the slim one or the full one?".
|
||||
fn build_features() -> Vec<&'static str> {
|
||||
let mut feats: Vec<&'static str> = Vec::new();
|
||||
feats.push("p1-scaffold");
|
||||
feats.push("p2-keypoint-bindings"); // Keypoint + KeypointType
|
||||
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
|
||||
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
|
||||
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
|
||||
feats
|
||||
}
|
||||
|
||||
/// Quick smoke test exposed to Python. Returns "ok" — used by the
|
||||
/// integration tests in `python/tests/test_smoke.py` to assert the
|
||||
/// PyO3 module is importable and callable.
|
||||
#[pyfunction]
|
||||
fn hello() -> PyResult<&'static str> {
|
||||
Ok("ok")
|
||||
}
|
||||
|
||||
/// The `_native` module — re-exported in pure-Python as
|
||||
/// `wifi_densepose._native`. End users should import the parent
|
||||
/// package (`import wifi_densepose`) and never reach into `_native`
|
||||
/// directly; the leading underscore is a Python convention marking
|
||||
/// it as private.
|
||||
///
|
||||
/// The function name MUST match the `module-name` in pyproject.toml's
|
||||
/// `[tool.maturin]` block — i.e. it must be `_native` because the
|
||||
/// pyproject says `module-name = "wifi_densepose._native"`. PyO3
|
||||
/// generates the `PyInit__native` symbol from this function name.
|
||||
#[pymodule]
|
||||
#[pyo3(name = "_native")]
|
||||
fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add("__rust_version__", RUST_CORE_VERSION)?;
|
||||
m.add("__rust_build_tag__", RUST_BUILD_TAG)?;
|
||||
m.add("__build_features__", build_features())?;
|
||||
m.add_function(wrap_pyfunction!(hello, m)?)?;
|
||||
|
||||
// P2 — Keypoint + KeypointType bindings.
|
||||
bindings::keypoint::register(m)?;
|
||||
// P2 — BoundingBox + PersonPose + PoseEstimate bindings.
|
||||
bindings::pose::register(m)?;
|
||||
// P3 — Vital sign extraction bindings.
|
||||
bindings::vitals::register(m)?;
|
||||
// P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate
|
||||
// will replace the stub without changing the Python API).
|
||||
bindings::bfld::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"""ADR-117 P3.5 — Tests for BFLD (Beamforming Feedback Loop Data) bindings.
|
||||
|
||||
These tests cover the *stub-Rust-backed* forward-compatible Python
|
||||
surface defined in ADR-117 §5.7a. The real Rust ingestion crate
|
||||
(`wifi-densepose-bfld`) lands post-v2.0; this test suite locks in the
|
||||
Python API so a future swap-in is non-breaking.
|
||||
|
||||
Coverage:
|
||||
|
||||
- BfldKind enum — HE20/40/80/160 + HT20/40 variants
|
||||
- BfldKind metadata getters — n_subcarriers, bandwidth_mhz, is_he
|
||||
- BfldFrame.from_compressed_feedback — happy path + dim mismatch
|
||||
- BfldFrame numpy round-trip — feedback_matrix returns ndarray
|
||||
- BfldReport — frame aggregation, kind-mismatch error, coherence score
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wifi_densepose
|
||||
from wifi_densepose import BfldFrame, BfldKind, BfldReport
|
||||
|
||||
|
||||
# ─── BfldKind enum ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bfld_kind_variants_exist() -> None:
|
||||
assert BfldKind.CompressedHE20 != BfldKind.CompressedHE40
|
||||
assert BfldKind.CompressedHE80 != BfldKind.CompressedHE160
|
||||
assert BfldKind.UncompressedHT20 != BfldKind.UncompressedHT40
|
||||
|
||||
|
||||
def test_bfld_kind_is_hashable() -> None:
|
||||
s = {BfldKind.CompressedHE80, BfldKind.CompressedHE80}
|
||||
assert len(s) == 1
|
||||
|
||||
|
||||
def test_bfld_kind_n_subcarriers_he() -> None:
|
||||
assert BfldKind.CompressedHE20.n_subcarriers == 242
|
||||
assert BfldKind.CompressedHE40.n_subcarriers == 484
|
||||
assert BfldKind.CompressedHE80.n_subcarriers == 996
|
||||
assert BfldKind.CompressedHE160.n_subcarriers == 1992
|
||||
|
||||
|
||||
def test_bfld_kind_n_subcarriers_ht() -> None:
|
||||
assert BfldKind.UncompressedHT20.n_subcarriers == 52
|
||||
assert BfldKind.UncompressedHT40.n_subcarriers == 108
|
||||
|
||||
|
||||
def test_bfld_kind_bandwidth_mhz() -> None:
|
||||
assert BfldKind.CompressedHE20.bandwidth_mhz == 20
|
||||
assert BfldKind.CompressedHE40.bandwidth_mhz == 40
|
||||
assert BfldKind.CompressedHE80.bandwidth_mhz == 80
|
||||
assert BfldKind.CompressedHE160.bandwidth_mhz == 160
|
||||
assert BfldKind.UncompressedHT20.bandwidth_mhz == 20
|
||||
assert BfldKind.UncompressedHT40.bandwidth_mhz == 40
|
||||
|
||||
|
||||
def test_bfld_kind_is_he_flag() -> None:
|
||||
assert BfldKind.CompressedHE20.is_he is True
|
||||
assert BfldKind.CompressedHE160.is_he is True
|
||||
assert BfldKind.UncompressedHT20.is_he is False
|
||||
assert BfldKind.UncompressedHT40.is_he is False
|
||||
|
||||
|
||||
def test_bfld_kind_repr() -> None:
|
||||
r = repr(BfldKind.CompressedHE80)
|
||||
assert "BfldKind" in r and "CompressedHE80" in r
|
||||
|
||||
|
||||
# ─── BfldFrame construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_matrix(n_rows: int, n_cols: int, n_subcarriers: int) -> np.ndarray:
|
||||
"""Synthetic feedback matrix with non-trivial amplitudes so the
|
||||
mean_amplitude getter has something to chew on."""
|
||||
rng = np.random.default_rng(seed=42)
|
||||
real = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
|
||||
imag = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
|
||||
return (real + 1j * imag).astype(np.complex128)
|
||||
|
||||
|
||||
def test_bfld_frame_he80_happy_path() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=1234,
|
||||
sounding_index=42,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.timestamp_ms == 1234
|
||||
assert frame.sounding_index == 42
|
||||
assert frame.sta_mac == "aa:bb:cc:dd:ee:ff"
|
||||
assert frame.kind == BfldKind.CompressedHE80
|
||||
assert frame.n_rows == 2
|
||||
assert frame.n_cols == 1
|
||||
assert frame.n_subcarriers == 996
|
||||
|
||||
|
||||
def test_bfld_frame_he160_2x2() -> None:
|
||||
fb = _make_matrix(2, 2, 1992)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="00:00:00:00:00:00",
|
||||
kind=BfldKind.CompressedHE160,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.n_rows == 2
|
||||
assert frame.n_cols == 2
|
||||
assert frame.n_subcarriers == 1992
|
||||
|
||||
|
||||
def test_bfld_frame_ht20_legacy_path() -> None:
|
||||
fb = _make_matrix(1, 1, 52)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.UncompressedHT20,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
assert frame.kind == BfldKind.UncompressedHT20
|
||||
assert frame.n_subcarriers == 52
|
||||
|
||||
|
||||
def test_bfld_frame_subcarrier_dim_mismatch_raises() -> None:
|
||||
# HE80 requires 996 subcarriers; pass 64 → ValueError.
|
||||
bad = _make_matrix(2, 1, 64)
|
||||
with pytest.raises(ValueError, match="subcarrier"):
|
||||
BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=bad,
|
||||
)
|
||||
|
||||
|
||||
def test_bfld_frame_mean_amplitude_is_finite() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
amp = frame.mean_amplitude
|
||||
assert math.isfinite(amp) and amp > 0.0
|
||||
|
||||
|
||||
def test_bfld_frame_numpy_roundtrip_preserves_shape() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
out = frame.feedback_matrix()
|
||||
assert out.shape == (2, 1, 996)
|
||||
# Roundtrip should be lossless (Complex64 in, Complex64 out).
|
||||
assert np.allclose(out, fb.astype(np.complex128))
|
||||
|
||||
|
||||
def test_bfld_frame_repr_is_readable() -> None:
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
r = repr(frame)
|
||||
assert "BfldFrame" in r
|
||||
assert "996" in r
|
||||
assert "CompressedHE80" in r
|
||||
|
||||
|
||||
# ─── BfldReport ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bfld_report_starts_empty() -> None:
|
||||
report = BfldReport()
|
||||
assert report.n_frames == 0
|
||||
assert report.kind is None
|
||||
assert report.timestamp_first is None
|
||||
assert report.timestamp_last is None
|
||||
assert report.coherence_score == 0.0
|
||||
|
||||
|
||||
def test_bfld_report_aggregates_homogeneous_frames() -> None:
|
||||
report = BfldReport()
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
for i in range(5):
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=1000 + i * 100,
|
||||
sounding_index=i,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
report.add_frame(frame)
|
||||
assert report.n_frames == 5
|
||||
assert report.kind == BfldKind.CompressedHE80
|
||||
assert report.timestamp_first == 1000
|
||||
assert report.timestamp_last == 1400
|
||||
# Identical synthetic matrices → near-perfect coherence.
|
||||
assert report.coherence_score >= 0.99
|
||||
|
||||
|
||||
def test_bfld_report_rejects_mismatched_kind() -> None:
|
||||
report = BfldReport()
|
||||
fb_he80 = _make_matrix(2, 1, 996)
|
||||
fb_he40 = _make_matrix(2, 1, 484)
|
||||
he80 = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb_he80,
|
||||
)
|
||||
he40 = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE40,
|
||||
feedback_matrix=fb_he40,
|
||||
)
|
||||
report.add_frame(he80)
|
||||
with pytest.raises(ValueError, match="kind"):
|
||||
report.add_frame(he40)
|
||||
|
||||
|
||||
def test_bfld_report_repr_summarises() -> None:
|
||||
report = BfldReport()
|
||||
fb = _make_matrix(2, 1, 996)
|
||||
frame = BfldFrame.from_compressed_feedback(
|
||||
timestamp_ms=0,
|
||||
sounding_index=0,
|
||||
sta_mac="aa:bb:cc:dd:ee:ff",
|
||||
kind=BfldKind.CompressedHE80,
|
||||
feedback_matrix=fb,
|
||||
)
|
||||
report.add_frame(frame)
|
||||
r = repr(report)
|
||||
assert "BfldReport" in r
|
||||
assert "n_frames=1" in r
|
||||
|
||||
|
||||
# ─── Build feature flag ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_p3_5_bfld_in_build_features() -> None:
|
||||
assert "p3.5-bfld-bindings" in wifi_densepose.__build_features__
|
||||
@@ -0,0 +1,205 @@
|
||||
"""ADR-117 P4 — Tests for HA-DISCO payload parsing.
|
||||
|
||||
Pure parsing tests — no MQTT broker needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose.client import (
|
||||
HABlueprintHelper,
|
||||
HaDiscoveryPayload,
|
||||
HaEntity,
|
||||
)
|
||||
from wifi_densepose.client.ha import (
|
||||
parse_discovery_payload,
|
||||
parse_discovery_topic,
|
||||
)
|
||||
|
||||
|
||||
# Real discovery payloads pulled from ADR-115 §3 (formatted for test
|
||||
# readability; payloads are otherwise verbatim).
|
||||
_PRESENCE_TOPIC = "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/config"
|
||||
_PRESENCE_BODY = {
|
||||
"name": "Presence",
|
||||
"unique_id": "wifi_densepose_aabbccddeeff_presence",
|
||||
"object_id": "wifi_densepose_aabbccddeeff_presence",
|
||||
"state_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state",
|
||||
"availability_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/availability",
|
||||
"device_class": "occupancy",
|
||||
"icon": "mdi:motion-sensor",
|
||||
}
|
||||
|
||||
_HEART_RATE_TOPIC = "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/config"
|
||||
_HEART_RATE_BODY = {
|
||||
"name": "Heart rate",
|
||||
"unique_id": "wifi_densepose_aabbccddeeff_heart_rate",
|
||||
"state_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
|
||||
"state_class": "measurement",
|
||||
"unit_of_measurement": "bpm",
|
||||
"icon": "mdi:heart-pulse",
|
||||
"json_attributes_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
|
||||
}
|
||||
|
||||
|
||||
# ─── Topic parsing ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_discovery_topic_binary_sensor() -> None:
|
||||
out = parse_discovery_topic(_PRESENCE_TOPIC)
|
||||
assert out == ("binary_sensor", "aabbccddeeff", "presence")
|
||||
|
||||
|
||||
def test_parse_discovery_topic_sensor() -> None:
|
||||
out = parse_discovery_topic(_HEART_RATE_TOPIC)
|
||||
assert out == ("sensor", "aabbccddeeff", "heart_rate")
|
||||
|
||||
|
||||
def test_parse_discovery_topic_event() -> None:
|
||||
out = parse_discovery_topic(
|
||||
"homeassistant/event/wifi_densepose_aabbccddeeff/fall/config"
|
||||
)
|
||||
assert out == ("event", "aabbccddeeff", "fall")
|
||||
|
||||
|
||||
def test_parse_discovery_topic_returns_none_for_non_discovery() -> None:
|
||||
assert parse_discovery_topic("homeassistant/binary_sensor/foo/state") is None
|
||||
assert parse_discovery_topic("ruview/aabbccddeeff/raw/edge_vitals") is None
|
||||
assert parse_discovery_topic("") is None
|
||||
|
||||
|
||||
# ─── Payload parsing ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_discovery_payload_from_dict() -> None:
|
||||
out = parse_discovery_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
|
||||
assert out is not None
|
||||
assert out.entity_kind == "binary_sensor"
|
||||
assert out.node_id == "aabbccddeeff"
|
||||
assert out.object_id == "presence"
|
||||
assert out.payload["device_class"] == "occupancy"
|
||||
|
||||
|
||||
def test_parse_discovery_payload_from_bytes() -> None:
|
||||
raw = json.dumps(_PRESENCE_BODY).encode("utf-8")
|
||||
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
|
||||
assert out is not None
|
||||
assert out.payload["unique_id"] == "wifi_densepose_aabbccddeeff_presence"
|
||||
|
||||
|
||||
def test_parse_discovery_payload_from_string() -> None:
|
||||
raw = json.dumps(_PRESENCE_BODY)
|
||||
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
|
||||
assert out is not None
|
||||
assert out.entity_kind == "binary_sensor"
|
||||
|
||||
|
||||
def test_parse_discovery_payload_rejects_malformed_json() -> None:
|
||||
assert parse_discovery_payload(_PRESENCE_TOPIC, "{ broken: json") is None
|
||||
|
||||
|
||||
def test_parse_discovery_payload_rejects_non_object_root() -> None:
|
||||
assert parse_discovery_payload(_PRESENCE_TOPIC, "[1, 2, 3]") is None
|
||||
|
||||
|
||||
def test_parse_discovery_payload_returns_none_for_non_discovery_topic() -> None:
|
||||
assert parse_discovery_payload(
|
||||
"ruview/aabbccddeeff/raw/edge_vitals",
|
||||
_PRESENCE_BODY,
|
||||
) is None
|
||||
|
||||
|
||||
# ─── HaEntity projection ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ha_entity_from_payload_extracts_fields() -> None:
|
||||
p = HaDiscoveryPayload(
|
||||
entity_kind="sensor",
|
||||
node_id="aabbccddeeff",
|
||||
object_id="heart_rate",
|
||||
payload=_HEART_RATE_BODY,
|
||||
)
|
||||
e = HaEntity.from_payload(p)
|
||||
assert e.entity_kind == "sensor"
|
||||
assert e.unique_id == "wifi_densepose_aabbccddeeff_heart_rate"
|
||||
assert e.unit_of_measurement == "bpm"
|
||||
assert e.icon == "mdi:heart-pulse"
|
||||
assert e.json_attributes_topic == _HEART_RATE_BODY["json_attributes_topic"]
|
||||
|
||||
|
||||
def test_ha_entity_handles_missing_optional_fields() -> None:
|
||||
p = HaDiscoveryPayload(
|
||||
entity_kind="event",
|
||||
node_id="aabbccddeeff",
|
||||
object_id="bed_exit",
|
||||
payload={"unique_id": "wifi_densepose_aabbccddeeff_bed_exit"},
|
||||
)
|
||||
e = HaEntity.from_payload(p)
|
||||
assert e.unique_id == "wifi_densepose_aabbccddeeff_bed_exit"
|
||||
assert e.device_class == ""
|
||||
assert e.unit_of_measurement == ""
|
||||
|
||||
|
||||
# ─── HABlueprintHelper aggregation ───────────────────────────────────
|
||||
|
||||
|
||||
def _populated_helper() -> HABlueprintHelper:
|
||||
h = HABlueprintHelper()
|
||||
h.add_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
|
||||
h.add_payload(_HEART_RATE_TOPIC, _HEART_RATE_BODY)
|
||||
# Same fields but a different node
|
||||
h.add_payload(
|
||||
"homeassistant/binary_sensor/wifi_densepose_ff00ff00ff00/presence/config",
|
||||
{**_PRESENCE_BODY, "unique_id": "wifi_densepose_ff00ff00ff00_presence"},
|
||||
)
|
||||
return h
|
||||
|
||||
|
||||
def test_helper_starts_empty() -> None:
|
||||
h = HABlueprintHelper()
|
||||
assert len(h) == 0
|
||||
assert h.nodes() == []
|
||||
assert h.all_payloads() == []
|
||||
|
||||
|
||||
def test_helper_aggregates_multiple_payloads() -> None:
|
||||
h = _populated_helper()
|
||||
assert len(h) == 3
|
||||
assert h.nodes() == ["aabbccddeeff", "ff00ff00ff00"]
|
||||
|
||||
|
||||
def test_helper_entities_for_node() -> None:
|
||||
h = _populated_helper()
|
||||
entities = h.entities_for_node("aabbccddeeff")
|
||||
object_ids = sorted(e.object_id for e in entities)
|
||||
assert object_ids == ["heart_rate", "presence"]
|
||||
|
||||
|
||||
def test_helper_by_device_class() -> None:
|
||||
h = _populated_helper()
|
||||
occupancy_entities = h.by_device_class("occupancy")
|
||||
assert len(occupancy_entities) == 2 # presence on both nodes
|
||||
assert {e.node_id for e in occupancy_entities} == {"aabbccddeeff", "ff00ff00ff00"}
|
||||
|
||||
|
||||
def test_helper_remove() -> None:
|
||||
h = _populated_helper()
|
||||
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is True
|
||||
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is False # no-op
|
||||
assert len(h) == 2
|
||||
|
||||
|
||||
def test_helper_rejects_non_discovery_topics() -> None:
|
||||
h = HABlueprintHelper()
|
||||
ok = h.add_payload("ruview/aabbccddeeff/raw/edge_vitals", _PRESENCE_BODY)
|
||||
assert ok is False
|
||||
assert len(h) == 0
|
||||
|
||||
|
||||
def test_helper_in_operator() -> None:
|
||||
h = _populated_helper()
|
||||
assert ("aabbccddeeff", "binary_sensor", "presence") in h
|
||||
assert ("nonexistent", "binary_sensor", "presence") not in h
|
||||
@@ -0,0 +1,208 @@
|
||||
"""ADR-117 P4 — Tests for RuViewMqttClient.
|
||||
|
||||
These tests do NOT bring up a broker — they exercise:
|
||||
|
||||
1. Topic-wildcard matching (`_topic_matches`)
|
||||
2. Client construction + handler registration
|
||||
3. The callback path by directly invoking the paho callback methods
|
||||
with synthesized messages
|
||||
|
||||
End-to-end broker integration is a P4-followon item (the mosquitto
|
||||
patterns from memory [[feedback_mqtt_integration_test_patterns]] go
|
||||
there). This file keeps unit coverage tight without requiring a
|
||||
broker on every CI run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose.client import RuViewMqttClient
|
||||
from wifi_densepose.client.mqtt import _topic_matches
|
||||
|
||||
|
||||
# ─── Topic wildcard matcher ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern,topic,expected", [
|
||||
("ruview/+/raw/edge_vitals", "ruview/aabb/raw/edge_vitals", True),
|
||||
("ruview/+/raw/edge_vitals", "ruview/aabb/cooked/edge_vitals", False),
|
||||
("ruview/+/raw/+", "ruview/aabb/raw/pose", True),
|
||||
("ruview/+/raw/+", "ruview/aabb/raw/pose/extra", False),
|
||||
# Per MQTT v5 §4.7.1.2: `+` is a whole-level wildcard only — mid-
|
||||
# segment `+` is a literal `+` character, not a wildcard. The
|
||||
# spec-correct way to wildcard the third segment of the HA
|
||||
# discovery topic is `homeassistant/+/+/+/config`.
|
||||
("homeassistant/+/+/+/config",
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", True),
|
||||
# `wifi_densepose_+` is therefore NOT a wildcard — it matches the
|
||||
# literal string only. Asserting that behaviour stays stable.
|
||||
("homeassistant/+/wifi_densepose_+/+/config",
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", False),
|
||||
("ruview/#", "ruview/aabb/raw/edge_vitals", True),
|
||||
# Per MQTT v5 §4.7.1.2: `<prefix>/#` ALSO matches the bare
|
||||
# `<prefix>` itself (it represents "this topic and all sub-topics").
|
||||
("ruview/#", "ruview", True),
|
||||
("ruview/+/raw/#", "ruview/aabb/raw/pose/extra", True),
|
||||
("exact/topic", "exact/topic", True),
|
||||
("exact/topic", "exact/topic/extra", False),
|
||||
("a/b/c", "a/b", False),
|
||||
])
|
||||
def test_topic_matches(pattern: str, topic: str, expected: bool) -> None:
|
||||
assert _topic_matches(pattern, topic) is expected
|
||||
|
||||
|
||||
# ─── RuViewMqttClient construction ──────────────────────────────────
|
||||
|
||||
|
||||
def test_client_constructs_with_defaults() -> None:
|
||||
c = RuViewMqttClient()
|
||||
assert c.broker_host == "localhost"
|
||||
assert c.broker_port == 1883
|
||||
assert c.connected is False
|
||||
assert c.client_id.startswith("wifi-densepose-client-")
|
||||
|
||||
|
||||
def test_client_unique_client_id_per_instance() -> None:
|
||||
"""Per the rumqttc memory lesson — each instance needs a unique
|
||||
client_id so parallel tests don't kick each other off the broker."""
|
||||
c1 = RuViewMqttClient()
|
||||
c2 = RuViewMqttClient()
|
||||
assert c1.client_id != c2.client_id
|
||||
|
||||
|
||||
def test_client_accepts_explicit_client_id() -> None:
|
||||
c = RuViewMqttClient(client_id="explicit-id")
|
||||
assert c.client_id == "explicit-id"
|
||||
|
||||
|
||||
# ─── Handler registration ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_handler_registration_stores_callback() -> None:
|
||||
c = RuViewMqttClient()
|
||||
seen: list[Any] = []
|
||||
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: seen.append((t, p)))
|
||||
# Internal state — we're allowed to inspect since the handler
|
||||
# path needs to be unit-testable without a broker.
|
||||
assert "ruview/+/raw/edge_vitals" in c._handlers
|
||||
|
||||
|
||||
def test_handler_unregister_drops_callback() -> None:
|
||||
c = RuViewMqttClient()
|
||||
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
|
||||
c.unsubscribe_handler("ruview/+/raw/edge_vitals")
|
||||
assert "ruview/+/raw/edge_vitals" not in c._handlers
|
||||
|
||||
|
||||
# ─── Callback dispatch (synthesized) ─────────────────────────────────
|
||||
|
||||
|
||||
def _fake_message(topic: str, body: Any) -> Any:
|
||||
"""Synthesize a paho-mqtt MQTTMessage-ish object."""
|
||||
if isinstance(body, (dict, list)):
|
||||
payload_bytes = json.dumps(body).encode("utf-8")
|
||||
elif isinstance(body, bytes):
|
||||
payload_bytes = body
|
||||
else:
|
||||
payload_bytes = str(body).encode("utf-8")
|
||||
return SimpleNamespace(topic=topic, payload=payload_bytes)
|
||||
|
||||
|
||||
def test_message_dispatch_to_matching_handler() -> None:
|
||||
c = RuViewMqttClient()
|
||||
received: list[tuple[str, Any]] = []
|
||||
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append((t, p)))
|
||||
|
||||
msg = _fake_message(
|
||||
"ruview/aabbccddeeff/raw/edge_vitals",
|
||||
{"breathing_rate_bpm": 14.0, "heartrate_bpm": 72.0, "presence": True},
|
||||
)
|
||||
c._on_message(None, None, msg)
|
||||
|
||||
assert len(received) == 1
|
||||
topic, payload = received[0]
|
||||
assert topic == "ruview/aabbccddeeff/raw/edge_vitals"
|
||||
assert payload["breathing_rate_bpm"] == 14.0
|
||||
|
||||
|
||||
def test_message_dispatch_ignores_non_matching_topic() -> None:
|
||||
c = RuViewMqttClient()
|
||||
received: list[Any] = []
|
||||
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append(p))
|
||||
|
||||
msg = _fake_message("ruview/aabb/raw/pose", {"persons": []})
|
||||
c._on_message(None, None, msg)
|
||||
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_message_dispatch_falls_back_to_bytes_on_non_json() -> None:
|
||||
c = RuViewMqttClient()
|
||||
received: list[Any] = []
|
||||
c.on_message("custom/binary/+", lambda t, p: received.append(p))
|
||||
|
||||
msg = _fake_message("custom/binary/data", b"\x00\x01\x02not-json")
|
||||
c._on_message(None, None, msg)
|
||||
|
||||
assert received == [b"\x00\x01\x02not-json"]
|
||||
|
||||
|
||||
def test_handler_exception_does_not_propagate() -> None:
|
||||
"""A misbehaving user callback must not crash the paho network
|
||||
loop — exceptions are caught and logged."""
|
||||
c = RuViewMqttClient()
|
||||
seen_after_crash: list[Any] = []
|
||||
|
||||
def crashing(_topic: str, _p: Any) -> None:
|
||||
raise RuntimeError("simulated callback crash")
|
||||
|
||||
c.on_message("crashy/topic", crashing)
|
||||
c.on_message("safe/topic", lambda t, p: seen_after_crash.append(p))
|
||||
|
||||
# First, the crashing handler — must NOT raise out of _on_message.
|
||||
c._on_message(None, None, _fake_message("crashy/topic", "anything"))
|
||||
# Then the safe handler — must still fire on a subsequent message.
|
||||
c._on_message(None, None, _fake_message("safe/topic", {"x": 1}))
|
||||
assert seen_after_crash == [{"x": 1}]
|
||||
|
||||
|
||||
def test_multiple_handlers_for_overlapping_patterns_all_fire() -> None:
|
||||
c = RuViewMqttClient()
|
||||
a_received: list[Any] = []
|
||||
b_received: list[Any] = []
|
||||
c.on_message("ruview/+/raw/+", lambda t, p: a_received.append(p))
|
||||
c.on_message("ruview/aabb/raw/edge_vitals", lambda t, p: b_received.append(p))
|
||||
|
||||
msg = _fake_message("ruview/aabb/raw/edge_vitals", {"presence": True})
|
||||
c._on_message(None, None, msg)
|
||||
|
||||
assert len(a_received) == 1
|
||||
assert len(b_received) == 1
|
||||
|
||||
|
||||
# ─── on_connect path ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_on_connect_sets_event_and_subscribes() -> None:
|
||||
c = RuViewMqttClient()
|
||||
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
|
||||
|
||||
# Stub the paho client so we can capture subscribe() calls.
|
||||
subscribed: list[str] = []
|
||||
stub = SimpleNamespace(subscribe=lambda pattern: subscribed.append(pattern))
|
||||
|
||||
c._on_connect(stub, None, None, 0)
|
||||
assert c.connected is True
|
||||
assert subscribed == ["ruview/+/raw/edge_vitals"]
|
||||
|
||||
|
||||
def test_on_connect_with_nonzero_rc_does_not_set_connected() -> None:
|
||||
c = RuViewMqttClient()
|
||||
stub = SimpleNamespace(subscribe=lambda pattern: None)
|
||||
c._on_connect(stub, None, None, 5) # CONNACK fail
|
||||
assert c.connected is False
|
||||
@@ -0,0 +1,180 @@
|
||||
"""ADR-117 P4 — Tests for the HA-MIND semantic primitive listener.
|
||||
|
||||
Pure routing tests — no MQTT broker needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from wifi_densepose.client import (
|
||||
SemanticPrimitive,
|
||||
SemanticPrimitiveEvent,
|
||||
SemanticPrimitiveListener,
|
||||
)
|
||||
|
||||
|
||||
# ─── SemanticPrimitive enum ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_enum_covers_all_10_v1_primitives() -> None:
|
||||
expected = {
|
||||
"someone_sleeping",
|
||||
"possible_distress",
|
||||
"room_active",
|
||||
"elderly_inactivity",
|
||||
"meeting_in_progress",
|
||||
"bathroom_occupied",
|
||||
"fall_risk_elevated",
|
||||
"bed_exit",
|
||||
"no_movement_safety",
|
||||
"multi_room_transition",
|
||||
}
|
||||
actual = {p.value for p in SemanticPrimitive}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_enum_from_object_id_round_trips() -> None:
|
||||
for p in SemanticPrimitive:
|
||||
assert SemanticPrimitive.from_object_id(p.value) is p
|
||||
|
||||
|
||||
def test_enum_from_object_id_returns_none_for_unknown() -> None:
|
||||
assert SemanticPrimitive.from_object_id("garbage") is None
|
||||
|
||||
|
||||
# ─── Listener routing ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_listener_dispatches_to_specific_handler() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
received: list[SemanticPrimitiveEvent] = []
|
||||
listener.on(SemanticPrimitive.SomeoneSleeping, received.append)
|
||||
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
|
||||
json.dumps({"state": "ON", "confidence": 0.92, "explanation": ["motion<5%"]}),
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.kind is SemanticPrimitive.SomeoneSleeping
|
||||
assert evt.node_id == "aabb"
|
||||
assert evt.state == "ON"
|
||||
assert evt.confidence == 0.92
|
||||
assert evt.explanation == ("motion<5%",)
|
||||
assert len(received) == 1
|
||||
assert received[0] is evt
|
||||
|
||||
|
||||
def test_listener_on_any_fires_for_every_primitive() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
seen: list[SemanticPrimitiveEvent] = []
|
||||
listener.on_any(seen.append)
|
||||
|
||||
listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||||
json.dumps({"state": "ON"}),
|
||||
)
|
||||
listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/bathroom_occupied/state",
|
||||
json.dumps({"state": "OFF"}),
|
||||
)
|
||||
assert len(seen) == 2
|
||||
assert seen[0].kind is SemanticPrimitive.RoomActive
|
||||
assert seen[1].kind is SemanticPrimitive.BathroomOccupied
|
||||
|
||||
|
||||
def test_listener_specific_handler_does_not_fire_for_other_primitives() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
received: list[SemanticPrimitiveEvent] = []
|
||||
listener.on(SemanticPrimitive.PossibleDistress, received.append)
|
||||
|
||||
listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
|
||||
json.dumps({"state": "ON"}),
|
||||
)
|
||||
assert received == []
|
||||
|
||||
|
||||
def test_listener_decodes_plain_state_string() -> None:
|
||||
"""HA convention: binary_sensors that don't carry attributes emit
|
||||
plain strings ('ON' / 'OFF'). We must accept that too."""
|
||||
listener = SemanticPrimitiveListener()
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||||
"ON",
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.state == "ON"
|
||||
assert evt.confidence == 0.0 # not provided in plain string
|
||||
assert evt.explanation == ()
|
||||
|
||||
|
||||
def test_listener_decodes_numeric_sensor_state() -> None:
|
||||
"""fall_risk_elevated is a 0–100 sensor — verify numeric string."""
|
||||
listener = SemanticPrimitiveListener()
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/sensor/wifi_densepose_aabb/fall_risk_elevated/state",
|
||||
"73",
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.kind is SemanticPrimitive.FallRiskElevated
|
||||
assert evt.state == "73"
|
||||
|
||||
|
||||
def test_listener_decodes_bytes_payload() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
|
||||
b"ON",
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.state == "ON"
|
||||
|
||||
|
||||
def test_listener_ignores_non_state_topics() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
assert listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/config",
|
||||
json.dumps({"name": "Room Active"}),
|
||||
) is None
|
||||
|
||||
|
||||
def test_listener_ignores_unknown_slug() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
assert listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/unknown_primitive/state",
|
||||
"ON",
|
||||
) is None
|
||||
|
||||
|
||||
def test_listener_ignores_non_wifi_densepose_node() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
# third segment doesn't start with wifi_densepose_
|
||||
assert listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/aqara_fp2/room_active/state",
|
||||
"ON",
|
||||
) is None
|
||||
|
||||
|
||||
def test_listener_explanation_string_is_normalised_to_tuple() -> None:
|
||||
"""Producers may send `explanation` as a single string by mistake;
|
||||
accept that and wrap in a 1-tuple so downstream code can iterate
|
||||
uniformly."""
|
||||
listener = SemanticPrimitiveListener()
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aabb/possible_distress/state",
|
||||
json.dumps({"state": "ON", "explanation": "HR=120 baseline=80"}),
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.explanation == ("HR=120 baseline=80",)
|
||||
|
||||
|
||||
def test_event_is_frozen() -> None:
|
||||
evt = SemanticPrimitiveEvent(
|
||||
kind=SemanticPrimitive.SomeoneSleeping,
|
||||
node_id="aabb",
|
||||
state="ON",
|
||||
)
|
||||
import pytest
|
||||
with pytest.raises((AttributeError, Exception)): # FrozenInstanceError subclass
|
||||
evt.state = "OFF" # type: ignore[misc]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""ADR-117 P4 — End-to-end test for SensingClient against an in-process
|
||||
WS server.
|
||||
|
||||
We spin up a real `websockets.serve()` server in the same event loop,
|
||||
send the four message types defined in ADR-115 §1, and assert the
|
||||
client decodes them into the right dataclasses. No mocks — the only
|
||||
moving part this test does NOT exercise is the actual sensing-server
|
||||
binary, but the wire protocol is the contract under test here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from wifi_densepose.client import (
|
||||
ConnectionEstablishedMessage,
|
||||
EdgeVitalsMessage,
|
||||
PoseDataMessage,
|
||||
SensingClient,
|
||||
SensingMessage,
|
||||
)
|
||||
|
||||
|
||||
# ─── In-process WS server fixture ────────────────────────────────────
|
||||
|
||||
|
||||
_FIXTURE_MESSAGES = [
|
||||
{
|
||||
"type": "connection_established",
|
||||
"node_id": "test-node-001",
|
||||
"version": "0.7.4",
|
||||
"capabilities": ["edge_vitals", "pose_data"],
|
||||
},
|
||||
{
|
||||
"type": "edge_vitals",
|
||||
"node_id": "test-node-001",
|
||||
"presence": True,
|
||||
"fall_detected": False,
|
||||
"motion": 0.21,
|
||||
"breathing_rate_bpm": 14.5,
|
||||
"heartrate_bpm": 72.3,
|
||||
"n_persons": 1,
|
||||
"motion_energy": 0.034,
|
||||
"presence_score": 0.91,
|
||||
"rssi": -42.0,
|
||||
},
|
||||
{
|
||||
"type": "pose_data",
|
||||
"node_id": "test-node-001",
|
||||
"timestamp": 1700000000.5,
|
||||
"persons": [{"id": 1, "keypoints": []}],
|
||||
"confidence": 0.88,
|
||||
},
|
||||
# Unknown type — should NOT crash the stream; should yield a plain
|
||||
# SensingMessage.
|
||||
{
|
||||
"type": "future_message_type_not_yet_modelled",
|
||||
"extra": "data",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _handler(websocket: Any) -> None:
|
||||
for msg in _FIXTURE_MESSAGES:
|
||||
await websocket.send(json.dumps(msg))
|
||||
# Send one malformed frame to assert the client logs+drops it
|
||||
# rather than crashing the stream.
|
||||
await websocket.send("{not valid json")
|
||||
# And one final "real" message so the test can confirm the stream
|
||||
# survived the malformed one.
|
||||
await websocket.send(json.dumps({"type": "edge_vitals", "node_id": "post-bad-frame"}))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ws_server() -> Any:
|
||||
"""Start a websocket server on a random port; yield the bound URL."""
|
||||
server = await websockets.serve(_handler, "127.0.0.1", 0)
|
||||
# Get the bound port (host="127.0.0.1" returns one socket).
|
||||
port = server.sockets[0].getsockname()[1] # type: ignore[union-attr]
|
||||
try:
|
||||
yield f"ws://127.0.0.1:{port}/ws/sensing"
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
# ─── End-to-end stream test ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_sensing_client_decodes_all_message_types(ws_server: str) -> None:
|
||||
received: list[SensingMessage] = []
|
||||
async with SensingClient(ws_server) as client:
|
||||
async for msg in client.stream():
|
||||
received.append(msg)
|
||||
if len(received) >= len(_FIXTURE_MESSAGES) + 1: # +1 for post-bad-frame
|
||||
break
|
||||
|
||||
# connection_established → typed
|
||||
assert isinstance(received[0], ConnectionEstablishedMessage)
|
||||
assert received[0].node_id == "test-node-001"
|
||||
assert received[0].version == "0.7.4"
|
||||
assert "edge_vitals" in received[0].capabilities
|
||||
|
||||
# edge_vitals → typed with full fields
|
||||
assert isinstance(received[1], EdgeVitalsMessage)
|
||||
assert received[1].presence is True
|
||||
assert received[1].fall_detected is False
|
||||
assert received[1].breathing_rate_bpm == 14.5
|
||||
assert received[1].heartrate_bpm == 72.3
|
||||
assert received[1].n_persons == 1
|
||||
assert received[1].rssi == -42.0
|
||||
|
||||
# pose_data → typed
|
||||
assert isinstance(received[2], PoseDataMessage)
|
||||
assert received[2].timestamp == 1700000000.5
|
||||
assert len(received[2].persons) == 1
|
||||
assert received[2].confidence == 0.88
|
||||
|
||||
# Unknown type → plain SensingMessage (forward-compat)
|
||||
assert type(received[3]) is SensingMessage # exact base class
|
||||
assert received[3].type == "future_message_type_not_yet_modelled"
|
||||
assert received[3].raw["extra"] == "data"
|
||||
|
||||
# After the malformed frame: the stream should have survived and
|
||||
# yielded the post-bad-frame message.
|
||||
assert isinstance(received[4], EdgeVitalsMessage)
|
||||
assert received[4].node_id == "post-bad-frame"
|
||||
|
||||
|
||||
async def test_sensing_client_recv_one(ws_server: str) -> None:
|
||||
async with SensingClient(ws_server) as client:
|
||||
msg = await client.recv_one(timeout=2.0)
|
||||
assert isinstance(msg, ConnectionEstablishedMessage)
|
||||
|
||||
|
||||
async def test_sensing_client_raises_when_used_without_context() -> None:
|
||||
client = SensingClient("ws://127.0.0.1:1/") # never connects
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await client.recv_one(timeout=0.1)
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
async for _ in client.stream():
|
||||
pass
|
||||
|
||||
|
||||
async def test_sensing_client_close_is_idempotent(ws_server: str) -> None:
|
||||
client = SensingClient(ws_server)
|
||||
await client.__aenter__()
|
||||
await client.close()
|
||||
await client.close() # second close is a no-op
|
||||
|
||||
|
||||
def test_sensing_client_decoder_directly() -> None:
|
||||
"""The decoder is pure — exercise it without bringing up a WS
|
||||
server, so we have a fast unit test for the type mapping."""
|
||||
from wifi_densepose.client.ws import _decode
|
||||
|
||||
msg = _decode(json.dumps({
|
||||
"type": "edge_vitals",
|
||||
"node_id": "x",
|
||||
"presence": True,
|
||||
"fall_detected": False,
|
||||
"motion": 1.5,
|
||||
}))
|
||||
assert isinstance(msg, EdgeVitalsMessage)
|
||||
assert msg.presence is True
|
||||
assert msg.motion == 1.5
|
||||
assert msg.breathing_rate_bpm is None # not present → None, not 0.0
|
||||
assert msg.heartrate_bpm is None
|
||||
assert msg.rssi is None
|
||||
|
||||
|
||||
def test_sensing_client_decoder_handles_None_subfields() -> None:
|
||||
"""When the sensing-server explicitly emits null for HR/BR (no
|
||||
measurement yet), the client should propagate None, not crash."""
|
||||
from wifi_densepose.client.ws import _decode
|
||||
|
||||
msg = _decode(json.dumps({
|
||||
"type": "edge_vitals",
|
||||
"node_id": "x",
|
||||
"presence": False,
|
||||
"fall_detected": False,
|
||||
"motion": 0.0,
|
||||
"breathing_rate_bpm": None,
|
||||
"heartrate_bpm": None,
|
||||
"rssi": None,
|
||||
}))
|
||||
assert isinstance(msg, EdgeVitalsMessage)
|
||||
assert msg.breathing_rate_bpm is None
|
||||
assert msg.heartrate_bpm is None
|
||||
assert msg.rssi is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""ADR-117 P2 tests — Keypoint + KeypointType binding round-trips.
|
||||
|
||||
Run with: cd python && .venv/Scripts/python -m pytest tests/test_keypoint.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import Keypoint, KeypointType
|
||||
|
||||
|
||||
# ─── KeypointType ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_keypoint_type_all_returns_17() -> None:
|
||||
"""COCO standard defines exactly 17 keypoints."""
|
||||
assert len(KeypointType.all()) == 17
|
||||
|
||||
|
||||
def test_keypoint_type_index_matches_coco_ordering() -> None:
|
||||
"""Indexes 0..16 match the COCO canonical ordering."""
|
||||
expected = [
|
||||
(KeypointType.Nose, 0),
|
||||
(KeypointType.LeftEye, 1),
|
||||
(KeypointType.RightEye, 2),
|
||||
(KeypointType.LeftEar, 3),
|
||||
(KeypointType.RightEar, 4),
|
||||
(KeypointType.LeftShoulder, 5),
|
||||
(KeypointType.RightShoulder, 6),
|
||||
(KeypointType.LeftElbow, 7),
|
||||
(KeypointType.RightElbow, 8),
|
||||
(KeypointType.LeftWrist, 9),
|
||||
(KeypointType.RightWrist, 10),
|
||||
(KeypointType.LeftHip, 11),
|
||||
(KeypointType.RightHip, 12),
|
||||
(KeypointType.LeftKnee, 13),
|
||||
(KeypointType.RightKnee, 14),
|
||||
(KeypointType.LeftAnkle, 15),
|
||||
(KeypointType.RightAnkle, 16),
|
||||
]
|
||||
for kp, idx in expected:
|
||||
assert kp.index == idx, f"{kp} expected index {idx} got {kp.index}"
|
||||
|
||||
|
||||
def test_keypoint_type_snake_name() -> None:
|
||||
"""snake_name follows COCO convention."""
|
||||
assert KeypointType.Nose.snake_name == "nose"
|
||||
assert KeypointType.LeftShoulder.snake_name == "left_shoulder"
|
||||
assert KeypointType.RightAnkle.snake_name == "right_ankle"
|
||||
|
||||
|
||||
def test_keypoint_type_is_face() -> None:
|
||||
"""is_face() matches the 5 facial keypoints."""
|
||||
face = {
|
||||
KeypointType.Nose,
|
||||
KeypointType.LeftEye,
|
||||
KeypointType.RightEye,
|
||||
KeypointType.LeftEar,
|
||||
KeypointType.RightEar,
|
||||
}
|
||||
for kp in KeypointType.all():
|
||||
assert kp.is_face() == (kp in face)
|
||||
|
||||
|
||||
def test_keypoint_type_is_upper_body() -> None:
|
||||
"""is_upper_body() catches shoulders, elbows, wrists."""
|
||||
assert KeypointType.LeftShoulder.is_upper_body()
|
||||
assert KeypointType.RightShoulder.is_upper_body()
|
||||
assert KeypointType.LeftElbow.is_upper_body()
|
||||
assert KeypointType.LeftWrist.is_upper_body()
|
||||
assert not KeypointType.LeftHip.is_upper_body()
|
||||
|
||||
|
||||
def test_keypoint_type_eq() -> None:
|
||||
"""Equality + identity work across calls."""
|
||||
assert KeypointType.Nose == KeypointType.Nose
|
||||
assert KeypointType.Nose != KeypointType.LeftEye
|
||||
|
||||
|
||||
def test_keypoint_type_repr() -> None:
|
||||
"""repr is a useful Python expression."""
|
||||
assert repr(KeypointType.Nose) == "KeypointType.Nose"
|
||||
assert repr(KeypointType.LeftWrist) == "KeypointType.LeftWrist"
|
||||
|
||||
|
||||
# ─── Keypoint ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_keypoint_2d_construct() -> None:
|
||||
"""Default 2D keypoint."""
|
||||
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
assert kp.x == pytest.approx(0.5)
|
||||
assert kp.y == pytest.approx(0.3)
|
||||
assert kp.z is None
|
||||
assert kp.confidence == pytest.approx(0.95)
|
||||
assert kp.keypoint_type == KeypointType.Nose
|
||||
assert kp.is_visible
|
||||
|
||||
|
||||
def test_keypoint_3d_construct() -> None:
|
||||
"""3D keypoint with kwarg z."""
|
||||
kp = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
|
||||
assert kp.position_3d == pytest.approx((0.2, 0.4, 0.1))
|
||||
assert kp.z == pytest.approx(0.1)
|
||||
|
||||
|
||||
def test_keypoint_position_2d_tuple() -> None:
|
||||
kp = Keypoint(KeypointType.RightHip, 0.6, 0.7, 0.99)
|
||||
assert kp.position_2d == pytest.approx((0.6, 0.7))
|
||||
|
||||
|
||||
def test_keypoint_position_3d_none_for_2d() -> None:
|
||||
"""2D keypoints return None for position_3d, not a default z."""
|
||||
kp = Keypoint(KeypointType.Nose, 0.5, 0.5, 0.99)
|
||||
assert kp.position_3d is None
|
||||
|
||||
|
||||
def test_keypoint_is_visible_below_threshold() -> None:
|
||||
"""Confidence under 0.5 is NOT visible (default threshold)."""
|
||||
kp_low = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.3)
|
||||
kp_high = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.7)
|
||||
assert not kp_low.is_visible
|
||||
assert kp_high.is_visible
|
||||
|
||||
|
||||
def test_keypoint_confidence_validation_too_high() -> None:
|
||||
"""Confidence > 1.0 rejected."""
|
||||
with pytest.raises(ValueError, match="Confidence must be in"):
|
||||
Keypoint(KeypointType.Nose, 0.0, 0.0, 1.5)
|
||||
|
||||
|
||||
def test_keypoint_confidence_validation_negative() -> None:
|
||||
"""Negative confidence rejected."""
|
||||
with pytest.raises(ValueError, match="Confidence must be in"):
|
||||
Keypoint(KeypointType.Nose, 0.0, 0.0, -0.1)
|
||||
|
||||
|
||||
def test_keypoint_distance_2d() -> None:
|
||||
"""Euclidean distance in 2D."""
|
||||
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0)
|
||||
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0)
|
||||
assert a.distance_to(b) == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_keypoint_distance_3d() -> None:
|
||||
"""Euclidean distance in 3D when both have z."""
|
||||
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0, z=0.0)
|
||||
b = Keypoint(KeypointType.LeftEye, 1.0, 2.0, 1.0, z=2.0)
|
||||
# sqrt(1 + 4 + 4) = 3.0
|
||||
assert a.distance_to(b) == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_keypoint_distance_falls_back_to_2d_if_mixed() -> None:
|
||||
"""Mixing 2D and 3D keypoints uses 2D distance only."""
|
||||
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0) # 2D
|
||||
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0, z=99.0) # 3D
|
||||
# Should be 5.0 (2D distance), not include the z=99 term
|
||||
assert a.distance_to(b) == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_keypoint_repr_2d() -> None:
|
||||
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
r = repr(kp)
|
||||
assert "KeypointType.Nose" in r
|
||||
assert "x=0.5" in r
|
||||
assert "y=0.3" in r
|
||||
assert "z" not in r # no z field for 2D
|
||||
|
||||
|
||||
def test_keypoint_repr_3d() -> None:
|
||||
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95, z=0.1)
|
||||
r = repr(kp)
|
||||
assert "z=0.1" in r
|
||||
|
||||
|
||||
def test_keypoint_eq() -> None:
|
||||
"""Two keypoints with same fields compare equal."""
|
||||
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
b = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_keypoint_neq_different_type() -> None:
|
||||
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
b = Keypoint(KeypointType.LeftEye, 0.5, 0.3, 0.95)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_keypoint_neq_different_position() -> None:
|
||||
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
b = Keypoint(KeypointType.Nose, 0.6, 0.3, 0.95)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_build_features_marks_p2() -> None:
|
||||
"""The P2 marker is now in the wheel's feature list."""
|
||||
import wifi_densepose
|
||||
|
||||
assert "p2-keypoint-bindings" in wifi_densepose.__build_features__
|
||||
@@ -0,0 +1,248 @@
|
||||
"""ADR-117 P2 tests — BoundingBox + PersonPose + PoseEstimate bindings.
|
||||
|
||||
Run with: cd python && .venv/Scripts/python -m pytest tests/test_pose.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose import (
|
||||
BoundingBox,
|
||||
Keypoint,
|
||||
KeypointType,
|
||||
PersonPose,
|
||||
PoseEstimate,
|
||||
)
|
||||
|
||||
|
||||
# ─── BoundingBox ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bounding_box_construct() -> None:
|
||||
bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
|
||||
assert bb.x_min == pytest.approx(0.1)
|
||||
assert bb.y_min == pytest.approx(0.2)
|
||||
assert bb.x_max == pytest.approx(0.5)
|
||||
assert bb.y_max == pytest.approx(0.7)
|
||||
|
||||
|
||||
def test_bounding_box_dimensions() -> None:
|
||||
bb = BoundingBox(0.0, 0.0, 4.0, 3.0)
|
||||
assert bb.width == pytest.approx(4.0)
|
||||
assert bb.height == pytest.approx(3.0)
|
||||
assert bb.area == pytest.approx(12.0)
|
||||
assert bb.center == pytest.approx((2.0, 1.5))
|
||||
|
||||
|
||||
def test_bounding_box_from_center() -> None:
|
||||
bb = BoundingBox.from_center(2.0, 3.0, 4.0, 6.0)
|
||||
assert bb.x_min == pytest.approx(0.0)
|
||||
assert bb.y_min == pytest.approx(0.0)
|
||||
assert bb.x_max == pytest.approx(4.0)
|
||||
assert bb.y_max == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_bounding_box_iou_no_overlap() -> None:
|
||||
a = BoundingBox(0.0, 0.0, 1.0, 1.0)
|
||||
b = BoundingBox(2.0, 2.0, 3.0, 3.0)
|
||||
assert a.iou(b) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_bounding_box_iou_full_overlap() -> None:
|
||||
a = BoundingBox(0.0, 0.0, 1.0, 1.0)
|
||||
b = BoundingBox(0.0, 0.0, 1.0, 1.0)
|
||||
assert a.iou(b) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_bounding_box_iou_partial() -> None:
|
||||
a = BoundingBox(0.0, 0.0, 10.0, 10.0)
|
||||
b = BoundingBox(5.0, 5.0, 15.0, 15.0)
|
||||
# intersection 25, union 175 → 1/7
|
||||
assert a.iou(b) == pytest.approx(25.0 / 175.0)
|
||||
|
||||
|
||||
def test_bounding_box_eq() -> None:
|
||||
assert BoundingBox(1, 2, 3, 4) == BoundingBox(1, 2, 3, 4)
|
||||
assert BoundingBox(1, 2, 3, 4) != BoundingBox(1, 2, 3, 5)
|
||||
|
||||
|
||||
def test_bounding_box_repr() -> None:
|
||||
bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
|
||||
assert "BoundingBox" in repr(bb)
|
||||
assert "x_min=0.1" in repr(bb)
|
||||
|
||||
|
||||
# ─── PersonPose ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_person_pose_empty() -> None:
|
||||
p = PersonPose()
|
||||
assert p.id is None
|
||||
assert p.visible_keypoint_count == 0
|
||||
assert p.bounding_box is None
|
||||
assert p.confidence == 0.0
|
||||
|
||||
|
||||
def test_person_pose_set_get_keypoint() -> None:
|
||||
p = PersonPose()
|
||||
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
p.set_keypoint(kp)
|
||||
got = p.get_keypoint(KeypointType.Nose)
|
||||
assert got is not None
|
||||
assert got.x == pytest.approx(0.5)
|
||||
assert got.confidence == pytest.approx(0.95)
|
||||
|
||||
|
||||
def test_person_pose_get_missing_returns_none() -> None:
|
||||
p = PersonPose()
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95))
|
||||
assert p.get_keypoint(KeypointType.LeftWrist) is None
|
||||
|
||||
|
||||
def test_person_pose_visible_count() -> None:
|
||||
p = PersonPose()
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9)) # visible
|
||||
p.set_keypoint(Keypoint(KeypointType.LeftEar, 0.0, 0.0, 0.2)) # invisible
|
||||
p.set_keypoint(Keypoint(KeypointType.RightEar, 0.0, 0.0, 0.8)) # visible
|
||||
assert p.visible_keypoint_count == 2
|
||||
|
||||
|
||||
def test_person_pose_visible_keypoints_list() -> None:
|
||||
p = PersonPose()
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
|
||||
p.set_keypoint(Keypoint(KeypointType.LeftEar, 0.0, 0.0, 0.2))
|
||||
vis = p.visible_keypoints()
|
||||
assert len(vis) == 1
|
||||
assert vis[0].keypoint_type == KeypointType.Nose
|
||||
|
||||
|
||||
def test_person_pose_keypoints_dict_excludes_missing() -> None:
|
||||
p = PersonPose()
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
|
||||
p.set_keypoint(Keypoint(KeypointType.LeftWrist, 0.5, 0.5, 0.6))
|
||||
d = p.keypoints()
|
||||
assert KeypointType.Nose in d
|
||||
assert KeypointType.LeftWrist in d
|
||||
assert KeypointType.RightAnkle not in d
|
||||
assert len(d) == 2
|
||||
|
||||
|
||||
def test_person_pose_set_id() -> None:
|
||||
p = PersonPose()
|
||||
p.set_id(7)
|
||||
assert p.id == 7
|
||||
|
||||
|
||||
def test_person_pose_set_bounding_box() -> None:
|
||||
p = PersonPose()
|
||||
bb = BoundingBox(0.1, 0.1, 0.5, 0.9)
|
||||
p.set_bounding_box(bb)
|
||||
assert p.bounding_box == bb
|
||||
|
||||
|
||||
def test_person_pose_compute_bbox_returns_none_when_empty() -> None:
|
||||
p = PersonPose()
|
||||
assert p.compute_bounding_box() is None
|
||||
|
||||
|
||||
def test_person_pose_compute_bbox_from_keypoints() -> None:
|
||||
p = PersonPose()
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.95))
|
||||
p.set_keypoint(Keypoint(KeypointType.RightAnkle, 1.0, 2.0, 0.95))
|
||||
bb = p.compute_bounding_box()
|
||||
assert bb is not None
|
||||
# bbox should span both keypoints
|
||||
assert bb.x_min <= 0.0
|
||||
assert bb.y_min <= 0.0
|
||||
assert bb.x_max >= 1.0
|
||||
assert bb.y_max >= 2.0
|
||||
# also stored
|
||||
assert p.bounding_box is not None
|
||||
|
||||
|
||||
def test_person_pose_set_confidence_validation() -> None:
|
||||
p = PersonPose()
|
||||
p.set_confidence(0.85)
|
||||
assert p.confidence == pytest.approx(0.85)
|
||||
with pytest.raises(ValueError):
|
||||
p.set_confidence(1.5)
|
||||
|
||||
|
||||
def test_person_pose_repr() -> None:
|
||||
p = PersonPose()
|
||||
p.set_id(3)
|
||||
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
|
||||
r = repr(p)
|
||||
assert "PersonPose" in r
|
||||
assert "id=Some(3)" in r or "id=3" in r
|
||||
|
||||
|
||||
# ─── PoseEstimate ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pose_estimate_construct_empty() -> None:
|
||||
e = PoseEstimate([], 0.5, 1.0, "test-v0")
|
||||
assert e.person_count == 0
|
||||
assert not e.has_detections
|
||||
assert e.confidence == pytest.approx(0.5)
|
||||
assert e.latency_ms == pytest.approx(1.0)
|
||||
assert e.model_version == "test-v0"
|
||||
|
||||
|
||||
def test_pose_estimate_construct_with_persons() -> None:
|
||||
p1 = PersonPose()
|
||||
p1.set_id(1)
|
||||
p1.set_confidence(0.8)
|
||||
p2 = PersonPose()
|
||||
p2.set_id(2)
|
||||
p2.set_confidence(0.9)
|
||||
e = PoseEstimate([p1, p2], 0.85, 5.2, "v0.7.0")
|
||||
assert e.person_count == 2
|
||||
assert e.has_detections
|
||||
assert e.confidence == pytest.approx(0.85)
|
||||
|
||||
|
||||
def test_pose_estimate_highest_confidence_person() -> None:
|
||||
p1 = PersonPose()
|
||||
p1.set_confidence(0.5)
|
||||
p2 = PersonPose()
|
||||
p2.set_confidence(0.95)
|
||||
p3 = PersonPose()
|
||||
p3.set_confidence(0.7)
|
||||
e = PoseEstimate([p1, p2, p3], 0.85, 5.2, "v0.7.0")
|
||||
best = e.highest_confidence_person()
|
||||
assert best is not None
|
||||
assert best.confidence == pytest.approx(0.95)
|
||||
|
||||
|
||||
def test_pose_estimate_highest_confidence_returns_none_when_empty() -> None:
|
||||
e = PoseEstimate([], 0.5, 1.0, "test")
|
||||
assert e.highest_confidence_person() is None
|
||||
|
||||
|
||||
def test_pose_estimate_metadata_strings_nonempty() -> None:
|
||||
e = PoseEstimate([], 0.5, 1.0, "test")
|
||||
assert isinstance(e.id, str)
|
||||
assert isinstance(e.timestamp, str)
|
||||
assert e.id # non-empty
|
||||
assert e.timestamp # non-empty
|
||||
|
||||
|
||||
def test_pose_estimate_confidence_validation() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
PoseEstimate([], 1.5, 0.0, "test")
|
||||
|
||||
|
||||
def test_pose_estimate_repr_contains_counts() -> None:
|
||||
e = PoseEstimate([], 0.5, 2.3, "v0.7.0")
|
||||
r = repr(e)
|
||||
assert "PoseEstimate" in r
|
||||
assert "v0.7.0" in r
|
||||
|
||||
|
||||
def test_build_features_marks_p2_complete() -> None:
|
||||
import wifi_densepose
|
||||
|
||||
assert "p2-keypoint-bindings" in wifi_densepose.__build_features__
|
||||
assert "p2-pose-bindings" in wifi_densepose.__build_features__
|
||||
@@ -0,0 +1,260 @@
|
||||
"""ADR-117 hardening sweep — Security & robustness tests for the
|
||||
client surface.
|
||||
|
||||
Scope: malformed/hostile input handling across the WS decoder, MQTT
|
||||
matcher + dispatch, HA discovery parser, and semantic primitive
|
||||
listener. The goal is to ensure that an adversarial broker or
|
||||
sensing-server can't:
|
||||
|
||||
- Crash the client process via malformed JSON, UTF-8, or topic shapes
|
||||
- Bypass topic-wildcard matching to deliver messages to the wrong handler
|
||||
- Leak MQTT credentials through `repr()` or string conversion
|
||||
- Trigger unbounded memory growth via deeply-nested JSON
|
||||
- Get a handler exception to crash the network loop
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from wifi_densepose.client import RuViewMqttClient, SemanticPrimitiveListener
|
||||
from wifi_densepose.client.ha import (
|
||||
HABlueprintHelper,
|
||||
parse_discovery_payload,
|
||||
parse_discovery_topic,
|
||||
)
|
||||
from wifi_densepose.client.mqtt import _topic_matches
|
||||
from wifi_densepose.client.ws import _decode
|
||||
|
||||
|
||||
# ─── WS decoder robustness ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ws_decoder_rejects_non_object_root() -> None:
|
||||
"""A JSON array at the root must NOT crash the decoder. Plain
|
||||
string/array root values are valid JSON but not valid sensing-
|
||||
server messages — the decoder must reject them cleanly."""
|
||||
with pytest.raises(ValueError):
|
||||
_decode("[1, 2, 3]")
|
||||
with pytest.raises(ValueError):
|
||||
_decode('"just a string"')
|
||||
with pytest.raises(ValueError):
|
||||
_decode("42")
|
||||
|
||||
|
||||
def test_ws_decoder_rejects_malformed_json() -> None:
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_decode("{ broken: json")
|
||||
|
||||
|
||||
def test_ws_decoder_handles_deeply_nested_payload_without_crash() -> None:
|
||||
"""Hostile JSON nested 1000 levels deep must not crash via
|
||||
Python's default recursion limit. Json.loads has a built-in
|
||||
guard; verify we don't accidentally bypass it."""
|
||||
nested = "{" + '"a":{' * 999 + '"x":1' + "}" * 1000
|
||||
# json.loads either succeeds (since 999 < ~1000 limit) or raises
|
||||
# RecursionError; either is acceptable — the key is no segfault
|
||||
# or hang.
|
||||
try:
|
||||
_decode(nested)
|
||||
except (RecursionError, json.JSONDecodeError, ValueError):
|
||||
pass # All acceptable.
|
||||
|
||||
|
||||
def test_ws_decoder_handles_huge_string_values() -> None:
|
||||
"""A 1 MB string in a JSON field must decode without exploding.
|
||||
The websockets `max_size` parameter (default 16 MB) is the actual
|
||||
DoS guard — this just confirms the decoder itself is linear."""
|
||||
huge_payload = json.dumps({
|
||||
"type": "edge_vitals",
|
||||
"node_id": "x" * (1024 * 1024), # 1 MB string
|
||||
"presence": True,
|
||||
"fall_detected": False,
|
||||
"motion": 0.0,
|
||||
})
|
||||
msg = _decode(huge_payload)
|
||||
assert msg.type == "edge_vitals"
|
||||
|
||||
|
||||
def test_ws_decoder_handles_unicode_in_node_id() -> None:
|
||||
"""Non-ASCII node IDs (e.g. accidental terminal escapes) must
|
||||
round-trip cleanly without re-encoding errors."""
|
||||
payload = json.dumps({"type": "edge_vitals", "node_id": "nöde-中", "presence": True, "fall_detected": False, "motion": 0.0})
|
||||
msg = _decode(payload)
|
||||
assert msg.node_id == "nöde-中" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ─── MQTT topic matcher — exhaustive edge cases ─────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern,topic,expected", [
|
||||
# Empty / boundary
|
||||
("", "", True),
|
||||
("a", "", False),
|
||||
("", "a", False),
|
||||
# `+` cannot bypass a literal level boundary
|
||||
("a/+/c", "a/b/c", True),
|
||||
("a/+/c", "a/b/d", False),
|
||||
("a/+/c", "a/b/c/d", False),
|
||||
# `#` is greedy from its position but does not match if it's
|
||||
# mid-pattern (per MQTT spec; our matcher returns False then).
|
||||
("a/#/c", "a/b/c", False), # `#` must be terminal
|
||||
# Topics starting with `$` are legal here — we don't filter them;
|
||||
# matching is purely syntactic. `+` is one-level only, so `$SYS/+`
|
||||
# matches `$SYS/broker` but NOT `$SYS/broker/version`.
|
||||
("$SYS/+", "$SYS/broker", True),
|
||||
("$SYS/+", "$SYS/broker/version", False),
|
||||
("$SYS/#", "$SYS/broker/version", True),
|
||||
# Null byte in topic: still string comparison, but useful to lock
|
||||
# down behaviour.
|
||||
("a/b", "a\x00/b", False),
|
||||
])
|
||||
def test_topic_matcher_edge_cases(pattern: str, topic: str, expected: bool) -> None:
|
||||
assert _topic_matches(pattern, topic) is expected
|
||||
|
||||
|
||||
# ─── MQTT credential confidentiality ────────────────────────────────
|
||||
|
||||
|
||||
def test_mqtt_password_never_in_repr() -> None:
|
||||
"""A user's broker password must NOT leak through __repr__ or
|
||||
__str__. Currently RuViewMqttClient doesn't define repr — that's
|
||||
the safest default (uses object identity). Lock that down so a
|
||||
future "let's add a friendly repr" change doesn't expose creds."""
|
||||
c = RuViewMqttClient(
|
||||
broker_host="broker.example.com",
|
||||
username="alice",
|
||||
password="super-secret-token-do-not-leak",
|
||||
)
|
||||
rep = repr(c)
|
||||
s = str(c)
|
||||
assert "super-secret-token-do-not-leak" not in rep
|
||||
assert "super-secret-token-do-not-leak" not in s
|
||||
|
||||
|
||||
def test_mqtt_password_never_stored_in_plain_attribute() -> None:
|
||||
"""The plaintext password must not be stored on the client
|
||||
instance — paho-mqtt internalises it into `_client._username_pw`
|
||||
which we never expose. Audit by walking the public dict."""
|
||||
c = RuViewMqttClient(password="dont-leak-me")
|
||||
for k, v in vars(c).items():
|
||||
if isinstance(v, str):
|
||||
assert "dont-leak-me" not in v, f"password leaked via attribute {k!r}"
|
||||
|
||||
|
||||
# ─── HA discovery — adversarial topics ──────────────────────────────
|
||||
|
||||
|
||||
def test_ha_discovery_rejects_topic_with_null_byte() -> None:
|
||||
"""Defensive: regex must not match a null-byte-laced topic."""
|
||||
bad = "homeassistant/binary_sensor/wifi_densepose_aa\x00bb/presence/config"
|
||||
assert parse_discovery_topic(bad) is None
|
||||
assert parse_discovery_payload(bad, {"name": "x"}) is None
|
||||
|
||||
|
||||
def test_ha_discovery_rejects_topic_with_slash_in_node_id() -> None:
|
||||
"""A node_id with embedded slashes would break the unique_id
|
||||
contract; reject."""
|
||||
bad = "homeassistant/binary_sensor/wifi_densepose_aa/bb/presence/config"
|
||||
# The regex won't match because there are too many segments.
|
||||
assert parse_discovery_topic(bad) is None
|
||||
|
||||
|
||||
def test_ha_helper_drops_invalid_topic_silently() -> None:
|
||||
"""`add_payload` should return False (not raise) for non-discovery
|
||||
topics so a misconfigured broker doesn't bring down the client."""
|
||||
h = HABlueprintHelper()
|
||||
assert h.add_payload("garbage", {"x": 1}) is False
|
||||
assert h.add_payload("ruview/aa/raw/edge_vitals", {"x": 1}) is False
|
||||
assert len(h) == 0
|
||||
|
||||
|
||||
def test_ha_helper_handles_non_dict_payload() -> None:
|
||||
"""If the HA discovery body is a list or scalar (broken producer),
|
||||
the helper must reject rather than crash on attribute access."""
|
||||
h = HABlueprintHelper()
|
||||
topic = "homeassistant/binary_sensor/wifi_densepose_aabb/presence/config"
|
||||
assert h.add_payload(topic, "[1, 2, 3]") is False
|
||||
assert h.add_payload(topic, "42") is False
|
||||
assert h.add_payload(topic, b"\xff\xfe invalid utf-8") is False
|
||||
|
||||
|
||||
# ─── Semantic primitive listener — adversarial input ────────────────
|
||||
|
||||
|
||||
def test_primitive_listener_ignores_topic_injection_attempts() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
# Extra leading segments
|
||||
assert listener.handle_mqtt_message(
|
||||
"evil/homeassistant/binary_sensor/wifi_densepose_aa/someone_sleeping/state",
|
||||
"ON",
|
||||
) is None
|
||||
# Wrong final segment
|
||||
assert listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aa/someone_sleeping/STATE",
|
||||
"ON",
|
||||
) is None
|
||||
# Empty node_id after the wifi_densepose_ prefix is still routed
|
||||
# (the node_id is "") because we don't enforce a minimum length —
|
||||
# but that's not an injection vector. Confirm behaviour.
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_/someone_sleeping/state",
|
||||
"ON",
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.node_id == ""
|
||||
|
||||
|
||||
def test_primitive_listener_handles_garbage_payload_without_crash() -> None:
|
||||
listener = SemanticPrimitiveListener()
|
||||
# Bytes that aren't valid UTF-8
|
||||
evt = listener.handle_mqtt_message(
|
||||
"homeassistant/binary_sensor/wifi_densepose_aa/room_active/state",
|
||||
b"\xff\xfe\xfd",
|
||||
)
|
||||
assert evt is not None # we return a sentinel rather than crash
|
||||
# No assertions on state content — undefined for invalid UTF-8;
|
||||
# what matters is no exception escaped.
|
||||
|
||||
|
||||
# ─── Public surface integrity ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_public_surface_is_stable() -> None:
|
||||
"""Every name in `wifi_densepose.__all__` must be resolvable.
|
||||
Catches accidental re-export breakage between phases."""
|
||||
import wifi_densepose
|
||||
for name in wifi_densepose.__all__:
|
||||
assert hasattr(wifi_densepose, name), f"__all__ promises {name!r} but attribute missing"
|
||||
|
||||
|
||||
def test_client_public_surface_is_stable() -> None:
|
||||
import wifi_densepose.client as c
|
||||
for name in c.__all__:
|
||||
# Lazy re-exports for SensingClient + RuViewMqttClient need to
|
||||
# be resolvable too — touch them to exercise __getattr__.
|
||||
_ = getattr(c, name)
|
||||
|
||||
|
||||
# ─── Handler crash isolation (expanded) ─────────────────────────────
|
||||
|
||||
|
||||
def test_mqtt_handler_exception_isolation_with_multiple_handlers() -> None:
|
||||
"""Earlier test covered one crashing handler; this version makes
|
||||
sure a crashing handler in the *middle* of a list of registered
|
||||
handlers doesn't prevent later handlers from firing."""
|
||||
c = RuViewMqttClient()
|
||||
received_before: list[str] = []
|
||||
received_after: list[str] = []
|
||||
c.on_message("a/+", lambda t, p: received_before.append(t))
|
||||
c.on_message("a/b", lambda t, p: (_ for _ in ()).throw(RuntimeError("middle crash")))
|
||||
c.on_message("+/b", lambda t, p: received_after.append(t))
|
||||
|
||||
msg = SimpleNamespace(topic="a/b", payload=b"x")
|
||||
c._on_message(None, None, msg)
|
||||
|
||||
assert received_before == ["a/b"]
|
||||
assert received_after == ["a/b"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""ADR-117 P1 smoke tests — assert the maturin-built wheel loads and
|
||||
its compiled module is callable.
|
||||
|
||||
These tests are the first acceptance gate of the v2.0 PyPI publish
|
||||
pipeline (ADR-117 §11.1 — ``cargo test`` equivalent at the Python
|
||||
level). They run on every cibuildwheel target in P5's CI matrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_package_imports() -> None:
|
||||
"""The top-level package must import without error."""
|
||||
import wifi_densepose # noqa: F401
|
||||
|
||||
|
||||
def test_version_string_well_formed() -> None:
|
||||
"""Version string follows PEP 440 + matches pyproject.toml."""
|
||||
import re
|
||||
|
||||
import wifi_densepose
|
||||
|
||||
assert isinstance(wifi_densepose.__version__, str)
|
||||
# Allow pre-release segments (a, b, rc, dev) for non-final wheels.
|
||||
assert re.match(
|
||||
r"^\d+\.\d+\.\d+(a|b|rc|\.dev)?\d*$", wifi_densepose.__version__
|
||||
), f"non-PEP-440 version: {wifi_densepose.__version__}"
|
||||
|
||||
|
||||
def test_rust_version_surfaced() -> None:
|
||||
"""Bound Rust core version must be reachable from Python.
|
||||
|
||||
This is the diagnostic surface ADR-117 §5.2 promised — users in
|
||||
bug reports can paste ``wifi_densepose.__rust_version__`` so we
|
||||
correlate behaviour with the exact ``v2/crates/`` HEAD.
|
||||
"""
|
||||
import wifi_densepose
|
||||
|
||||
assert isinstance(wifi_densepose.__rust_version__, str)
|
||||
assert wifi_densepose.__rust_version__ # non-empty
|
||||
|
||||
|
||||
def test_build_features_listed() -> None:
|
||||
"""The wheel's build-time features must be enumerable.
|
||||
|
||||
P1 ships only the ``p1-scaffold`` feature marker; later phases
|
||||
add more entries. The test asserts the contract that the list
|
||||
exists and contains the P1 marker.
|
||||
"""
|
||||
import wifi_densepose
|
||||
|
||||
feats = wifi_densepose.__build_features__
|
||||
assert isinstance(feats, list)
|
||||
assert all(isinstance(f, str) for f in feats)
|
||||
assert "p1-scaffold" in feats, f"P1 marker missing: {feats}"
|
||||
|
||||
|
||||
def test_hello_returns_ok() -> None:
|
||||
"""The compiled ``hello`` function round-trips through PyO3.
|
||||
|
||||
This is the actual smoke test — proves the FFI works end-to-end.
|
||||
If this passes on every cibuildwheel target, the PyO3 build matrix
|
||||
is healthy.
|
||||
"""
|
||||
import wifi_densepose
|
||||
|
||||
assert wifi_densepose.hello() == "ok"
|
||||
|
||||
|
||||
def test_native_module_private() -> None:
|
||||
"""The compiled module is reachable but marked private.
|
||||
|
||||
Users should ``import wifi_densepose``, not ``import
|
||||
wifi_densepose._native``. The underscore prefix communicates that.
|
||||
"""
|
||||
import wifi_densepose
|
||||
from wifi_densepose import _native
|
||||
|
||||
assert hasattr(_native, "hello"), "compiled module missing hello()"
|
||||
# Both paths must return the same value.
|
||||
assert wifi_densepose.hello() == _native.hello()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""ADR-117 P3 — Tests for vital-sign extraction bindings.
|
||||
|
||||
Covers:
|
||||
|
||||
- VitalStatus enum (eq, eq_int, hash, frozen)
|
||||
- VitalEstimate construction + getters + immutability
|
||||
- VitalReading composite + getters
|
||||
- BreathingExtractor + HeartRateExtractor — esp32_default, explicit
|
||||
ctor, extract() return type, validation behaviour
|
||||
|
||||
The Rust pipeline is unit-tested in `v2/crates/wifi-densepose-vitals/`.
|
||||
These tests are deliberately scoped to the *binding* layer — does the
|
||||
Python surface return the right shapes, raise the right errors, and
|
||||
release the GIL safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from random import Random
|
||||
|
||||
import pytest
|
||||
|
||||
import wifi_densepose
|
||||
from wifi_densepose import (
|
||||
BreathingExtractor,
|
||||
HeartRateExtractor,
|
||||
VitalEstimate,
|
||||
VitalReading,
|
||||
VitalStatus,
|
||||
)
|
||||
|
||||
|
||||
# ─── VitalStatus enum ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_status_variants_present() -> None:
|
||||
assert VitalStatus.Valid != VitalStatus.Degraded
|
||||
assert VitalStatus.Unreliable != VitalStatus.Unavailable
|
||||
|
||||
|
||||
def test_vital_status_equality_against_int() -> None:
|
||||
# eq_int → enum can be compared to int (PyO3 0.22 surface)
|
||||
assert VitalStatus.Valid == 0
|
||||
assert VitalStatus.Unavailable == 3
|
||||
|
||||
|
||||
def test_vital_status_is_hashable() -> None:
|
||||
# frozen + hash → can be used as dict key / set member
|
||||
s = {VitalStatus.Valid, VitalStatus.Valid, VitalStatus.Degraded}
|
||||
assert len(s) == 2
|
||||
|
||||
|
||||
def test_vital_status_repr_contains_variant_name() -> None:
|
||||
r = repr(VitalStatus.Valid)
|
||||
assert "VitalStatus" in r and "Valid" in r
|
||||
|
||||
|
||||
# ─── VitalEstimate ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_estimate_construction_and_getters() -> None:
|
||||
est = VitalEstimate(value_bpm=72.4, confidence=0.85, status=VitalStatus.Valid)
|
||||
assert math.isclose(est.value_bpm, 72.4)
|
||||
assert math.isclose(est.confidence, 0.85)
|
||||
assert est.status == VitalStatus.Valid
|
||||
|
||||
|
||||
def test_vital_estimate_is_frozen() -> None:
|
||||
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
with pytest.raises(AttributeError):
|
||||
est.value_bpm = 100.0 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_vital_estimate_repr_is_readable() -> None:
|
||||
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
r = repr(est)
|
||||
assert "VitalEstimate" in r
|
||||
assert "72" in r
|
||||
|
||||
|
||||
# ─── VitalReading ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vital_reading_construction_and_getters() -> None:
|
||||
br = VitalEstimate(value_bpm=14.0, confidence=0.9, status=VitalStatus.Valid)
|
||||
hr = VitalEstimate(value_bpm=72.0, confidence=0.8, status=VitalStatus.Degraded)
|
||||
reading = VitalReading(
|
||||
respiratory_rate=br,
|
||||
heart_rate=hr,
|
||||
subcarrier_count=56,
|
||||
signal_quality=0.77,
|
||||
timestamp_secs=1700000000.5,
|
||||
)
|
||||
assert reading.respiratory_rate.value_bpm == 14.0
|
||||
assert reading.heart_rate.status == VitalStatus.Degraded
|
||||
assert reading.subcarrier_count == 56
|
||||
assert math.isclose(reading.signal_quality, 0.77)
|
||||
assert math.isclose(reading.timestamp_secs, 1700000000.5)
|
||||
|
||||
|
||||
# ─── BreathingExtractor ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_breathing_esp32_default_constructs() -> None:
|
||||
br = BreathingExtractor.esp32_default()
|
||||
assert br is not None
|
||||
assert "BreathingExtractor" in repr(br)
|
||||
|
||||
|
||||
def test_breathing_explicit_ctor() -> None:
|
||||
br = BreathingExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=20.0)
|
||||
assert br is not None
|
||||
|
||||
|
||||
def test_breathing_extract_returns_none_with_too_few_samples() -> None:
|
||||
"""One frame can't produce a 30-second window — must return None.
|
||||
|
||||
Verifies the binding propagates Rust's `Option<VitalEstimate>` →
|
||||
Python None correctly (vs raising or returning a default).
|
||||
"""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
out = br.extract(residuals=[0.0] * 56, weights=[])
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_breathing_extract_accepts_empty_weights() -> None:
|
||||
"""Empty weights vector means "equal weight per subcarrier" by
|
||||
convention (per breathing.rs)."""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
out = br.extract(residuals=[0.01] * 56, weights=[])
|
||||
# Even with synthetic input it may return None until enough history
|
||||
# accumulates — what matters is that the call doesn't panic.
|
||||
assert out is None or isinstance(out, VitalEstimate)
|
||||
|
||||
|
||||
def test_breathing_extract_with_synthetic_signal() -> None:
|
||||
"""Drive the extractor with a synthetic 0.25 Hz sine (15 BPM) for
|
||||
enough samples to fill the 30-second window. Don't assert the exact
|
||||
BPM — just that the extractor *eventually* produces a result (rather
|
||||
than returning None forever)."""
|
||||
br = BreathingExtractor.esp32_default()
|
||||
sample_rate = 100.0
|
||||
target_freq = 0.25 # 15 BPM
|
||||
# Run 40 seconds of synthetic data — comfortably past the 30s window.
|
||||
n_samples = int(40 * sample_rate)
|
||||
weights = [1.0] * 56
|
||||
|
||||
produced_estimate = False
|
||||
rng = Random(42)
|
||||
for i in range(n_samples):
|
||||
t = i / sample_rate
|
||||
base = math.sin(2.0 * math.pi * target_freq * t)
|
||||
# Per-subcarrier residual: same signal + small per-carrier noise
|
||||
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
|
||||
est = br.extract(residuals=residuals, weights=weights)
|
||||
if est is not None:
|
||||
produced_estimate = True
|
||||
assert isinstance(est.value_bpm, float)
|
||||
assert 0.0 <= est.confidence <= 1.0
|
||||
assert est.status in (
|
||||
VitalStatus.Valid,
|
||||
VitalStatus.Degraded,
|
||||
VitalStatus.Unreliable,
|
||||
VitalStatus.Unavailable,
|
||||
)
|
||||
break
|
||||
|
||||
assert produced_estimate, "BreathingExtractor never produced an estimate after 40s of synthetic data"
|
||||
|
||||
|
||||
# ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_heart_rate_esp32_default_constructs() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
assert hr is not None
|
||||
assert "HeartRateExtractor" in repr(hr)
|
||||
|
||||
|
||||
def test_heart_rate_explicit_ctor() -> None:
|
||||
hr = HeartRateExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=10.0)
|
||||
assert hr is not None
|
||||
|
||||
|
||||
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
|
||||
hr = HeartRateExtractor.esp32_default()
|
||||
out = hr.extract(residuals=[0.0] * 56, weights=[])
|
||||
assert out is None
|
||||
|
||||
|
||||
# ─── Build feature flag ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_p3_vitals_in_build_features() -> None:
|
||||
assert "p3-vitals-bindings" in wifi_densepose.__build_features__
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,38 @@
|
||||
# wifi-densepose 1.99.0 — tombstone release
|
||||
|
||||
This sub-directory builds the **tombstone wheel** described in
|
||||
[ADR-117 §7.2](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md).
|
||||
|
||||
`wifi-densepose==1.1.0` was published on 2025-06-07 as a pure-Python
|
||||
FastAPI + PyTorch server. v2.0+ is a hard rewrite around the Rust
|
||||
crates in [`v2/crates/`](../../v2/crates/) exposed via PyO3.
|
||||
|
||||
`wifi-densepose==1.99.0` ships **no real code** — its `__init__.py`
|
||||
raises `ImportError` with a migration URL. The point is that any
|
||||
project pinned to `wifi-densepose>=1,<2` that runs `pip install -U
|
||||
wifi-densepose` gets a clear, actionable error instead of a silent
|
||||
import of a broken legacy server.
|
||||
|
||||
## Build locally
|
||||
|
||||
```bash
|
||||
cd python/tombstone
|
||||
python -m build
|
||||
```
|
||||
|
||||
Result: `dist/wifi_densepose-1.99.0-py3-none-any.whl` and the matching sdist.
|
||||
|
||||
## Smoke-test
|
||||
|
||||
```bash
|
||||
pip install dist/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
python -c "import wifi_densepose"
|
||||
# Expected: ImportError with the migration URL.
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
Publishing is done by the `pip-release.yml` GH Actions workflow, gated
|
||||
on a `v1.99.0-pip` tag OR an explicit `workflow_dispatch` with
|
||||
`target: v1-99-tombstone`. Per ADR-117 §7.3 this should publish
|
||||
*before* `v2.0.0` to claim the "current" slot in pip's resolver.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR-117 §7.2 / §7.4 — v1.99.0 tombstone release.
|
||||
#
|
||||
# This sub-directory builds a SEPARATE PyPI artifact from the v2.0+
|
||||
# PyO3 wheel in ../. The two share the PyPI project name
|
||||
# `wifi-densepose` but represent different versions:
|
||||
#
|
||||
# 1.0.0–1.1.0 legacy pure-Python server (archive/v1/)
|
||||
# 1.99.0 THIS PACKAGE — pure-Python wheel whose only behaviour
|
||||
# is to raise ImportError with the migration URL on
|
||||
# first import. Acts as a soft-fence for users pinned
|
||||
# to wifi-densepose>=1,<2.
|
||||
# 2.0.0+ PyO3 + maturin Rust core (../pyproject.toml)
|
||||
#
|
||||
# Build:
|
||||
# cd python/tombstone
|
||||
# python -m build
|
||||
#
|
||||
# Result: a SINGLE `py3-none-any` wheel plus an sdist. Nothing
|
||||
# compiled, no platform-specific tags.
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wifi-densepose"
|
||||
version = "1.99.0"
|
||||
description = "Tombstone release. wifi-densepose v1.x is superseded by v2.0+ (PyO3 bindings to the Rust core). Install wifi-densepose==2.0.0 — see https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "rUv", email = "ruv@ruv.net" },
|
||||
]
|
||||
keywords = ["wifi", "csi", "pose-estimation", "deprecated", "migration"]
|
||||
classifiers = [
|
||||
"Development Status :: 7 - Inactive",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
# No runtime dependencies — the import raises before any code runs.
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ruvnet/RuView"
|
||||
"Migration guide" = "https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md"
|
||||
"ADR-117 (modernization plan)" = "https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["wifi_densepose"]
|
||||
package-dir = { "" = "src" }
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-117 §7.2 — v1.99.0 tombstone.
|
||||
#
|
||||
# This module is part of the `wifi-densepose==1.99.0` PyPI release.
|
||||
# Its ONLY job is to raise ImportError on import so any project that
|
||||
# upgraded from the legacy 1.x line gets a clear migration error
|
||||
# rather than a silent broken import.
|
||||
#
|
||||
# The real package lives at `wifi-densepose>=2.0.0` (built by the
|
||||
# PyO3+maturin pipeline in `python/`).
|
||||
raise ImportError(
|
||||
"wifi-densepose 1.x has been superseded by v2.0.0 which wraps the Rust-based stack.\n"
|
||||
"\n"
|
||||
" pip install wifi-densepose==2.0.0\n"
|
||||
"\n"
|
||||
"Migration guide: https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md\n"
|
||||
"Modernization rationale: https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md\n"
|
||||
"Legacy v1 source (archived): https://github.com/ruvnet/RuView/tree/main/archive/v1\n"
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""ADR-117 §7.2 — Unit test for the v1.99.0 tombstone wheel.
|
||||
|
||||
Verifies the *file content* of the tombstone module without actually
|
||||
importing it (importing it would raise ImportError, which is the
|
||||
behaviour under test). The CI workflow `pip-release.yml` runs the
|
||||
real end-to-end install + import test inside an ephemeral venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
|
||||
TOMBSTONE = pathlib.Path(__file__).parent.parent / "src" / "wifi_densepose" / "__init__.py"
|
||||
|
||||
|
||||
def test_tombstone_file_exists() -> None:
|
||||
assert TOMBSTONE.is_file(), f"tombstone module missing: {TOMBSTONE}"
|
||||
|
||||
|
||||
def test_tombstone_raises_import_error() -> None:
|
||||
"""The source must call `raise ImportError(...)`. We grep rather
|
||||
than exec because actually running it would terminate the test."""
|
||||
src = TOMBSTONE.read_text(encoding="utf-8")
|
||||
assert "raise ImportError(" in src, "tombstone does not raise ImportError"
|
||||
|
||||
|
||||
def test_tombstone_contains_v2_install_hint() -> None:
|
||||
src = TOMBSTONE.read_text(encoding="utf-8")
|
||||
assert "pip install wifi-densepose==2.0.0" in src, (
|
||||
"tombstone ImportError message must include the v2 pip install hint"
|
||||
)
|
||||
|
||||
|
||||
def test_tombstone_contains_migration_url() -> None:
|
||||
src = TOMBSTONE.read_text(encoding="utf-8")
|
||||
assert "docs/pip-migration.md" in src, (
|
||||
"tombstone must point users at the migration guide"
|
||||
)
|
||||
|
||||
|
||||
def test_tombstone_is_minimal() -> None:
|
||||
"""The whole point of the tombstone is that it's MINIMAL — no
|
||||
imports, no helper functions, no class definitions. Lock that
|
||||
down so a well-intentioned refactor doesn't accidentally bloat it
|
||||
into a real module that loads partway before failing."""
|
||||
src = TOMBSTONE.read_text(encoding="utf-8")
|
||||
forbidden = ("def ", "class ", "import wifi_densepose", "import os", "import sys")
|
||||
for f in forbidden:
|
||||
assert f not in src, f"tombstone must not contain {f!r} — it should ONLY raise"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""WiFi-DensePose — passive human sensing from WiFi CSI.
|
||||
|
||||
ADR-117 — v2.0 is a PyO3-bound replacement for the legacy pure-Python
|
||||
``wifi-densepose==1.1.0`` (released 2025-06-07). The compiled core is
|
||||
the same Rust workspace published in `v2/crates/` of the
|
||||
`ruvnet/RuView <https://github.com/ruvnet/RuView>`_ repository.
|
||||
|
||||
Quick start::
|
||||
|
||||
import wifi_densepose
|
||||
print(wifi_densepose.__version__)
|
||||
print(wifi_densepose.__rust_version__)
|
||||
print(wifi_densepose.hello()) # → "ok"
|
||||
|
||||
P1 (this release): scaffold. Core types land in P2; vital signs +
|
||||
signal DSP in P3; WebSocket/MQTT client in P4. See the
|
||||
`ADR-117 modernization plan
|
||||
<https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md>`_
|
||||
for the full phase ledger.
|
||||
|
||||
Migrating from v1.x: the v1 line was pure-Python and had a different
|
||||
API surface. v2 is a hard break (semver-justified). See the
|
||||
``v1.99.0`` tombstone wheel for the migration URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Public Python version follows the wheel version, NOT the Rust core
|
||||
# version. The Rust core version is surfaced separately as
|
||||
# `__rust_version__` for diagnostics.
|
||||
__version__ = "2.0.0a1"
|
||||
|
||||
# Re-export the compiled module's surface. The leading underscore on
|
||||
# `_native` is intentional — it marks the binding module as internal.
|
||||
# Users always import from `wifi_densepose` directly.
|
||||
from wifi_densepose import _native
|
||||
|
||||
# ─── P2 — Core type re-exports ───────────────────────────────────────
|
||||
# Bound types land in `wifi_densepose._native` and are re-exported here
|
||||
# under their stable public names. Users always `from wifi_densepose
|
||||
# import Keypoint, KeypointType` — never reach into `_native`.
|
||||
Keypoint = _native.Keypoint
|
||||
KeypointType = _native.KeypointType
|
||||
BoundingBox = _native.BoundingBox
|
||||
PersonPose = _native.PersonPose
|
||||
PoseEstimate = _native.PoseEstimate
|
||||
|
||||
# ─── P3 — Vital sign extraction ──────────────────────────────────────
|
||||
VitalStatus = _native.VitalStatus
|
||||
VitalEstimate = _native.VitalEstimate
|
||||
VitalReading = _native.VitalReading
|
||||
BreathingExtractor = _native.BreathingExtractor
|
||||
HeartRateExtractor = _native.HeartRateExtractor
|
||||
|
||||
# ─── P3.5 — BFLD (Beamforming Feedback Loop Data) ─────────────────────
|
||||
BfldKind = _native.BfldKind
|
||||
BfldFrame = _native.BfldFrame
|
||||
BfldReport = _native.BfldReport
|
||||
|
||||
|
||||
__rust_version__: str = _native.__rust_version__
|
||||
"""Version of the bound Rust core. Useful for bug reports."""
|
||||
|
||||
__rust_build_tag__: str = _native.__rust_build_tag__
|
||||
"""Build tag of the Rust core (P5 will swap this for the git SHA)."""
|
||||
|
||||
__build_features__: list[str] = list(_native.__build_features__)
|
||||
"""Feature flags the wheel was compiled with."""
|
||||
|
||||
|
||||
def hello() -> str:
|
||||
"""Smoke test — confirms the compiled module loads and is callable.
|
||||
|
||||
Returns:
|
||||
Always ``"ok"`` if the wheel built and loaded correctly.
|
||||
|
||||
Used by ``python/tests/test_smoke.py`` to assert the PyO3 round-trip
|
||||
works end-to-end on every cibuildwheel target.
|
||||
"""
|
||||
return _native.hello()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"__rust_version__",
|
||||
"__rust_build_tag__",
|
||||
"__build_features__",
|
||||
"hello",
|
||||
# P2 — core types
|
||||
"Keypoint",
|
||||
"KeypointType",
|
||||
"BoundingBox",
|
||||
"PersonPose",
|
||||
"PoseEstimate",
|
||||
# P3 — vital sign extraction
|
||||
"VitalStatus",
|
||||
"VitalEstimate",
|
||||
"VitalReading",
|
||||
"BreathingExtractor",
|
||||
"HeartRateExtractor",
|
||||
# P3.5 — BFLD (forward-compat surface for the future Rust crate)
|
||||
"BfldKind",
|
||||
"BfldFrame",
|
||||
"BfldReport",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""ADR-117 P4 — Pure-Python client layer.
|
||||
|
||||
This sub-package is the **client-facing** half of `wifi-densepose`:
|
||||
end users who only want to *consume* live RuView telemetry (rather than
|
||||
running DSP locally) get a tight, opt-in client extra:
|
||||
|
||||
```
|
||||
pip install "wifi-densepose[client]"
|
||||
```
|
||||
|
||||
The runtime install footprint stays small for users who only need the
|
||||
compiled PyO3 surface: `websockets` and `paho-mqtt` are declared as the
|
||||
`[client]` extra in `pyproject.toml` and are NOT pulled in by the
|
||||
default install.
|
||||
|
||||
## Modules
|
||||
|
||||
- `ws` — `SensingClient`: asyncio WebSocket client for the
|
||||
sensing-server `/ws/sensing` endpoint (ADR-115 §1)
|
||||
- `mqtt` — `RuViewMqttClient`: paho-mqtt v2 wrapper for
|
||||
`ruview/<node>/raw/+` + `homeassistant/+/wifi_densepose_<node>/+/+`
|
||||
topics (ADR-115 §3)
|
||||
- `primitives` — `SemanticPrimitiveListener`: typed view over the
|
||||
10 HA-MIND semantic primitives (ADR-115 §3.12)
|
||||
- `ha` — `HABlueprintHelper`: parses MQTT-discovery payloads, helps
|
||||
users introspect what entities a node is publishing
|
||||
|
||||
No PyO3 here — this module is pure Python so it loads without the
|
||||
compiled extension (useful for users who only want the client surface
|
||||
and not the DSP pipeline).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export the user-facing types. Import errors are deferred to the
|
||||
# moment the user actually instantiates one of these classes — that way
|
||||
# `from wifi_densepose.client import HABlueprintHelper` still works
|
||||
# even if the user hasn't installed `[client]` extras yet (HABlueprint
|
||||
# is pure stdlib).
|
||||
from wifi_densepose.client.ha import (
|
||||
HaDiscoveryPayload,
|
||||
HaEntity,
|
||||
HABlueprintHelper,
|
||||
)
|
||||
from wifi_densepose.client.primitives import (
|
||||
SemanticPrimitive,
|
||||
SemanticPrimitiveEvent,
|
||||
SemanticPrimitiveListener,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# ws — re-exported lazily; see module docstring
|
||||
"SensingClient",
|
||||
"SensingMessage",
|
||||
"EdgeVitalsMessage",
|
||||
"PoseDataMessage",
|
||||
"ConnectionEstablishedMessage",
|
||||
# mqtt — re-exported lazily; see module docstring
|
||||
"RuViewMqttClient",
|
||||
# ha — pure stdlib
|
||||
"HaDiscoveryPayload",
|
||||
"HaEntity",
|
||||
"HABlueprintHelper",
|
||||
# primitives — pure stdlib
|
||||
"SemanticPrimitive",
|
||||
"SemanticPrimitiveEvent",
|
||||
"SemanticPrimitiveListener",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy re-exports for the modules that pull in optional extras.
|
||||
|
||||
`SensingClient` needs `websockets`; `RuViewMqttClient` needs
|
||||
`paho-mqtt`. Importing those at package init would make
|
||||
`wifi_densepose.client` unusable without the extras installed
|
||||
— defeating the point of an *optional* extra. We defer the import
|
||||
until the attribute is actually looked up.
|
||||
"""
|
||||
if name in {
|
||||
"SensingClient",
|
||||
"SensingMessage",
|
||||
"EdgeVitalsMessage",
|
||||
"PoseDataMessage",
|
||||
"ConnectionEstablishedMessage",
|
||||
}:
|
||||
from wifi_densepose.client import ws as _ws
|
||||
return getattr(_ws, name)
|
||||
if name == "RuViewMqttClient":
|
||||
from wifi_densepose.client.mqtt import RuViewMqttClient as _R
|
||||
return _R
|
||||
raise AttributeError(f"module 'wifi_densepose.client' has no attribute {name!r}")
|
||||
@@ -0,0 +1,194 @@
|
||||
"""ADR-117 P4 — Home Assistant MQTT-discovery payload helpers.
|
||||
|
||||
Parses the `homeassistant/<entity_kind>/wifi_densepose_<node>/<id>/config`
|
||||
discovery payloads described in ADR-115 §3 into typed Python objects so
|
||||
client code can introspect what a node is publishing without
|
||||
hand-parsing JSON.
|
||||
|
||||
This is **read-only**: we do NOT generate discovery payloads from
|
||||
Python (that's the sensing-server's job). The helper exists so a
|
||||
client (HA blueprint author, debugger, dashboard) can ask "what
|
||||
entities does this node expose?" and get a structured answer.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from wifi_densepose.client import HaDiscoveryPayload, HABlueprintHelper
|
||||
|
||||
helper = HABlueprintHelper()
|
||||
helper.add_payload(topic, json_bytes)
|
||||
for entity in helper.entities_for_node("aabbccddeeff"):
|
||||
print(entity.entity_kind, entity.object_id, entity.unique_id)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
# ─── Topic schema ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Matches discovery topics like:
|
||||
# homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/config
|
||||
# homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/config
|
||||
# homeassistant/event/wifi_densepose_aabbccddeeff/fall/config
|
||||
_DISCOVERY_TOPIC_RE = re.compile(
|
||||
r"^homeassistant/"
|
||||
r"(?P<entity_kind>[A-Za-z_]+)/"
|
||||
r"wifi_densepose_(?P<node_id>[A-Za-z0-9]+)/"
|
||||
r"(?P<object_id>[A-Za-z0-9_\-]+)/"
|
||||
r"config$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaDiscoveryPayload:
|
||||
"""One MQTT discovery payload (config topic + JSON body)."""
|
||||
entity_kind: str # "binary_sensor", "sensor", "event", "switch", ...
|
||||
node_id: str # the node's MAC-ish identifier
|
||||
object_id: str # entity slug (e.g. "presence", "heart_rate")
|
||||
payload: dict[str, Any]
|
||||
|
||||
@property
|
||||
def topic(self) -> str:
|
||||
return (
|
||||
f"homeassistant/{self.entity_kind}/"
|
||||
f"wifi_densepose_{self.node_id}/{self.object_id}/config"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaEntity:
|
||||
"""A user-facing view of one HA entity registered by a node."""
|
||||
entity_kind: str
|
||||
node_id: str
|
||||
object_id: str
|
||||
unique_id: str = ""
|
||||
name: str = ""
|
||||
state_topic: str = ""
|
||||
device_class: str = ""
|
||||
unit_of_measurement: str = ""
|
||||
icon: str = ""
|
||||
json_attributes_topic: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, p: HaDiscoveryPayload) -> "HaEntity":
|
||||
body = p.payload
|
||||
return cls(
|
||||
entity_kind=p.entity_kind,
|
||||
node_id=p.node_id,
|
||||
object_id=p.object_id,
|
||||
unique_id=str(body.get("unique_id", "")),
|
||||
name=str(body.get("name", "")),
|
||||
state_topic=str(body.get("state_topic", "")),
|
||||
device_class=str(body.get("device_class", "")),
|
||||
unit_of_measurement=str(body.get("unit_of_measurement", "")),
|
||||
icon=str(body.get("icon", "")),
|
||||
json_attributes_topic=str(body.get("json_attributes_topic", "")),
|
||||
)
|
||||
|
||||
|
||||
def parse_discovery_topic(topic: str) -> tuple[str, str, str] | None:
|
||||
"""Parse a discovery config topic into (entity_kind, node_id,
|
||||
object_id). Returns None for non-discovery topics."""
|
||||
m = _DISCOVERY_TOPIC_RE.match(topic)
|
||||
if not m:
|
||||
return None
|
||||
return (m.group("entity_kind"), m.group("node_id"), m.group("object_id"))
|
||||
|
||||
|
||||
def parse_discovery_payload(
|
||||
topic: str, payload: bytes | str | dict[str, Any]
|
||||
) -> HaDiscoveryPayload | None:
|
||||
"""Decode an HA discovery payload. Returns None for non-discovery
|
||||
topics OR malformed JSON; raises only on programmer error."""
|
||||
parsed = parse_discovery_topic(topic)
|
||||
if parsed is None:
|
||||
return None
|
||||
entity_kind, node_id, object_id = parsed
|
||||
body: dict[str, Any]
|
||||
if isinstance(payload, dict):
|
||||
body = payload
|
||||
else:
|
||||
if isinstance(payload, bytes):
|
||||
try:
|
||||
payload = payload.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
body = decoded
|
||||
return HaDiscoveryPayload(
|
||||
entity_kind=entity_kind,
|
||||
node_id=node_id,
|
||||
object_id=object_id,
|
||||
payload=body,
|
||||
)
|
||||
|
||||
|
||||
# ─── Helper / aggregator ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class HABlueprintHelper:
|
||||
"""Aggregates HA discovery payloads observed on the bus and offers
|
||||
structured queries against them.
|
||||
|
||||
Intended use: subscribe a RuViewMqttClient to
|
||||
`homeassistant/+/wifi_densepose_+/+/config`, feed every message
|
||||
into `add_payload()`, then ask the helper "what entities does
|
||||
node X expose?" or "what binary_sensors are presence-class?".
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (node_id, entity_kind, object_id) → HaDiscoveryPayload
|
||||
self._payloads: dict[tuple[str, str, str], HaDiscoveryPayload] = {}
|
||||
|
||||
def add_payload(self, topic: str, payload: bytes | str | dict[str, Any]) -> bool:
|
||||
"""Returns True if the payload was a valid HA discovery
|
||||
message and was stored; False otherwise."""
|
||||
parsed = parse_discovery_payload(topic, payload)
|
||||
if parsed is None:
|
||||
return False
|
||||
self._payloads[(parsed.node_id, parsed.entity_kind, parsed.object_id)] = parsed
|
||||
return True
|
||||
|
||||
def remove(self, node_id: str, entity_kind: str, object_id: str) -> bool:
|
||||
"""Drop a stored payload — useful when handling a discovery
|
||||
retain-flag clear (HA's convention for removing an entity)."""
|
||||
return self._payloads.pop((node_id, entity_kind, object_id), None) is not None
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._payloads)
|
||||
|
||||
def __contains__(self, item: tuple[str, str, str]) -> bool:
|
||||
return item in self._payloads
|
||||
|
||||
def all_payloads(self) -> list[HaDiscoveryPayload]:
|
||||
return list(self._payloads.values())
|
||||
|
||||
def entities_for_node(self, node_id: str) -> list[HaEntity]:
|
||||
return [
|
||||
HaEntity.from_payload(p)
|
||||
for p in self._payloads.values()
|
||||
if p.node_id == node_id
|
||||
]
|
||||
|
||||
def nodes(self) -> list[str]:
|
||||
return sorted({p.node_id for p in self._payloads.values()})
|
||||
|
||||
def by_device_class(self, device_class: str) -> list[HaEntity]:
|
||||
out: list[HaEntity] = []
|
||||
for p in self._payloads.values():
|
||||
e = HaEntity.from_payload(p)
|
||||
if e.device_class == device_class:
|
||||
out.append(e)
|
||||
return out
|
||||
@@ -0,0 +1,257 @@
|
||||
"""ADR-117 P4 — paho-mqtt v2 wrapper for RuView MQTT topics.
|
||||
|
||||
Subscribes to the topic namespaces defined in ADR-115:
|
||||
|
||||
- `ruview/<node>/raw/edge_vitals` — opt-in firehose of the WS edge_vitals
|
||||
- `ruview/<node>/raw/pose` — opt-in firehose of pose data
|
||||
- `ruview/<node>/raw/sensing_update` — opt-in firehose of every sensing update
|
||||
- `homeassistant/+/wifi_densepose_<node>/+/config` — HA discovery payloads
|
||||
- `homeassistant/+/wifi_densepose_<node>/+/state` — HA state payloads
|
||||
|
||||
The client uses **paho-mqtt v2's `Client(CallbackAPIVersion.VERSION2)`**
|
||||
API explicitly. v1's deprecated callback signatures will not work.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from wifi_densepose.client import RuViewMqttClient
|
||||
|
||||
def on_edge_vitals(topic, payload):
|
||||
print(topic, payload["breathing_rate_bpm"])
|
||||
|
||||
client = RuViewMqttClient(broker_host="localhost", broker_port=1883)
|
||||
client.on_message("ruview/+/raw/edge_vitals", on_edge_vitals)
|
||||
client.start()
|
||||
# ... runs in a background thread; call client.stop() to disconnect
|
||||
```
|
||||
|
||||
The constructor never connects; call `.start()` to enter the network
|
||||
loop and `.stop()` to disconnect cleanly. Both are idempotent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt # type: ignore[import-not-found]
|
||||
from paho.mqtt.enums import CallbackAPIVersion # type: ignore[import-not-found]
|
||||
_PAHO_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
_PAHO_AVAILABLE = False
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MessageHandler = Callable[[str, Any], None]
|
||||
"""(topic, decoded_payload) → None. The payload is JSON-decoded if the
|
||||
content is valid JSON, otherwise the raw bytes are passed through."""
|
||||
|
||||
|
||||
class RuViewMqttClient:
|
||||
"""Wrapper around paho-mqtt v2 with per-topic-pattern callbacks.
|
||||
|
||||
Per the rumqttc lesson [[feedback_mqtt_integration_test_patterns]]:
|
||||
- Each instance gets a unique client_id (per-test isolation when
|
||||
tests run in parallel against the same broker).
|
||||
- Subscription wildcards (`+`, `#`) are supported by paho's
|
||||
built-in matcher; we route by exact pattern match against the
|
||||
registered handler.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
broker_host: str = "localhost",
|
||||
broker_port: int = 1883,
|
||||
client_id: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
keepalive: int = 60,
|
||||
tls: bool = False,
|
||||
) -> None:
|
||||
if not _PAHO_AVAILABLE:
|
||||
raise ImportError(
|
||||
"RuViewMqttClient requires the `paho-mqtt` package. Install with "
|
||||
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
|
||||
)
|
||||
self.broker_host = broker_host
|
||||
self.broker_port = broker_port
|
||||
self.keepalive = keepalive
|
||||
self._client_id = client_id or f"wifi-densepose-client-{uuid.uuid4().hex[:12]}"
|
||||
self._handlers: dict[str, MessageHandler] = {}
|
||||
self._handlers_lock = threading.Lock()
|
||||
self._client = mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=self._client_id,
|
||||
clean_session=True,
|
||||
)
|
||||
if username is not None:
|
||||
self._client.username_pw_set(username, password)
|
||||
if tls:
|
||||
self._client.tls_set()
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_message = self._on_message
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._started = False
|
||||
self._connected_event = threading.Event()
|
||||
|
||||
@property
|
||||
def client_id(self) -> str:
|
||||
return self._client_id
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected_event.is_set()
|
||||
|
||||
# ── handler registration ─────────────────────────────────────────
|
||||
|
||||
def on_message(self, topic_pattern: str, handler: MessageHandler) -> None:
|
||||
"""Register a handler for a topic pattern. Replaces any
|
||||
previous handler for the same pattern."""
|
||||
with self._handlers_lock:
|
||||
self._handlers[topic_pattern] = handler
|
||||
|
||||
def unsubscribe_handler(self, topic_pattern: str) -> None:
|
||||
with self._handlers_lock:
|
||||
self._handlers.pop(topic_pattern, None)
|
||||
if self._started:
|
||||
self._client.unsubscribe(topic_pattern)
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
"""Connect to the broker and enter the network loop in a
|
||||
background thread. Idempotent."""
|
||||
if self._started:
|
||||
return
|
||||
self._client.connect(self.broker_host, self.broker_port, self.keepalive)
|
||||
self._client.loop_start()
|
||||
self._started = True
|
||||
|
||||
def wait_connected(self, timeout: float = 5.0) -> bool:
|
||||
"""Block until CONNACK has been received. Returns True on
|
||||
connect, False on timeout. Mirrors the rumqttc SubAck pump
|
||||
pattern but for paho's connect step."""
|
||||
return self._connected_event.wait(timeout=timeout)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Disconnect and stop the network loop. Idempotent."""
|
||||
if not self._started:
|
||||
return
|
||||
try:
|
||||
self._client.disconnect()
|
||||
except Exception as e: # pragma: no cover — best-effort
|
||||
log.debug("ignored mqtt disconnect error: %r", e)
|
||||
try:
|
||||
self._client.loop_stop()
|
||||
except Exception as e: # pragma: no cover
|
||||
log.debug("ignored mqtt loop_stop error: %r", e)
|
||||
self._started = False
|
||||
self._connected_event.clear()
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: Any,
|
||||
*,
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
) -> None:
|
||||
"""Publish a payload. Dicts/lists are JSON-encoded; bytes pass
|
||||
through; strings are encoded UTF-8."""
|
||||
if isinstance(payload, (dict, list)):
|
||||
data: Any = json.dumps(payload, default=str)
|
||||
else:
|
||||
data = payload
|
||||
info = self._client.publish(topic, data, qos=qos, retain=retain)
|
||||
# paho v2 returns MQTTMessageInfo; rc != MQTT_ERR_SUCCESS is a
|
||||
# broker-side error we should propagate so callers don't think
|
||||
# the publish succeeded.
|
||||
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||
raise RuntimeError(f"mqtt publish failed: topic={topic} rc={info.rc}")
|
||||
|
||||
# ── paho callbacks (v2 signatures) ───────────────────────────────
|
||||
|
||||
def _on_connect(self, client: Any, _userdata: Any, _flags: Any, reason_code: Any, _properties: Any = None) -> None:
|
||||
# paho v2 passes ReasonCode; success is 0 ("Success" / Granted_QoS_0)
|
||||
rc = int(reason_code) if hasattr(reason_code, "__int__") else reason_code
|
||||
if rc == 0:
|
||||
self._connected_event.set()
|
||||
# Re-subscribe to all known patterns. Important after a
|
||||
# reconnect — paho doesn't auto-resubscribe with
|
||||
# clean_session=True.
|
||||
with self._handlers_lock:
|
||||
patterns = list(self._handlers.keys())
|
||||
for pattern in patterns:
|
||||
client.subscribe(pattern)
|
||||
log.debug("mqtt CONNACK ok; subscribed to %d pattern(s)", len(patterns))
|
||||
else:
|
||||
log.warning("mqtt CONNACK with non-success rc=%r", reason_code)
|
||||
|
||||
def _on_disconnect(self, _client: Any, _userdata: Any, _flags: Any = None, reason_code: Any = None, _properties: Any = None) -> None:
|
||||
self._connected_event.clear()
|
||||
log.debug("mqtt disconnected rc=%r", reason_code)
|
||||
|
||||
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
|
||||
topic = message.topic
|
||||
# Best-effort JSON decode — fall back to raw bytes if it's not JSON.
|
||||
payload: Any
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
payload = message.payload
|
||||
|
||||
with self._handlers_lock:
|
||||
handlers = list(self._handlers.items())
|
||||
|
||||
for pattern, handler in handlers:
|
||||
if _topic_matches(pattern, topic):
|
||||
try:
|
||||
handler(topic, payload)
|
||||
except Exception as e: # never let a user callback crash the loop
|
||||
log.exception("handler for pattern %r raised: %r", pattern, e)
|
||||
|
||||
# ── re-subscribe on demand ──────────────────────────────────────
|
||||
|
||||
def subscribe_registered(self) -> None:
|
||||
"""Explicitly issue SUBSCRIBE for every registered handler.
|
||||
Useful when you registered handlers AFTER calling start().
|
||||
"""
|
||||
if not self._started:
|
||||
return
|
||||
with self._handlers_lock:
|
||||
patterns = list(self._handlers.keys())
|
||||
for pattern in patterns:
|
||||
self._client.subscribe(pattern)
|
||||
|
||||
|
||||
# ─── Topic-pattern matching ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _topic_matches(pattern: str, topic: str) -> bool:
|
||||
"""MQTT topic wildcard matcher.
|
||||
|
||||
- `+` matches exactly one topic level
|
||||
- `#` matches one or more remaining levels (must be the final segment)
|
||||
"""
|
||||
p_parts = pattern.split("/")
|
||||
t_parts = topic.split("/")
|
||||
i = 0
|
||||
while i < len(p_parts):
|
||||
if p_parts[i] == "#":
|
||||
return i == len(p_parts) - 1 and len(t_parts) >= i
|
||||
if i >= len(t_parts):
|
||||
return False
|
||||
if p_parts[i] == "+":
|
||||
i += 1
|
||||
continue
|
||||
if p_parts[i] != t_parts[i]:
|
||||
return False
|
||||
i += 1
|
||||
return len(p_parts) == len(t_parts)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""ADR-117 P4 — Typed listener for HA-MIND semantic primitives.
|
||||
|
||||
ADR-115 §3.12 defines 10 fused inference outputs that the sensing-server
|
||||
publishes under the HA-DISCO MQTT namespace. This module gives clients
|
||||
a typed handle on them so they can write `if event.kind ==
|
||||
SemanticPrimitive.SomeoneSleeping: ...` instead of pattern-matching
|
||||
strings.
|
||||
|
||||
The 10 v1 primitives (ADR-115 §3.12.1):
|
||||
|
||||
| Enum value | Topic suffix | Output kind |
|
||||
|---|---|---|
|
||||
| `SomeoneSleeping` | `someone_sleeping` | binary_sensor |
|
||||
| `PossibleDistress` | `possible_distress` | binary_sensor + event |
|
||||
| `RoomActive` | `room_active` | binary_sensor |
|
||||
| `ElderlyInactivityAnomaly` | `elderly_inactivity` | binary_sensor + event |
|
||||
| `MeetingInProgress` | `meeting_in_progress` | binary_sensor |
|
||||
| `BathroomOccupied` | `bathroom_occupied` | binary_sensor |
|
||||
| `FallRiskElevated` | `fall_risk_elevated` | sensor (0–100) + event |
|
||||
| `BedExit` | `bed_exit` | event |
|
||||
| `NoMovementSafety` | `no_movement_safety` | binary_sensor + event |
|
||||
| `MultiRoomTransition` | `multi_room_transition` | event |
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
# ─── Enum ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SemanticPrimitive(enum.Enum):
|
||||
"""One of the 10 HA-MIND fused inference outputs."""
|
||||
SomeoneSleeping = "someone_sleeping"
|
||||
PossibleDistress = "possible_distress"
|
||||
RoomActive = "room_active"
|
||||
ElderlyInactivityAnomaly = "elderly_inactivity"
|
||||
MeetingInProgress = "meeting_in_progress"
|
||||
BathroomOccupied = "bathroom_occupied"
|
||||
FallRiskElevated = "fall_risk_elevated"
|
||||
BedExit = "bed_exit"
|
||||
NoMovementSafety = "no_movement_safety"
|
||||
MultiRoomTransition = "multi_room_transition"
|
||||
|
||||
@classmethod
|
||||
def from_object_id(cls, object_id: str) -> Optional["SemanticPrimitive"]:
|
||||
for v in cls:
|
||||
if v.value == object_id:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
# ─── Event payload ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticPrimitiveEvent:
|
||||
"""A single fired event for one semantic primitive.
|
||||
|
||||
`state` semantics depend on the primitive kind:
|
||||
- binary_sensor: "ON" / "OFF"
|
||||
- sensor: numeric string (e.g. "73" for fall_risk_elevated 0–100)
|
||||
- event: "fired" or an event-class string like "bed_exit_detected"
|
||||
"""
|
||||
kind: SemanticPrimitive
|
||||
node_id: str
|
||||
state: str
|
||||
confidence: float = 0.0
|
||||
explanation: tuple[str, ...] = ()
|
||||
timestamp: float = 0.0
|
||||
raw: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)
|
||||
|
||||
|
||||
# ─── Listener ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Callback = Callable[[SemanticPrimitiveEvent], None]
|
||||
|
||||
|
||||
class SemanticPrimitiveListener:
|
||||
"""Routes raw MQTT state messages to per-primitive callbacks.
|
||||
|
||||
Designed to plug into RuViewMqttClient:
|
||||
|
||||
```python
|
||||
from wifi_densepose.client import (
|
||||
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener
|
||||
)
|
||||
|
||||
listener = SemanticPrimitiveListener()
|
||||
listener.on(SemanticPrimitive.SomeoneSleeping, lambda e: print(e))
|
||||
|
||||
client = RuViewMqttClient()
|
||||
client.on_message(
|
||||
"homeassistant/+/wifi_densepose_+/+/state",
|
||||
listener.handle_mqtt_message,
|
||||
)
|
||||
client.start()
|
||||
```
|
||||
|
||||
The listener itself never touches MQTT — it's a pure router. You
|
||||
feed it `(topic, payload)` pairs and it figures out which primitive
|
||||
the topic refers to and decodes the payload.
|
||||
"""
|
||||
|
||||
# Matches state topics for any of the 10 primitives.
|
||||
# homeassistant/<kind>/wifi_densepose_<node>/<primitive_slug>/state
|
||||
_SLUGS = {p.value for p in SemanticPrimitive}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[Optional[SemanticPrimitive], list[Callback]] = {}
|
||||
|
||||
def on(self, primitive: SemanticPrimitive, cb: Callback) -> None:
|
||||
"""Register a callback for a specific primitive."""
|
||||
self._handlers.setdefault(primitive, []).append(cb)
|
||||
|
||||
def on_any(self, cb: Callback) -> None:
|
||||
"""Register a callback that fires for ALL primitives. Useful
|
||||
for logging or dashboards."""
|
||||
self._handlers.setdefault(None, []).append(cb)
|
||||
|
||||
def handle_mqtt_message(self, topic: str, payload: Any) -> Optional[SemanticPrimitiveEvent]:
|
||||
"""Decode one MQTT message into a SemanticPrimitiveEvent and
|
||||
fire the matching callbacks. Returns the event (or None if the
|
||||
topic was not a semantic-primitive state topic)."""
|
||||
parts = topic.split("/")
|
||||
# Shape: homeassistant / <kind> / wifi_densepose_<node> / <slug> / state
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
if parts[0] != "homeassistant" or parts[4] != "state":
|
||||
return None
|
||||
node_prefix = parts[2]
|
||||
if not node_prefix.startswith("wifi_densepose_"):
|
||||
return None
|
||||
slug = parts[3]
|
||||
if slug not in self._SLUGS:
|
||||
return None
|
||||
|
||||
primitive = SemanticPrimitive.from_object_id(slug)
|
||||
if primitive is None: # pragma: no cover — guarded above
|
||||
return None
|
||||
|
||||
node_id = node_prefix[len("wifi_densepose_"):]
|
||||
event = _decode_event(primitive, node_id, payload)
|
||||
|
||||
# Dispatch — primitive-specific first, then "any" handlers.
|
||||
for cb in self._handlers.get(primitive, ()):
|
||||
cb(event)
|
||||
for cb in self._handlers.get(None, ()):
|
||||
cb(event)
|
||||
return event
|
||||
|
||||
|
||||
def _decode_event(
|
||||
primitive: SemanticPrimitive,
|
||||
node_id: str,
|
||||
payload: Any,
|
||||
) -> SemanticPrimitiveEvent:
|
||||
"""Decode a raw state payload into a typed event.
|
||||
|
||||
HA state payloads come in two shapes:
|
||||
1. Plain string ("ON", "OFF", "73") — used by binary_sensor/sensor
|
||||
with no json_attributes_topic.
|
||||
2. JSON object with `state` + `confidence` + `explanation` fields —
|
||||
used by HA-MIND semantic primitives per ADR-115 §3.12.4.
|
||||
|
||||
Both are supported transparently.
|
||||
"""
|
||||
if isinstance(payload, bytes):
|
||||
try:
|
||||
payload = payload.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return SemanticPrimitiveEvent(
|
||||
kind=primitive, node_id=node_id, state="", raw={}
|
||||
)
|
||||
|
||||
if isinstance(payload, dict):
|
||||
body = payload
|
||||
elif isinstance(payload, str):
|
||||
# Try to JSON-decode; if it's not JSON, treat as a plain state string.
|
||||
try:
|
||||
decoded = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return SemanticPrimitiveEvent(
|
||||
kind=primitive,
|
||||
node_id=node_id,
|
||||
state=payload,
|
||||
raw={"state": payload},
|
||||
)
|
||||
if isinstance(decoded, dict):
|
||||
body = decoded
|
||||
else:
|
||||
return SemanticPrimitiveEvent(
|
||||
kind=primitive,
|
||||
node_id=node_id,
|
||||
state=str(decoded),
|
||||
raw={"state": decoded},
|
||||
)
|
||||
else:
|
||||
return SemanticPrimitiveEvent(
|
||||
kind=primitive, node_id=node_id, state=str(payload), raw={}
|
||||
)
|
||||
|
||||
expl = body.get("explanation") or body.get("reason") or ()
|
||||
if isinstance(expl, str):
|
||||
expl_tuple: tuple[str, ...] = (expl,)
|
||||
else:
|
||||
expl_tuple = tuple(str(x) for x in expl)
|
||||
|
||||
return SemanticPrimitiveEvent(
|
||||
kind=primitive,
|
||||
node_id=node_id,
|
||||
state=str(body.get("state", "")),
|
||||
confidence=float(body.get("confidence", 0.0)),
|
||||
explanation=expl_tuple,
|
||||
timestamp=float(body.get("timestamp", 0.0)),
|
||||
raw=body,
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""ADR-117 P4 — Asyncio WebSocket client for the sensing-server.
|
||||
|
||||
The Rust sensing-server (`v2/crates/wifi-densepose-sensing-server`)
|
||||
broadcasts three structured message types over `ws://<host>:<port>/ws/sensing`:
|
||||
|
||||
| `type` field | Source line in main.rs | Payload shape |
|
||||
|---|---|---|
|
||||
| `connection_established` | 2596 | `{node_id, version, capabilities}` |
|
||||
| `pose_data` | 2655 | `{node_id, timestamp, persons: [...], confidence}` |
|
||||
| `edge_vitals` | 4548 | `{node_id, presence, fall_detected, motion, breathing_rate_bpm, heartrate_bpm, ...}` |
|
||||
|
||||
`SensingClient` is a pure-Python asyncio wrapper around `websockets>=12`
|
||||
that connects, decodes JSON, and yields typed dataclasses.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from wifi_densepose.client import SensingClient, EdgeVitalsMessage
|
||||
|
||||
async def main():
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as client:
|
||||
async for msg in client.stream():
|
||||
if isinstance(msg, EdgeVitalsMessage):
|
||||
print(f"BR={msg.breathing_rate_bpm}, HR={msg.heartrate_bpm}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
# Defer import — only fail at construction time, not at module load.
|
||||
try:
|
||||
import websockets # type: ignore[import-not-found]
|
||||
from websockets.exceptions import ConnectionClosed # type: ignore[import-not-found]
|
||||
_WEBSOCKETS_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
_WEBSOCKETS_AVAILABLE = False
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Typed messages ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensingMessage:
|
||||
"""Base class for typed sensing-server messages. The original JSON
|
||||
payload is preserved in ``raw`` for forward-compatibility with
|
||||
fields not yet modelled here."""
|
||||
type: str
|
||||
raw: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionEstablishedMessage(SensingMessage):
|
||||
"""First message after a successful WS handshake. Lets the client
|
||||
discover the node ID and capability flags without making a separate
|
||||
REST call."""
|
||||
node_id: str = ""
|
||||
version: str = ""
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EdgeVitalsMessage(SensingMessage):
|
||||
"""Vital-sign telemetry fused from the edge-vitals path
|
||||
(ADR-021/ADR-110). Optional fields may be ``None`` when the
|
||||
upstream channel hasn't produced a measurement yet."""
|
||||
node_id: str = ""
|
||||
presence: bool = False
|
||||
fall_detected: bool = False
|
||||
motion: float = 0.0
|
||||
breathing_rate_bpm: Optional[float] = None
|
||||
heartrate_bpm: Optional[float] = None
|
||||
n_persons: int = 0
|
||||
motion_energy: float = 0.0
|
||||
presence_score: float = 0.0
|
||||
rssi: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoseDataMessage(SensingMessage):
|
||||
"""17-keypoint pose data broadcast at the sensing-server's frame
|
||||
cadence. Persons are a list of opaque dicts — typed PoseEstimate
|
||||
decoding lives in the P2 bindings; the WS client passes through."""
|
||||
node_id: str = ""
|
||||
timestamp: float = 0.0
|
||||
persons: tuple[dict[str, Any], ...] = ()
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
# ─── Decoder ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _decode(raw_text: str) -> SensingMessage:
|
||||
"""Decode a single WS frame into a typed message.
|
||||
|
||||
Unknown ``type`` values yield a plain ``SensingMessage`` rather
|
||||
than raising — the sensing-server is on a faster release cadence
|
||||
than this client, and unknown types should not break the stream.
|
||||
"""
|
||||
obj = json.loads(raw_text)
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError(f"sensing-server emitted non-dict payload: {type(obj).__name__}")
|
||||
mtype = obj.get("type", "")
|
||||
if mtype == "connection_established":
|
||||
return ConnectionEstablishedMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
version=obj.get("version", ""),
|
||||
capabilities=tuple(obj.get("capabilities", ())),
|
||||
)
|
||||
if mtype == "edge_vitals":
|
||||
return EdgeVitalsMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
presence=bool(obj.get("presence", False)),
|
||||
fall_detected=bool(obj.get("fall_detected", False)),
|
||||
motion=float(obj.get("motion", 0.0)),
|
||||
breathing_rate_bpm=(
|
||||
float(obj["breathing_rate_bpm"])
|
||||
if obj.get("breathing_rate_bpm") is not None else None
|
||||
),
|
||||
heartrate_bpm=(
|
||||
float(obj["heartrate_bpm"])
|
||||
if obj.get("heartrate_bpm") is not None else None
|
||||
),
|
||||
n_persons=int(obj.get("n_persons", 0)),
|
||||
motion_energy=float(obj.get("motion_energy", 0.0)),
|
||||
presence_score=float(obj.get("presence_score", 0.0)),
|
||||
rssi=(float(obj["rssi"]) if obj.get("rssi") is not None else None),
|
||||
)
|
||||
if mtype == "pose_data":
|
||||
persons = obj.get("persons", ())
|
||||
return PoseDataMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
timestamp=float(obj.get("timestamp", 0.0)),
|
||||
persons=tuple(persons) if isinstance(persons, list) else (),
|
||||
confidence=float(obj.get("confidence", 0.0)),
|
||||
)
|
||||
return SensingMessage(type=mtype, raw=obj)
|
||||
|
||||
|
||||
# ─── Client ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SensingClient:
|
||||
"""Asyncio WebSocket client for the RuView sensing-server.
|
||||
|
||||
Usage as async context manager:
|
||||
|
||||
```python
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as c:
|
||||
async for msg in c.stream():
|
||||
...
|
||||
```
|
||||
|
||||
The client does NOT auto-reconnect — if you want resilience, wrap
|
||||
the ``async with`` in your own retry loop. Auto-reconnect logic is
|
||||
application-specific (e.g., "retry forever" for a long-running
|
||||
automation vs "fail fast" for a CLI tool that should exit).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ping_interval: float = 20.0,
|
||||
ping_timeout: float = 20.0,
|
||||
max_size: int = 16 * 1024 * 1024,
|
||||
) -> None:
|
||||
if not _WEBSOCKETS_AVAILABLE:
|
||||
raise ImportError(
|
||||
"SensingClient requires the `websockets` package. Install with "
|
||||
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
|
||||
)
|
||||
self.url = url
|
||||
self._ping_interval = ping_interval
|
||||
self._ping_timeout = ping_timeout
|
||||
self._max_size = max_size
|
||||
self._ws: Any = None # websockets.WebSocketClientProtocol — typed Any to avoid import cost
|
||||
|
||||
async def __aenter__(self) -> "SensingClient":
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
max_size=self._max_size,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Idempotent connection close."""
|
||||
if self._ws is not None:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e: # pragma: no cover — best-effort close
|
||||
log.debug("ignored WS close error: %r", e)
|
||||
self._ws = None
|
||||
|
||||
async def stream(self) -> AsyncIterator[SensingMessage]:
|
||||
"""Yield typed messages until the server closes the connection
|
||||
or the context is exited.
|
||||
|
||||
Decode failures on individual frames are logged at WARN and
|
||||
swallowed — a malformed frame should not terminate the stream
|
||||
(the next frame may be fine)."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
try:
|
||||
async for frame in self._ws:
|
||||
if isinstance(frame, bytes):
|
||||
frame = frame.decode("utf-8", errors="replace")
|
||||
try:
|
||||
yield _decode(frame)
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
log.warning("dropping malformed sensing-server frame: %r", e)
|
||||
except ConnectionClosed:
|
||||
# Graceful EOF — exit the iterator normally.
|
||||
return
|
||||
|
||||
async def send_ping(self) -> None:
|
||||
"""Send an application-level ping. The sensing-server replies
|
||||
with `{"type": "pong"}` (main.rs:2698)."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
await self._ws.send(json.dumps({"type": "ping"}))
|
||||
|
||||
async def recv_one(self, *, timeout: Optional[float] = None) -> SensingMessage:
|
||||
"""Receive a single decoded message. Convenience for short
|
||||
scripts and tests that don't need an async generator."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
if timeout is None:
|
||||
frame = await self._ws.recv()
|
||||
else:
|
||||
frame = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
|
||||
if isinstance(frame, bytes):
|
||||
frame = frame.decode("utf-8", errors="replace")
|
||||
return _decode(frame)
|
||||
@@ -233,6 +233,46 @@
|
||||
],
|
||||
"rationale": "At edge tier>=2 on N16R8 PSRAM boards, process_frame() runs update_multi_person_vitals() (4 persons × 256 history samples) plus wasm_runtime_on_frame() back-to-back. The vTaskDelay(1) in edge_task() only fires AFTER process_frame() fully returns — if process_frame() takes >5 s (common on PSRAM-backed boards under sustained 30 pps CSI load), IDLE1 on Core 1 never runs and the Task Watchdog Timer fires. The fix adds two vTaskDelay(1) calls inside process_frame(), gated on tier>=2, at the multi-person vitals boundary and after WASM dispatch. Removing them re-opens the WDT storm on N16R8 hardware.",
|
||||
"ref": "https://github.com/ruvnet/RuView/issues/683"
|
||||
},
|
||||
{
|
||||
"id": "RuView#786-tombstone-import",
|
||||
"title": "Tombstone (v1.99.0) __init__.py must raise ImportError with migration URL on import",
|
||||
"files": ["python/tombstone/src/wifi_densepose/__init__.py"],
|
||||
"require": [
|
||||
"raise ImportError(",
|
||||
"pip install wifi-densepose==2.0.0",
|
||||
"github.com/ruvnet/RuView"
|
||||
],
|
||||
"forbid": [
|
||||
"/^def\\s/",
|
||||
"/^class\\s/",
|
||||
"/^import\\s+wifi_densepose/"
|
||||
],
|
||||
"rationale": "ADR-117 §7.2 — the v1.99.0 tombstone wheel exists solely to raise a legible ImportError when v1.x users upgrade. If a future refactor adds real code (def / class / imports beyond the bare raise), the module may load partway before failing, breaking the migration narrative. The require patterns lock in the raise + the v2 install hint + the repo URL.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/786"
|
||||
},
|
||||
{
|
||||
"id": "RuView#786-tombstone-smoke-cwd",
|
||||
"title": "pip-release.yml tombstone smoke-test must cd out of repo root before importing",
|
||||
"files": [".github/workflows/pip-release.yml"],
|
||||
"require": [
|
||||
"cd /tmp # away from the repo root's stray wifi_densepose/"
|
||||
],
|
||||
"rationale": "ADR-117 §P5 — the repo root contains a legacy `./wifi_densepose/__init__.py` from v1. Python places cwd at sys.path[0], so running `import wifi_densepose` from the repo root after a fresh venv install resolves to the legacy directory and bypasses the tombstone wheel entirely. The smoke-test step MUST `cd /tmp` before the import, otherwise CI silently passes against the wrong package. This was the root cause of run 26366648768.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/786"
|
||||
},
|
||||
{
|
||||
"id": "RuView#786-pypi-token-auth",
|
||||
"title": "pip-release.yml must authenticate to PyPI via PYPI_API_TOKEN secret, not OIDC",
|
||||
"files": [".github/workflows/pip-release.yml"],
|
||||
"require": [
|
||||
"password: ${{ secrets.PYPI_API_TOKEN }}"
|
||||
],
|
||||
"forbid": [
|
||||
"id-token: write"
|
||||
],
|
||||
"rationale": "ADR-117 §P5 — the project is registered with PyPI via API token, not OIDC Trusted Publisher. The token is sourced from GCP Secret Manager (see docs/integrations/pypi-release.md). Re-introducing the `id-token: write` permission would suggest a partial OIDC migration that won't actually work without registering the Trusted Publisher on pypi.org first — a silent regression that would 403 on the next publish.",
|
||||
"ref": "https://github.com/ruvnet/RuView/pull/786"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+247
-22
@@ -929,6 +929,25 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cog-ha-matter"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"mdns-sd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-sensing-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cog-person-count"
|
||||
version = "0.3.0"
|
||||
@@ -1057,6 +1076,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
@@ -1350,6 +1375,33 @@ dependencies = [
|
||||
"libloading 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1411,6 +1463,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -1505,7 +1558,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1626,6 +1679,30 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1726,7 +1803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1756,6 +1833,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -3098,7 +3181,7 @@ dependencies = [
|
||||
"hyper 0.14.32",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3134,7 +3217,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3395,7 +3478,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4102,10 +4185,10 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
@@ -4296,7 +4379,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4661,6 +4744,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4725,7 +4814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5074,6 +5163,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -5469,7 +5568,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5508,9 +5607,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5875,14 +5974,14 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -6109,6 +6208,24 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1568e15fab2d546f940ed3a21f48bbbd1c494c90c99c4481339364a497f94a9"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"flume",
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls-native-certs 0.7.3",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-webpki 0.102.8",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls 0.25.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -6148,7 +6265,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6163,6 +6280,20 @@ dependencies = [
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -6178,16 +6309,29 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
|
||||
dependencies = [
|
||||
"openssl-probe 0.1.6",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6199,6 +6343,15 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -6221,13 +6374,13 @@ dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs",
|
||||
"rustls-native-certs 0.8.3",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.13",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6246,6 +6399,17 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
@@ -6548,6 +6712,19 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -6895,6 +7072,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.9.1"
|
||||
@@ -7053,12 +7239,28 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
@@ -7650,7 +7852,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7843,6 +8045,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
|
||||
dependencies = [
|
||||
"rustls 0.22.4",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-serial"
|
||||
version = "5.4.5"
|
||||
@@ -8926,6 +9139,15 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
@@ -9125,9 +9347,12 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"criterion",
|
||||
"futures-util",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"proptest",
|
||||
"rumqttc",
|
||||
"ruvector-mincut",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9270,7 +9495,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -38,6 +38,15 @@ members = [
|
||||
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
|
||||
# Motivated by #499 ghost-skeleton reports.
|
||||
"crates/cog-person-count",
|
||||
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
|
||||
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
||||
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
||||
"crates/cog-ha-matter",
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection. The
|
||||
# privacy/safety layer that measures and gates identity leakage from
|
||||
# WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class),
|
||||
# 121 (identity risk), 122 (HA/Matter), 123 (capture path).
|
||||
"crates/wifi-densepose-bfld",
|
||||
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
|
||||
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
|
||||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "cog-ha-matter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: Home Assistant + Matter integration for the Seed (ADR-116). Wraps ADR-115's HA-DISCO + HA-MIND publisher as a Seed-installable artifact with mDNS, embedded broker, RuVector-backed thresholds, and Ed25519 witness."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-ha-matter"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "cog_ha_matter"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI + logging — same shape as cog-pose-estimation (ADR-101).
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Async runtime for the publisher + mDNS responder + WebSocket pump.
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
# ADR-115 publisher is the heart of this cog — we wrap it.
|
||||
# default-features = false matches the sensing-server's pattern.
|
||||
wifi-densepose-sensing-server = { version = "0.3.0", path = "../wifi-densepose-sensing-server", default-features = false, features = ["mqtt"] }
|
||||
|
||||
# Hardware crate for SyncPacket + NodeState bridging (ADR-110 substrate).
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Witness chain (ADR-116 P4): SHA-256 hash chain + Ed25519 signature
|
||||
# layer for tamper-evident audit logs (ADR-116 §2.2). Same version
|
||||
# already vetted by ruv-neural — keep them aligned.
|
||||
sha2 = { workspace = true }
|
||||
ed25519-dalek = "2.1"
|
||||
|
||||
# mDNS responder (ADR-116 P4 §2.2): pure-Rust zero-conf daemon.
|
||||
# Same version pinned in wifi-densepose-desktop to keep the
|
||||
# workspace lockfile narrow.
|
||||
mdns-sd = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
@@ -0,0 +1,83 @@
|
||||
# Build / sign / upload pipeline for cog-ha-matter.
|
||||
# See ADR-100 §"Build pipeline" + ADR-116 §"Phases" for the contract.
|
||||
# Mirrors cog-pose-estimation/cog/Makefile so the Seed runtime treats
|
||||
# both cogs identically — `cognitum cog install ha-matter` works the
|
||||
# same as `cognitum cog install pose-estimation`.
|
||||
|
||||
CRATE := cog-ha-matter
|
||||
VERSION := $(shell cargo pkgid -p $(CRATE) 2>/dev/null | sed -E 's/.*#([0-9.]+).*/\1/')
|
||||
GCS_BUCKET := gs://cognitum-apps/cogs
|
||||
|
||||
ARCHES := arm x86_64
|
||||
|
||||
# --- Build targets ---
|
||||
|
||||
.PHONY: build build-arm build-x86_64
|
||||
|
||||
build: build-arm build-x86_64
|
||||
|
||||
build-arm:
|
||||
mkdir -p dist
|
||||
cargo build -p $(CRATE) --release --target aarch64-unknown-linux-gnu
|
||||
cp ../../target/aarch64-unknown-linux-gnu/release/$(CRATE) ./dist/$(CRATE)-arm
|
||||
|
||||
build-x86_64:
|
||||
mkdir -p dist
|
||||
cargo build -p $(CRATE) --release --target x86_64-unknown-linux-gnu
|
||||
cp ../../target/x86_64-unknown-linux-gnu/release/$(CRATE) ./dist/$(CRATE)-x86_64
|
||||
|
||||
# --- Sign ---
|
||||
|
||||
.PHONY: sign sign-arm sign-x86_64
|
||||
|
||||
sign: sign-arm sign-x86_64
|
||||
|
||||
sign-arm: dist/$(CRATE)-arm
|
||||
sha256sum dist/$(CRATE)-arm | cut -d' ' -f1 > dist/$(CRATE)-arm.sha256
|
||||
# Signature: gcloud secrets versions access latest --secret=COGNITUM_OWNER_SIGNING_KEY \
|
||||
# | openssl pkeyutl -sign -inkey /dev/stdin -rawin -in dist/$(CRATE)-arm.sha256 \
|
||||
# | base64 -w0 > dist/$(CRATE)-arm.sig
|
||||
@echo "TODO: wire Ed25519 sign step once COGNITUM_OWNER_SIGNING_KEY is provisioned to CI."
|
||||
|
||||
sign-x86_64: dist/$(CRATE)-x86_64
|
||||
sha256sum dist/$(CRATE)-x86_64 | cut -d' ' -f1 > dist/$(CRATE)-x86_64.sha256
|
||||
@echo "TODO: wire Ed25519 sign step once COGNITUM_OWNER_SIGNING_KEY is provisioned to CI."
|
||||
|
||||
# --- Upload to GCS ---
|
||||
|
||||
.PHONY: upload upload-arm upload-x86_64
|
||||
|
||||
upload: upload-arm upload-x86_64
|
||||
|
||||
upload-arm: dist/$(CRATE)-arm
|
||||
gsutil cp dist/$(CRATE)-arm $(GCS_BUCKET)/arm/$(CRATE)-arm
|
||||
|
||||
upload-x86_64: dist/$(CRATE)-x86_64
|
||||
gsutil cp dist/$(CRATE)-x86_64 $(GCS_BUCKET)/x86_64/$(CRATE)-x86_64
|
||||
|
||||
# --- Manifest ---
|
||||
|
||||
.PHONY: manifest
|
||||
|
||||
manifest:
|
||||
@cargo run -p $(CRATE) --release -- --print-manifest
|
||||
|
||||
# --- Convenience ---
|
||||
|
||||
.PHONY: release verify clean
|
||||
|
||||
release: build sign upload manifest
|
||||
@echo "Release pipeline complete for $(CRATE) v$(VERSION)"
|
||||
|
||||
verify:
|
||||
@for arch in $(ARCHES); do \
|
||||
f=dist/$(CRATE)-$$arch; \
|
||||
if [ ! -f $$f ]; then echo " MISSING $$f"; continue; fi; \
|
||||
actual=$$(sha256sum $$f | cut -d' ' -f1); \
|
||||
expected=$$(cat $$f.sha256 2>/dev/null); \
|
||||
if [ "$$actual" = "$$expected" ]; then echo " OK $$f ($$actual)"; \
|
||||
else echo " FAIL $$f (expected $$expected, got $$actual)"; fi; \
|
||||
done
|
||||
|
||||
clean:
|
||||
rm -rf dist/$(CRATE)-*
|
||||
@@ -0,0 +1,71 @@
|
||||
# HA-Matter Cog Packaging
|
||||
|
||||
Build / sign / upload pipeline for `cog-ha-matter`, mirroring the
|
||||
[`cog-pose-estimation`](../../cog-pose-estimation/cog/) precedent so the
|
||||
Seed runtime treats both cogs identically.
|
||||
|
||||
See [ADR-100 — Cog Packaging Specification](../../../../docs/adr/ADR-100-cog-packaging-specification.md)
|
||||
and [ADR-116 — HA-Matter Seed Cog](../../../../docs/adr/ADR-116-cog-ha-matter-seed.md).
|
||||
|
||||
## What this cog does
|
||||
|
||||
Wraps the ADR-115 HA-DISCO + HA-MIND MQTT publisher as a Seed-installable
|
||||
artifact with:
|
||||
|
||||
- mDNS auto-discovery (`_ruview-ha._tcp`)
|
||||
- Ed25519-signed witness chain for tamper-evident audit logs
|
||||
- Privacy-mode flag (only semantic primitives, no biometrics)
|
||||
- One-flag deferral to v0.7 for the embedded broker / v0.8 for the Matter Bridge
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `manifest.template.json` | Build-time manifest with `{{VERSION}}` / `{{ARCH}}` slots; `make manifest` substitutes them |
|
||||
| `Makefile` | `build` / `sign` / `upload` / `release` / `verify` / `clean` targets |
|
||||
| `dist/` | Created by `make build`; gitignored, holds release binaries + sha256 + sig |
|
||||
|
||||
## Local build (dry-run)
|
||||
|
||||
```sh
|
||||
cd v2/crates/cog-ha-matter/cog
|
||||
make build # builds aarch64 + x86_64 release binaries
|
||||
make sign # writes .sha256 + (TODO) .sig sidecars
|
||||
make manifest # prints the manifest the Seed would record
|
||||
```
|
||||
|
||||
`make sign` is currently a no-op for the signature itself — the
|
||||
`COGNITUM_OWNER_SIGNING_KEY` provisioning is the same TODO that
|
||||
blocks [`cog-pose-estimation`](../../cog-pose-estimation/cog/Makefile).
|
||||
Until then, dev cogs ship unsigned and `app-registry.json` lists
|
||||
them with `"binary_signature": ""`.
|
||||
|
||||
## Upload (requires `gcloud auth`)
|
||||
|
||||
```sh
|
||||
gcloud auth login
|
||||
make upload # gsutil cp dist/* gs://cognitum-apps/cogs/{arch}/
|
||||
```
|
||||
|
||||
The GCS bucket is shared with `cog-pose-estimation` and is part of
|
||||
the `cognitum-apps` project. Write access requires membership in the
|
||||
`cog-publishers` IAM group.
|
||||
|
||||
## app-registry.json
|
||||
|
||||
Lives in the [`cognitum-one`](https://github.com/ruvnet/cognitum-one)
|
||||
repo, **not here**. After `make upload` succeeds, file a PR there
|
||||
that appends:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"version": "<the version make manifest printed>",
|
||||
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/{arch}/cog-ha-matter-{arch}",
|
||||
"binary_sha256": "<from dist/cog-ha-matter-{arch}.sha256>",
|
||||
"binary_signature": "<from dist/cog-ha-matter-{arch}.sig — empty until signing is wired>",
|
||||
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
|
||||
"min_seed_version": "0.6.0",
|
||||
"installable_on": ["arm", "x86_64"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# cog-ha-matter Release Checklist
|
||||
|
||||
Mechanical steps to publish a new version. **Everything local-side is
|
||||
automated; the four "🔑 USER ACTION" blocks below are the only manual
|
||||
gates.** Each one is a credential-bearing step the cog/ pipeline cannot
|
||||
do on its own.
|
||||
|
||||
## 1. Pre-release (local)
|
||||
|
||||
```sh
|
||||
# Bump version in v2/crates/cog-ha-matter/Cargo.toml then:
|
||||
cargo test -p cog-ha-matter --no-default-features --lib # 64+ tests must pass
|
||||
cargo check -p cog-ha-matter --no-default-features # green
|
||||
```
|
||||
|
||||
## 2. Tag the release
|
||||
|
||||
```sh
|
||||
git tag cog-ha-matter-v$(cargo pkgid -p cog-ha-matter | sed -E 's/.*#//')
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
The push fires `.github/workflows/cog-ha-matter-release.yml` which:
|
||||
|
||||
* builds `cog-ha-matter-x86_64` + `cog-ha-matter-arm` (cross-compiled
|
||||
via apt-installed `gcc-aarch64-linux-gnu`)
|
||||
* computes SHA-256 sidecars
|
||||
* runs the Ed25519 sign step **if** `COGNITUM_OWNER_SIGNING_KEY` is set
|
||||
* uploads workflow artifacts (always — these are downloadable from
|
||||
the run page)
|
||||
* uploads to `gs://cognitum-apps/cogs/{arch}/` **if** the org var
|
||||
`HAS_GCP_CREDENTIALS == 'true'` and the `GCP_CREDENTIALS` secret is set
|
||||
|
||||
## 3. Update app-registry.json
|
||||
|
||||
Take `cog/app-registry-entry.json` from this directory, fill in the
|
||||
post-build values, and PR it into the [`cognitum-one`](https://github.com/ruvnet/cognitum-one)
|
||||
repo at `app-registry.json`.
|
||||
|
||||
Values to fill in:
|
||||
|
||||
* `version` — bump to match the new tag
|
||||
* `sha256` — paste from the workflow artifact's `.sha256` sidecar
|
||||
* `binary_size` — bytes of the binary (`wc -c < cog-ha-matter-x86_64`)
|
||||
|
||||
## 🔑 USER ACTION items (cannot be automated)
|
||||
|
||||
| # | What | Why this can't be automated |
|
||||
|---|---|---|
|
||||
| 1 | Set the `HAS_GCP_CREDENTIALS` org variable to `true` and provision the `GCP_CREDENTIALS` GitHub Actions secret with a service-account JSON that has `storage.objectAdmin` on `gs://cognitum-apps/cogs/` | Requires org-admin access + a GCP project owner's signoff |
|
||||
| 2 | Provision `COGNITUM_OWNER_SIGNING_KEY` GitHub secret with the Ed25519 private key in PEM form | Long-lived secret material; humans must rotate it; same blocker for cog-pose-estimation |
|
||||
| 3 | `gcloud auth login` (only if running `make upload` locally instead of via CI) | Browser OAuth flow |
|
||||
| 4 | File a PR in `cognitum-one` against `app-registry.json` adding the entry from `cog/app-registry-entry.json` | Cross-repo write requires the user's GitHub auth + reviewer signoff |
|
||||
|
||||
## Post-release verification
|
||||
|
||||
Once the cognitum-one PR merges and the cache rolls over (~hourly):
|
||||
|
||||
```sh
|
||||
curl -sS https://storage.googleapis.com/cognitum-apps/app-registry.json \
|
||||
| jq '.[] | select(.id == "ha-matter")'
|
||||
```
|
||||
|
||||
Should print the new entry. On the Seed UI, the cog appears under
|
||||
**Settings → Cogs → building → Home Assistant + Matter Bridge**.
|
||||
|
||||
## Reverting a bad release
|
||||
|
||||
Cogs ship via GCS object versioning (per ADR-100). To roll back:
|
||||
|
||||
```sh
|
||||
gsutil ls -a gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
|
||||
# Pick the previous generation, then:
|
||||
gsutil cp gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64#<generation> \
|
||||
gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
|
||||
```
|
||||
|
||||
Then PR a `version` bump in `cognitum-one`'s `app-registry.json` so
|
||||
Seeds know to refetch.
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"name": "Home Assistant + Matter Bridge",
|
||||
"category": "building",
|
||||
"version": "0.3.0",
|
||||
"size_kb": 12,
|
||||
"difficulty": "easy",
|
||||
"description": "Exposes WiFi-CSI sensing as Home Assistant entities over MQTT auto-discovery, with mDNS announcement on _ruview-ha._tcp and tamper-evident Ed25519-signed audit logs. Adds 10 semantic primitives (someone_sleeping, possible_distress, fall_risk_elevated, ...) on top of the 11 raw measurements. Privacy mode strips biometrics at the wire so only the semantic layer reaches HA — the right default for any deployment with non-tenant occupants.",
|
||||
"featured": false,
|
||||
"config": [
|
||||
{
|
||||
"key": "sensing_url",
|
||||
"type": "string",
|
||||
"label": "Sensing server URL",
|
||||
"description": "Where the cog reads VitalsSnapshot from",
|
||||
"default": "http://127.0.0.1:3000",
|
||||
"cli_arg": "--sensing-url"
|
||||
},
|
||||
{
|
||||
"key": "mqtt_host",
|
||||
"type": "string",
|
||||
"label": "MQTT broker host",
|
||||
"description": "External mosquitto / HA Core MQTT host (v0.7 will add an embedded broker option)",
|
||||
"default": "127.0.0.1",
|
||||
"cli_arg": "--mqtt-host"
|
||||
},
|
||||
{
|
||||
"key": "mqtt_port",
|
||||
"type": "integer",
|
||||
"label": "MQTT broker port",
|
||||
"default": 1883,
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"cli_arg": "--mqtt-port"
|
||||
},
|
||||
{
|
||||
"key": "privacy_mode",
|
||||
"type": "boolean",
|
||||
"label": "Privacy mode",
|
||||
"description": "Strip biometrics at the wire — only semantic primitives are published. Recommended for any deployment with non-tenant occupants (care homes, education, shared housing).",
|
||||
"default": false,
|
||||
"cli_arg": "--privacy-mode"
|
||||
},
|
||||
{
|
||||
"key": "mdns_hostname",
|
||||
"type": "string",
|
||||
"label": "mDNS hostname",
|
||||
"description": "Must end with .local. per RFC 6762. HA's discovery integration looks up this hostname.",
|
||||
"default": "cog-ha-matter.local.",
|
||||
"cli_arg": "--mdns-hostname"
|
||||
},
|
||||
{
|
||||
"key": "mdns_ipv4",
|
||||
"type": "string",
|
||||
"label": "Advertised IPv4",
|
||||
"description": "LAN-routable address the mDNS responder advertises. HA reaches back to this for MQTT.",
|
||||
"default": "127.0.0.1",
|
||||
"cli_arg": "--mdns-ipv4"
|
||||
},
|
||||
{
|
||||
"key": "no_mdns",
|
||||
"type": "boolean",
|
||||
"label": "Disable mDNS",
|
||||
"description": "Skip the mDNS responder. Useful in containerised setups where multicast is filtered.",
|
||||
"default": false,
|
||||
"cli_arg": "--no-mdns"
|
||||
}
|
||||
],
|
||||
"sha256": "<FILL_IN_FROM_dist/cog-ha-matter-x86_64.sha256_AFTER_make_build>",
|
||||
"binary_size": 0
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"version": "{{VERSION}}",
|
||||
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/{{ARCH}}/cog-ha-matter-{{ARCH}}",
|
||||
"binary_bytes": 0,
|
||||
"binary_sha256": "",
|
||||
"binary_signature": "",
|
||||
"installed_at": 0,
|
||||
"status": "installed"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! ADR-116 — Home Assistant + Matter Cognitum Seed cog.
|
||||
//!
|
||||
//! This crate is the Seed-installable wrapper around ADR-115's
|
||||
//! `wifi-densepose-sensing-server::mqtt` publisher. It adds the
|
||||
//! Seed-native surfaces ADR-115's `--mqtt` flag can't easily reach:
|
||||
//!
|
||||
//! 1. **mDNS service advertisement** — `_ruview-ha._tcp` so HA discovers
|
||||
//! the cog automatically (no manual broker host/port config).
|
||||
//! 2. **Optional embedded MQTT broker** — for Seeds running without an
|
||||
//! external mosquitto. Defaults to off; the cog can either embed
|
||||
//! rumqttd or connect to a user-provided broker.
|
||||
//! 3. **RuVector-backed semantic-primitive thresholds** — replaces
|
||||
//! static `semantic-thresholds.yaml` with a SONA-adapted RuVector
|
||||
//! inference. Per-home thresholds learned from the Seed's own
|
||||
//! long-term observation stream.
|
||||
//! 4. **Ed25519 witness chain** — every state transition signed so
|
||||
//! regulated deployments (healthcare, education, shared housing)
|
||||
//! have a tamper-evident audit log.
|
||||
//! 5. **Multi-Seed federation** — peer discovery via mDNS + cross-Seed
|
||||
//! event deduplication keyed on ADR-110's ≤100 µs mesh-aligned
|
||||
//! timestamps. One fall in a shared room emits one alert, not N.
|
||||
//! 6. **OTA firmware coordination** — the cog manages C6 firmware
|
||||
//! rollouts for ESP32-C6 nodes in the local mesh.
|
||||
//!
|
||||
//! The cog binary entrypoint is in `bin/main.rs`. Library modules
|
||||
//! below are intentionally small and testable per the /loop-worker
|
||||
//! discipline rules (see `docs/ADR-110-BRANCH-STATE.md`).
|
||||
|
||||
pub mod manifest;
|
||||
pub mod mdns;
|
||||
pub mod runtime;
|
||||
pub mod witness;
|
||||
pub mod witness_signing;
|
||||
|
||||
/// Cog identifier used in Seed's app-registry.json + the manifest.
|
||||
pub const COG_ID: &str = "ha-matter";
|
||||
|
||||
/// mDNS service type advertised when the cog starts.
|
||||
pub const MDNS_SERVICE_TYPE: &str = "_ruview-ha._tcp";
|
||||
|
||||
/// Default port for the cog's local HTTP control surface (`/health`,
|
||||
/// `/api/v1/cog/status`). Distinct from the MQTT broker port.
|
||||
pub const DEFAULT_CONTROL_PORT: u16 = 9180;
|
||||
|
||||
/// Default port for the embedded MQTT broker, when enabled.
|
||||
pub const DEFAULT_EMBEDDED_BROKER_PORT: u16 = 1883;
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `cog-ha-matter` — Home Assistant + Matter Cognitum Seed cog (ADR-116).
|
||||
//!
|
||||
//! Binary entrypoint. The actual wiring lives in [`cog_ha_matter`] —
|
||||
//! this main.rs is intentionally tiny so the cog runtime can call
|
||||
//! into the library from tests and from the Seed's control plane
|
||||
//! integration tests without re-launching the binary.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use cog_ha_matter::runtime;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "cog-ha-matter",
|
||||
version,
|
||||
about = "Home Assistant + Matter Cognitum Seed cog",
|
||||
long_about = "Wraps the ADR-115 HA-DISCO + HA-MIND publisher as a \
|
||||
Seed-installable artifact with mDNS, embedded broker, \
|
||||
RuVector-backed thresholds, and Ed25519 witness. See \
|
||||
docs/adr/ADR-116-cog-ha-matter-seed.md for the design."
|
||||
)]
|
||||
struct Args {
|
||||
/// Where to find the local sensing-server (the cog speaks to it
|
||||
/// to pull `VitalsSnapshot` for republication over MQTT/Matter).
|
||||
#[arg(long, default_value = "http://127.0.0.1:3000")]
|
||||
sensing_url: String,
|
||||
|
||||
/// MQTT broker host. When omitted the cog can spin up an embedded
|
||||
/// rumqttd on `DEFAULT_EMBEDDED_BROKER_PORT` (v1: external only).
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
mqtt_host: String,
|
||||
|
||||
/// MQTT broker port.
|
||||
#[arg(long, default_value_t = cog_ha_matter::DEFAULT_EMBEDDED_BROKER_PORT)]
|
||||
mqtt_port: u16,
|
||||
|
||||
/// Strip biometrics at the wire — only semantic primitives published.
|
||||
/// Matches ADR-115 `--privacy-mode`. The right default for any
|
||||
/// deployment with non-tenant occupants.
|
||||
#[arg(long)]
|
||||
privacy_mode: bool,
|
||||
|
||||
/// Print the manifest the cog would self-report to the Seed's
|
||||
/// control plane and exit. Useful for the build-time signer.
|
||||
#[arg(long)]
|
||||
print_manifest: bool,
|
||||
|
||||
/// mDNS hostname for the Seed advertisement. Must end with
|
||||
/// `.local.` per RFC 6762. Default lets HA's discovery find a
|
||||
/// dev cog on localhost without LAN config.
|
||||
#[arg(long, default_value = "cog-ha-matter.local.")]
|
||||
mdns_hostname: String,
|
||||
|
||||
/// LAN-routable IPv4 the cog binds the control plane on. The
|
||||
/// mDNS responder advertises this; HA reaches back to it for
|
||||
/// MQTT + Matter Bridge.
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
mdns_ipv4: String,
|
||||
|
||||
/// Skip the mDNS responder. Useful in containerised CI where
|
||||
/// multicast bind is filtered, or when running multiple cog
|
||||
/// instances on the same loopback.
|
||||
#[arg(long)]
|
||||
no_mdns: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "cog_ha_matter=info,info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
info!(
|
||||
sensing_url = %args.sensing_url,
|
||||
mqtt = format!("{}:{}", args.mqtt_host, args.mqtt_port),
|
||||
privacy = args.privacy_mode,
|
||||
"cog-ha-matter starting (ADR-116 P2 scaffold)"
|
||||
);
|
||||
|
||||
if args.print_manifest {
|
||||
// Emit the manifest with build-time-template placeholders. The
|
||||
// Makefile substitutes {{VERSION}} / {{ARCH}} before signing.
|
||||
let m = cog_ha_matter::manifest::CogManifest {
|
||||
id: cog_ha_matter::COG_ID.into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
binary_url:
|
||||
"https://storage.googleapis.com/cognitum-apps/cogs/{{ARCH}}/cog-ha-matter-{{ARCH}}"
|
||||
.into(),
|
||||
binary_bytes: 0,
|
||||
binary_sha256: String::new(),
|
||||
binary_signature: String::new(),
|
||||
installed_at: 0,
|
||||
status: "installed".into(),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&m).expect("manifest serialization is infallible")
|
||||
);
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// P3: boot the ADR-115 publisher. The broadcast tx is held by
|
||||
// main so the channel doesn't close before the sensing-server
|
||||
// bridge (next iter) wires its VitalsSnapshot producer in.
|
||||
let identity = runtime::CogIdentity::default_for_build();
|
||||
let inputs = runtime::build_publisher_inputs(
|
||||
&args.mqtt_host,
|
||||
args.mqtt_port,
|
||||
args.privacy_mode,
|
||||
identity,
|
||||
);
|
||||
let (state_tx, state_rx) =
|
||||
broadcast::channel::<VitalsSnapshot>(runtime::DEFAULT_STATE_CHANNEL_CAPACITY);
|
||||
let publisher_handle = runtime::spawn_publisher(inputs, state_rx);
|
||||
info!(
|
||||
capacity = runtime::DEFAULT_STATE_CHANNEL_CAPACITY,
|
||||
"publisher spawned — awaiting VitalsSnapshot bridge (P3.5)"
|
||||
);
|
||||
|
||||
// P3.5 (next iter): subscribe to the sensing-server's
|
||||
// `/v1/snapshot` WebSocket and republish into `state_tx`. Until
|
||||
// that lands the cog connects to MQTT, advertises discovery,
|
||||
// and just doesn't have any state to publish — exactly what an
|
||||
// HA install with no nodes online looks like.
|
||||
let _ = &state_tx;
|
||||
|
||||
// P4: mDNS responder. HA's auto-discovery picks the cog up on
|
||||
// `_ruview-ha._tcp` so users don't need to type broker host/port.
|
||||
let _mdns_handle = if args.no_mdns {
|
||||
None
|
||||
} else {
|
||||
let identity = runtime::CogIdentity::default_for_build();
|
||||
let service = cog_ha_matter::mdns::build_mdns_service(
|
||||
&identity,
|
||||
cog_ha_matter::DEFAULT_CONTROL_PORT,
|
||||
args.mqtt_port,
|
||||
args.privacy_mode,
|
||||
);
|
||||
match runtime::start_mdns_responder(&service, &args.mdns_hostname, &args.mdns_ipv4) {
|
||||
Ok(h) => {
|
||||
info!(
|
||||
fullname = h.fullname(),
|
||||
hostname = %args.mdns_hostname,
|
||||
ipv4 = %args.mdns_ipv4,
|
||||
"mDNS responder registered — HA auto-discovery should find the cog now"
|
||||
);
|
||||
Some(h)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "mDNS responder failed to start — discovery disabled, falling back to manual HA config");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait on Ctrl-C so the cog runs as a long-lived daemon under
|
||||
// the Seed's process supervisor.
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("ctrl-c received — shutting down");
|
||||
}
|
||||
joined = publisher_handle => {
|
||||
warn!(?joined, "publisher task exited unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
// _mdns_handle drops here, sending the mDNS goodbye packet so
|
||||
// HA's discovery integration sees the service leave cleanly.
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Cog manifest — same shape as `cog-pose-estimation/cog/manifest.template.json`
|
||||
//! per ADR-101 / ADR-102 / ADR-116. Generated at build time by the cog's
|
||||
//! Makefile, signed by the project's Ed25519 release key, uploaded to
|
||||
//! `gs://cognitum-apps/cogs/<arch>/cog-ha-matter-<arch>` for Seeds to fetch
|
||||
//! via `app-registry.json`.
|
||||
//!
|
||||
//! The runtime ships the typed view here so the cog can self-report its
|
||||
//! manifest to the Seed's control plane (`/api/v1/cog/status`).
|
||||
//!
|
||||
//! Kept in lib.rs's nearest sibling module so manifest format drift between
|
||||
//! build-time template and runtime serializer fires a named test.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire-format mirror of `cog/manifest.template.json`.
|
||||
///
|
||||
/// Every field is required at install time; `binary_signature` is the
|
||||
/// Ed25519 sig over `binary_sha256` so the Seed can verify the cog
|
||||
/// binary wasn't tampered with between GCS and the device.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CogManifest {
|
||||
/// Stable cog identifier ("ha-matter"). Becomes the directory name
|
||||
/// under `/var/lib/cognitum/apps/<id>/` on the Seed.
|
||||
pub id: String,
|
||||
/// SemVer of the cog binary. Bumped by the Makefile from
|
||||
/// `cargo pkgid` at release time.
|
||||
pub version: String,
|
||||
/// Where the Seed fetches the binary from. Arch-specific URL with
|
||||
/// the `{{ARCH}}` template slot filled in (e.g. `arm`, `x86_64`).
|
||||
pub binary_url: String,
|
||||
/// Bytes of the binary blob. Set at build time after `wc -c`.
|
||||
pub binary_bytes: u64,
|
||||
/// SHA-256 of the binary, hex-lowercase, no `0x` prefix. The Seed
|
||||
/// verifies this before exec().
|
||||
pub binary_sha256: String,
|
||||
/// Ed25519 signature over `binary_sha256`, base64-encoded. Optional
|
||||
/// for unsigned dev builds; required for cogs listed in
|
||||
/// `app-registry.json`.
|
||||
pub binary_signature: String,
|
||||
/// Unix epoch seconds at install time. The Seed stamps this when it
|
||||
/// completes a successful install/upgrade.
|
||||
pub installed_at: u64,
|
||||
/// One of `"installed"`, `"upgrading"`, `"degraded"`, `"removed"`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl CogManifest {
|
||||
pub fn id() -> &'static str {
|
||||
super::COG_ID
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Lock the JSON wire shape against accidental field renames. Both
|
||||
/// the Seed's control plane and the build-time signer parse this —
|
||||
/// any drift fires a named test instead of silently breaking ops.
|
||||
#[test]
|
||||
fn manifest_round_trip_matches_template() {
|
||||
let m = CogManifest {
|
||||
id: "ha-matter".into(),
|
||||
version: "0.1.0".into(),
|
||||
binary_url:
|
||||
"https://storage.googleapis.com/cognitum-apps/cogs/arm/cog-ha-matter-arm"
|
||||
.into(),
|
||||
binary_bytes: 4_200_000,
|
||||
binary_sha256:
|
||||
"a".repeat(64),
|
||||
binary_signature: "Zm9v".into(),
|
||||
installed_at: 1_779_512_400,
|
||||
status: "installed".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&m).unwrap();
|
||||
// Eight required fields, no extras.
|
||||
for key in [
|
||||
"id",
|
||||
"version",
|
||||
"binary_url",
|
||||
"binary_bytes",
|
||||
"binary_sha256",
|
||||
"binary_signature",
|
||||
"installed_at",
|
||||
"status",
|
||||
] {
|
||||
assert!(json.get(key).is_some(), "missing manifest field `{key}`");
|
||||
}
|
||||
let m2: CogManifest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(m, m2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_id_constant_matches_cog_id() {
|
||||
// The id helper must agree with the crate-level COG_ID constant
|
||||
// (regression guard for a future rename).
|
||||
assert_eq!(CogManifest::id(), super::super::COG_ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! `mdns` — pure builder for the cog's mDNS advertisement record.
|
||||
//!
|
||||
//! ADR-116 §2.2: the cog must advertise itself as `_ruview-ha._tcp`
|
||||
//! so HA's discovery integration finds the Seed without manual
|
||||
//! `broker host` config. This module produces the typed wire-format
|
||||
//! shape — no socket I/O, no responder. The actual mDNS responder
|
||||
//! (mdns-sd / zeroconf / pnet) lands next iter and consumes this
|
||||
//! struct as its single input.
|
||||
//!
|
||||
//! Keeping the record-builder pure means:
|
||||
//!
|
||||
//! * the responder library can be swapped without touching the
|
||||
//! content of the advertisement;
|
||||
//! * the build-time `--print-manifest` path can include the
|
||||
//! advertisement shape so Seed integration tests can assert on
|
||||
//! it without booting tokio;
|
||||
//! * the TXT keys are locked by named unit tests — drift between
|
||||
//! the cog and the HA-side YAML auto-discovery (`hass-wifi-...`)
|
||||
//! fires a test instead of silently breaking a deployment.
|
||||
//!
|
||||
//! ## TXT record convention (RFC 6763)
|
||||
//!
|
||||
//! HA's mDNS discovery integration reads TXT records when binding a
|
||||
//! manifest to a `homeassistant.<integration>` zeroconf hook. We
|
||||
//! publish the minimum set that lets HA distinguish a Seed cog from
|
||||
//! a bare sensing-server and pick the right config flow:
|
||||
//!
|
||||
//! | Key | Value | Purpose |
|
||||
//! |---|---|---|
|
||||
//! | `cog_id` | `"ha-matter"` | Disambiguates from other RuView cogs |
|
||||
//! | `cog_version` | `CARGO_PKG_VERSION` | HA Repairs surfaces upgrade nudges |
|
||||
//! | `node_id` | identity node id | HA device registry key |
|
||||
//! | `mqtt_port` | u16 string | Tells HA where to reach the cog's MQTT broker (embedded or external) |
|
||||
//! | `privacy` | `"1"` / `"0"` | If `1`, HA's config flow gates biometric entities by default |
|
||||
//! | `proto` | `"ruview-ha/1"` | Protocol version — bumps on breaking auto-discovery changes |
|
||||
//!
|
||||
//! No biometric data, no node coordinates, no SSID — TXT records
|
||||
//! are broadcast in cleartext and harvested by passive scanners, so
|
||||
//! treating them as PII-clean is part of the privacy posture.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
use crate::COG_ID;
|
||||
|
||||
/// Default mDNS instance name template. `{node_id}` is substituted
|
||||
/// at build time. Visible in HA's UI when the integration card is
|
||||
/// added — "Cognitum Seed (kitchen)" beats a raw UUID.
|
||||
const INSTANCE_TEMPLATE: &str = "Cognitum Seed — {node_id}";
|
||||
|
||||
/// Wire-format twin of the mDNS service record this cog publishes.
|
||||
/// Owned so the responder can move the whole thing into its task.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MdnsService {
|
||||
/// RFC 6763 service type. Locked to `_ruview-ha._tcp` by a named
|
||||
/// test — drift breaks HA's YAML auto-discovery binding.
|
||||
pub service_type: String,
|
||||
/// Human-readable instance name shown in HA's discovery UI.
|
||||
pub instance_name: String,
|
||||
/// Port the cog's control plane listens on (NOT the MQTT broker
|
||||
/// port — HA needs both, but the service record advertises the
|
||||
/// control plane; the MQTT port rides as a TXT record).
|
||||
pub control_port: u16,
|
||||
/// TXT records sorted by key for deterministic ordering. RFC
|
||||
/// 6763 §6.4 makes ordering implementation-defined, but locking
|
||||
/// it keeps the cog's wire shape byte-stable across rebuilds.
|
||||
pub txt_records: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl MdnsService {
|
||||
/// Look up a TXT key without iterating the caller. `None` if the
|
||||
/// key isn't published — the responder treats absence as
|
||||
/// "feature off" rather than "unknown".
|
||||
pub fn txt(&self, key: &str) -> Option<&str> {
|
||||
self.txt_records
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// Convert into the `mdns_sd::ServiceInfo` the responder daemon
|
||||
/// consumes. Pure transform — no socket binding, no daemon
|
||||
/// registration. The caller wires the resulting `ServiceInfo`
|
||||
/// into `ServiceDaemon::register` (next iter).
|
||||
///
|
||||
/// `hostname` should end in `.local.` per RFC 6762 — e.g.
|
||||
/// `"cognitum-seed-1.local."`. `ipv4` is the LAN-routable
|
||||
/// address HA's discovery will reach back on.
|
||||
pub fn to_service_info(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<ServiceInfo, mdns_sd::Error> {
|
||||
let mut props: HashMap<String, String> = HashMap::with_capacity(self.txt_records.len());
|
||||
for (k, v) in &self.txt_records {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
ServiceInfo::new(
|
||||
&self.service_type,
|
||||
&self.instance_name,
|
||||
hostname,
|
||||
ipv4,
|
||||
self.control_port,
|
||||
Some(props),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the cog's mDNS advertisement record from the cog's typed
|
||||
/// identity + ports. Pure: no I/O, no env reads.
|
||||
pub fn build_mdns_service(
|
||||
identity: &crate::runtime::CogIdentity,
|
||||
control_port: u16,
|
||||
mqtt_port: u16,
|
||||
privacy_mode: bool,
|
||||
) -> MdnsService {
|
||||
let mut txt_records = vec![
|
||||
("cog_id".to_string(), COG_ID.to_string()),
|
||||
("cog_version".to_string(), identity.sw_version.clone()),
|
||||
("node_id".to_string(), identity.node_id.clone()),
|
||||
("mqtt_port".to_string(), mqtt_port.to_string()),
|
||||
(
|
||||
"privacy".to_string(),
|
||||
if privacy_mode { "1" } else { "0" }.to_string(),
|
||||
),
|
||||
("proto".to_string(), "ruview-ha/1".to_string()),
|
||||
];
|
||||
// Deterministic ordering — see field docstring.
|
||||
txt_records.sort();
|
||||
|
||||
MdnsService {
|
||||
service_type: crate::MDNS_SERVICE_TYPE.to_string(),
|
||||
instance_name: INSTANCE_TEMPLATE.replace("{node_id}", &identity.node_id),
|
||||
control_port,
|
||||
txt_records,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::CogIdentity;
|
||||
|
||||
fn id() -> CogIdentity {
|
||||
CogIdentity {
|
||||
node_id: "kitchen-seed".into(),
|
||||
friendly_name: "Kitchen Seed".into(),
|
||||
sw_version: "0.3.0".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_type_locked_to_ruview_ha_tcp() {
|
||||
// Drift here breaks HA's YAML auto-discovery binding. Lock
|
||||
// it so a future rename surfaces a named test instead of a
|
||||
// silent broken deployment.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert_eq!(svc.service_type, "_ruview-ha._tcp");
|
||||
assert_eq!(svc.service_type, crate::MDNS_SERVICE_TYPE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_name_carries_node_id() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert!(svc.instance_name.contains("kitchen-seed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_field_holds_control_not_mqtt_port() {
|
||||
// Easy to swap by accident. Lock the binding so a refactor
|
||||
// doesn't silently advertise the MQTT broker as the control
|
||||
// plane.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert_eq!(svc.control_port, 9180);
|
||||
assert_eq!(svc.txt("mqtt_port"), Some("1883"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_flag_is_one_or_zero() {
|
||||
let on = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let off = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert_eq!(on.txt("privacy"), Some("1"));
|
||||
assert_eq!(off.txt("privacy"), Some("0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proto_version_bumps_surface_in_txt() {
|
||||
// Locked so a future breaking-change in the cog ↔ HA YAML
|
||||
// contract surfaces here. Bumping it is a deliberate act.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert_eq!(svc.txt("proto"), Some("ruview-ha/1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cog_id_in_txt_matches_crate_constant() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
assert_eq!(svc.txt("cog_id"), Some(crate::COG_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_records_are_sorted_for_byte_stable_advertisement() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let keys: Vec<&str> = svc.txt_records.iter().map(|(k, _)| k.as_str()).collect();
|
||||
let mut sorted = keys.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(keys, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_carries_no_biometric_or_pii_keys() {
|
||||
// TXT records broadcast in cleartext; passive scanners
|
||||
// harvest them. Lock the publishable surface so a future
|
||||
// "let's add hr_bpm to TXT for convenience" patch fires a
|
||||
// named test instead of leaking biometrics.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let forbidden = [
|
||||
"hr_bpm",
|
||||
"br_bpm",
|
||||
"pose_x",
|
||||
"pose_y",
|
||||
"keypoint",
|
||||
"ssid",
|
||||
"lat",
|
||||
"lon",
|
||||
"mac",
|
||||
"rssi",
|
||||
];
|
||||
for key in forbidden {
|
||||
assert!(
|
||||
svc.txt(key).is_none(),
|
||||
"TXT key `{key}` leaks PII / biometric data — must not be advertised"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_carries_service_type_and_port() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// mdns-sd may rewrite the type with a trailing dot; allow
|
||||
// both forms.
|
||||
let ty = info.get_type();
|
||||
assert!(
|
||||
ty == "_ruview-ha._tcp" || ty == "_ruview-ha._tcp.",
|
||||
"unexpected service type: {ty}"
|
||||
);
|
||||
assert_eq!(info.get_port(), 9180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_propagates_txt_records() {
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.expect("valid service info");
|
||||
// Every locked TXT key must reach the wire-format payload.
|
||||
assert_eq!(info.get_property_val_str("cog_id"), Some(crate::COG_ID));
|
||||
assert_eq!(info.get_property_val_str("mqtt_port"), Some("1883"));
|
||||
assert_eq!(info.get_property_val_str("privacy"), Some("1"));
|
||||
assert_eq!(info.get_property_val_str("proto"), Some("ruview-ha/1"));
|
||||
assert!(info.get_property_val_str("node_id").is_some());
|
||||
assert!(info.get_property_val_str("cog_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_service_info_does_not_silently_drop_caller_hostname() {
|
||||
// mdns-sd 0.11 accepts bare hostnames (no `.local.`); the
|
||||
// responsibility for the trailing dot lives in our wrapper.
|
||||
// Lock that the caller's hostname survives the conversion
|
||||
// verbatim — a future bump that starts mutating the value
|
||||
// surfaces a named test instead of a silent change.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let info = svc
|
||||
.to_service_info("cognitum-seed-1.local.", "192.168.1.50")
|
||||
.unwrap();
|
||||
assert!(info.get_hostname().contains("cognitum-seed-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_keys_match_locked_surface() {
|
||||
// The HA-side YAML auto-discovery binds on these exact keys.
|
||||
// Adding a key is fine; removing or renaming one breaks
|
||||
// every deployed Seed. This test catches both directions.
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, true);
|
||||
let required = ["cog_id", "cog_version", "node_id", "mqtt_port", "privacy", "proto"];
|
||||
for key in required {
|
||||
assert!(svc.txt(key).is_some(), "TXT key `{key}` missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! `runtime` — pure builders that turn the cog's small CLI surface
|
||||
//! into the shapes ADR-115's `publisher::spawn` consumes.
|
||||
//!
|
||||
//! Kept side-effect-free so the tests don't need a tokio runtime, and
|
||||
//! so the cog's mDNS responder / control plane (P4) can build the
|
||||
//! same inputs from a different source (Seed control config, JSON
|
||||
//! POST) without going through `clap`.
|
||||
//!
|
||||
//! Per the ADR-115 integration-test post-mortem (iter 45-48 of the
|
||||
//! ADR-110 sprint), the MQTT `client_id` MUST be unique per process —
|
||||
//! reusing a client_id causes the broker to disconnect the previous
|
||||
//! session and the new publisher reconnects in a loop. We derive
|
||||
//! `client_id` from the caller-supplied `node_id` for that reason.
|
||||
//!
|
||||
//! P3 of ADR-116: this module produces the input pair; the binary
|
||||
//! wires the actual `tokio::spawn(publisher::run(...))` next iter.
|
||||
//!
|
||||
//! The publisher inputs are intentionally typed in *this* crate, so
|
||||
//! the cog's tests and the `--print-manifest` path can exercise the
|
||||
//! builder without pulling in the rumqttc event loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use mdns_sd::ServiceDaemon;
|
||||
use tokio::{sync::broadcast, task::JoinHandle};
|
||||
use wifi_densepose_sensing_server::mqtt::{
|
||||
config::{MqttConfig, PublishRates, TlsConfig},
|
||||
publisher::{self, OwnedDiscoveryBuilder},
|
||||
state::VitalsSnapshot,
|
||||
DEFAULT_DISCOVERY_PREFIX, MANUFACTURER,
|
||||
};
|
||||
|
||||
use crate::mdns::MdnsService;
|
||||
|
||||
/// Caller-supplied identity for the cog instance. Filled in by the
|
||||
/// cog runtime from the mDNS hostname / Seed control plane in
|
||||
/// production; threaded as a parameter so tests can build inputs
|
||||
/// without touching the environment.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CogIdentity {
|
||||
/// Stable node identifier — appears in MQTT topics, HA device
|
||||
/// registry, mDNS service name. Must be ASCII-safe; the cog
|
||||
/// runtime is responsible for sanitising user input.
|
||||
pub node_id: String,
|
||||
/// Human-readable name surfaced in the HA UI.
|
||||
pub friendly_name: String,
|
||||
/// SemVer of the cog binary. Surfaces as the HA device `sw_version`.
|
||||
pub sw_version: String,
|
||||
}
|
||||
|
||||
impl CogIdentity {
|
||||
/// Default identity used when the cog runs standalone (no Seed
|
||||
/// control plane). Uses the PID for uniqueness so two cog
|
||||
/// instances on the same host don't fight over the same MQTT
|
||||
/// session — same trick the ADR-115 publisher uses.
|
||||
pub fn default_for_build() -> Self {
|
||||
Self {
|
||||
node_id: format!("cog-ha-matter-{}", std::process::id()),
|
||||
friendly_name: "Cognitum Seed — HA cog".into(),
|
||||
sw_version: env!("CARGO_PKG_VERSION").into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pair ADR-115's `publisher::spawn` needs. Owned so we can move
|
||||
/// the whole thing into a `tokio::spawn` closure without lifetime
|
||||
/// gymnastics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublisherInputs {
|
||||
pub config: MqttConfig,
|
||||
pub discovery: OwnedDiscoveryBuilder,
|
||||
}
|
||||
|
||||
/// Build the publisher inputs from the cog's small CLI surface.
|
||||
///
|
||||
/// Pure function — no I/O, no env reads. The caller wraps `config`
|
||||
/// in an `Arc` before handing it to `publisher::spawn`.
|
||||
pub fn build_publisher_inputs(
|
||||
mqtt_host: &str,
|
||||
mqtt_port: u16,
|
||||
privacy_mode: bool,
|
||||
identity: CogIdentity,
|
||||
) -> PublisherInputs {
|
||||
let config = MqttConfig {
|
||||
host: mqtt_host.to_string(),
|
||||
port: mqtt_port,
|
||||
username: None,
|
||||
password: None,
|
||||
client_id: format!("{}-{}", super::COG_ID, identity.node_id),
|
||||
discovery_prefix: DEFAULT_DISCOVERY_PREFIX.to_string(),
|
||||
tls: TlsConfig::Off,
|
||||
refresh_secs: 60,
|
||||
rates: PublishRates::default(),
|
||||
publish_pose: false,
|
||||
privacy_mode,
|
||||
};
|
||||
|
||||
let discovery = OwnedDiscoveryBuilder {
|
||||
discovery_prefix: DEFAULT_DISCOVERY_PREFIX.to_string(),
|
||||
node_id: identity.node_id,
|
||||
node_friendly_name: Some(identity.friendly_name),
|
||||
sw_version: identity.sw_version,
|
||||
model: format!("{MANUFACTURER} cog-ha-matter"),
|
||||
via_device: Some(super::COG_ID.to_string()),
|
||||
};
|
||||
|
||||
PublisherInputs { config, discovery }
|
||||
}
|
||||
|
||||
/// Default broadcast-channel capacity for the cog's VitalsSnapshot
|
||||
/// stream. Matches the sensing-server's own default so the cog
|
||||
/// doesn't bottleneck the publisher under bursty loads (multi-Seed
|
||||
/// federation, mesh re-sync events).
|
||||
pub const DEFAULT_STATE_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// Spawn the ADR-115 MQTT publisher with the cog's typed inputs.
|
||||
///
|
||||
/// Thin wrapper around [`publisher::spawn`] that:
|
||||
/// 1. wraps `inputs.config` in `Arc` (publisher requires shared
|
||||
/// ownership across reconnects),
|
||||
/// 2. moves `inputs.discovery` into the spawn (publisher clones it
|
||||
/// per reconnect; `OwnedDiscoveryBuilder` is `Clone`),
|
||||
/// 3. hands the broadcast receiver across without an intermediate.
|
||||
///
|
||||
/// Returning the `JoinHandle` lets `main.rs` await it on shutdown
|
||||
/// (or `abort()` it from a control-plane handler).
|
||||
pub fn spawn_publisher(
|
||||
inputs: PublisherInputs,
|
||||
state_rx: broadcast::Receiver<VitalsSnapshot>,
|
||||
) -> JoinHandle<()> {
|
||||
let PublisherInputs { config, discovery } = inputs;
|
||||
publisher::spawn(Arc::new(config), discovery, state_rx)
|
||||
}
|
||||
|
||||
/// Owned handle to a live mDNS responder. Holding it keeps the
|
||||
/// service advertised; `shutdown` unregisters cleanly so HA's
|
||||
/// discovery integration sees a goodbye packet instead of a
|
||||
/// dropped advertisement.
|
||||
///
|
||||
/// `Drop` is best-effort: tries unregister + daemon shutdown but
|
||||
/// swallows errors, since panicking in Drop would mask the real
|
||||
/// failure that prompted the shutdown.
|
||||
pub struct MdnsResponderHandle {
|
||||
daemon: ServiceDaemon,
|
||||
fullname: String,
|
||||
}
|
||||
|
||||
impl MdnsResponderHandle {
|
||||
/// Fully-qualified DNS-SD name (`<instance>.<type>.<domain>`).
|
||||
/// Exposed for tests + logging; the responder uses it to
|
||||
/// unregister.
|
||||
pub fn fullname(&self) -> &str {
|
||||
&self.fullname
|
||||
}
|
||||
|
||||
/// Unregister the service and shut down the daemon. Returns
|
||||
/// any error so the caller's shutdown sequence can surface it.
|
||||
pub fn shutdown(self) -> Result<(), mdns_sd::Error> {
|
||||
let _ = self.daemon.unregister(&self.fullname);
|
||||
let _ = self.daemon.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MdnsResponderHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.unregister(&self.fullname);
|
||||
let _ = self.daemon.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the mDNS responder for a cog and register its service.
|
||||
///
|
||||
/// Binds a multicast socket (`mdns_sd::ServiceDaemon::new`) and
|
||||
/// publishes `service` under `hostname` (must end in `.local.`)
|
||||
/// and `ipv4` (the LAN-routable address HA's discovery reaches
|
||||
/// back on).
|
||||
///
|
||||
/// Live-I/O: binding multicast may fail in containerised CI or
|
||||
/// on networks where 5353/udp is filtered — callers should treat
|
||||
/// the error as recoverable (log + retry, or fall back to manual
|
||||
/// HA configuration) rather than fatal to the cog.
|
||||
pub fn start_mdns_responder(
|
||||
service: &MdnsService,
|
||||
hostname: &str,
|
||||
ipv4: &str,
|
||||
) -> Result<MdnsResponderHandle, mdns_sd::Error> {
|
||||
let daemon = ServiceDaemon::new()?;
|
||||
let info = service.to_service_info(hostname, ipv4)?;
|
||||
let fullname = info.get_fullname().to_string();
|
||||
daemon.register(info)?;
|
||||
Ok(MdnsResponderHandle { daemon, fullname })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id() -> CogIdentity {
|
||||
CogIdentity {
|
||||
node_id: "seed-7".into(),
|
||||
friendly_name: "test-seed".into(),
|
||||
sw_version: "0.0.1-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_and_port_round_trip_into_mqtt_config() {
|
||||
let out = build_publisher_inputs("10.0.0.5", 8883, false, id());
|
||||
assert_eq!(out.config.host, "10.0.0.5");
|
||||
assert_eq!(out.config.port, 8883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_mode_propagates_to_mqtt_config() {
|
||||
let on = build_publisher_inputs("h", 1883, true, id());
|
||||
let off = build_publisher_inputs("h", 1883, false, id());
|
||||
assert!(on.config.privacy_mode);
|
||||
assert!(!off.config.privacy_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_prefix_defaults_to_homeassistant() {
|
||||
let out = build_publisher_inputs("h", 1883, false, id());
|
||||
assert_eq!(out.config.discovery_prefix, DEFAULT_DISCOVERY_PREFIX);
|
||||
assert_eq!(out.discovery.discovery_prefix, DEFAULT_DISCOVERY_PREFIX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_carries_identity_fields() {
|
||||
let out = build_publisher_inputs("h", 1883, false, id());
|
||||
assert_eq!(out.discovery.node_id, "seed-7");
|
||||
assert_eq!(out.discovery.sw_version, "0.0.1-test");
|
||||
assert_eq!(out.discovery.node_friendly_name.as_deref(), Some("test-seed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn via_device_advertises_cog_id() {
|
||||
// ADR-101 / ADR-102: every cog must surface its `id` as the
|
||||
// HA device's `via_device` so the appliance shows up as the
|
||||
// bridge — fires a named test instead of silently breaking
|
||||
// the device-registry shape.
|
||||
let out = build_publisher_inputs("h", 1883, false, id());
|
||||
assert_eq!(out.discovery.via_device.as_deref(), Some(super::super::COG_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_includes_node_id_for_session_uniqueness() {
|
||||
// Lesson from the ADR-115 integration-test post-mortem: two
|
||||
// publishers sharing a `client_id` fight over the broker
|
||||
// session and one reconnects forever. The cog must derive
|
||||
// `client_id` from `node_id` so multi-Seed deployments don't
|
||||
// collide.
|
||||
let out = build_publisher_inputs("h", 1883, false, id());
|
||||
assert!(out.config.client_id.contains("seed-7"));
|
||||
assert!(out.config.client_id.starts_with(super::super::COG_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_defaults_to_off_for_v1_lan_only() {
|
||||
// v1 ships LAN-only (no broker on the open internet); TLS
|
||||
// wiring lands in v0.8 alongside Matter Bridge per ADR-116
|
||||
// §4. Lock the default so a future refactor surfaces a
|
||||
// named test instead of silently enabling TLS.
|
||||
let out = build_publisher_inputs("h", 1883, false, id());
|
||||
assert!(matches!(out.config.tls, TlsConfig::Off));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_publisher_returns_live_handle_without_broker() {
|
||||
// No real broker on this port — rumqttc retries internally so
|
||||
// the spawned task stays alive. We just prove the wiring
|
||||
// compiles + the JoinHandle is not pre-finished. Aborting
|
||||
// immediately keeps the test under 100 ms.
|
||||
let inputs = build_publisher_inputs("127.0.0.1", 1, false, id());
|
||||
let (tx, rx) = broadcast::channel::<VitalsSnapshot>(DEFAULT_STATE_CHANNEL_CAPACITY);
|
||||
let handle = spawn_publisher(inputs, rx);
|
||||
// Task is still running (not pre-finished by config validation).
|
||||
assert!(!handle.is_finished());
|
||||
// Keep `tx` alive past the handle abort so the receiver side
|
||||
// doesn't panic on drop before the task notices the channel
|
||||
// closed.
|
||||
handle.abort();
|
||||
let _ = handle.await; // joined, may be Err(Cancelled) — OK.
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_state_channel_capacity_is_reasonable() {
|
||||
// Lock the default so a regression to e.g. 1 surfaces a named
|
||||
// test. Multi-Seed federation needs headroom for bursty
|
||||
// mesh re-sync events.
|
||||
assert!(DEFAULT_STATE_CHANNEL_CAPACITY >= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mdns_responder_fullname_concatenates_instance_and_service_type() {
|
||||
// Live-I/O test: binds multicast on the loopback adapter.
|
||||
// Skips with a warning if the host's network stack refuses
|
||||
// the bind (containerised CI without --network host, etc.)
|
||||
// rather than failing the whole test suite.
|
||||
use crate::mdns::build_mdns_service;
|
||||
let svc = build_mdns_service(&id(), 9180, 1883, false);
|
||||
let handle = match start_mdns_responder(&svc, "cog-ha-matter-test.local.", "127.0.0.1") {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("mdns multicast bind not available in this sandbox: {e} — skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Fullname format is "<instance>.<service_type>." per RFC 6763.
|
||||
// mdns-sd may URL-escape special chars (— in instance name) so
|
||||
// we only assert on the service-type segment which is stable.
|
||||
let fullname = handle.fullname().to_string();
|
||||
assert!(
|
||||
!fullname.is_empty(),
|
||||
"fullname empty after register"
|
||||
);
|
||||
assert!(
|
||||
fullname.contains("_ruview-ha._tcp"),
|
||||
"fullname `{fullname}` missing service type"
|
||||
);
|
||||
handle.shutdown().expect("clean shutdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_identity_carries_pkg_version_and_pid() {
|
||||
let identity = CogIdentity::default_for_build();
|
||||
assert_eq!(identity.sw_version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(identity.node_id.starts_with("cog-ha-matter-"));
|
||||
// Friendly name is non-empty so HA's device card has a label.
|
||||
assert!(!identity.friendly_name.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
//! `witness` — append-only hash-chained event log for the cog.
|
||||
//!
|
||||
//! ADR-116 §2.2 promises a tamper-evident audit log so regulated
|
||||
//! deployments (healthcare, education, shared housing) can prove
|
||||
//! that the state transitions a Seed reported were actually emitted
|
||||
//! by the cog at the time they were emitted, not retroactively
|
||||
//! rewritten.
|
||||
//!
|
||||
//! This module is the **pure hash-chain primitive**:
|
||||
//!
|
||||
//! * SHA-256 over deterministic canonical bytes,
|
||||
//! * `prev_hash` chains each event to its predecessor,
|
||||
//! * `WitnessChain::append` is the only mutator — no random
|
||||
//! access, no replace, no delete.
|
||||
//!
|
||||
//! Ed25519 signing layers on top once the key-management story
|
||||
//! lands (probably as `witness_signing.rs` reading a key from the
|
||||
//! Seed's secure store). Keeping the hash chain and the signature
|
||||
//! in separate modules means the chain primitive can be tested
|
||||
//! without a key fixture, and a future key rotation doesn't
|
||||
//! invalidate the chain itself — only the signature over each
|
||||
//! event.
|
||||
//!
|
||||
//! ## Why hash-chain first, not Merkle tree?
|
||||
//!
|
||||
//! The cog emits witness events at the rate of semantic-primitive
|
||||
//! transitions — a few per minute in steady state, dozens during
|
||||
//! a fall-detection / room-transition event. Linear scan is fine
|
||||
//! at that rate; we save the Merkle complexity for a future tier
|
||||
//! when the chain spans days and the auditor wants O(log n)
|
||||
//! inclusion proofs.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// 32-byte hash output. Lifted into a newtype so a future migration
|
||||
/// to Blake3 / SHA-512 surfaces as a type change instead of a
|
||||
/// silent length difference in serialized witness bundles.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct WitnessHash(pub [u8; 32]);
|
||||
|
||||
impl WitnessHash {
|
||||
/// Genesis hash — the predecessor of the first event. Sentinel
|
||||
/// "no prior event" value.
|
||||
pub const GENESIS: WitnessHash = WitnessHash([0u8; 32]);
|
||||
|
||||
/// Lowercase hex without `0x` prefix. Matches the format the
|
||||
/// `cog-pose-estimation` manifest uses for `binary_sha256` so
|
||||
/// downstream tooling can apply one parser.
|
||||
pub fn to_hex(&self) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in self.0 {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse a 64-char lowercase-hex string back into a `WitnessHash`.
|
||||
/// Rejects wrong-length input and non-hex characters — used by
|
||||
/// the JSONL parser when reading audit bundles.
|
||||
pub fn from_hex(s: &str) -> Result<WitnessHash, WitnessParseError> {
|
||||
if s.len() != 64 {
|
||||
return Err(WitnessParseError::HashLength { found: s.len() });
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for (i, byte) in out.iter_mut().enumerate() {
|
||||
let lo = i * 2;
|
||||
*byte = u8::from_str_radix(&s[lo..lo + 2], 16)
|
||||
.map_err(|_| WitnessParseError::HashHex { at: lo })?;
|
||||
}
|
||||
Ok(WitnessHash(out))
|
||||
}
|
||||
}
|
||||
|
||||
/// A single witnessed event. Append-only — once committed to a
|
||||
/// `WitnessChain`, the fields here are immutable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WitnessEvent {
|
||||
/// Zero-based sequence number. Strictly monotonically
|
||||
/// increasing within a chain — gaps mean the chain was
|
||||
/// truncated.
|
||||
pub seq: u64,
|
||||
/// Hash of the previous event, or [`WitnessHash::GENESIS`] for
|
||||
/// the first.
|
||||
pub prev_hash: WitnessHash,
|
||||
/// Unix epoch seconds at append time. Caller-supplied so the
|
||||
/// test suite isn't time-coupled; production uses
|
||||
/// `SystemTime::now()`.
|
||||
pub timestamp_unix_s: u64,
|
||||
/// Short stable kind tag — e.g. `"fall_risk_elevated"`,
|
||||
/// `"bed_exit"`, `"privacy_mode_toggled"`. Locked vocabulary
|
||||
/// in the future; free-form here until the semantic-primitive
|
||||
/// catalog stabilises.
|
||||
pub kind: String,
|
||||
/// Opaque payload bytes. Typically the JSON of the emitted MQTT
|
||||
/// state message so an auditor can re-derive what HA was told.
|
||||
pub payload: Vec<u8>,
|
||||
/// Hash of *this* event, computed over canonical bytes that
|
||||
/// include `prev_hash` — so reconstructing the chain proves
|
||||
/// nothing in the past was rewritten.
|
||||
pub this_hash: WitnessHash,
|
||||
}
|
||||
|
||||
/// Compute the canonical-bytes form an event is hashed over.
|
||||
///
|
||||
/// The format is intentionally simple and length-prefixed so a
|
||||
/// future migration can be staged with a `version` byte in front
|
||||
/// without ambiguity:
|
||||
///
|
||||
/// ```text
|
||||
/// prev_hash[32] | seq:u64-be | ts:u64-be | kind_len:u32-be | kind | payload_len:u32-be | payload
|
||||
/// ```
|
||||
///
|
||||
/// Length-prefixing prevents the classic "concatenation forgery"
|
||||
/// attack where `"abc" + "def"` and `"ab" + "cdef"` would hash the
|
||||
/// same.
|
||||
pub fn canonical_bytes(
|
||||
prev_hash: WitnessHash,
|
||||
seq: u64,
|
||||
timestamp_unix_s: u64,
|
||||
kind: &str,
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let kind_bytes = kind.as_bytes();
|
||||
let mut out = Vec::with_capacity(32 + 8 + 8 + 4 + kind_bytes.len() + 4 + payload.len());
|
||||
out.extend_from_slice(&prev_hash.0);
|
||||
out.extend_from_slice(&seq.to_be_bytes());
|
||||
out.extend_from_slice(×tamp_unix_s.to_be_bytes());
|
||||
out.extend_from_slice(&(kind_bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind_bytes);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the SHA-256 hash for an event.
|
||||
pub fn hash_event(
|
||||
prev_hash: WitnessHash,
|
||||
seq: u64,
|
||||
timestamp_unix_s: u64,
|
||||
kind: &str,
|
||||
payload: &[u8],
|
||||
) -> WitnessHash {
|
||||
let mut h = Sha256::new();
|
||||
h.update(canonical_bytes(prev_hash, seq, timestamp_unix_s, kind, payload));
|
||||
let digest = h.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&digest);
|
||||
WitnessHash(out)
|
||||
}
|
||||
|
||||
/// In-memory append-only chain. Persistence (write to the Seed's
|
||||
/// `~/cognitum/witness/<cog>/events.jsonl`) is a separate concern
|
||||
/// kept out of this module.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WitnessChain {
|
||||
events: Vec<WitnessEvent>,
|
||||
}
|
||||
|
||||
impl WitnessChain {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Last committed hash, or `GENESIS` if the chain is empty.
|
||||
pub fn tip(&self) -> WitnessHash {
|
||||
self.events
|
||||
.last()
|
||||
.map(|e| e.this_hash)
|
||||
.unwrap_or(WitnessHash::GENESIS)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
/// Append a new event. Caller supplies the wall-clock so tests
|
||||
/// stay deterministic.
|
||||
pub fn append(&mut self, kind: &str, payload: &[u8], timestamp_unix_s: u64) -> &WitnessEvent {
|
||||
let prev_hash = self.tip();
|
||||
let seq = self.events.len() as u64;
|
||||
let this_hash = hash_event(prev_hash, seq, timestamp_unix_s, kind, payload);
|
||||
self.events.push(WitnessEvent {
|
||||
seq,
|
||||
prev_hash,
|
||||
timestamp_unix_s,
|
||||
kind: kind.to_string(),
|
||||
payload: payload.to_vec(),
|
||||
this_hash,
|
||||
});
|
||||
self.events.last().expect("just pushed")
|
||||
}
|
||||
|
||||
pub fn events(&self) -> &[WitnessEvent] {
|
||||
&self.events
|
||||
}
|
||||
|
||||
/// Stream every event to a JSONL sink. Each event becomes one
|
||||
/// line terminated by `\n`. Empty chains write zero bytes.
|
||||
///
|
||||
/// The caller owns the writer — `File`, `BufWriter`, an
|
||||
/// in-memory `Vec<u8>` for tests — so this method never
|
||||
/// allocates beyond per-event line buffers.
|
||||
pub fn write_jsonl<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||
for ev in &self.events {
|
||||
w.write_all(ev.to_jsonl_line().as_bytes())?;
|
||||
w.write_all(b"\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a JSONL audit bundle into a fresh `WitnessChain`. Each
|
||||
/// non-empty line is parsed via `WitnessEvent::from_jsonl_line`
|
||||
/// (which re-verifies the stored hash), then the loaded chain
|
||||
/// is end-to-end verified via [`WitnessChain::verify`] to catch
|
||||
/// out-of-order events or replayed prefixes.
|
||||
///
|
||||
/// Bundle errors surface with their `line_no` (1-indexed) so an
|
||||
/// auditor can point at the bad record.
|
||||
pub fn read_jsonl<R: BufRead>(r: R) -> Result<WitnessChain, WitnessReadError> {
|
||||
let mut chain = WitnessChain::new();
|
||||
for (i, line_res) in r.lines().enumerate() {
|
||||
let line_no = i + 1;
|
||||
let line = line_res.map_err(|e| WitnessReadError::Io {
|
||||
line_no,
|
||||
msg: e.to_string(),
|
||||
})?;
|
||||
if line.trim().is_empty() {
|
||||
continue; // tolerate blank lines / trailing \n
|
||||
}
|
||||
let ev = WitnessEvent::from_jsonl_line(&line)
|
||||
.map_err(|source| WitnessReadError::Parse { line_no, source })?;
|
||||
chain.events.push(ev);
|
||||
}
|
||||
chain
|
||||
.verify()
|
||||
.map_err(|source| WitnessReadError::Verify { source })?;
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Verify every event's `this_hash` matches the canonical bytes,
|
||||
/// every `prev_hash` matches the predecessor's `this_hash`, and
|
||||
/// `seq` is gap-free starting at 0.
|
||||
///
|
||||
/// Returns `Ok(())` on a sound chain or an `Err` with the first
|
||||
/// failing index + reason — auditor-friendly.
|
||||
pub fn verify(&self) -> Result<(), WitnessVerifyError> {
|
||||
let mut prev = WitnessHash::GENESIS;
|
||||
for (i, ev) in self.events.iter().enumerate() {
|
||||
if ev.seq != i as u64 {
|
||||
return Err(WitnessVerifyError::SeqGap { at: i, found: ev.seq });
|
||||
}
|
||||
if ev.prev_hash != prev {
|
||||
return Err(WitnessVerifyError::PrevHashMismatch { at: i });
|
||||
}
|
||||
let recomputed = hash_event(
|
||||
ev.prev_hash,
|
||||
ev.seq,
|
||||
ev.timestamp_unix_s,
|
||||
&ev.kind,
|
||||
&ev.payload,
|
||||
);
|
||||
if recomputed != ev.this_hash {
|
||||
return Err(WitnessVerifyError::HashMismatch { at: i });
|
||||
}
|
||||
prev = ev.this_hash;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum WitnessVerifyError {
|
||||
#[error("seq gap at index {at}: expected {at}, found {found}")]
|
||||
SeqGap { at: usize, found: u64 },
|
||||
#[error("prev_hash mismatch at index {at}")]
|
||||
PrevHashMismatch { at: usize },
|
||||
#[error("this_hash mismatch at index {at} — event tampered")]
|
||||
HashMismatch { at: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WitnessReadError {
|
||||
#[error("io error at line {line_no}: {msg}")]
|
||||
Io { line_no: usize, msg: String },
|
||||
#[error("parse error at line {line_no}: {source}")]
|
||||
Parse {
|
||||
line_no: usize,
|
||||
#[source]
|
||||
source: WitnessParseError,
|
||||
},
|
||||
#[error("chain-level verify failed: {source}")]
|
||||
Verify {
|
||||
#[source]
|
||||
source: WitnessVerifyError,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum WitnessParseError {
|
||||
#[error("invalid JSON: {0}")]
|
||||
Json(String),
|
||||
#[error("missing required field `{0}`")]
|
||||
MissingField(&'static str),
|
||||
#[error("field `{field}` has wrong type")]
|
||||
WrongType { field: &'static str },
|
||||
#[error("hash hex must be 64 chars, got {found}")]
|
||||
HashLength { found: usize },
|
||||
#[error("hash hex parse error at byte offset {at}")]
|
||||
HashHex { at: usize },
|
||||
#[error("payload hex parse error at byte offset {at}")]
|
||||
PayloadHex { at: usize },
|
||||
#[error("payload hex must be even length, got {found}")]
|
||||
PayloadLength { found: usize },
|
||||
#[error("recomputed hash does not match this_hash — bundle is forged or corrupted")]
|
||||
HashMismatch,
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> Result<Vec<u8>, WitnessParseError> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(WitnessParseError::PayloadLength { found: s.len() });
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
let byte = u8::from_str_radix(&s[i..i + 2], 16)
|
||||
.map_err(|_| WitnessParseError::PayloadHex { at: i })?;
|
||||
out.push(byte);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl WitnessEvent {
|
||||
/// Serialize one event to a single JSONL line (no trailing
|
||||
/// newline). The format is the audit-bundle wire shape; tools
|
||||
/// downstream parse it line-by-line with [`Self::from_jsonl_line`].
|
||||
///
|
||||
/// Field ordering is locked alphabetically for byte-stable
|
||||
/// output across rebuilds — auditors hash whole bundles, so a
|
||||
/// rebuild that reordered fields would silently invalidate
|
||||
/// archival hashes.
|
||||
///
|
||||
/// Wire shape:
|
||||
///
|
||||
/// ```json
|
||||
/// {"kind":"...","payload_hex":"...","prev_hash":"...","seq":N,"this_hash":"...","timestamp_unix_s":N}
|
||||
/// ```
|
||||
pub fn to_jsonl_line(&self) -> String {
|
||||
// Hand-rolled instead of serde_derive so the wire-format
|
||||
// ordering is under direct test control.
|
||||
format!(
|
||||
"{{\"kind\":{kind},\"payload_hex\":\"{payload}\",\"prev_hash\":\"{prev}\",\"seq\":{seq},\"this_hash\":\"{this}\",\"timestamp_unix_s\":{ts}}}",
|
||||
kind = serde_json::to_string(&self.kind).expect("string is always serializable"),
|
||||
payload = hex_encode(&self.payload),
|
||||
prev = self.prev_hash.to_hex(),
|
||||
seq = self.seq,
|
||||
this = self.this_hash.to_hex(),
|
||||
ts = self.timestamp_unix_s,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse one JSONL line back into a `WitnessEvent`. Re-verifies
|
||||
/// the stored `this_hash` against the canonical bytes — a
|
||||
/// tampered bundle fires [`WitnessParseError::HashMismatch`]
|
||||
/// instead of silently loading forged events.
|
||||
pub fn from_jsonl_line(line: &str) -> Result<WitnessEvent, WitnessParseError> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| WitnessParseError::Json(e.to_string()))?;
|
||||
let obj = v
|
||||
.as_object()
|
||||
.ok_or(WitnessParseError::WrongType { field: "<root>" })?;
|
||||
|
||||
let seq = obj
|
||||
.get("seq")
|
||||
.ok_or(WitnessParseError::MissingField("seq"))?
|
||||
.as_u64()
|
||||
.ok_or(WitnessParseError::WrongType { field: "seq" })?;
|
||||
let timestamp_unix_s = obj
|
||||
.get("timestamp_unix_s")
|
||||
.ok_or(WitnessParseError::MissingField("timestamp_unix_s"))?
|
||||
.as_u64()
|
||||
.ok_or(WitnessParseError::WrongType {
|
||||
field: "timestamp_unix_s",
|
||||
})?;
|
||||
let kind = obj
|
||||
.get("kind")
|
||||
.ok_or(WitnessParseError::MissingField("kind"))?
|
||||
.as_str()
|
||||
.ok_or(WitnessParseError::WrongType { field: "kind" })?
|
||||
.to_string();
|
||||
let prev_hash = WitnessHash::from_hex(
|
||||
obj.get("prev_hash")
|
||||
.ok_or(WitnessParseError::MissingField("prev_hash"))?
|
||||
.as_str()
|
||||
.ok_or(WitnessParseError::WrongType { field: "prev_hash" })?,
|
||||
)?;
|
||||
let this_hash = WitnessHash::from_hex(
|
||||
obj.get("this_hash")
|
||||
.ok_or(WitnessParseError::MissingField("this_hash"))?
|
||||
.as_str()
|
||||
.ok_or(WitnessParseError::WrongType { field: "this_hash" })?,
|
||||
)?;
|
||||
let payload = hex_decode(
|
||||
obj.get("payload_hex")
|
||||
.ok_or(WitnessParseError::MissingField("payload_hex"))?
|
||||
.as_str()
|
||||
.ok_or(WitnessParseError::WrongType {
|
||||
field: "payload_hex",
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Re-verify the stored hash. The on-disk hash is purely
|
||||
// declarative; this is what makes the JSONL a witness.
|
||||
let recomputed = hash_event(prev_hash, seq, timestamp_unix_s, &kind, &payload);
|
||||
if recomputed != this_hash {
|
||||
return Err(WitnessParseError::HashMismatch);
|
||||
}
|
||||
|
||||
Ok(WitnessEvent {
|
||||
seq,
|
||||
prev_hash,
|
||||
timestamp_unix_s,
|
||||
kind,
|
||||
payload,
|
||||
this_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn genesis_hash_is_all_zeros() {
|
||||
assert_eq!(WitnessHash::GENESIS.0, [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_chain_tip_is_genesis() {
|
||||
let c = WitnessChain::new();
|
||||
assert_eq!(c.tip(), WitnessHash::GENESIS);
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_length_prefixing_prevents_ambiguity() {
|
||||
// Classic concatenation forgery: without length prefixes,
|
||||
// ("abc","def") and ("ab","cdef") would produce the same
|
||||
// hash. With them, they don't.
|
||||
let a = canonical_bytes(WitnessHash::GENESIS, 0, 0, "abc", b"def");
|
||||
let b = canonical_bytes(WitnessHash::GENESIS, 0, 0, "ab", b"cdef");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_bytes_starts_with_prev_hash() {
|
||||
// Locks the on-wire format. A future migration that flips
|
||||
// field order must bump a version byte and update this test.
|
||||
let bytes = canonical_bytes(WitnessHash([7u8; 32]), 1, 2, "k", b"p");
|
||||
assert_eq!(&bytes[..32], &[7u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_links_to_prev_hash() {
|
||||
let mut c = WitnessChain::new();
|
||||
let h1 = c.append("a", b"1", 100).this_hash;
|
||||
let e2 = c.append("b", b"2", 101);
|
||||
assert_eq!(e2.prev_hash, h1);
|
||||
assert_eq!(e2.seq, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_is_monotonic_starting_at_zero() {
|
||||
let mut c = WitnessChain::new();
|
||||
for i in 0..5 {
|
||||
c.append("k", &[i], 0);
|
||||
}
|
||||
for (i, ev) in c.events().iter().enumerate() {
|
||||
assert_eq!(ev.seq, i as u64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_passes_on_clean_chain() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("fall_risk_elevated", b"{}", 100);
|
||||
c.append("bed_exit", b"{}", 101);
|
||||
c.append("privacy_mode_toggled", br#"{"on":true}"#, 102);
|
||||
c.verify().expect("clean chain verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_catches_tampered_payload() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"original", 100);
|
||||
c.append("b", b"original2", 101);
|
||||
// Tamper with event 0's payload directly.
|
||||
c.events[0].payload = b"forged".to_vec();
|
||||
let err = c.verify().unwrap_err();
|
||||
assert!(matches!(err, WitnessVerifyError::HashMismatch { at: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_catches_broken_prev_link() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
c.events[1].prev_hash = WitnessHash([0xff; 32]);
|
||||
let err = c.verify().unwrap_err();
|
||||
assert!(matches!(err, WitnessVerifyError::PrevHashMismatch { at: 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_catches_seq_gap() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
c.events[1].seq = 99;
|
||||
let err = c.verify().unwrap_err();
|
||||
assert!(matches!(err, WitnessVerifyError::SeqGap { at: 1, found: 99 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_to_hex_is_64_lowercase_chars() {
|
||||
let h = hash_event(WitnessHash::GENESIS, 0, 0, "k", b"p");
|
||||
let hex = h.to_hex();
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_event_prev_hash_is_genesis() {
|
||||
// Auditor relies on this: a witness bundle that doesn't start
|
||||
// with prev_hash == GENESIS is either truncated or stitched
|
||||
// together from two chains.
|
||||
let mut c = WitnessChain::new();
|
||||
let e = c.append("init", b"", 0);
|
||||
assert_eq!(e.prev_hash, WitnessHash::GENESIS);
|
||||
assert_eq!(e.seq, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_payloads_produce_different_hashes() {
|
||||
let h1 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"a");
|
||||
let h2 = hash_event(WitnessHash::GENESIS, 0, 100, "k", b"b");
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
// ---- JSONL persistence ----
|
||||
|
||||
fn fresh_event() -> WitnessEvent {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("fall_risk_elevated", br#"{"node":"kitchen"}"#, 1779512400);
|
||||
c.events()[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_round_trip_preserves_all_fields() {
|
||||
let original = fresh_event();
|
||||
let line = original.to_jsonl_line();
|
||||
let parsed = WitnessEvent::from_jsonl_line(&line).expect("clean line round-trips");
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_line_has_no_embedded_newline() {
|
||||
// JSONL is one record per line; an embedded \n in the
|
||||
// serialized form would corrupt the file format.
|
||||
let line = fresh_event().to_jsonl_line();
|
||||
assert!(!line.contains('\n'));
|
||||
assert!(!line.contains('\r'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_field_order_is_alphabetical_for_byte_stability() {
|
||||
// Auditors archive whole bundles and hash them — reordered
|
||||
// fields would silently invalidate archival hashes. Lock
|
||||
// the order with a substring check on a known event.
|
||||
let line = fresh_event().to_jsonl_line();
|
||||
let order = ["kind", "payload_hex", "prev_hash", "seq", "this_hash", "timestamp_unix_s"];
|
||||
let mut last = 0usize;
|
||||
for field in order {
|
||||
let pos = line.find(field).unwrap_or_else(|| panic!("missing field `{field}`"));
|
||||
assert!(pos > last, "field `{field}` out of alphabetical order");
|
||||
last = pos;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_parser_rejects_tampered_payload() {
|
||||
let original = fresh_event();
|
||||
let line = original.to_jsonl_line();
|
||||
// Flip one nibble in the payload hex — the stored hash
|
||||
// won't match the recomputed hash.
|
||||
let tampered = line.replacen("payload_hex\":\"7b", "payload_hex\":\"6b", 1);
|
||||
assert_ne!(line, tampered, "test fixture didn't flip a byte");
|
||||
let err = WitnessEvent::from_jsonl_line(&tampered).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, WitnessParseError::HashMismatch),
|
||||
"expected HashMismatch, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_parser_rejects_non_hex_hash() {
|
||||
// Replace the hex hash with non-hex chars — must fire a
|
||||
// structured error, not a panic.
|
||||
let original = fresh_event();
|
||||
let line = original.to_jsonl_line();
|
||||
let broken = line.replacen(
|
||||
&original.this_hash.to_hex()[..4],
|
||||
"ZZZZ",
|
||||
1,
|
||||
);
|
||||
let err = WitnessEvent::from_jsonl_line(&broken).unwrap_err();
|
||||
assert!(matches!(err, WitnessParseError::HashHex { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_parser_rejects_missing_field() {
|
||||
let bad = r#"{"seq":0,"kind":"k","prev_hash":"00","this_hash":"00","timestamp_unix_s":1}"#;
|
||||
let err = WitnessEvent::from_jsonl_line(bad).unwrap_err();
|
||||
// Missing payload_hex; should fire MissingField before any
|
||||
// hex decode happens.
|
||||
assert!(matches!(err, WitnessParseError::MissingField("payload_hex")
|
||||
| WitnessParseError::HashLength { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_encode_decode_round_trip() {
|
||||
let cases: &[&[u8]] = &[
|
||||
b"",
|
||||
b"\x00",
|
||||
b"\xff",
|
||||
b"hello world",
|
||||
&[0x00, 0x01, 0xab, 0xcd, 0xef],
|
||||
];
|
||||
for c in cases {
|
||||
let encoded = hex_encode(c);
|
||||
let decoded = hex_decode(&encoded).unwrap();
|
||||
assert_eq!(&decoded[..], *c, "round-trip failed for {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_rejects_odd_length() {
|
||||
let err = hex_decode("abc").unwrap_err();
|
||||
assert!(matches!(err, WitnessParseError::PayloadLength { found: 3 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_hash_from_hex_round_trip() {
|
||||
let h = WitnessHash([0x12; 32]);
|
||||
let hex = h.to_hex();
|
||||
let parsed = WitnessHash::from_hex(&hex).unwrap();
|
||||
assert_eq!(parsed, h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_hash_from_hex_rejects_wrong_length() {
|
||||
let err = WitnessHash::from_hex("ab").unwrap_err();
|
||||
assert!(matches!(err, WitnessParseError::HashLength { found: 2 }));
|
||||
}
|
||||
|
||||
// ---- file persistence (write_jsonl / read_jsonl) ----
|
||||
|
||||
#[test]
|
||||
fn write_jsonl_empty_chain_writes_zero_bytes() {
|
||||
let c = WitnessChain::new();
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
assert_eq!(buf, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_round_trips_multi_event_chain() {
|
||||
let mut written = WitnessChain::new();
|
||||
written.append("a", b"first", 100);
|
||||
written.append("b", b"second", 101);
|
||||
written.append("c", br#"{"x":1}"#, 102);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
written.write_jsonl(&mut buf).unwrap();
|
||||
|
||||
let read_back = WitnessChain::read_jsonl(buf.as_slice()).unwrap();
|
||||
assert_eq!(read_back.len(), 3);
|
||||
assert_eq!(read_back.events(), written.events());
|
||||
assert_eq!(read_back.tip(), written.tip());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_jsonl_separates_events_with_newline() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
let s = std::str::from_utf8(&buf).unwrap();
|
||||
// Exactly N newlines for N events.
|
||||
assert_eq!(s.matches('\n').count(), 2);
|
||||
assert!(s.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_tolerates_blank_lines() {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
// Inject blanks — sometimes happens when files are edited.
|
||||
let with_blanks = format!(
|
||||
"\n{}\n\n",
|
||||
std::str::from_utf8(&buf).unwrap().trim_end()
|
||||
);
|
||||
let read = WitnessChain::read_jsonl(with_blanks.as_bytes()).unwrap();
|
||||
assert_eq!(read.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_surfaces_line_no_on_parse_error() {
|
||||
// Two good events, then one with a flipped payload byte.
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("a", b"1", 100);
|
||||
c.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
let mut text = String::from_utf8(buf).unwrap();
|
||||
let forged = c.events()[0].to_jsonl_line().replacen(
|
||||
"payload_hex\":\"31",
|
||||
"payload_hex\":\"32",
|
||||
1,
|
||||
);
|
||||
text.push_str(&forged);
|
||||
text.push('\n');
|
||||
|
||||
let err = WitnessChain::read_jsonl(text.as_bytes()).unwrap_err();
|
||||
match err {
|
||||
WitnessReadError::Parse { line_no, .. } => assert_eq!(line_no, 3),
|
||||
other => panic!("expected Parse error at line 3, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_chain_verify_catches_reordered_events() {
|
||||
// Build a chain, then write it out with the events swapped.
|
||||
// Each individual event still verifies its own hash (because
|
||||
// its prev_hash is internally consistent with what *it*
|
||||
// claimed), but the cross-event chain check fires.
|
||||
let mut original = WitnessChain::new();
|
||||
original.append("a", b"1", 100);
|
||||
original.append("b", b"2", 101);
|
||||
let mut buf = Vec::new();
|
||||
original.write_jsonl(&mut buf).unwrap();
|
||||
let lines: Vec<&[u8]> = buf.split(|&b| b == b'\n').filter(|s| !s.is_empty()).collect();
|
||||
// Reverse order, send through reader.
|
||||
let mut reversed: Vec<u8> = Vec::new();
|
||||
reversed.extend_from_slice(lines[1]);
|
||||
reversed.push(b'\n');
|
||||
reversed.extend_from_slice(lines[0]);
|
||||
reversed.push(b'\n');
|
||||
let err = WitnessChain::read_jsonl(reversed.as_slice()).unwrap_err();
|
||||
assert!(matches!(err, WitnessReadError::Verify { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jsonl_no_trailing_newline_still_works() {
|
||||
// BufRead's lines() handles the no-final-newline case; lock
|
||||
// the behavior so a future swap to a different reader can't
|
||||
// silently truncate the last event.
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("only", b"x", 100);
|
||||
let mut buf = Vec::new();
|
||||
c.write_jsonl(&mut buf).unwrap();
|
||||
// Strip the trailing \n.
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
}
|
||||
let read = WitnessChain::read_jsonl(buf.as_slice()).unwrap();
|
||||
assert_eq!(read.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! `witness_signing` — Ed25519 signature layer over the witness chain.
|
||||
//!
|
||||
//! ADR-116 §2.2: every state transition must be signed by the
|
||||
//! Seed so a downstream auditor can prove the chain wasn't
|
||||
//! retroactively assembled. The chain primitive
|
||||
//! (`witness::WitnessChain`) handles hash linkage; this module
|
||||
//! adds the cryptographic attestation.
|
||||
//!
|
||||
//! Kept in a separate module from the chain itself so:
|
||||
//!
|
||||
//! * the hash chain stays usable without `ed25519-dalek` linked
|
||||
//! in (good for the `wasm32-unknown-unknown` cog variant we'll
|
||||
//! ship for browser-side audit verification),
|
||||
//! * key rotation invalidates *signatures* but not the chain —
|
||||
//! the auditor only needs the new public key to re-verify,
|
||||
//! * the signing surface stays small enough to audit in one
|
||||
//! read.
|
||||
//!
|
||||
//! ## What gets signed
|
||||
//!
|
||||
//! `sign_event(event, key)` signs the same canonical byte form
|
||||
//! that `witness::hash_event` hashes. That means:
|
||||
//!
|
||||
//! 1. A signature commits to the entire event (kind, payload,
|
||||
//! timestamp, seq, prev_hash) — no field can be retroactively
|
||||
//! changed without invalidating both the hash AND the
|
||||
//! signature.
|
||||
//! 2. The signature implicitly commits to the *chain position*
|
||||
//! via `prev_hash` — splicing a signed event into a different
|
||||
//! chain breaks verification.
|
||||
//!
|
||||
//! ## Key management
|
||||
//!
|
||||
//! Out of scope for this module. The cog runtime reads the Seed's
|
||||
//! Ed25519 signing key from the Cognitum control plane's secure
|
||||
//! key store (separate concern). Tests use a fixed-bytes seed for
|
||||
//! determinism — never check in real Seed keys here.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
|
||||
use crate::witness::{canonical_bytes, WitnessEvent};
|
||||
|
||||
/// Sign a witness event with the Seed's Ed25519 key. Returns the
|
||||
/// 64-byte Ed25519 signature over the event's canonical bytes —
|
||||
/// the same bytes `witness::hash_event` hashes, so a verifier that
|
||||
/// already trusts the hash chain only needs one extra check.
|
||||
pub fn sign_event(event: &WitnessEvent, key: &SigningKey) -> Signature {
|
||||
let bytes = canonical_bytes(
|
||||
event.prev_hash,
|
||||
event.seq,
|
||||
event.timestamp_unix_s,
|
||||
&event.kind,
|
||||
&event.payload,
|
||||
);
|
||||
key.sign(&bytes)
|
||||
}
|
||||
|
||||
/// Verify an Ed25519 signature against a witness event using the
|
||||
/// Seed's public key. `Ok(())` iff the signature is valid for the
|
||||
/// event's canonical bytes under this key.
|
||||
pub fn verify_signature(
|
||||
event: &WitnessEvent,
|
||||
signature: &Signature,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<(), SignatureVerifyError> {
|
||||
let bytes = canonical_bytes(
|
||||
event.prev_hash,
|
||||
event.seq,
|
||||
event.timestamp_unix_s,
|
||||
&event.kind,
|
||||
&event.payload,
|
||||
);
|
||||
public_key
|
||||
.verify(&bytes, signature)
|
||||
.map_err(|_| SignatureVerifyError::Invalid)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SignatureVerifyError {
|
||||
#[error("Ed25519 signature does not match event under this public key")]
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Encode a signature as 128 hex chars (no `0x` prefix). Matches the
|
||||
/// hex convention the rest of the witness wire format uses.
|
||||
pub fn signature_to_hex(sig: &Signature) -> String {
|
||||
let bytes = sig.to_bytes();
|
||||
let mut s = String::with_capacity(128);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse a 128-char lowercase-hex string back into a `Signature`.
|
||||
pub fn signature_from_hex(s: &str) -> Result<Signature, SignatureParseError> {
|
||||
if s.len() != 128 {
|
||||
return Err(SignatureParseError::Length { found: s.len() });
|
||||
}
|
||||
let mut bytes = [0u8; 64];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
let lo = i * 2;
|
||||
*byte = u8::from_str_radix(&s[lo..lo + 2], 16)
|
||||
.map_err(|_| SignatureParseError::Hex { at: lo })?;
|
||||
}
|
||||
Ok(Signature::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum SignatureParseError {
|
||||
#[error("signature hex must be 128 chars, got {found}")]
|
||||
Length { found: usize },
|
||||
#[error("signature hex parse error at byte offset {at}")]
|
||||
Hex { at: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::witness::{WitnessChain, WitnessHash};
|
||||
|
||||
fn fixed_key() -> SigningKey {
|
||||
// Deterministic test key — DO NOT use in production. The
|
||||
// seed is `b"cog-ha-matter-unit-tests--------"` (32 bytes).
|
||||
SigningKey::from_bytes(b"cog-ha-matter-unit-tests--------")
|
||||
}
|
||||
|
||||
fn fresh_event() -> WitnessEvent {
|
||||
let mut c = WitnessChain::new();
|
||||
c.append("fall_risk_elevated", br#"{"node":"kitchen"}"#, 1779512400);
|
||||
c.events()[0].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_and_verify_round_trip() {
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
verify_signature(&event, &sig, &public).expect("clean signature verifies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_signature_under_wrong_key() {
|
||||
let key = fixed_key();
|
||||
let other = SigningKey::from_bytes(b"different-key-different-key-----");
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
// Same event, signature from `key`, but verify under `other`'s
|
||||
// public key — must fail.
|
||||
let err = verify_signature(&event, &sig, &other.verifying_key()).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_tampered_event() {
|
||||
// Sign one event, then mutate the payload and verify the
|
||||
// *mutated* event under the same signature. Must fail.
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let mut event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
event.payload = b"forged-after-sign".to_vec();
|
||||
let err = verify_signature(&event, &sig, &public).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_event_with_wrong_prev_hash() {
|
||||
// Same payload + kind, but the event claims a different
|
||||
// chain position. Cryptographically bound to prev_hash via
|
||||
// canonical bytes.
|
||||
let key = fixed_key();
|
||||
let public = key.verifying_key();
|
||||
let mut event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
event.prev_hash = WitnessHash([0x77; 32]);
|
||||
let err = verify_signature(&event, &sig, &public).unwrap_err();
|
||||
assert_eq!(err, SignatureVerifyError::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_hex_round_trip() {
|
||||
let key = fixed_key();
|
||||
let event = fresh_event();
|
||||
let sig = sign_event(&event, &key);
|
||||
let hex = signature_to_hex(&sig);
|
||||
assert_eq!(hex.len(), 128);
|
||||
assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
|
||||
let parsed = signature_from_hex(&hex).unwrap();
|
||||
assert_eq!(parsed.to_bytes(), sig.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_from_hex_rejects_wrong_length() {
|
||||
let err = signature_from_hex("abcd").unwrap_err();
|
||||
assert_eq!(err, SignatureParseError::Length { found: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_from_hex_rejects_non_hex() {
|
||||
// 128 chars but non-hex.
|
||||
let bad = "Z".repeat(128);
|
||||
let err = signature_from_hex(&bad).unwrap_err();
|
||||
assert!(matches!(err, SignatureParseError::Hex { at: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_is_deterministic_for_same_event_and_key() {
|
||||
// Ed25519 is deterministic; locking this means a future
|
||||
// accidental switch to a randomized scheme (RustCrypto's
|
||||
// optional rand-based API) fires a named test.
|
||||
let key = fixed_key();
|
||||
let event = fresh_event();
|
||||
let sig1 = sign_event(&event, &key);
|
||||
let sig2 = sign_event(&event, &key);
|
||||
assert_eq!(sig1.to_bytes(), sig2.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_events_produce_different_signatures() {
|
||||
let key = fixed_key();
|
||||
let mut a = fresh_event();
|
||||
let mut b = fresh_event();
|
||||
a.payload = b"a".to_vec();
|
||||
b.payload = b"b".to_vec();
|
||||
let sig_a = sign_event(&a, &key);
|
||||
let sig_b = sign_event(&b, &key);
|
||||
assert_ne!(sig_a.to_bytes(), sig_b.to_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "wifi-densepose-bfld"
|
||||
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
# Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) —
|
||||
# enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate
|
||||
# exemption. Disabled by default per the structural class-2 default.
|
||||
soul-signature = []
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
static_assertions = "1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
module_name_repetitions = "allow"
|
||||
missing_const_for_fn = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `BfldFrame` wire-format primitives. See ADR-119.
|
||||
//!
|
||||
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
|
||||
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
|
||||
//! (ADR-028) extends cleanly to BFLD frames.
|
||||
//!
|
||||
//! All multi-byte integers serialize as **little-endian**. The
|
||||
//! `to_le_bytes`/`from_le_bytes` helpers encode/decode without `unsafe`, which
|
||||
//! is forbidden in this crate; the encoded bytes are the canonical wire form.
|
||||
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
|
||||
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
|
||||
|
||||
/// Current `BfldFrame` major version. Bumps on any incompatible layout change.
|
||||
pub const BFLD_VERSION: u16 = 1;
|
||||
|
||||
/// Size of the packed header in bytes. Asserted at compile time below.
|
||||
///
|
||||
/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error.
|
||||
/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation.
|
||||
pub const BFLD_HEADER_SIZE: usize = 86;
|
||||
|
||||
/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1.
|
||||
pub mod flags {
|
||||
/// Payload contains an optional CSI delta section.
|
||||
pub const HAS_CSI_DELTA: u16 = 1 << 0;
|
||||
/// `privacy_mode` is engaged: identity-derived fields suppressed.
|
||||
pub const PRIVACY_MODE: u16 = 1 << 1;
|
||||
/// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`.
|
||||
pub const SELF_ONLY: u16 = 1 << 3;
|
||||
}
|
||||
|
||||
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct BfldFrameHeader {
|
||||
/// Must equal [`BFLD_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// Layout version. Currently [`BFLD_VERSION`].
|
||||
pub version: u16,
|
||||
/// Flag bits — see [`flags`].
|
||||
pub flags: u16,
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
|
||||
pub ap_hash: [u8; 16],
|
||||
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
|
||||
pub sta_hash: [u8; 16],
|
||||
/// Ephemeral session identifier, rotated on capture-session boundary.
|
||||
pub session_id: [u8; 16],
|
||||
/// 802.11 channel number.
|
||||
pub channel: u16,
|
||||
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
|
||||
pub bandwidth_mhz: u16,
|
||||
/// Received signal strength in dBm.
|
||||
pub rssi_dbm: i16,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor_dbm: i16,
|
||||
/// Number of OFDM subcarriers represented.
|
||||
pub n_subcarriers: u16,
|
||||
/// Number of transmit antennas.
|
||||
pub n_tx: u8,
|
||||
/// Number of receive antennas.
|
||||
pub n_rx: u8,
|
||||
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
|
||||
pub quantization: u8,
|
||||
/// `PrivacyClass` byte — see ADR-120 §2.1.
|
||||
pub privacy_class: u8,
|
||||
/// Length of the payload section in bytes.
|
||||
pub payload_len: u32,
|
||||
/// CRC-32/ISO-HDLC over payload bytes only.
|
||||
pub payload_crc32: u32,
|
||||
}
|
||||
|
||||
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
|
||||
|
||||
impl BfldFrameHeader {
|
||||
/// Build a header with `magic` and `version` already set correctly.
|
||||
/// All other fields default to zero — caller fills them in.
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
magic: BFLD_MAGIC,
|
||||
version: BFLD_VERSION,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical little-endian wire form (86 bytes).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
|
||||
let mut buf = [0u8; BFLD_HEADER_SIZE];
|
||||
let mut o = 0usize;
|
||||
|
||||
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
|
||||
let magic = self.magic;
|
||||
let version = self.version;
|
||||
let flags = self.flags;
|
||||
let timestamp_ns = self.timestamp_ns;
|
||||
let channel = self.channel;
|
||||
let bandwidth_mhz = self.bandwidth_mhz;
|
||||
let rssi_dbm = self.rssi_dbm;
|
||||
let noise_floor_dbm = self.noise_floor_dbm;
|
||||
let n_subcarriers = self.n_subcarriers;
|
||||
let payload_len = self.payload_len;
|
||||
let payload_crc32 = self.payload_crc32;
|
||||
|
||||
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
|
||||
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
|
||||
buf[o..o + 8].copy_from_slice(×tamp_ns.to_le_bytes()); o += 8;
|
||||
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
|
||||
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
|
||||
buf[o] = self.n_tx; o += 1;
|
||||
buf[o] = self.n_rx; o += 1;
|
||||
buf[o] = self.quantization; o += 1;
|
||||
buf[o] = self.privacy_class; o += 1;
|
||||
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
|
||||
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
|
||||
|
||||
debug_assert_eq!(o, BFLD_HEADER_SIZE);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse from canonical little-endian wire form.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
|
||||
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
|
||||
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
|
||||
/// performed here — that lives at the frame-level parser.
|
||||
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
|
||||
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||
if magic != BFLD_MAGIC {
|
||||
return Err(BfldError::InvalidMagic(magic));
|
||||
}
|
||||
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
||||
if version != BFLD_VERSION {
|
||||
return Err(BfldError::UnsupportedVersion(version));
|
||||
}
|
||||
|
||||
let mut h = Self {
|
||||
magic,
|
||||
version,
|
||||
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
|
||||
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||
ap_hash: [0; 16],
|
||||
sta_hash: [0; 16],
|
||||
session_id: [0; 16],
|
||||
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
|
||||
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
|
||||
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
|
||||
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
|
||||
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
|
||||
n_tx: bytes[74],
|
||||
n_rx: bytes[75],
|
||||
quantization: bytes[76],
|
||||
privacy_class: bytes[77],
|
||||
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
|
||||
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
|
||||
};
|
||||
h.ap_hash.copy_from_slice(&bytes[16..32]);
|
||||
h.sta_hash.copy_from_slice(&bytes[32..48]);
|
||||
h.session_id.copy_from_slice(&bytes[48..64]);
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! # BFLD — Beamforming Feedback Layer for Detection
|
||||
//!
|
||||
//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming
|
||||
//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||||
//!
|
||||
//! ## Three structural invariants
|
||||
//!
|
||||
//! - **I1**: Raw BFI never exits the node.
|
||||
//! - **I2**: Identity embedding is in-RAM-only.
|
||||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
//!
|
||||
//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow.
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
pub mod frame;
|
||||
pub mod sink;
|
||||
|
||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||||
|
||||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PrivacyClass {
|
||||
/// Local-only research data including raw BFI matrix. Never networked.
|
||||
Raw = 0,
|
||||
/// Operator-acknowledged research mode over LAN. Downsampled angles +
|
||||
/// identity_embedding + identity_risk_score available. Required for
|
||||
/// Soul Signature deployments (ADR-120 §2.7).
|
||||
Derived = 1,
|
||||
/// Production default: aggregate sensing only, no identity-derived fields.
|
||||
Anonymous = 2,
|
||||
/// Care-home / regulated deployments: class 2 minus risk score and hash.
|
||||
Restricted = 3,
|
||||
}
|
||||
|
||||
impl PrivacyClass {
|
||||
/// Returns `true` if frames of this class may cross a `NetworkSink`.
|
||||
/// Class 0 (`Raw`) is local-only by structural invariant I1.
|
||||
#[must_use]
|
||||
pub const fn allows_network(self) -> bool {
|
||||
!matches!(self, Self::Raw)
|
||||
}
|
||||
|
||||
/// Returns `true` if frames of this class may cross the Matter boundary.
|
||||
/// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4.
|
||||
#[must_use]
|
||||
pub const fn allows_matter(self) -> bool {
|
||||
matches!(self, Self::Anonymous | Self::Restricted)
|
||||
}
|
||||
|
||||
/// Returns the byte value of this class (0..=3) for serialization.
|
||||
#[must_use]
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for PrivacyClass {
|
||||
type Error = BfldError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Raw),
|
||||
1 => Ok(Self::Derived),
|
||||
2 => Ok(Self::Anonymous),
|
||||
3 => Ok(Self::Restricted),
|
||||
other => Err(BfldError::InvalidPrivacyClass(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by BFLD operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BfldError {
|
||||
/// Header magic did not match `BFLD_MAGIC`.
|
||||
#[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")]
|
||||
InvalidMagic(u32),
|
||||
|
||||
/// Header version unsupported.
|
||||
#[error("unsupported BFLD version: {0}")]
|
||||
UnsupportedVersion(u16),
|
||||
|
||||
/// Payload CRC32 mismatch — frame corrupted or tampered.
|
||||
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
|
||||
Crc { expected: u32, actual: u32 },
|
||||
|
||||
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
|
||||
/// Enforces structural invariant I1.
|
||||
#[error("privacy violation: {reason}")]
|
||||
PrivacyViolation { reason: &'static str },
|
||||
|
||||
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||||
#[error("invalid PrivacyClass byte: {0}")]
|
||||
InvalidPrivacyClass(u8),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Sink marker traits — structural enforcement of invariant I1.
|
||||
//!
|
||||
//! Every output destination (memory buffer, MQTT topic, Matter cluster) implements
|
||||
//! exactly one of [`LocalSink`], [`NetworkSink`], or [`MatterSink`]. The associated
|
||||
//! constant [`Sink::MIN_CLASS`] declares the lowest `PrivacyClass` value that sink
|
||||
//! is willing to accept; the runtime gate [`check_class`] enforces this on every
|
||||
//! publish.
|
||||
//!
|
||||
//! Mapping (ADR-120 §2.2, ADR-122 §2.4):
|
||||
//!
|
||||
//! | Sink trait | `MIN_CLASS` | Accepts classes |
|
||||
//! |---------------|----------------------|-----------------|
|
||||
//! | `LocalSink` | `PrivacyClass::Raw` | 0, 1, 2, 3 |
|
||||
//! | `NetworkSink` | `PrivacyClass::Derived` | 1, 2, 3 |
|
||||
//! | `MatterSink` | `PrivacyClass::Anonymous` | 2, 3 |
|
||||
//!
|
||||
//! `MatterSink: NetworkSink` — every Matter sink is also a network sink.
|
||||
|
||||
use crate::{BfldError, PrivacyClass};
|
||||
|
||||
/// Base sink trait. Every sink type declares the minimum `PrivacyClass` it accepts.
|
||||
pub trait Sink {
|
||||
/// Lowest privacy class (highest information density) this sink will publish.
|
||||
const MIN_CLASS: PrivacyClass;
|
||||
/// Human-readable sink kind, used in `BfldError::PrivacyViolation` messages.
|
||||
const KIND: &'static str;
|
||||
}
|
||||
|
||||
/// Marker for sinks that stay on the originating node (memory, in-RAM channel,
|
||||
/// local file with explicit operator opt-in). Accepts every class including `Raw`.
|
||||
pub trait LocalSink: Sink {}
|
||||
|
||||
/// Marker for sinks that cross the node boundary (MQTT, HTTP, gRPC). Rejects
|
||||
/// `Raw` frames by structural invariant I1.
|
||||
pub trait NetworkSink: Sink {}
|
||||
|
||||
/// Marker for sinks that bridge into the Matter cluster surface. Rejects `Raw`
|
||||
/// and `Derived`; the `cog-ha-matter` boundary filter consumes only classes 2/3.
|
||||
pub trait MatterSink: NetworkSink {}
|
||||
|
||||
/// Runtime gate. Returns `Ok(())` if `class` is acceptable for `S`, otherwise
|
||||
/// returns `BfldError::PrivacyViolation` with the offending sink kind.
|
||||
///
|
||||
/// Class numerical order *is* meaningful here: a sink that accepts `MIN_CLASS`
|
||||
/// also accepts every higher-numbered class (less identity content). The check
|
||||
/// is therefore a simple `>=` on the byte representation.
|
||||
pub fn check_class<S: Sink>(class: PrivacyClass) -> Result<(), BfldError> {
|
||||
if class.as_u8() >= S::MIN_CLASS.as_u8() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BfldError::PrivacyViolation {
|
||||
reason: S::KIND,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Default sink types ----------------------------------------------------
|
||||
//
|
||||
// Concrete sinks live in downstream crates (emitter.rs, mqtt.rs, the cog-ha-matter
|
||||
// Matter bridge). These three "kind tags" are convenient zero-sized stand-ins for
|
||||
// unit tests and for the privacy_gate compile-time tables.
|
||||
|
||||
/// Zero-sized tag: a local in-memory ring buffer or file sink.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LocalKind;
|
||||
|
||||
impl Sink for LocalKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Raw;
|
||||
const KIND: &'static str = "LocalKind";
|
||||
}
|
||||
impl LocalSink for LocalKind {}
|
||||
|
||||
/// Zero-sized tag: a generic network sink (MQTT, HTTP, gRPC).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NetworkKind;
|
||||
|
||||
impl Sink for NetworkKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Derived;
|
||||
const KIND: &'static str = "NetworkKind";
|
||||
}
|
||||
impl NetworkSink for NetworkKind {}
|
||||
|
||||
/// Zero-sized tag: the Matter cluster boundary in `cog-ha-matter`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MatterKind;
|
||||
|
||||
impl Sink for MatterKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Anonymous;
|
||||
const KIND: &'static str = "MatterKind";
|
||||
}
|
||||
impl NetworkSink for MatterKind {}
|
||||
impl MatterSink for MatterKind {}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Acceptance test ADR-119 AC1: `BfldFrameHeader` size is platform-stable.
|
||||
//!
|
||||
//! The static assertion in `frame.rs` already enforces this at compile time on
|
||||
//! the local target. This runtime test exists so CI surfaces the failure with
|
||||
//! a useful message rather than a `const_assert_eq!` link error.
|
||||
|
||||
use wifi_densepose_bfld::{BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC, BFLD_VERSION};
|
||||
|
||||
#[test]
|
||||
fn header_size_is_86_bytes() {
|
||||
assert_eq!(
|
||||
core::mem::size_of::<BfldFrameHeader>(),
|
||||
BFLD_HEADER_SIZE,
|
||||
"BfldFrameHeader must be exactly {BFLD_HEADER_SIZE} bytes (packed)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_reads_as_bfld_in_hex() {
|
||||
// 0xBF1D_0001 — "BF1D" looks like "BFLD" in xxd output; final 0001 is the
|
||||
// major version that lives in the dedicated `version` field as well.
|
||||
assert_eq!(BFLD_MAGIC, 0xBF1D_0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_is_one() {
|
||||
assert_eq!(BFLD_VERSION, 1);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Acceptance tests for `BfldFrameHeader` serialization (ADR-119 AC5/AC6).
|
||||
|
||||
use wifi_densepose_bfld::frame::flags;
|
||||
use wifi_densepose_bfld::{BfldError, BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC};
|
||||
|
||||
fn sample_header() -> BfldFrameHeader {
|
||||
let mut h = BfldFrameHeader::empty();
|
||||
h.flags = flags::HAS_CSI_DELTA | flags::PRIVACY_MODE;
|
||||
h.timestamp_ns = 0x0123_4567_89AB_CDEF;
|
||||
h.ap_hash = [0xAA; 16];
|
||||
h.sta_hash = [0xBB; 16];
|
||||
h.session_id = [0xCC; 16];
|
||||
h.channel = 36;
|
||||
h.bandwidth_mhz = 80;
|
||||
h.rssi_dbm = -55;
|
||||
h.noise_floor_dbm = -95;
|
||||
h.n_subcarriers = 234;
|
||||
h.n_tx = 3;
|
||||
h.n_rx = 4;
|
||||
h.quantization = 1;
|
||||
h.privacy_class = 2;
|
||||
h.payload_len = 12_345;
|
||||
h.payload_crc32 = 0xDEAD_BEEF;
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_roundtrip_preserves_all_fields() {
|
||||
let original = sample_header();
|
||||
let bytes = original.to_le_bytes();
|
||||
let parsed = BfldFrameHeader::from_le_bytes(&bytes).expect("parse must succeed");
|
||||
|
||||
assert_eq!({ parsed.magic }, BFLD_MAGIC);
|
||||
assert_eq!({ parsed.version }, 1);
|
||||
assert_eq!({ parsed.flags }, flags::HAS_CSI_DELTA | flags::PRIVACY_MODE);
|
||||
assert_eq!({ parsed.timestamp_ns }, 0x0123_4567_89AB_CDEF);
|
||||
assert_eq!(parsed.ap_hash, [0xAA; 16]);
|
||||
assert_eq!(parsed.sta_hash, [0xBB; 16]);
|
||||
assert_eq!(parsed.session_id, [0xCC; 16]);
|
||||
assert_eq!({ parsed.channel }, 36);
|
||||
assert_eq!({ parsed.bandwidth_mhz }, 80);
|
||||
assert_eq!({ parsed.rssi_dbm }, -55);
|
||||
assert_eq!({ parsed.noise_floor_dbm }, -95);
|
||||
assert_eq!({ parsed.n_subcarriers }, 234);
|
||||
assert_eq!(parsed.n_tx, 3);
|
||||
assert_eq!(parsed.n_rx, 4);
|
||||
assert_eq!(parsed.quantization, 1);
|
||||
assert_eq!(parsed.privacy_class, 2);
|
||||
assert_eq!({ parsed.payload_len }, 12_345);
|
||||
assert_eq!({ parsed.payload_crc32 }, 0xDEAD_BEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_serialization_is_deterministic() {
|
||||
let h = sample_header();
|
||||
let a = h.to_le_bytes();
|
||||
let b = h.to_le_bytes();
|
||||
assert_eq!(a, b, "two serializations of the same header must be bit-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_magic_is_at_offset_zero_little_endian() {
|
||||
let bytes = sample_header().to_le_bytes();
|
||||
// BFLD_MAGIC = 0xBF1D_0001 → little-endian: 01 00 1D BF
|
||||
assert_eq!(&bytes[0..4], &[0x01, 0x00, 0x1D, 0xBF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_invalid_magic() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[0] = 0xFF; // clobber magic
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::InvalidMagic(got)) => {
|
||||
assert_ne!(got, BFLD_MAGIC);
|
||||
}
|
||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_unsupported_version() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[4] = 99; // version field at offset 4 (LE u16)
|
||||
bytes[5] = 0;
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::UnsupportedVersion(v)) => assert_eq!(v, 99),
|
||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_size_is_constant() {
|
||||
assert_eq!(sample_header().to_le_bytes().len(), BFLD_HEADER_SIZE);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Acceptance tests for ADR-120 §2.2 sink marker enforcement (invariant I1).
|
||||
|
||||
use wifi_densepose_bfld::sink::{LocalKind, MatterKind, NetworkKind};
|
||||
use wifi_densepose_bfld::{check_class, BfldError, PrivacyClass};
|
||||
|
||||
// --- PrivacyClass::try_from ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn privacy_class_try_from_accepts_all_four_valid_bytes() {
|
||||
assert_eq!(PrivacyClass::try_from(0).unwrap(), PrivacyClass::Raw);
|
||||
assert_eq!(PrivacyClass::try_from(1).unwrap(), PrivacyClass::Derived);
|
||||
assert_eq!(PrivacyClass::try_from(2).unwrap(), PrivacyClass::Anonymous);
|
||||
assert_eq!(PrivacyClass::try_from(3).unwrap(), PrivacyClass::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_class_try_from_rejects_out_of_range_bytes() {
|
||||
for b in [4u8, 5, 7, 17, 42, 100, 200, 255] {
|
||||
match PrivacyClass::try_from(b) {
|
||||
Err(BfldError::InvalidPrivacyClass(got)) => assert_eq!(got, b),
|
||||
other => panic!("expected InvalidPrivacyClass({b}), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_class_byte_roundtrip_is_stable() {
|
||||
for c in [
|
||||
PrivacyClass::Raw,
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
assert_eq!(PrivacyClass::try_from(c.as_u8()).unwrap(), c);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LocalSink accepts everything ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn local_sink_accepts_all_classes() {
|
||||
for c in [
|
||||
PrivacyClass::Raw,
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
check_class::<LocalKind>(c).expect("LocalSink must accept every class");
|
||||
}
|
||||
}
|
||||
|
||||
// --- NetworkSink rejects Raw, accepts the rest --------------------------
|
||||
|
||||
#[test]
|
||||
fn network_sink_rejects_raw_frames() {
|
||||
let err = check_class::<NetworkKind>(PrivacyClass::Raw).unwrap_err();
|
||||
match err {
|
||||
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "NetworkKind"),
|
||||
other => panic!("expected PrivacyViolation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_sink_accepts_derived_anonymous_restricted() {
|
||||
for c in [
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
check_class::<NetworkKind>(c)
|
||||
.expect("NetworkSink must accept Derived/Anonymous/Restricted");
|
||||
}
|
||||
}
|
||||
|
||||
// --- MatterSink rejects Raw and Derived ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn matter_sink_rejects_raw_and_derived() {
|
||||
for c in [PrivacyClass::Raw, PrivacyClass::Derived] {
|
||||
let err = check_class::<MatterKind>(c).unwrap_err();
|
||||
match err {
|
||||
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "MatterKind"),
|
||||
other => panic!("expected PrivacyViolation for {c:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matter_sink_accepts_anonymous_and_restricted() {
|
||||
for c in [PrivacyClass::Anonymous, PrivacyClass::Restricted] {
|
||||
check_class::<MatterKind>(c).expect("MatterSink must accept anonymous + restricted");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user