mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faecee9a37 | |||
| efadeb3a73 | |||
| eb996294fb | |||
| be4dad6ede | |||
| c965e3e6c0 | |||
| 833ac84059 | |||
| 0bffe27288 | |||
| 753f0a23b7 |
@@ -0,0 +1,99 @@
|
||||
name: BFLD MQTT Integration
|
||||
|
||||
# Runs the env-gated mosquitto integration tests from iters 24 + 29 of the
|
||||
# BFLD rollout (ADR-118 / ADR-122 §2.2). Spins up an eclipse-mosquitto:2
|
||||
# service container, exports BFLD_MQTT_BROKER, runs `cargo test --features
|
||||
# mqtt`. Local developers can reproduce with:
|
||||
#
|
||||
# scoop install mosquitto # Windows
|
||||
# # or: docker run -p 1883:1883 eclipse-mosquitto:2
|
||||
# BFLD_MQTT_BROKER=tcp://localhost:1883 \
|
||||
# cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/adr-118-*'
|
||||
- 'feat/bfld-*'
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mqtt-live-broker:
|
||||
name: cargo test --features mqtt (live mosquitto)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- 1883:1883
|
||||
# Allow anonymous connections — local-only CI broker, no exposure
|
||||
# to the public internet, never touches production credentials.
|
||||
options: >-
|
||||
--health-cmd "mosquitto_pub -h localhost -t healthcheck -m ping || exit 1"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
BFLD_MQTT_BROKER: tcp://localhost:1883
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo registry + target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: bfld-mqtt-${{ runner.os }}-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Wait for mosquitto to be ready
|
||||
run: |
|
||||
for i in {1..20}; do
|
||||
if nc -z localhost 1883; then
|
||||
echo "mosquitto reachable on port 1883 (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for mosquitto ($i/20)..."
|
||||
sleep 1
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
exit 1
|
||||
|
||||
- name: cargo test --no-default-features (baseline regression)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --no-default-features
|
||||
|
||||
- name: cargo test (default features)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld
|
||||
|
||||
- name: cargo test --features mqtt (incl. live mosquitto roundtrip)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
- name: cargo clippy --features mqtt (lint gate)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p wifi-densepose-bfld --features mqtt --all-targets -- -D warnings
|
||||
continue-on-error: true
|
||||
@@ -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
|
||||
@@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **BFLD — Beamforming Feedback Layer for Detection (ADR-118 umbrella + ADR-119 frame format + ADR-120 privacy class + ADR-121 identity risk scoring + ADR-122 RuView HA/Matter exposure + ADR-123 capture path, [#787](https://github.com/ruvnet/RuView/issues/787)).** New crate `wifi-densepose-bfld` (`v2/crates/wifi-densepose-bfld/`) — the privacy-gated WiFi sensing layer that detects when RF data crosses from "ambient sensing" into "identity record" and **structurally prevents** identity-correlated data from leaving the node. Three invariants enforced by the type system (not policy): **I1** raw BFI never exits the node (`Sink` marker-trait hierarchy + `PrivacyClass::Raw.allows_network() == false`), **I2** identity embedding is in-RAM-only (`IdentityEmbedding` has no `Serialize`/`Clone`/`Copy` + `Drop` zeroizes), **I3** cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed `SignatureHasher` with daily epoch rotation; mean cross-site Hamming distance ≥120 bits across 100 trials). Ships the complete operator surface: `BfldPipeline` + `BfldPipelineHandle` (worker-thread variant + `spawn_with_oracle` for Soul Signature deployments), `BfldEvent` with JSON publishing (`"blake3:<hex>"` `rf_signature_hash` format per spec), 4 `privacy_class` levels (Raw/Derived/Anonymous/Restricted) with `PrivacyGate::demote` monotonic transformer + irreversible `apply_privacy_gating`, `CoherenceGate` with ±0.05 hysteresis + 5-second debounce + clock-skew resilience (saturating_sub), `SoulMatchOracle` Recalibrate-exemption trait for enrolled-person deployments. **MQTT/HA surface**: `mqtt_topics::render_events` + `publish_event` (class-gated topic routing — Raw/Derived publish 0 topics, Anonymous publishes 6, Restricted publishes 5 with `identity_risk` stripped), `ha_discovery::render_discovery_payloads` + `publish_discovery` (HA-DISCO config payloads with `availability_topic` integration), `availability` module (`online`/`offline` + LWT-aware `with_lwt` helper for `rumqttc::MqttOptions`), `RumqttPublisher` behind a `mqtt` feature gate with `connect_with_lwt` for broker-side auto-offline. **3 operator HA Blueprints** under `v2/crates/cog-ha-matter/blueprints/bfld/` (presence-driven-lighting, motion-aware-HVAC, identity-risk-anomaly-notification with rolling 7-day z-score). **Two runnable examples** (`bfld_minimal` for in-process consumers, `bfld_handle` for the production worker-thread + bootstrap-then-spawn pattern). **GitHub Actions CI workflow** (`.github/workflows/bfld-mqtt-integration.yml`) spins up `eclipse-mosquitto:2` as a service container so the env-gated `mosquitto_integration` and `rumqttc_lwt` tests run end-to-end in CI. **Performance**: `BfldFrame::to_bytes()` measured at **320,255 frames/sec** debug (6.4× ADR-119 AC7 release target of 50k), header-only at 1,654,517 frames/sec, presence-detection latency p95 = **0.9µs** (~1,000,000× under ADR-119 AC2's 1s target), 9.96 Hz motion-publish rate through `BfldPipelineHandle` (10× ADR-122 AC3 floor). **Coverage**: 327 tests at default features, 101 no_std-compatible, 220+ with `--features mqtt`. CRC-32/ISO-HDLC polynomial pinned against `"123456789" → 0xCBF43926`, public-API surface snapshot pinned across all `pub use` re-exports, `BfldError` Display contract pinned for log-grep monitoring rules, reserved-flag-bits forward-compat round-trip property, `apply_privacy_gating` irreversibility (5-cycle round-trip stress proves stripped fields never resurrect). Companion research dossier in `docs/research/BFLD/` (11 files, 13,544 words). 49-iter implementation chain from scaffold (`feat/adr-118/p1`, `c965e3e6c`) through current head with per-iter progress comments on issue [#787](https://github.com/ruvnet/RuView/issues/787). Try it: `cargo run -p wifi-densepose-bfld --example bfld_handle`.
|
||||
- **Home Assistant + Matter integration (ADR-115).** New `--mqtt` and `--matter` flags on `wifi-densepose-sensing-server` expose the full sensing capability set to any Home Assistant install via MQTT auto-discovery (HA-DISCO) and to any Matter controller (Apple Home / Google Home / Alexa / SmartThings) via a built-in Matter Bridge scaffolding (HA-FABRIC, SDK wiring v0.7.1). Includes 21 entity kinds per node — 11 raw signals + 10 inferred semantic primitives (HA-MIND: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room-transition). The semantic primitives run server-side so `--privacy-mode` strips HR/BR/pose values from the wire while still publishing the inferred *states* — the architectural win for healthcare and AAL deployments. Ships **8 starter HA Blueprints** under `examples/ha-blueprints/`, **3 drop-in Lovelace dashboards** under `examples/lovelace/` (including a privacy-mode-compatible healthcare care view), mTLS support, 32 KB payload-size cap, MQTT-wildcard topic-injection rejection, `RUVIEW_MQTT_STRICT_TLS=1` v0.8.0 upgrade path. **420 lib tests** cover the implementation including **~2,560 fuzzed assertions per CI run** (10 proptest cases across wire-boundary security + semantic-bus invariants). Plus mosquitto-backed integration tests in `.github/workflows/mqtt-integration.yml`, criterion benchmarks beating every ADR target by 1.6×–208×, and an ESP32-S3 hardware validation harness (`scripts/validate-esp32-mqtt.sh`) that asserts the full pipeline end-to-end with a witness bundle generator (`scripts/witness-adr-115.sh`) that self-verifies. See [`docs/releases/v0.7.0-mqtt-matter.md`](docs/releases/v0.7.0-mqtt-matter.md), [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md), [`docs/integrations/semantic-primitives-metrics.md`](docs/integrations/semantic-primitives-metrics.md), [`docs/integrations/benchmarks.md`](docs/integrations/benchmarks.md), [`docs/adr/ADR-115-home-assistant-integration.md`](docs/adr/ADR-115-home-assistant-integration.md), tracking issue [#776](https://github.com/ruvnet/RuView/issues/776), PR [#778](https://github.com/ruvnet/RuView/pull/778). Matter SDK wiring (P8b) and CSA-certification path (P10) deferred to v0.7.1+ per ADR §9.10. Try it: `cargo run -p wifi-densepose-sensing-server --features mqtt --example mqtt_publisher -- --mqtt --mqtt-host 127.0.0.1`.
|
||||
- **ESP32-C6 firmware target with Wi-Fi 6 / 802.15.4 / TWT / LP-core support ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), #762).** `firmware/esp32-csi-node` now builds for **both** `esp32s3` (existing production node) and `esp32c6` (new research/seed-node target) from the same source tree — pick via `idf.py set-target esp32c6` and ESP-IDF auto-applies the new `sdkconfig.defaults.esp32c6` overlay. Every C6 module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build is byte-identical to today (no regression).
|
||||
- **Wi-Fi 6 HE-LTF subcarrier tagging** — `csi_collector.c` now reads `rx_ctrl.cur_bb_format` and writes the PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB) into ADR-018 frame byte 18, plus bandwidth flags (20/40 MHz, STBC, 802.15.4-sync-valid) into byte 19. Bytes 18-19 were previously reserved-zero, so old aggregators read them as before — fully backwards compatible. Magic stays `0xC5110001`. Default on via `CONFIG_CSI_FRAME_HE_TAGGING`. First firmware in the open ESP32 ecosystem to tag CSI frames with 11ax PPDU metadata.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -582,6 +594,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
|
||||
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
|
||||
| [**Home Assistant + Matter Integration**](docs/integrations/home-assistant.md) | **Works with Home Assistant** via MQTT auto-discovery + **Works with Matter** (Apple Home / Google Home / Alexa / SmartThings) — full entity catalog, 3 starter blueprints, Lovelace dashboards, privacy mode, threshold tuning ([ADR-115](docs/adr/ADR-115-home-assistant-integration.md)). |
|
||||
| [**BFLD — Beamforming Feedback Layer for Detection**](v2/crates/wifi-densepose-bfld/README.md) | New privacy-gated WiFi sensing layer that measures + structurally prevents identity leakage from 802.11ac/ax Beamforming Feedback Information. Three type-enforced invariants (raw BFI never exits node, identity embedding is in-RAM-only, cross-site correlation cryptographically impossible via per-site BLAKE3 keyed hash + daily rotation). Ships full operator surface (`BfldPipeline`, `BfldPipelineHandle`, Soul Signature `SoulMatchOracle` integration), MQTT topic router + HA-DISCO + availability + LWT, 3 operator HA blueprints, two runnable examples, eclipse-mosquitto:2 CI service container. 327+ tests. [ADR-118](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) umbrella + sub-ADRs [119](docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md)/[120](docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md)/[121](docs/adr/ADR-121-bfld-identity-risk-scoring.md)/[122](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md)/[123](docs/adr/ADR-123-bfld-capture-path-nexmon-and-esp32.md). Research dossier: [`docs/research/BFLD/`](docs/research/BFLD/) (11 files, 13,544 words). |
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
|
||||
@@ -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
|
||||
@@ -9,6 +9,7 @@
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -36,7 +37,21 @@ This gap becomes a compliance and liability issue at deployment scale. An operat
|
||||
|
||||
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 What this ADR is *not*
|
||||
### 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).
|
||||
|
||||
@@ -57,7 +57,7 @@ pub struct BfldFrameHeader {
|
||||
}
|
||||
```
|
||||
|
||||
Total header size: 40 bytes (validated by `static_assertions::const_assert_eq!`).
|
||||
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
|
||||
|
||||
@@ -144,7 +144,7 @@ Rejected: CRC must be computed after the payload, so its value would otherwise f
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `BfldFrameHeader` size is exactly 40 bytes on x86_64, aarch64, and xtensa-esp32s3.
|
||||
- [ ] **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.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -114,6 +115,18 @@ A compile-time `#[forbid(serde::Serialize)]` lint on `IdentityEmbedding` ensures
|
||||
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -93,7 +94,19 @@ The `Recalibrate` action triggers a forced site-salt rotation — an aggressive
|
||||
|
||||
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 Compute budget
|
||||
### 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 |
|
||||
|-------|----------------|----------------|
|
||||
@@ -102,7 +115,7 @@ To prevent oscillation around the gate thresholds, the gate uses ±0.05 hysteres
|
||||
| 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).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -124,6 +125,24 @@ Three operator-ready blueprints under `cog-ha-matter/blueprints/`:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
# ADR-124: rvagent — MCP (stdio + Streamable HTTP) + ruvector npm/TypeScript library for RuView with ruflo integration
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-24 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **SENSE-BRIDGE** — a typed bridge between the RuView sensing stack and the MCP agent ecosystem |
|
||||
| **Relates to** | [ADR-055](ADR-055-integrated-sensing-server.md) (sensing-server), [ADR-095](ADR-095-rvcsi-edge-rf-sensing-platform.md) (rvCSI), [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) (rvCSI adoption), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO), [ADR-116](ADR-116-cog-ha-matter-seed.md) (Seed cog), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The access-layer gap
|
||||
|
||||
The RuView / wifi-densepose Rust stack exposes sensing data through three surfaces: a Tokio/Axum HTTP REST API and WebSocket at `wifi-densepose-sensing-server` (ADR-055); an MQTT namespace under `ruview/<node_id>/*` (ADR-115); and an rvCSI edge runtime (ADR-095/096). None of these surfaces speaks Model Context Protocol (MCP).
|
||||
|
||||
MCP is the dominant inter-process contract through which AI assistants (Claude, GPT, Codex) invoke external capabilities in 2026. Without an MCP bridge, RuView's sensing primitives are invisible to AI-driven automation workflows. An agent cannot ask "who is in the room?" or "subscribe me to fall alerts" without bespoke HTTP integration code in every consuming agent.
|
||||
|
||||
Two concrete user stories that SENSE-BRIDGE resolves:
|
||||
|
||||
1. A developer has a Claude Code session and wants to call `vitals.get_heart_rate` from a prompt — today this requires them to write an HTTP fetch, parse JSON, and handle WebSocket reconnect logic; with SENSE-BRIDGE they install `@ruvnet/rvagent` and the tool is available immediately via `claude mcp add rvagent`.
|
||||
2. A ruflo-orchestrated multi-agent swarm needs real-world presence data to gate a workflow: SENSE-BRIDGE gives the swarm an MCP tool call with the same `mcp__claude-flow__*` signature pattern already used for all other ruflo tools (CLAUDE.md §Ruflo Automation Primitives).
|
||||
|
||||
### 1.2 What rvagent is today
|
||||
|
||||
Research of the ruvnet npm registry profile and the ruflo GitHub repository (issue #1689) establishes that **rvagent is not yet a published standalone npm package** as of 2026-05-24. The name "rvagent" appears in the ruflo project exclusively as a WASM artifact (`rvagent_wasm_bg.wasm`, 588 KB) bundled with the RuFlo Web UI (PR #1687). That artifact exports 13 WASM functions including `callMcp`, `executeTool`, `listTools`, `listGalleryTemplates`, `searchGalleryTemplates`, and `loadGalleryTemplate`. It is an in-browser MCP client runner, not a RuView-specific MCP server.
|
||||
|
||||
There is no `rvagent` package on the npm registry as of this writing. The npm name is therefore available (Q1 in §8). The package name to register is `@ruvnet/rvagent` (scoped form, reduces name-squatting risk) or `rvagent` (unscoped form, simpler `npx` invocation). This ADR proposes `@ruvnet/rvagent`.
|
||||
|
||||
The WASM `callMcp` / `executeTool` surface of the existing ruflo rvagent is the functional model for what the new npm package should expose in TypeScript — but the new package is a **server**, not a client, and its tools are RuView-domain-specific rather than general ruflo-gallery tools.
|
||||
|
||||
### 1.3 MCP transport landscape as of 2026-05-24
|
||||
|
||||
The MCP specification shipped version `2025-03-26` (Streamable HTTP) and `2025-06-18` (current stable) replacing the legacy `2024-11-05` HTTP+SSE transport. Key facts relevant to this ADR:
|
||||
|
||||
- **stdio** remains the recommended local transport. Clients launch the MCP server as a subprocess; the server reads JSON-RPC from stdin and writes to stdout. This is the path `claude mcp add <name> -- npx @ruvnet/rvagent stdio` uses (CLAUDE.md §Quick Setup mirrors this pattern for the claude-flow MCP server).
|
||||
- **Streamable HTTP** (colloquially "SSE" in earlier documentation) replaces the deprecated pure-SSE transport. A single HTTP endpoint at e.g. `POST /mcp` accepts JSON-RPC requests and may respond with `Content-Type: text/event-stream` for streaming, or `application/json` for single-turn responses. The server must validate `Origin` headers and bind to `127.0.0.1` by default (MCP spec security requirement).
|
||||
- The `@modelcontextprotocol/sdk` npm package (latest stable at time of writing) ships `Server`, `StdioServerTransport`, and `StreamableHTTPServerTransport`. A single `Server` instance can be connected to both transports simultaneously by calling `server.connect(transport)` for each.
|
||||
- The legacy `SSEServerTransport` from protocol version `2024-11-05` is deprecated but still ship-able for backwards compatibility with older Claude desktop clients. SENSE-BRIDGE will support it behind an `--legacy-sse` flag for a single release cycle, then remove it.
|
||||
|
||||
### 1.4 ruvector npm surface
|
||||
|
||||
The `ruvector` npm package (version 0.2.x, latest 0.2.25 as of ~2026-05-01) is a napi-rs WASM/Node.js binding of the RuVector Rust crate. It provides:
|
||||
|
||||
- HNSW in-memory vector index (sub-0.5 ms query latency, 50 K+ QPS single-threaded)
|
||||
- 50+ attention mechanisms from the RuVector Rust crate
|
||||
- FlashAttention-3 SIMD path
|
||||
- Graph Neural Network support via `@ruvector/gnn`
|
||||
- Full TypeScript types; ships both ESM and CJS
|
||||
|
||||
The `ruvector` package is already a dependency in the existing Rust workspace's napi-rs node bindings (`ruvector-node` crate, version 0.1.29 on crates.io). The npm package and the Rust crate are developed in the same repository (`github.com/ruvnet/ruvector`). SENSE-BRIDGE can depend on `ruvector` directly without needing to add new Rust FFI — the vector ops needed (HNSW index of pose keypoints, embedding storage for AETHER person re-ID) are already exposed in the npm package's public surface.
|
||||
|
||||
### 1.5 ruflo integration context
|
||||
|
||||
The project's `CLAUDE.md` documents the 3-tier model routing (ADR-026) and the `mcp__claude-flow__*` tool namespace. ruflo exposes 314 native MCP tools. SENSE-BRIDGE adds a new domain namespace `mcp__rvagent__*` that represents RuView sensing capabilities, parallel to but separate from the ruflo tools. The boundary is:
|
||||
- **ruflo**: agent orchestration, memory, swarm coordination, hooks, task management
|
||||
- **rvagent / SENSE-BRIDGE**: RuView-specific sensing — presence, vitals, pose, BFLD, semantic primitives
|
||||
|
||||
ruflo can call rvagent tools via the standard MCP tool-call mechanism; rvagent does not depend on ruflo at runtime (but may optionally use ruflo memory namespaces for persistence).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship `@ruvnet/rvagent` as a standalone npm TypeScript library that:
|
||||
|
||||
1. Exposes a **dual-transport MCP server** (stdio + Streamable HTTP) wrapping RuView sensing primitives.
|
||||
2. Uses `ruvector` (npm) as the vector storage layer for pose embeddings and AETHER-class semantic search, with no reimplementation of vector ops in TypeScript.
|
||||
3. Mirrors the Python `wifi_densepose.client.*` surface (ADR-117 P4 — `python/wifi_densepose/client/ws.py`, `mqtt.py`, `primitives.py`) in TypeScript for parity across runtimes.
|
||||
4. Integrates as a ruflo plugin via the `ruflo-plugin` manifest convention, exposing tools in the `mcp__rvagent__*` namespace callable by ruflo agents.
|
||||
5. Ships strict TypeScript source, ESM + CJS dual output, Node.js 20+ minimum, type definitions in the tarball, zero bundler required.
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport comparison
|
||||
|
||||
| Dimension | stdio | Streamable HTTP |
|
||||
|---|---|---|
|
||||
| **Launch mechanism** | Client forks `npx @ruvnet/rvagent stdio` as subprocess | Client POSTs to `http://host:port/mcp` |
|
||||
| **Primary use case** | Claude Code, Cursor, IDE plugins — local developer flow | Remote agents, ruflo swarms on separate hosts, browser-based dashboards |
|
||||
| **Connection state** | One client per server process; process dies with client | Multiple clients per server process; stateless or session-keyed |
|
||||
| **Streaming** | Newline-delimited JSON on stdout | `text/event-stream` response body |
|
||||
| **Auth** | None needed (process-level isolation) | Bearer token or mTLS required (per MCP spec security rules) |
|
||||
| **RuView sensing-server connectivity** | Server process holds a single WebSocket + MQTT connection to sensing-server; results forwarded to client via JSON-RPC | Server process holds a connection pool; session affinity via `Mcp-Session-Id` header |
|
||||
| **Tailscale fleet** | Works on local node only | Works across Tailscale fleet (cognitum-v0, cognitum-seed-1, ruvultra) with DNS name |
|
||||
| **Origin validation** | Not applicable | Required; server MUST reject cross-origin requests unless CORS policy explicitly permits |
|
||||
| **Resumability** | Not applicable (process is co-located) | Optional `Last-Event-ID` header for stream resumption after reconnect |
|
||||
| **Logging** | stderr — captured by Claude Code, displayed in conversation | Structured JSON to stdout, shipped to ruflo observability (ADR-observability) |
|
||||
| **Process lifecycle** | Ephemeral — exits when Claude Code session ends | Long-lived — suitable for always-on sensing daemon |
|
||||
| **When to choose** | Single developer, local ESP32 (COM9), quick scripting | Fleet deployment, multi-agent ruflo swarms, web dashboards |
|
||||
|
||||
Both transports are served by the same `Server` instance from `@modelcontextprotocol/sdk`. The only difference is the `Transport` class passed to `server.connect()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP tool catalog
|
||||
|
||||
All tools are in the `ruview` namespace. Input schemas below are TypeScript interface stubs; output types mirror the Python dataclasses from `python/wifi_densepose/client/ws.py` and `primitives.py`.
|
||||
|
||||
### 4.1 Tool catalog table
|
||||
|
||||
| Tool name | Input interface | Return shape | RuView surface wrapped |
|
||||
|---|---|---|---|
|
||||
| `ruview.presence.now` | `{ node_id?: string }` | `{ node_id: string; present: boolean; n_persons: number; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.presence` / `EdgeVitalsMessage.n_persons` (ws.py:74-88) |
|
||||
| `ruview.vitals.get_breathing` | `{ node_id?: string; window_s?: number }` | `{ node_id: string; breathing_rate_bpm: number \| null; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.breathing_rate_bpm` (ws.py:82) |
|
||||
| `ruview.vitals.get_heart_rate` | `{ node_id?: string; window_s?: number }` | `{ node_id: string; heartrate_bpm: number \| null; confidence: number; timestamp_ms: number }` | `EdgeVitalsMessage.heartrate_bpm` (ws.py:83) |
|
||||
| `ruview.vitals.get_all` | `{ node_id?: string }` | `EdgeVitalsResult` (all fields of `EdgeVitalsMessage` except `raw`) | Full `EdgeVitalsMessage` (ws.py:74-88) |
|
||||
| `ruview.pose.latest` | `{ node_id?: string }` | `{ node_id: string; persons: PosePersonResult[]; confidence: number; timestamp_ms: number }` | `PoseDataMessage` (ws.py:91-98) |
|
||||
| `ruview.pose.subscribe` | `{ node_id?: string; duration_s: number; callback_url?: string }` | `{ subscription_id: string; started_at: number; expires_at: number }` | WS stream — streams `PoseDataMessage` events for `duration_s` seconds |
|
||||
| `ruview.primitives.get` | `{ node_id?: string; primitive: SemanticPrimitiveKind }` | `SemanticPrimitiveResult` | `SemanticPrimitive` + `SemanticPrimitiveEvent` (primitives.py:36-75) |
|
||||
| `ruview.primitives.list_active` | `{ node_id?: string }` | `{ primitives: SemanticPrimitiveResult[] }` | All 10 ADR-115 semantic primitives (primitives.py:36-45) |
|
||||
| `ruview.primitives.subscribe` | `{ node_id?: string; primitive?: SemanticPrimitiveKind; duration_s: number }` | `{ subscription_id: string; expires_at: number }` | MQTT topic `homeassistant/+/wifi_densepose_<node>/+/state` (mqtt.py:8-9) |
|
||||
| `ruview.bfld.last_scan` | `{ node_id?: string }` | `{ node_id: string; identity_risk_score: number; privacy_class: number; n_frames: number; timestamp_ms: number }` | MQTT `ruview/<node_id>/bfld/scan_result` (ADR-118/ADR-121) |
|
||||
| `ruview.bfld.subscribe` | `{ node_id?: string; duration_s: number }` | `{ subscription_id: string; expires_at: number }` | MQTT `ruview/<node_id>/bfld/*` |
|
||||
| `ruview.node.list` | `{ }` | `{ nodes: NodeInfo[] }` | MQTT discovery + REST `/api/nodes` |
|
||||
| `ruview.node.status` | `{ node_id: string }` | `NodeStatusResult` | REST `/api/status` or MQTT will-message |
|
||||
| `ruview.vector.search_pose` | `{ query_embedding: number[]; k?: number; node_id?: string }` | `{ matches: VectorMatch[] }` | `ruvector` HNSW index of stored pose keypoints (ADR-016) |
|
||||
| `ruview.vector.store_pose` | `{ pose: PosePersonResult; node_id: string }` | `{ vector_id: string }` | `ruvector` HNSW upsert |
|
||||
|
||||
### 4.1a Policy / governance tools (RUVIEW-POLICY)
|
||||
|
||||
**Added 2026-05-24 per maintainer review.** Once tools can answer "who is in the room?", the library is no longer middleware — it is environmental intelligence infrastructure, and that changes the trust model. Every sensing tool above MUST route through this policy layer before returning data. The layer is enforced server-side in the MCP server, not client-side, so a malicious or misconfigured agent cannot bypass it.
|
||||
|
||||
| Tool name | Input interface | Return shape | Purpose |
|
||||
|---|---|---|---|
|
||||
| `ruview.policy.can_access_vitals` | `{ agent_id: string; node_id: string; vital: "breathing" \| "heart_rate" \| "all" }` | `{ allowed: boolean; reason: string; expires_at?: number }` | Gate every `ruview.vitals.*` call. Default-deny when no policy is registered for the (agent_id, node_id) pair. |
|
||||
| `ruview.policy.can_query_presence` | `{ agent_id: string; scope: "node" \| "fleet"; node_id?: string; zone?: string }` | `{ allowed: boolean; reason: string; redactions?: string[] }` | Fleet-scope presence queries (e.g. "is anyone home?") require explicit scope grant; node-scope is the safer default. |
|
||||
| `ruview.policy.can_subscribe` | `{ agent_id: string; topic: string; duration_s: number }` | `{ allowed: boolean; max_duration_s: number; reason: string }` | Subscriptions can be denied entirely or capped to a shorter duration than requested (e.g. agent asks for 1 h, policy returns 5 min). |
|
||||
| `ruview.policy.redact_identity_fields` | `{ payload: Record<string, unknown>; agent_id: string }` | `{ payload: Record<string, unknown>; redacted_fields: string[] }` | Server-side redaction pass applied to every tool return value. Strips `sta_mac`, raw BFLD matrices, and any keypoint set marked `privacy_class >= 2` per ADR-120. Called automatically by the MCP server; agents never see the un-redacted payload. |
|
||||
| `ruview.policy.audit_log` | `{ agent_id?: string; since_ts?: number }` | `{ events: PolicyAuditEvent[] }` | Returns the policy-decision audit trail for a maintainer-tier agent. Other agents are denied even if they hold valid tool grants — auditability of the auditor is itself a policy decision. |
|
||||
|
||||
Policy storage is a local JSON file (`~/.config/rvagent/policy.json` on Unix, `%APPDATA%\rvagent\policy.json` on Windows) backed by a CLI editor (`npx @ruvnet/rvagent policy grant ...`). Schema mirrors the ADR-010 claims-based authorization model where it exists in the Rust workspace, but the npm library keeps a self-contained store so SENSE-BRIDGE can ship without the full claims infrastructure on day one.
|
||||
|
||||
**Default policy when no file exists**: deny `ruview.vitals.*` and `ruview.policy.audit_log`; allow `ruview.presence.now` and `ruview.node.list` (coarse, non-biometric); allow `ruview.primitives.list_active` with `redact_identity_fields` applied. This is the "explore safely" default so a new install can sanity-check the agent is wired up without leaking biometric data.
|
||||
|
||||
### 4.2 MCP resource catalog
|
||||
|
||||
Resources provide read-only data that can be embedded in the LLM context window.
|
||||
|
||||
| Resource URI | Description | MIME type |
|
||||
|---|---|---|
|
||||
| `ruview://nodes` | JSON list of all discovered nodes (IP, firmware version, capabilities) | `application/json` |
|
||||
| `ruview://nodes/{node_id}/config` | Node configuration (channel, MAC filter, privacy class) | `application/json` |
|
||||
| `ruview://nodes/{node_id}/vitals/latest` | Latest `EdgeVitalsMessage` for the node | `application/json` |
|
||||
| `ruview://nodes/{node_id}/pose/latest` | Latest `PoseDataMessage` | `application/json` |
|
||||
| `ruview://nodes/{node_id}/bfld/latest` | Latest BFLD scan result | `application/json` |
|
||||
| `ruview://primitives/schema` | JSON schema for the 10 semantic primitives (ADR-115) | `application/json` |
|
||||
| `ruview://fleet/topology` | Tailscale-fleet topology (host, TS IP, role) — sourced from local CLAUDE.local.md fleet table | `text/markdown` |
|
||||
|
||||
### 4.3 MCP prompt templates
|
||||
|
||||
| Prompt name | Description | Arguments |
|
||||
|---|---|---|
|
||||
| `ruview.diagnose_node` | Walk the user through node connectivity check, firmware version, and live vitals stream | `{ node_id: string }` |
|
||||
| `ruview.presence_report` | Summarize presence + persons over a time window in natural language | `{ node_id: string; window_s: number }` |
|
||||
| `ruview.vitals_alert_rule` | Generate an HA automation YAML fragment for a vitals threshold alert | `{ primitive: SemanticPrimitiveKind; threshold: number }` |
|
||||
| `ruview.bfld_privacy_audit` | Produce a compliance-ready privacy audit paragraph from the last BFLD scan | `{ node_id: string }` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependency graph
|
||||
|
||||
```
|
||||
@ruvnet/rvagent (npm / TypeScript)
|
||||
├── @modelcontextprotocol/sdk ^1.x — MCP Server, StdioServerTransport,
|
||||
│ StreamableHTTPServerTransport, McpError
|
||||
├── ruvector ^0.2 — HNSW vector index, embedding storage
|
||||
│ (napi-rs native bindings; NO reimplementation)
|
||||
├── zod ^3.x — Input schema validation for all tool inputs
|
||||
├── ws ^8.x — WebSocket client to sensing-server /ws/sensing
|
||||
│ └── @types/ws
|
||||
├── mqtt ^5.x — MQTT client for ruview/<node_id>/* topics
|
||||
│ (replaces paho-mqtt; mqtt.js is the npm standard)
|
||||
├── node-fetch / undici — — HTTP client for REST endpoints on sensing-server
|
||||
└── tsup (dev) — ESM + CJS dual build
|
||||
|
||||
Runtime back-ends (NOT bundled — must be reachable at runtime):
|
||||
├── wifi-densepose-sensing-server (Rust binary)
|
||||
│ ├── REST API :3000 /api/*
|
||||
│ ├── WebSocket :8765 /ws/sensing
|
||||
│ └── MQTT via local broker or ruview/<node_id>/*
|
||||
├── MQTT broker (mosquitto or broker at cognitum-v0:1883)
|
||||
└── ruvector HNSW index (in-process via napi-rs; no separate service)
|
||||
```
|
||||
|
||||
Key integration boundary: **ruvector is purely in-process**. The HNSW index lives in the `@ruvnet/rvagent` Node.js process memory, populated from pose keypoints received over the sensing-server WebSocket. There is no separate vector service. This matches the architecture of `wifi-densepose-ruvector` (Rust crate in the workspace) which is also in-process.
|
||||
|
||||
---
|
||||
|
||||
## 6. Python client surface parity table
|
||||
|
||||
The Python client in `python/wifi_densepose/client/` (ADR-117 P4) is the canonical reference for the TS surface. TypeScript should mirror it so users see the same domain model across runtimes.
|
||||
|
||||
| Python class / enum | File | TypeScript equivalent in @ruvnet/rvagent |
|
||||
|---|---|---|
|
||||
| `SensingMessage` | `ws.py:54-60` | `interface SensingMessage` |
|
||||
| `ConnectionEstablishedMessage` | `ws.py:63-70` | `interface ConnectionEstablishedMessage extends SensingMessage` |
|
||||
| `EdgeVitalsMessage` | `ws.py:74-88` | `interface EdgeVitalsMessage extends SensingMessage` |
|
||||
| `PoseDataMessage` | `ws.py:91-98` | `interface PoseDataMessage extends SensingMessage` |
|
||||
| `SensingClient` (asyncio) | `ws.py:160` | `class SensingClient` (EventEmitter-based, async iterator) |
|
||||
| `SemanticPrimitive` (enum) | `primitives.py:36-45` | `enum SemanticPrimitive` |
|
||||
| `SemanticPrimitiveEvent` | `primitives.py:60-75` | `interface SemanticPrimitiveEvent` |
|
||||
| `SemanticPrimitiveListener` | `primitives.py:84-155` | `class SemanticPrimitiveListener` |
|
||||
| `RuViewMqttClient` | `mqtt.py:56` | `class RuViewMqttClient` (wraps mqtt.js `MqttClient`) |
|
||||
| `_topic_matches` | `mqtt.py:237-257` | `function topicMatches(pattern, topic)` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan
|
||||
|
||||
```
|
||||
P1 ──► P2 ──► P3 ──► P4 ──► P5
|
||||
npm MCP MCP ruvector npm
|
||||
scaffold stdio SSE integration publish + ruflo bridge
|
||||
```
|
||||
|
||||
### P1 — Scaffold (1 week)
|
||||
|
||||
**Goal**: an installable npm package skeleton that compiles and passes CI.
|
||||
|
||||
- [ ] Create `npm/rvagent/` directory in the repo (mirrors `python/wifi_densepose/`). Do not add to `v2/` Rust workspace.
|
||||
- [ ] `package.json`: name `@ruvnet/rvagent`, version `0.1.0-alpha.1`, `type: "module"`, exports map with `./package.json`, `.` (ESM + CJS), `./stdio`, `./http`.
|
||||
- [ ] `tsconfig.json`: `strict: true`, `target: ES2022`, `module: NodeNext`, `moduleResolution: NodeNext`.
|
||||
- [ ] `tsup.config.ts`: dual `esm + cjs` build, `dts: true`.
|
||||
- [ ] Add `@modelcontextprotocol/sdk`, `ruvector`, `zod`, `ws`, `mqtt`, `tsup` as deps / devDeps.
|
||||
- [ ] CI job: `npm ci && npm run build` on `ubuntu-latest` with Node 20, 22.
|
||||
- [ ] Stub `src/index.ts` that exports package version string. Import succeeds.
|
||||
|
||||
### P2 — MCP stdio server (2 weeks)
|
||||
|
||||
**Goal**: `npx @ruvnet/rvagent stdio` connects to a running sensing-server over WebSocket + MQTT and exposes the tool catalog from §4.1 over stdio transport.
|
||||
|
||||
- [ ] `src/server.ts` — create `McpServer` instance, register all tools from §4.1 with Zod input schemas. Tools that require a live sensing-server connection return a structured error `{ error: "SENSING_SERVER_UNAVAILABLE" }` rather than throwing, so the LLM gets useful context.
|
||||
- [ ] `src/transports/stdio.ts` — `StdioServerTransport` entrypoint. Reads `RUVIEW_HOST` and `RUVIEW_PORT` env vars (default `localhost:8765` WS, `localhost:3000` REST, `localhost:1883` MQTT).
|
||||
- [ ] `src/sensing/ws-client.ts` — TypeScript port of `python/wifi_densepose/client/ws.py`. Async generator yielding `SensingMessage` variants. Reconnect with exponential back-off (the Python client explicitly does not reconnect — the TS one should, because the stdio process is long-lived).
|
||||
- [ ] `src/sensing/mqtt-client.ts` — TypeScript port of `python/wifi_densepose/client/mqtt.py` using `mqtt.js ^5`. Per-pattern callbacks, `topicMatches` wildcard helper.
|
||||
- [ ] `src/sensing/primitives.ts` — `SemanticPrimitive` enum + `SemanticPrimitiveListener`. Mirror of `primitives.py`.
|
||||
- [ ] Tool implementations for the 5 highest-priority tools: `ruview.presence.now`, `ruview.vitals.get_all`, `ruview.pose.latest`, `ruview.primitives.get`, `ruview.node.list`.
|
||||
- [ ] Resource implementations: `ruview://nodes`, `ruview://nodes/{node_id}/vitals/latest`.
|
||||
- [ ] Integration test: spin up `sensing-server --mock-frames` in Docker; assert `npx @ruvnet/rvagent stdio` receives a `ruview.vitals.get_all` tool call response with non-null `breathing_rate_bpm`.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` smoke-test (manual).
|
||||
|
||||
### P3 — MCP Streamable HTTP server (2 weeks)
|
||||
|
||||
**Goal**: `npx @ruvnet/rvagent serve --port 3100` starts an HTTP server that serves the full MCP tool catalog over Streamable HTTP (and optionally legacy SSE for backwards compat).
|
||||
|
||||
- [ ] `src/transports/http.ts` — `StreamableHTTPServerTransport` backed by an Express 5 or Hono app (Hono preferred for lightweight edge deployability).
|
||||
- [ ] Session management: issue `Mcp-Session-Id` UUIDs on `POST /mcp` initialize; reject subsequent requests without session header with HTTP 400.
|
||||
- [ ] Origin validation: configurable `RUVIEW_ALLOWED_ORIGINS` env var; default reject all cross-origin requests (MCP spec security requirement §Streamable HTTP §Security Warning).
|
||||
- [ ] Auth: optional `RUVIEW_BEARER_TOKEN` env var. If set, require `Authorization: Bearer <token>` on all requests. This mirrors `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs`.
|
||||
- [ ] Legacy SSE compatibility: `--legacy-sse` flag mounts the deprecated `SSEServerTransport` on `/sse` + `/message` for Claude Desktop clients on protocol version `2024-11-05`. Document this as a single-release compat shim.
|
||||
- [ ] Remaining tools from §4.1: `ruview.vitals.get_breathing`, `ruview.vitals.get_heart_rate`, `ruview.pose.subscribe`, `ruview.primitives.list_active`, `ruview.primitives.subscribe`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`, `ruview.node.status`.
|
||||
- [ ] Prompt template registrations from §4.3.
|
||||
- [ ] Integration test: `curl -X POST http://localhost:3100/mcp` with a `tools/list` request; assert the response lists all 15 tools.
|
||||
- [ ] Docker Compose entry for local fleet testing: `rvagent` HTTP container talking to `sensing-server` and `mosquitto` containers.
|
||||
|
||||
### P4 — ruvector integration (1 week)
|
||||
|
||||
**Goal**: `ruview.vector.search_pose` and `ruview.vector.store_pose` tools work end-to-end with a live HNSW index.
|
||||
|
||||
- [ ] `src/vector/index.ts` — wrapper around `ruvector` napi-rs bindings. Initialise an HNSW index at server startup; expose `store(id, embedding)` and `search(embedding, k)`.
|
||||
- [ ] Pose-to-embedding pipeline: when a `PoseDataMessage` arrives from the WS client, extract the 17-keypoint array, normalise to `[-1, 1]` per keypoint coordinate, flatten to a 34-dimensional float vector, store in HNSW with `node_id:person_index:timestamp_ms` as the ID.
|
||||
- [ ] `src/vector/aether.ts` — AETHER-style cross-viewpoint search (ADR-024): given a pose embedding query, search HNSW index across all stored poses and return the top-k matches with their source node IDs. This enables cross-node person re-identification via the MCP tool without any network call between nodes.
|
||||
- [ ] Verify that the `ruvector` napi-rs binary loads correctly on Node 20 linux/x86_64, macos/arm64, and windows/amd64. Document any platform-specific caveats.
|
||||
- [ ] Index persistence: optional `RUVIEW_VECTOR_DB_PATH` env var. If set, persist the HNSW index to disk using `ruvector`'s serialise API. If unset, in-memory only (default for stdio transport).
|
||||
- [ ] Integration test: feed 100 synthetic pose frames with known clustering, assert `ruview.vector.search_pose` retrieves nearest neighbours with recall >0.9.
|
||||
|
||||
### P5 — npm publish + ruflo bridge (1 week)
|
||||
|
||||
**Goal**: `npm install @ruvnet/rvagent` works for consumers; ruflo agents can call `mcp__rvagent__*` tools through the standard claude-flow MCP registration.
|
||||
|
||||
- [ ] Populate `package.json` with `publishConfig: { access: "public" }`, `engines: { node: ">=20" }`, `files` whitelist (`dist/`, `src/`, `README.md`).
|
||||
- [ ] Publish `@ruvnet/rvagent@0.1.0-alpha.1` to npm under the `@ruvnet` scope.
|
||||
- [ ] ruflo plugin manifest: create `.claude/plugins/rvagent/plugin.json` following the ruflo `plugin/` convention in the ruflo repo. The manifest registers the HTTP transport URL (configurable) and maps `mcp__rvagent__*` tool calls to the rvagent MCP server.
|
||||
- [ ] `ruview` skill in `.claude/agents/` (CLAUDE.md §Available Agents): an agent description that documents the rvagent tool namespace for ruflo orchestration.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` tested against claude-flow MCP server on the local dev machine (ruvzen host on CLAUDE.local.md fleet).
|
||||
- [ ] Document the fleet deployment pattern: run `npx @ruvnet/rvagent serve` on cognitum-v0 (Tailscale IP 100.77.59.83, port 50060 range to avoid conflict with existing services; see CLAUDE.local.md services table). Register the URL as a remote MCP server in `.claude/settings.json`.
|
||||
- [ ] Publish announcement: link from project README (`docs/` link, not root README per CLAUDE.md rules).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
**Q1. npm package name availability**
|
||||
`rvagent` (unscoped) does not appear in the npm registry as of 2026-05-24 based on search results. `@ruvnet/rvagent` is definitely available (the `@ruvnet` scope is owned by ruvnet per the npm profile page). Should the package be published unscoped (`rvagent`) for simpler `npx rvagent stdio` invocation, or scoped (`@ruvnet/rvagent`) for namespace clarity? The decision should be made before P5 because the npm name is permanent.
|
||||
|
||||
**Q2. ruvector binary compatibility on Windows**
|
||||
The `ruvector` npm package is a napi-rs native addon. The project's primary development machine (ruvzen) is Windows 11. It is not confirmed whether `ruvector@0.2.25` ships a prebuilt Windows binary in its npm tarball or requires a Rust toolchain to compile. If no Windows binary is shipped, developers on ruvzen would need the Rust toolchain installed to use `@ruvnet/rvagent`. This must be confirmed before P5 by running `npm install ruvector` on ruvzen.
|
||||
|
||||
**Q3. ruvector TypeScript API stability**
|
||||
ruvector `0.2.x` is not a 1.0 release. The HNSW insert and search API surface may change between minor versions. SENSE-BRIDGE P4 should pin `ruvector@~0.2.25` and document the version constraint explicitly. The question is whether ruvector publishes a changelog with breaking-change notices.
|
||||
|
||||
**Q4. MCP tool call latency budget — RESOLVED**
|
||||
Raw sensing frequency ≠ agent interaction frequency. If a tool call ever waits on the next CSI frame, agent orchestration latency becomes physically coupled to RF acquisition jitter, which is unacceptable at scale. The library MUST take option (a) — return from a continuous local cache:
|
||||
|
||||
1. **Continuous local cache**: on startup the rvagent MCP server opens one WebSocket + one MQTT subscription per configured sensing-server endpoint and ingests every frame into an in-memory `Map<node_id, EdgeVitalsMessage>` (plus parallel maps for `PoseDataMessage` and BFLD). Cache hits return in <1 ms regardless of CSI frame rate.
|
||||
2. **Event-driven invalidation**: the cache entry's `received_at` timestamp is bumped on every received frame. The cache itself is never purged on a timer — only overwritten when fresh data lands, so a node that went quiet still serves its last-known value.
|
||||
3. **Bounded freshness windows**: each tool accepts an optional `max_age_ms` argument (default 1000). If the cached `received_at` is older than `max_age_ms`, the tool returns `{ value: null, reason: "stale", last_seen_ms: N, threshold_ms: max_age_ms }` rather than blocking. The agent decides whether to accept the staleness, raise to the user, or escalate to a `ruview.node.status` health check.
|
||||
|
||||
This pattern is required because P3's Streamable HTTP transport may serve dozens of concurrent agent sessions — see Q8. A shared cache + per-session freshness contract scales; per-session WS connections do not.
|
||||
|
||||
P2 must implement this cache; P3 must verify that fanning the same cache to N concurrent HTTP sessions still maintains <1 ms median tool-call latency under load.
|
||||
|
||||
**Q5. Subscription tool lifetime management**
|
||||
Tools `ruview.pose.subscribe`, `ruview.primitives.subscribe`, and `ruview.bfld.subscribe` return a `subscription_id` and stream events. In the stdio transport there is one client, so this is straightforward. In the HTTP transport with multiple sessions, subscription state must be tracked per `Mcp-Session-Id`. When a session expires (HTTP 404) or is deleted via HTTP DELETE, the subscription must be cleaned up. The lifecycle mechanism is not fully designed — this is a known gap that P3 must close.
|
||||
|
||||
**Q6. AETHER embedding dimension**
|
||||
The ADR proposes a 34-dimensional pose embedding (17 keypoints × 2 coordinates). The actual AETHER embedding model (ADR-024) uses a learned contrastive encoder, not raw keypoints. If the AETHER ONNX model is available in the Rust workspace at P4 time, the embedding should use it. If not, the raw-keypoint approach is a reasonable placeholder. The question is whether `wifi-densepose-nn` exposes the AETHER encoder in a form that can be called from Node.js without bundling libtorch in the npm package.
|
||||
|
||||
**Q7. ruflo plugin manifest format**
|
||||
The ruflo plugin convention (`plugin/` directory in the ruflo repo) is not fully documented in a public spec as of this writing. The manifest format was inferred from the `ruflo-plugins.gif` directory listing and referenced in issue #952. Before P5, the actual plugin manifest schema must be confirmed from the ruflo repo so SENSE-BRIDGE does not ship an incompatible manifest.
|
||||
|
||||
**Q8. MQTT vs direct WebSocket for Streamable HTTP transport**
|
||||
In the stdio transport, rvagent holds a single WebSocket + single MQTT connection to the sensing-server. In the Streamable HTTP transport (potentially serving dozens of agent sessions), maintaining one connection per session is not scalable. The recommended pattern is a single shared connection per (sensing-server endpoint), multiplexed to all sessions. The implementation complexity of this fan-out is non-trivial and is not fully specified here.
|
||||
|
||||
**Q9. Legacy SSE deprecation timeline**
|
||||
The MCP `2024-11-05` SSE transport is deprecated in the current spec but Claude Desktop versions prior to the spec `2025-03-26` update still use it. SENSE-BRIDGE proposes `--legacy-sse` for one release cycle. The question is which specific Claude Desktop version drops legacy SSE support, and whether any of the active fleet nodes (cognitum-v0, cognitum-seed-1) run a Claude Desktop version old enough to need it.
|
||||
|
||||
**Q10. Node.js vs Bun runtime**
|
||||
The ruflo monorepo uses `bun` as the primary runtime (per `bunfig.toml` in `v3/`). Should `@ruvnet/rvagent` also support Bun? Bun's napi-rs compatibility for native addons like `ruvector` is improving but not guaranteed for 0.2.x. The P1 CI should test on Node 20 first; Bun support can be declared as a stretch goal for P5.
|
||||
|
||||
---
|
||||
|
||||
## 9. Alternatives considered
|
||||
|
||||
### Alt-A — Python-only client (extend ADR-117 with MCP bindings)
|
||||
|
||||
Add `wifi_densepose.mcp` as a P6 module in the PIP-PHOENIX wheel (ADR-117). The Python MCP SDK (`mcp[cli]`) supports both stdio and HTTP transports and the PyO3 bindings give direct access to the sensing types.
|
||||
|
||||
**Rejected because**: Python is not the dominant runtime for MCP server hosting in 2026 — the ecosystem tooling (Claude Desktop, Claude Code `mcp add`, ruflo) is TypeScript-first. A Python MCP server requires the full pip install including PyO3 bindings, which is a heavier install than `npx @ruvnet/rvagent stdio`. The ruflo plugin format is TypeScript. ADR-117 is already sizeable; adding MCP to it conflates two distinct concerns (Python developer library vs. AI agent interface). Python MCP remains a viable future addition (Q10 for a future ADR) but is not the right first-ship target.
|
||||
|
||||
### Alt-B — Pure WebSocket/REST client without MCP framing
|
||||
|
||||
Ship a TypeScript client library `@ruvnet/ruview-client` that wraps the sensing-server WebSocket and REST API without the MCP layer. Consumers who want MCP integration would wrap it themselves.
|
||||
|
||||
**Rejected because**: it solves the connectivity problem but not the agent integration problem. Without MCP framing, Claude Code and ruflo agents cannot discover or call RuView capabilities through the standard `mcp__*` namespace — they would need custom prompt injection or bespoke tool definitions per agent. The whole value proposition of this ADR is that a single `claude mcp add rvagent` command makes all RuView primitives discoverable to any MCP-capable AI assistant. Splitting the library forces every consumer to re-add the MCP layer.
|
||||
|
||||
### Alt-C — Embed MCP server inside the existing wifi-densepose-sensing-server Rust binary
|
||||
|
||||
Add an MCP endpoint to the existing Axum server in `v2/crates/wifi-densepose-sensing-server/` (`v2/crates/wifi-densepose-sensing-server/src/main.rs`). This would use the `rmcp` Rust crate (Model Context Protocol SDK for Rust) and expose MCP over an additional port.
|
||||
|
||||
**Rejected because**: (a) it couples the release cycle of the npm-hosted MCP interface to the firmware/Rust release cycle, which are on separate cadences — a new MCP tool that merely adds a JSON field should not require a firmware rebuild; (b) the ruflo plugin ecosystem is TypeScript and expects npm packages, not Rust binaries; (c) the ruvector vector layer is a napi-rs Node.js native module and cannot be called directly from a Rust process without going through the napi-rs server-side API, adding unnecessary complexity; (d) the sensing-server binary is already 15-30 MB stripped — adding the MCP endpoint and its JSON-RPC machinery would further bloat it. This alternative is worth revisiting if the Rust `rmcp` crate matures and the vector layer migrates fully to native Rust, but it is not appropriate for the first implementation.
|
||||
|
||||
### Alt-D — Wrapping the existing ruflo WASM rvagent in a RuView shim
|
||||
|
||||
The ruflo WASM rvagent (`rvagent_wasm_bg.wasm`) already exports `callMcp` / `executeTool` / `listTools`. One could define a RuView shim that registers custom tools into the ruflo WASM rvagent gallery.
|
||||
|
||||
**Rejected because**: the ruflo WASM rvagent is an in-browser MCP *client* runner for the ruflo gallery, not a general-purpose MCP server that can expose sensing data. Its 13 exported functions are focused on template management and ruflo-gallery operations. Patching sensing tools into a browser WASM module is the wrong architecture for a server-side sensing bridge. The naming overlap is a reason to publish the new package promptly and clearly document the distinction.
|
||||
|
||||
---
|
||||
|
||||
## 10. Compatibility
|
||||
|
||||
### 10.1 Backwards compatibility with ADR-117 (PIP-PHOENIX) Python client
|
||||
|
||||
SENSE-BRIDGE does not replace the Python client. Both can coexist:
|
||||
- Python integrators use `from wifi_densepose.client import SensingClient` (ADR-117).
|
||||
- TypeScript / MCP integrators use `import { SensingClient } from "@ruvnet/rvagent"`.
|
||||
- MCP-capable AI assistants use `claude mcp add rvagent -- npx @ruvnet/rvagent stdio`.
|
||||
|
||||
All three talk to the same sensing-server backend; there is no shared state between the Python and TypeScript clients beyond what the sensing-server itself maintains.
|
||||
|
||||
### 10.2 Sensing-server API contract
|
||||
|
||||
SENSE-BRIDGE depends on the sensing-server WebSocket protocol documented in `v2/crates/wifi-densepose-sensing-server/src/main.rs` (referenced in `python/wifi_densepose/client/ws.py:6-13`). The three message types (`connection_established`, `pose_data`, `edge_vitals`) are stable across v0.7.x releases. If the sensing-server adds new message types, SENSE-BRIDGE follows the same pattern as the Python client: unknown `type` values yield a plain `SensingMessage` rather than an error, ensuring forward compatibility.
|
||||
|
||||
### 10.3 MCP protocol version
|
||||
|
||||
SENSE-BRIDGE targets MCP protocol version `2025-06-18` (current stable). It will include backwards compatibility with `2025-03-26` (Streamable HTTP without session management) and optionally `2024-11-05` (legacy SSE via `--legacy-sse` flag). Protocol version `2025-06-18` requires the `MCP-Protocol-Version` header on HTTP requests; SENSE-BRIDGE validates this per spec.
|
||||
|
||||
### 10.4 Node.js version
|
||||
|
||||
Minimum Node.js 20 LTS. Node 22 is supported and recommended for production (active LTS as of 2026). The `ruvector` napi-rs bindings must be confirmed compatible with both (Q2). Node 18 is EOL and explicitly not supported.
|
||||
|
||||
### 10.5 MQTT broker compatibility
|
||||
|
||||
SENSE-BRIDGE uses `mqtt.js ^5` which implements MQTT 3.1.1 and MQTT 5.0. The `mosquitto` local broker (CLAUDE.local.md §Local mosquitto) and cognitum-v0's MQTT stack (CLAUDE.local.md fleet table) are both compatible. TLS mode is optional via `RUVIEW_MQTT_TLS=1` env var.
|
||||
|
||||
---
|
||||
|
||||
## 11. Consequences
|
||||
|
||||
### 11.1 Positive consequences
|
||||
|
||||
- Any MCP-capable AI assistant can query RuView presence, vitals, pose, and BFLD data with zero custom integration code after `claude mcp add rvagent`.
|
||||
- ruflo multi-agent swarms gain first-class access to real-world sensing data, enabling swarms to gate decisions on physical events (fall detected → page caregiver workflow).
|
||||
- The TypeScript surface provides a second reference implementation of the sensing-server client protocol alongside the Python client (ADR-117), validating the protocol design against two independent consumers.
|
||||
- The ruvector HNSW integration enables cross-node person re-identification entirely within the rvagent process — no additional network calls between sensing nodes.
|
||||
|
||||
### 11.2 Negative consequences / risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **ruvector napi-rs not building on Windows** | Medium | Medium | Confirm in P1 CI; if binaries not prebuilt, document requirement of Rust toolchain on Windows |
|
||||
| **MCP protocol churn** — spec updated twice in 2025; another update in 2026 possible | Medium | Low | Pin `@modelcontextprotocol/sdk` to a minor range; wrap SDK calls behind an internal `transport.ts` abstraction so changes are isolated |
|
||||
| **Subscription lifecycle bugs** — zombie subscriptions if session cleanup is missed | High | Medium | Implement per-session resource registry with TTL; all subscriptions auto-expire after `duration_s` even if session is not explicitly deleted |
|
||||
| **sensing-server WS disconnect** — stdio process dies if not reconnecting | Low | High | Implement exponential back-off reconnect in `ws-client.ts`; emit `{ error: "RECONNECTING" }` tool responses during gap |
|
||||
| **npm name collision** — `rvagent` taken by another publisher before P5 | Low | Medium | Publish `@ruvnet/rvagent` scoped; use that name throughout |
|
||||
| **ruflo plugin manifest incompatibility** — format not publicly specced | Medium | Medium | Confirm format in P5 preparation; use the minimal required fields only |
|
||||
| **Sensing-tool surface becomes a surveillance API** — "who is in the room" is a privacy-charged primitive | High | High | RUVIEW-POLICY layer (§4.1a) gates every sensing call; default-deny for biometric tools; redaction applied server-side so agents cannot opt out |
|
||||
|
||||
### 11.3 Strategic implication: ambient-sensing normalization layer
|
||||
|
||||
The MCP tool catalog in §4 is RuView-WiFi-CSI-specific today. The shape of the catalog — `presence.now`, `vitals.get_*`, `pose.latest`, `primitives.*`, `bfld.*` — is **modality-agnostic at the semantic layer**: the same tools could be backed by any sensing modality that produces the same questions.
|
||||
|
||||
If the project later adds BLE, mmWave (e.g. the ESP32-C6 + Seeed MR60BHA2 already on COM4 per CLAUDE.md), LiDAR, thermal, camera, radar, or UWB inputs, the rvagent MCP surface stays the same. Only the source-multiplexer behind `cache.ts` changes — it now ingests from multiple modalities and resolves conflicts (e.g. WiFi CSI says "presence: true" but mmWave says "presence: false" → fusion policy decides; this is the kind of decision the RUVIEW-POLICY layer can also gate).
|
||||
|
||||
This positions the npm package not as "a WiFi client" but as the **semantic-environment API**: agents ask "is anyone here?" without caring which radio answered. The competitive landscape (Aqara FP2, ESPHome LD2410) exposes raw telemetry; SENSE-BRIDGE exposes environmental cognition.
|
||||
|
||||
The follow-on ADR (call it ADR-13x — RUVIEW-FUSION) would formalize the per-modality adapter contract. It is intentionally out of scope for ADR-124 — this ADR ships the WiFi-CSI path only — but the tool catalog and policy layer are designed to absorb additional modalities without API churn.
|
||||
|
||||
---
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
The following must all pass before ADR-124 is considered Accepted:
|
||||
|
||||
- [ ] `npm install @ruvnet/rvagent` succeeds on Node 20/22, linux/x86_64, macos/arm64, windows/amd64 with no Rust toolchain required (ruvector prebuilts must ship).
|
||||
- [ ] `npx @ruvnet/rvagent stdio` starts and responds to a `tools/list` JSON-RPC request with the 15 tools from §4.1.
|
||||
- [ ] `npx @ruvnet/rvagent serve --port 3100` starts; `curl -X POST http://localhost:3100/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'` returns the tool list.
|
||||
- [ ] `ruview.vitals.get_all` with a running `sensing-server --mock-frames` returns `breathing_rate_bpm` and `heartrate_bpm` values within 5 seconds.
|
||||
- [ ] `ruview.vector.store_pose` followed by `ruview.vector.search_pose` with the same embedding returns the stored pose as the top-1 match.
|
||||
- [ ] `claude mcp add rvagent -- npx @ruvnet/rvagent stdio` followed by `/mcp` in a Claude Code session shows the rvagent tools listed.
|
||||
- [ ] All MCP tool input schemas are validated via Zod; an invalid input returns an MCP `INVALID_PARAMS` error, not an unhandled exception.
|
||||
- [ ] TypeScript strict-mode compilation (`tsc --noEmit`) passes with zero errors.
|
||||
- [ ] `npm run build` produces both ESM (`dist/esm/`) and CJS (`dist/cjs/`) outputs with `.d.ts` type declarations.
|
||||
- [ ] The published npm tarball size is `≤ 10 MB` including the ruvector napi-rs binary for the current platform.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
### This repo
|
||||
|
||||
- `python/wifi_densepose/client/ws.py` — WebSocket client (ADR-117 P4): connection protocol, message types `connection_established`, `pose_data`, `edge_vitals`
|
||||
- `python/wifi_densepose/client/mqtt.py` — MQTT client (ADR-117 P4): topic namespaces, wildcard matching
|
||||
- `python/wifi_densepose/client/primitives.py` — Semantic primitive enum and listener (ADR-117 P4): 10 ADR-115 primitives
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server: REST API, WebSocket endpoint `/ws/sensing`
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs` — Bearer token auth pattern for HTTP server
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/semantic/` — 10 semantic primitive modules
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/mqtt/` — MQTT publisher, discovery, topic routing
|
||||
- `docs/adr/ADR-055-integrated-sensing-server.md` — Sensing-server architectural context
|
||||
- `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` — rvCSI edge runtime
|
||||
- `docs/adr/ADR-115-home-assistant-integration.md` — MQTT topic structure, 10 semantic primitives, 21 HA entities
|
||||
- `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` — PIP-PHOENIX: Python client and PyO3 bindings (the Python-runtime parallel to this ADR)
|
||||
- `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md` — BFLD crate: `BfldEvent` MQTT topics
|
||||
- `docs/adr/ADR-024-contrastive-csi-embedding-model.md` — AETHER person re-ID embeddings
|
||||
- `docs/adr/ADR-016-ruvector-integration.md` — RuVector integration in the Rust workspace
|
||||
- `CLAUDE.md` — Project config: 3-tier model routing (ADR-026), ruflo MCP tools, `mcp__claude-flow__*` namespace
|
||||
- `CLAUDE.local.md` — Fleet table: Tailscale hosts, cognitum-v0 services table, local mosquitto pattern
|
||||
|
||||
### External
|
||||
|
||||
- [Model Context Protocol specification 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) — Transports: stdio and Streamable HTTP
|
||||
- [MCP TypeScript SDK — github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) — `Server`, `StdioServerTransport`, `StreamableHTTPServerTransport`
|
||||
- [@modelcontextprotocol/sdk on npm](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
|
||||
- [ruvector on npm](https://www.npmjs.com/package/ruvector) — v0.2.25, napi-rs HNSW vector DB
|
||||
- [ruvnet npm profile](https://www.npmjs.com/~ruvnet) — confirms `@ruvnet` scope ownership
|
||||
- [RuVector GitHub](https://github.com/ruvnet/ruvector) — Rust source + napi-rs node bindings
|
||||
- [ruflo (claude-flow) GitHub](https://github.com/ruvnet/ruflo) — ruflo plugin manifest convention, `v3/` structure
|
||||
- [ruflo issue #1689](https://github.com/ruvnet/ruflo/issues/1689) — documents existing rvagent WASM exports (`callMcp`, `executeTool`, `listTools`) and distinguishes them from this ADR's server-side rvagent
|
||||
- [Why MCP Deprecated SSE — fka.dev](https://blog.fka.dev/blog/2025-06-06-why-mcp-deprecated-sse-and-go-with-streamable-http/) — rationale for Streamable HTTP over legacy SSE
|
||||
- [MCP TypeScript SDK dual-transport patterns — dev.to](https://dev.to/zoricic/understanding-mcp-server-transports-stdio-sse-and-http-streamable-5b1p)
|
||||
@@ -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,113 @@
|
||||
# rvAgent + RVF integration for agentic flows in RuView
|
||||
|
||||
**Status**: Research (Exploration) — Pre-Proposal
|
||||
**Date**: 2026-05-24
|
||||
**Author**: ruv
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
`vendor/ruvector/crates/rvAgent/` ships a production-grade Rust AI-agent framework with eight composable crates (`rvagent-core`, `-middleware`, `-tools`, `-subagents`, `-backends`, `-a2a`, `-acp`, `-mcp`, `-cli`). The framework already speaks **RVF cognitive containers** as its native state-persistence and inter-agent transport. RuView already uses RVF in `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`.
|
||||
|
||||
**Integration thesis**: the two systems share a serialization substrate. Wiring `rvAgent` swarms into RuView turns the existing sensing pipeline into the substrate that an agentic flow can read from, reason about, and respond to — without writing a new agent runtime.
|
||||
|
||||
Concrete value:
|
||||
|
||||
1. **Operator-facing agents** that interpret BFLD / pose / vitals events live ("the kitchen has had no presence for 6 h but the kettle stayed on — page the carer").
|
||||
2. **In-process subagent coordination** for the multi-cog Cognitum Seed appliance — `cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, and the new BFLD pipeline can negotiate via rvAgent's CRDT state merging instead of ad-hoc IPC.
|
||||
3. **Witness chains** (ADR-028 / ADR-110) get an upstream consumer — rvAgent's audit-trail middleware persists per-decision attestations into the same RVF container an operator already verifies.
|
||||
4. **Local SONA learning** — rvAgent's 3-loop adaptive learning slots in alongside the per-home RuVector thresholds already proposed in ADR-116, with the same in-RAM-only privacy posture BFLD enforces (ADR-118 I2).
|
||||
|
||||
---
|
||||
|
||||
## 1. What rvAgent ships
|
||||
|
||||
| Crate | Role | Key types |
|
||||
|-------|------|-----------|
|
||||
| `rvagent-core` | State machine + COW state cloning + budget tracking | `AgentState`, `Message`, `AgiContainer`, `Arena`, `Budget`, `Graph` |
|
||||
| `rvagent-middleware` | 14 built-in middlewares (security, witness, sanitizer, sona, hnsw) | `PipelineConfig`, `build_default_pipeline()` |
|
||||
| `rvagent-tools` | Tool definitions + dispatch | `Tool`, `ToolInput`, `ToolOutput` |
|
||||
| `rvagent-subagents` | Spawn isolated subagents with O(1) state clone | `Subagent`, CRDT merge |
|
||||
| `rvagent-backends` | LLM provider abstraction (Anthropic, OpenAI, local) | `Backend` trait |
|
||||
| `rvagent-mcp` | MCP server integration | MCP-style tool registry |
|
||||
| `rvagent-a2a` / `-acp` | Agent-to-agent transport, agent communication protocol | wire format |
|
||||
| `rvagent-cli` | Operator CLI | argv parsing |
|
||||
|
||||
Selling points relevant to RuView:
|
||||
|
||||
- **O(1) state cloning via `Arc`** → can spawn one subagent per sensing zone without copying gigabytes of context.
|
||||
- **Parallel tool execution** → multiple sensor queries (BFLD presence, vitals BPM, pose) issued in parallel from one rvAgent decision step.
|
||||
- **Path confinement + env-var sanitization** → operator-facing agents that touch the host filesystem (e.g., reading `data/recordings/`) stay sandboxed.
|
||||
- **Witness chains** in `rvagent-middleware::witness` → already RVF-formatted; round-trips cleanly with ADR-028.
|
||||
|
||||
## 2. What RVF already does in RuView
|
||||
|
||||
`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` defines the on-disk container format used for:
|
||||
|
||||
- ADR-110 witness attestations (`SEG_MANIFEST`, `SEG_META`).
|
||||
- Soul Signature graphs (`docs/research/soul/specification.md` §3).
|
||||
- BFLD class-1 (derived) frames once the operator opts into research mode (ADR-118 §1.4).
|
||||
|
||||
Each RVF blob is content-addressed (BLAKE3 of the canonical byte representation) and carries a typed segment manifest. The format is intentionally extension-friendly — segment types are `u8` enums, new types can land without breaking older readers.
|
||||
|
||||
## 3. The integration surface
|
||||
|
||||
Three concrete touchpoints, each shippable independently.
|
||||
|
||||
### 3.1 RVF as the rvAgent ↔ RuView wire
|
||||
|
||||
rvAgent's `AgiContainer` (`rvagent-core/src/agi_container.rs`, 627 LOC) already produces RVF-compatible blobs as its persistent state format. RuView only needs to define **two segment types** in `rvf_container.rs`:
|
||||
|
||||
- `SEG_AGENT_STATE = 0x08` — serialized `rvagent_core::AgentState` (the cloned-on-write tree from `cow_state.rs`).
|
||||
- `SEG_DECISION = 0x09` — a single agent decision step: tool calls issued, outputs received, witness signature.
|
||||
|
||||
With these two segments, an rvAgent session and a RuView sensing session can interleave entries in the same RVF blob. The witness-bundle script (ADR-028) iterates segments by type, so it would attest both halves with one signing pass.
|
||||
|
||||
### 3.2 BFLD events as rvAgent tool inputs
|
||||
|
||||
`wifi-densepose-bfld::BfldEvent` (iter 13) is already JSON-serializable via `to_json()`. Wrapping it as an `rvagent_tools::ToolOutput` is a 20-line shim: the agent issues a `read_bfld_state()` tool, the runtime returns the latest event JSON, the agent reasons over it. The full event surface (presence/motion/count/identity_risk/zone_id) becomes available as agent context without any new IPC.
|
||||
|
||||
`BfldEvent → ToolOutput` mapping:
|
||||
```rust
|
||||
impl From<BfldEvent> for ToolOutput {
|
||||
fn from(e: BfldEvent) -> Self {
|
||||
ToolOutput::json(e.to_json().expect("BfldEvent JSON"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 cog-* as rvAgent subagents
|
||||
|
||||
`cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, and (proposed) `cog-bfld` already share a packaging convention (ADR-100). Each cog can register as a subagent with rvAgent's hub: the cog implements the `Subagent` trait, exports its tool surface, and inherits the parent agent's CRDT state. The queen agent (`rvagent-queen.md` persona) routes operator queries across the cog mesh.
|
||||
|
||||
Concrete example:
|
||||
- Operator query: "is grandma awake yet?"
|
||||
- Queen agent fans out to: `cog-bfld` (presence in bedroom), `cog-quantum-vitals` (HR baseline shift), `cog-pose-estimation` (sitting/standing transition).
|
||||
- Each cog returns within budget; queen synthesizes the answer; witness chain logs the decision for compliance audit.
|
||||
|
||||
## 4. Open questions
|
||||
|
||||
1. **Workspace inclusion**: is `vendor/ruvector/crates/rvAgent/` already on the v2 workspace path, or does it need to be added as a path dep under `wifi-densepose-bfld` / a new `wifi-densepose-agent` crate?
|
||||
2. **Async runtime**: rvAgent backends are tokio-based. The BFLD `Publish` trait is intentionally sync (iter 22). A small adapter (sync `Publish` ↔ async `Backend`) probably belongs in a `wifi-densepose-agent` crate, not in BFLD itself.
|
||||
3. **Privacy class composition**: what's the rvAgent equivalent of BFLD's `PrivacyClass`? `rvagent-middleware::sanitizer` strips at the tool-output boundary; should it consume `PrivacyClass` from the originating BFLD event so the agent never even sees a class-3 identity field?
|
||||
4. **Soul Signature interaction**: rvAgent's `SoulMatchOracle` integration (ADR-121 §2.6) could be the bridge from the Soul Signature graph (`docs/research/soul/`) to the agent decision layer. Worth a dedicated sub-section.
|
||||
5. **MCP**: `rvagent-mcp` exposes tools to external MCP clients. Should the BFLD `BfldPipelineHandle::send` surface land as an MCP tool here, or stay private to in-process rvAgent flows?
|
||||
|
||||
## 5. Proposed next steps (decision deferred)
|
||||
|
||||
- **D1**: Open ADR-124 — "rvAgent + RVF integration for RuView agentic flows" — capturing the segment-type assignments, the cog-subagent contract, and the privacy-class composition rule.
|
||||
- **D2**: Scaffold `v2/crates/wifi-densepose-agent` with the sync ↔ async adapter and one example tool (`read_bfld_state`).
|
||||
- **D3**: Add `SEG_AGENT_STATE` and `SEG_DECISION` to `rvf_container.rs` as `#[cfg(feature = "agent")]` segments so the v0 ship doesn't pull rvAgent's transitive deps by default.
|
||||
- **D4**: Land a one-page demo in `examples/agent-bedroom-check/` showing the queen-agent flow end-to-end against the `BfldPipelineHandle`.
|
||||
|
||||
## 6. References
|
||||
|
||||
- rvAgent: `vendor/ruvector/crates/rvAgent/README.md`, `rvagent-core/src/agi_container.rs`, `rvagent-middleware/docs/UNICODE_SECURITY.md`
|
||||
- Agent personas: `vendor/ruvector/crates/rvAgent/.ruv/agents/{rvagent-coder,rvagent-queen,rvagent-tester,rvagent-security}.md`
|
||||
- RVF container: `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`
|
||||
- ADR-028 (witness): `docs/adr/ADR-028-esp32-capability-audit.md`
|
||||
- ADR-100 (cog packaging), ADR-110 (witness chain), ADR-116 (cog-ha-matter)
|
||||
- ADR-118 (BFLD): `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`
|
||||
- Soul Signature: `docs/research/soul/specification.md`
|
||||
- BFLD impl branch: `feat/adr-118-bfld-impl`, currently at iter 25 (`e8b4fdbc8`)
|
||||
@@ -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.
|
||||
+128
-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:
|
||||
@@ -727,6 +772,79 @@ Open `/var/run/ruview-matter.txt` for the Matter pairing QR / 11-digit setup cod
|
||||
|
||||
Detailed entity reference, blueprint catalog, troubleshooting recipe matrix: see [`docs/integrations/home-assistant.md`](integrations/home-assistant.md).
|
||||
|
||||
### BFLD — privacy-gated WiFi BFI sensing layer (ADR-118)
|
||||
|
||||
The `wifi-densepose-bfld` crate adds an explicit privacy-gating layer on top of the sensing pipeline. It ingests 802.11ac/ax Beamforming Feedback Information (BFI) and emits bounded, classified sensing events that HA / Matter / MQTT consumers can read **without** leaking identity-discriminative data.
|
||||
|
||||
Three structural invariants enforced by the type system:
|
||||
|
||||
- **I1** — Raw BFI never exits the node (`Sink` marker-trait hierarchy)
|
||||
- **I2** — Identity embedding is in-RAM-only (no `Serialize`/`Clone`/`Copy`; `Drop` zeroizes)
|
||||
- **I3** — Cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed hash + daily epoch rotation)
|
||||
|
||||
#### Minimal operator quickstart
|
||||
|
||||
Two runnable examples ship with the crate:
|
||||
|
||||
```bash
|
||||
# In-process consumer: build pipeline, send one frame, print event JSON
|
||||
cargo run -p wifi-densepose-bfld --example bfld_minimal
|
||||
|
||||
# Worker thread + HA-DISCO: full publish lifecycle (availability + discovery + state + LWT)
|
||||
cargo run -p wifi-densepose-bfld --example bfld_handle
|
||||
```
|
||||
|
||||
#### Production publish lifecycle (HA-DISCO + MQTT)
|
||||
|
||||
```rust
|
||||
// Bootstrap (once at startup, retain=true messages):
|
||||
publish_availability_online(&mut retained_pub, "seed-01")?;
|
||||
publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
|
||||
// Per-frame:
|
||||
let handle = BfldPipelineHandle::spawn(pipeline, state_pub);
|
||||
handle.send(PipelineInput { inputs, embedding })?;
|
||||
```
|
||||
|
||||
Six HA entities are auto-created per node (`binary_sensor.*_bfld_presence`, `sensor.*_bfld_motion`/`person_count`/`zone_activity`/`confidence`/`identity_risk`). The `identity_risk` entity is **only present at `PrivacyClass::Anonymous`**; class `Restricted` deployments (care homes, regulated environments) drop it entirely from both discovery and state topics.
|
||||
|
||||
#### Three operator HA blueprints
|
||||
|
||||
Under `v2/crates/cog-ha-matter/blueprints/bfld/`:
|
||||
|
||||
- `presence-lighting.yaml` — `binary_sensor.*_bfld_presence` ⇒ `light.turn_on/off` with configurable hold time
|
||||
- `motion-hvac.yaml` — `sensor.*_bfld_motion > threshold` ⇒ `climate.set_temperature` ΔT
|
||||
- `identity-risk-anomaly.yaml` — rolling 7-day z-score notification (requires HA Statistics helper)
|
||||
|
||||
Import via HA UI: Settings → Automations & Scenes → Blueprints → Import.
|
||||
|
||||
#### Privacy class deployment matrix
|
||||
|
||||
| Class | Identity fields | Use case |
|
||||
|-------|-----------------|----------|
|
||||
| `Raw` | full BFI matrix | local-only research (never networked) |
|
||||
| `Derived` | downsampled angles + risk score | operator-acknowledged LAN research mode |
|
||||
| `Anonymous` (default) | aggregate sensing only + risk score + rotating hash | production HA / Matter deployments |
|
||||
| `Restricted` | aggregate sensing only, identity fields stripped | care homes, GDPR/HIPAA-style regulated environments |
|
||||
|
||||
The `enable_privacy_mode()` runtime toggle on `BfldPipeline` engages `Restricted` from any baseline without restarting the pipeline — useful for security-incident response.
|
||||
|
||||
#### MQTT topic tree
|
||||
|
||||
```
|
||||
ruview/<node_id>/bfld/availability online / offline
|
||||
ruview/<node_id>/bfld/presence/state true / false
|
||||
ruview/<node_id>/bfld/motion/state 0.000000..1.000000
|
||||
ruview/<node_id>/bfld/person_count/state integer
|
||||
ruview/<node_id>/bfld/confidence/state 0.000000..1.000000
|
||||
ruview/<node_id>/bfld/zone_activity/state "<zone_name>" (if configured)
|
||||
ruview/<node_id>/bfld/identity_risk/state 0.000000..1.000000 (class 2 only)
|
||||
```
|
||||
|
||||
The `rumqttc 0.24` (`use-rustls`) backend ships behind the `mqtt` feature; `RumqttPublisher::connect_with_lwt(node_id, opts, capacity)` pre-configures the Last Will and Testament so the broker auto-publishes `"offline"` on session drop.
|
||||
|
||||
Detailed surface: [`v2/crates/wifi-densepose-bfld/README.md`](../v2/crates/wifi-densepose-bfld/README.md), [`docs/research/BFLD/`](research/BFLD/) (11 files, 13,544 words), [ADR-118 through ADR-123](adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||||
|
||||
---
|
||||
|
||||
## Web UI
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, and witness verification — from practical to advanced.",
|
||||
"version": "0.1.0",
|
||||
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, witness verification, BFLD privacy layer, and rvAgent + RVF agentic flows — from practical to advanced.",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
|
||||
@@ -47,6 +47,7 @@ After significant changes: run the Rust tests + Python proof, then `bash scripts
|
||||
| `ruview-app` | Run a sensing application (presence / vitals / pose / sleep / MAT / point cloud) |
|
||||
| `ruview-train` | Train / evaluate / publish a model (incl. GPU on GCloud) |
|
||||
| `ruview-verify` | Run the trust pipeline + pre-merge checklist |
|
||||
| `ruview-rvagent` | Explore rvAgent + RVF agentic flows wiring into RuView |
|
||||
|
||||
Install: copy `codex/prompts/*.md` into `~/.codex/prompts/`, or run Codex with this directory on its prompt path.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# ruview-rvagent — explore rvAgent + RVF agentic flows for RuView
|
||||
|
||||
You are helping the operator explore or prototype the integration of `vendor/ruvector/crates/rvAgent/` (a production Rust AI-agent framework) with RuView's existing sensing pipeline (`v2/crates/wifi-densepose-*`) and the RVF cognitive container format (`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`).
|
||||
|
||||
## Trigger phrasing
|
||||
|
||||
- "wire rvAgent into RuView"
|
||||
- "I want a queen agent that fans out to cog-pose-estimation and cog-bfld"
|
||||
- "persist agent decisions in the same witness bundle as sensing events"
|
||||
- "how do I keep agent outputs class-3 compliant?"
|
||||
|
||||
## What to read first
|
||||
|
||||
1. `docs/research/rvagent-rvf-integration/README.md` — full integration thesis, open questions, next steps.
|
||||
2. `vendor/ruvector/crates/rvAgent/README.md` — what rvAgent ships (8 crates, 14 middlewares).
|
||||
3. `vendor/ruvector/crates/rvAgent/.ruv/agents/rvagent-queen.md` — queen-agent persona that coordinates cog subagents.
|
||||
4. `v2/crates/wifi-densepose-bfld/src/{event.rs,pipeline_handle.rs}` — the BFLD event surface and the operator-facing handle that an agent would call.
|
||||
5. `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` — segment types; `SEG_AGENT_STATE = 0x08` and `SEG_DECISION = 0x09` are the proposed additions.
|
||||
|
||||
## Three shippable touchpoints (each independent)
|
||||
|
||||
1. **RVF wire** — add `SEG_AGENT_STATE` + `SEG_DECISION` segments so rvAgent and RuView sessions can interleave in one blob (witness-bundle covers both halves).
|
||||
2. **Tool shim** — `BfldEvent::to_json()` already exists; wrap as `rvagent_tools::ToolOutput`.
|
||||
3. **Cog subagents** — register `cog-pose-estimation`, `cog-person-count`, `cog-ha-matter`, (proposed) `cog-bfld` under the queen via the `Subagent` trait.
|
||||
|
||||
## Open questions to surface
|
||||
|
||||
- Is `vendor/ruvector/crates/rvAgent/` on the v2 workspace path?
|
||||
- Sync ↔ async adapter location (BFLD `Publish` is sync; rvAgent backends are tokio).
|
||||
- Privacy-class composition — does `rvagent-middleware::sanitizer` consume `BfldEvent::privacy_class`?
|
||||
- Soul Signature ↔ `SoulMatchOracle` bridge (ADR-121 §2.6).
|
||||
- Should `BfldPipelineHandle::send` land as a public MCP tool via `rvagent-mcp`?
|
||||
|
||||
## Suggested next action
|
||||
|
||||
Draft ADR-124 — "rvAgent + RVF integration for RuView agentic flows" — capturing segment assignments, cog-subagent contract, and privacy-class composition. Land **before** scaffolding `v2/crates/wifi-densepose-agent`.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: ruview-rvagent
|
||||
description: Explore and prototype rvAgent + RVF integration for RuView agentic flows. Use when working on cross-cog coordination, operator-facing agents reading BFLD / pose / vitals events live, or persisting agent state alongside sensing data in the same RVF container.
|
||||
---
|
||||
|
||||
# RuView rvAgent + RVF integration
|
||||
|
||||
Surface area for wiring `vendor/ruvector/crates/rvAgent/` into RuView so the existing sensing pipeline becomes the substrate an agentic flow can read, reason about, and respond to.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- "I want an agent that reacts to BFLD presence in the kitchen and pages the carer."
|
||||
- "I need cog-pose-estimation and cog-bfld to negotiate before publishing a synthesized event."
|
||||
- "Can the witness chain attest both the sensing event AND the agent decision in one RVF blob?"
|
||||
- "How do we keep rvAgent's tool outputs class-3 compliant when the source BFLD event is Restricted?"
|
||||
|
||||
## Key surfaces
|
||||
|
||||
| Surface | File | Notes |
|
||||
|---------|------|-------|
|
||||
| rvAgent core | `vendor/ruvector/crates/rvAgent/rvagent-core/src/agi_container.rs` (627 LOC) | RVF-compatible state container |
|
||||
| rvAgent middleware | `vendor/ruvector/crates/rvAgent/rvagent-middleware/` | Witness, sanitizer, SONA, HNSW |
|
||||
| Agent personas | `vendor/ruvector/crates/rvAgent/.ruv/agents/rvagent-{queen,coder,tester,security}.md` | Reference patterns |
|
||||
| RVF container | `v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` | Add `SEG_AGENT_STATE`, `SEG_DECISION` |
|
||||
| BFLD event | `v2/crates/wifi-densepose-bfld/src/event.rs` | `BfldEvent::to_json()` → `ToolOutput` |
|
||||
| BFLD pipeline handle | `v2/crates/wifi-densepose-bfld/src/pipeline_handle.rs` | `BfldPipelineHandle::send` |
|
||||
|
||||
## Research dossier
|
||||
|
||||
Full integration analysis lives at `docs/research/rvagent-rvf-integration/README.md`.
|
||||
|
||||
Three shippable touchpoints, each independent:
|
||||
|
||||
1. **RVF wire**: two new segment types (`SEG_AGENT_STATE = 0x08`, `SEG_DECISION = 0x09`) let rvAgent sessions interleave with RuView sensing sessions in the same blob.
|
||||
2. **Tool surface**: `BfldEvent → ToolOutput` shim turns BFLD events into agent context with no new IPC.
|
||||
3. **Cog subagents**: `cog-pose-estimation` / `cog-person-count` / `cog-ha-matter` / `cog-bfld` register as rvAgent subagents under a queen-agent router.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Workspace inclusion of `vendor/ruvector/crates/rvAgent/` (path dep vs published crate)
|
||||
- Sync ↔ async adapter (BFLD `Publish` is sync, rvAgent backends are tokio)
|
||||
- Privacy-class composition (does rvAgent's sanitizer consume `PrivacyClass`?)
|
||||
- Soul Signature ↔ `SoulMatchOracle` bridge
|
||||
- Whether `BfldPipelineHandle::send` lands as a public MCP tool via `rvagent-mcp`
|
||||
|
||||
## Next decision
|
||||
|
||||
ADR-124 (proposed) — "rvAgent + RVF integration for RuView agentic flows" — would capture segment assignments, cog-subagent contract, and the privacy-class composition rule. Land before scaffolding `v2/crates/wifi-densepose-agent`.
|
||||
@@ -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
+75
-5
@@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -198,6 +198,12 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
@@ -456,6 +462,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq 0.4.2",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1088,6 +1108,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1173,6 +1199,30 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1382,7 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
@@ -7000,7 +7050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -7011,7 +7061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -7255,6 +7305,12 @@ 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"
|
||||
@@ -9133,6 +9189,20 @@ 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 = [
|
||||
"blake3",
|
||||
"crc",
|
||||
"proptest",
|
||||
"rumqttc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
@@ -10379,7 +10449,7 @@ dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"constant_time_eq 0.1.5",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
|
||||
@@ -42,6 +42,11 @@ members = [
|
||||
# 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,26 @@
|
||||
# BFLD HA Blueprints
|
||||
|
||||
Operator-ready Home Assistant automation blueprints for the BFLD entities
|
||||
published by `wifi-densepose-bfld`. Sourced from **ADR-122 §2.6**.
|
||||
|
||||
## Installing
|
||||
|
||||
Copy each `.yaml` file into your HA `blueprints/automation/` directory (or
|
||||
import via the HA UI: Settings → Automations & Scenes → Blueprints → Import).
|
||||
|
||||
## Available blueprints
|
||||
|
||||
| File | Purpose | BFLD entity consumed |
|
||||
|---|---|---|
|
||||
| `presence-lighting.yaml` | Turn a light on/off with BFLD occupancy | `binary_sensor.<node>_bfld_presence` |
|
||||
| `motion-hvac.yaml` | Adjust HVAC setpoint when motion crosses a threshold | `sensor.<node>_bfld_motion` |
|
||||
| `identity-risk-anomaly.yaml` | Notify operator on identity-risk z-score spike | `sensor.<node>_bfld_identity_risk` |
|
||||
|
||||
## Privacy notes
|
||||
|
||||
- `identity-risk-anomaly.yaml` requires `sensor.<node>_bfld_identity_risk` which is **only present at `privacy_class = Anonymous`** (per ADR-122 §2.1). At `privacy_class = Restricted` (e.g., care-home deployments) the entity is not advertised to HA at all, and this blueprint will fail validation — by design.
|
||||
- The `statistics_entity` input for `identity-risk-anomaly.yaml` requires the operator to first create an HA Statistics helper for the BFLD identity-risk sensor with a 7-day window. The blueprint reads `mean` + `standard_deviation` attributes.
|
||||
|
||||
## Source-of-truth blueprint structure tests
|
||||
|
||||
`v2/crates/wifi-densepose-bfld/tests/ha_blueprints.rs` validates each YAML at build time via `include_str!` and asserts the presence of the required HA-blueprint fields (`blueprint.name`, `blueprint.domain`, `input` block, `trigger`, `action`, `mode`).
|
||||
@@ -0,0 +1,76 @@
|
||||
blueprint:
|
||||
name: BFLD Identity-Risk Anomaly Notification
|
||||
description: >
|
||||
Notify the operator when BFLD's identity-risk score deviates significantly
|
||||
from its rolling 7-day baseline — a signal that the RF environment has
|
||||
shifted toward a higher-leakage regime (new AP firmware, attacker-grade
|
||||
sniffer in range, unusual propagation). Sourced from ADR-122 §2.6 and
|
||||
ADR-121 §2.4.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/identity-risk-anomaly.yaml
|
||||
input:
|
||||
bfld_identity_risk:
|
||||
name: BFLD Identity Risk sensor
|
||||
description: The `sensor.<node>_bfld_identity_risk` entity (only present at privacy_class = Anonymous).
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: mqtt
|
||||
notify_target:
|
||||
name: Notify target service
|
||||
description: HA notify service to call (e.g., notify.mobile_app_<phone>).
|
||||
selector:
|
||||
text: {}
|
||||
spike_threshold:
|
||||
name: Absolute spike threshold
|
||||
description: Trigger immediately when raw score >= this value.
|
||||
default: 0.8
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 0.99
|
||||
step: 0.01
|
||||
z_score_threshold:
|
||||
name: Rolling z-score threshold
|
||||
description: Trigger when deviation from 7-day mean exceeds this many sigmas.
|
||||
default: 3.0
|
||||
selector:
|
||||
number:
|
||||
min: 1.5
|
||||
max: 6.0
|
||||
step: 0.5
|
||||
statistics_entity:
|
||||
name: Statistics helper entity for the 7-day baseline
|
||||
description: >
|
||||
An HA `statistics` integration entity computing mean + standard
|
||||
deviation of the BFLD identity-risk sensor over a 7-day window.
|
||||
Configure via Settings → Devices & Services → Helpers → Statistics.
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_identity_risk
|
||||
above: !input spike_threshold
|
||||
id: absolute_spike
|
||||
- platform: template
|
||||
value_template: >
|
||||
{% set raw = states(trigger.entity_id) | float(0) %}
|
||||
{% set mean = state_attr(!input statistics_entity, 'mean') | float(0) %}
|
||||
{% set sigma = state_attr(!input statistics_entity, 'standard_deviation') | float(0.01) %}
|
||||
{{ (raw - mean) / sigma >= z_score_threshold }}
|
||||
id: z_score_spike
|
||||
|
||||
variables:
|
||||
z_score_threshold: !input z_score_threshold
|
||||
|
||||
action:
|
||||
- service: !input notify_target
|
||||
data:
|
||||
title: BFLD Identity-Risk Anomaly
|
||||
message: >
|
||||
Node {{ trigger.entity_id }} identity-risk score is {{ states(trigger.entity_id) }}.
|
||||
Investigate possible RF-environment shift (new AP firmware, nearby sniffer,
|
||||
unusual multipath). See ADR-118 / ADR-121 for context.
|
||||
mode: single
|
||||
@@ -0,0 +1,87 @@
|
||||
blueprint:
|
||||
name: BFLD Motion-Aware HVAC
|
||||
description: >
|
||||
Adjust an HVAC climate entity's setpoint when BFLD's normalized motion
|
||||
score crosses a threshold, indicating active occupancy. Off-trigger
|
||||
restores the original setpoint after a debounce window. Sourced from
|
||||
ADR-122 §2.6.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/motion-hvac.yaml
|
||||
input:
|
||||
bfld_motion:
|
||||
name: BFLD Motion sensor
|
||||
description: The `sensor.<node>_bfld_motion` entity (0.0–1.0 scalar).
|
||||
selector:
|
||||
entity:
|
||||
domain: sensor
|
||||
integration: mqtt
|
||||
target_climate:
|
||||
name: Climate entity to adjust
|
||||
selector:
|
||||
target:
|
||||
entity:
|
||||
domain: climate
|
||||
motion_threshold:
|
||||
name: Motion threshold
|
||||
description: Motion-score level above which HVAC is considered "active occupancy".
|
||||
default: 0.3
|
||||
selector:
|
||||
number:
|
||||
min: 0.05
|
||||
max: 0.95
|
||||
step: 0.05
|
||||
delta_temperature_c:
|
||||
name: Setpoint adjustment (°C)
|
||||
description: How much to raise the heating setpoint during active occupancy.
|
||||
default: 1.5
|
||||
selector:
|
||||
number:
|
||||
min: 0.5
|
||||
max: 5.0
|
||||
step: 0.5
|
||||
unit_of_measurement: "°C"
|
||||
quiet_seconds:
|
||||
name: Quiet hold (seconds)
|
||||
description: Continuous below-threshold time before restoring the original setpoint.
|
||||
default: 600
|
||||
selector:
|
||||
number:
|
||||
min: 60
|
||||
max: 7200
|
||||
unit_of_measurement: seconds
|
||||
|
||||
variables:
|
||||
motion_threshold: !input motion_threshold
|
||||
delta_c: !input delta_temperature_c
|
||||
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_motion
|
||||
above: !input motion_threshold
|
||||
id: occupied
|
||||
- platform: numeric_state
|
||||
entity_id: !input bfld_motion
|
||||
below: !input motion_threshold
|
||||
for:
|
||||
seconds: !input quiet_seconds
|
||||
id: quiet
|
||||
|
||||
action:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: occupied
|
||||
sequence:
|
||||
- service: climate.set_temperature
|
||||
target: !input target_climate
|
||||
data_template:
|
||||
temperature: "{{ (state_attr(this.attributes.target.entity_id, 'temperature') | float(20.0)) + delta_c }}"
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: quiet
|
||||
sequence:
|
||||
- service: climate.set_temperature
|
||||
target: !input target_climate
|
||||
data_template:
|
||||
temperature: "{{ (state_attr(this.attributes.target.entity_id, 'temperature') | float(20.0)) - delta_c }}"
|
||||
mode: restart
|
||||
@@ -0,0 +1,61 @@
|
||||
blueprint:
|
||||
name: BFLD Presence-Driven Lighting
|
||||
description: >
|
||||
Turn a light on when BFLD reports occupancy on a chosen node, and off
|
||||
after a configurable hold period of continuous non-presence. Sourced
|
||||
from ADR-122 §2.6 of the wifi-densepose / RuView repository.
|
||||
domain: automation
|
||||
source_url: https://github.com/ruvnet/RuView/blob/main/v2/crates/cog-ha-matter/blueprints/bfld/presence-lighting.yaml
|
||||
input:
|
||||
bfld_presence:
|
||||
name: BFLD Presence sensor
|
||||
description: The `binary_sensor.<node>_bfld_presence` entity exposed by BFLD.
|
||||
selector:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
integration: mqtt
|
||||
target_light:
|
||||
name: Light to control
|
||||
selector:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
hold_seconds:
|
||||
name: Off-delay hold (seconds)
|
||||
description: How long the room must stay empty before the light turns off.
|
||||
default: 120
|
||||
selector:
|
||||
number:
|
||||
min: 5
|
||||
max: 3600
|
||||
unit_of_measurement: seconds
|
||||
mode: slider
|
||||
step: 5
|
||||
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id: !input bfld_presence
|
||||
to: "on"
|
||||
id: presence_on
|
||||
- platform: state
|
||||
entity_id: !input bfld_presence
|
||||
to: "off"
|
||||
for:
|
||||
seconds: !input hold_seconds
|
||||
id: presence_off
|
||||
|
||||
action:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: presence_on
|
||||
sequence:
|
||||
- service: light.turn_on
|
||||
target: !input target_light
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: presence_off
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target: !input target_light
|
||||
mode: restart
|
||||
@@ -0,0 +1,65 @@
|
||||
[package]
|
||||
name = "wifi-densepose-bfld"
|
||||
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
|
||||
readme = "README.md"
|
||||
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", "serde-json"]
|
||||
std = []
|
||||
# JSON serialization for BfldEvent (ADR-121 §2.1, ADR-122 §2.1). Pulls in
|
||||
# serde + serde_json; tied to `std` because serde_json is std-only.
|
||||
serde-json = ["std", "dep:serde", "dep:serde_json"]
|
||||
# rumqttc-backed Publish trait impl. Pairs with the `mqtt` feature in
|
||||
# wifi-densepose-sensing-server so the same broker connection can serve
|
||||
# both publishers in the same process if desired.
|
||||
mqtt = ["std", "dep:rumqttc"]
|
||||
# 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"
|
||||
crc = "3"
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"], optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
# MQTT publisher backend (optional). Matches the `rumqttc` choice already in
|
||||
# `wifi-densepose-sensing-server` so both crates share TLS / version posture.
|
||||
rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
# The minimal example uses BfldEvent::to_json(), which is gated on serde-json.
|
||||
# Without this declaration, `cargo test --no-default-features` tries to build
|
||||
# the example and fails on the missing to_json() method.
|
||||
[[example]]
|
||||
name = "bfld_minimal"
|
||||
required-features = ["serde-json"]
|
||||
|
||||
# The handle example uses the std-only publish helpers and pipeline handle.
|
||||
[[example]]
|
||||
name = "bfld_handle"
|
||||
required-features = ["std"]
|
||||
|
||||
[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,116 @@
|
||||
# wifi-densepose-bfld
|
||||
|
||||
**BFLD — Beamforming Feedback Layer for Detection.** Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming Feedback Information (BFI). See [ADR-118](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) for the umbrella architecture decision and [`docs/research/BFLD/`](../../../docs/research/BFLD/) for the full design dossier.
|
||||
|
||||
## Three structural invariants
|
||||
|
||||
The crate enforces three privacy invariants **structurally** (via the type system + memory hygiene), not by policy text:
|
||||
|
||||
| ID | Invariant | Enforced by |
|
||||
|----|-----------|-------------|
|
||||
| **I1** | Raw BFI never exits the node | [`Sink`] marker-trait hierarchy + [`PrivacyClass::Raw.allows_network() == false`] |
|
||||
| **I2** | Identity embedding is in-RAM-only | [`IdentityEmbedding`] has no `Serialize` / `Clone` / `Copy` + `Drop` zeroizes storage |
|
||||
| **I3** | Cross-site identity correlation is cryptographically impossible | [`SignatureHasher`] per-site BLAKE3-keyed hash with daily epoch rotation |
|
||||
|
||||
## Quickstart
|
||||
|
||||
Minimal in-process consumer (see `examples/bfld_minimal.rs`):
|
||||
|
||||
```rust
|
||||
use wifi_densepose_bfld::{
|
||||
BfldConfig, BfldPipeline, IdentityEmbedding, SensingInputs,
|
||||
SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
let mut pipeline = BfldPipeline::new(
|
||||
BfldConfig::new("seed-01")
|
||||
.with_signature_hasher(SignatureHasher::new([0xAB; SITE_SALT_LEN])),
|
||||
);
|
||||
|
||||
let event = pipeline
|
||||
.process(
|
||||
SensingInputs { /* timestamp, presence, motion, ... */
|
||||
timestamp_ns: 1_700_000_000_000_000_000, presence: true,
|
||||
motion: 0.42, person_count: 1, sensing_confidence: 0.91,
|
||||
sep: 0.2, stab: 0.2, consist: 0.2, risk_conf: 0.2,
|
||||
rf_signature_hash: None,
|
||||
},
|
||||
Some(IdentityEmbedding::from_raw([0.05; EMBEDDING_DIM])),
|
||||
)
|
||||
.expect("low-risk emit");
|
||||
|
||||
println!("{}", event.to_json().unwrap());
|
||||
```
|
||||
|
||||
Production worker-thread + HA-DISCO publishing (see `examples/bfld_handle.rs`):
|
||||
|
||||
```rust
|
||||
use wifi_densepose_bfld::{
|
||||
publish_availability_online, publish_discovery, BfldConfig, BfldPipeline,
|
||||
BfldPipelineHandle, PipelineInput, PrivacyClass, SignatureHasher,
|
||||
};
|
||||
|
||||
// Bootstrap: retained "online" + 6 retained HA-DISCO config payloads.
|
||||
publish_availability_online(&mut publisher, "seed-01")?;
|
||||
publish_discovery(&mut publisher, "seed-01", PrivacyClass::Anonymous)?;
|
||||
|
||||
// Spawn worker. Per-frame: handle.send(PipelineInput { inputs, embedding }).
|
||||
let handle = BfldPipelineHandle::spawn(
|
||||
BfldPipeline::new(BfldConfig::new("seed-01")
|
||||
.with_signature_hasher(SignatureHasher::new(salt))),
|
||||
publisher,
|
||||
);
|
||||
handle.send(PipelineInput { inputs, embedding })?;
|
||||
```
|
||||
|
||||
## Feature flags
|
||||
|
||||
| Feature | Default | Pulls in | Enables |
|
||||
|---------|---------|----------|---------|
|
||||
| `std` | ✅ | (no extra deps) | `BfldFrame`, `BfldPayload`, `BfldPipeline`, `BfldPipelineHandle`, `BfldEvent`, `BfldEmitter`, `PrivacyGate`, MQTT topic router, HA discovery |
|
||||
| `serde-json` | ✅ | `serde` + `serde_json` | `BfldEvent::to_json()`, custom `rf_signature_hash: "blake3:<hex>"` serializer, `privacy_class` string encoding |
|
||||
| `mqtt` | — | `rumqttc 0.24` (`use-rustls`) | `RumqttPublisher`, `connect_with_lwt`, live broker integration |
|
||||
| `soul-signature`| — | — | `--features` gate signaling Soul Signature deployment (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) |
|
||||
|
||||
Stripping to `--no-default-features` keeps the no_std-compatible core (`BfldFrameHeader`, `PrivacyClass`, `Sink` traits, `CoherenceGate`, `SignatureHasher`, `IdentityEmbedding`, `EmbeddingRing`, risk-score function + `GateAction`).
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
cargo run -p wifi-densepose-bfld --example bfld_minimal # in-process consumer
|
||||
cargo run -p wifi-densepose-bfld --example bfld_handle # worker-thread + HA-DISCO
|
||||
```
|
||||
|
||||
## Companion artifacts
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/adr/ADR-118` through `ADR-123` | Architecture decisions |
|
||||
| `docs/research/BFLD/` | 13,544-word design bundle (11 files) |
|
||||
| `v2/crates/cog-ha-matter/blueprints/bfld/` | Three HA operator blueprints (presence-lighting, motion-HVAC, identity-risk-anomaly) |
|
||||
| `.github/workflows/bfld-mqtt-integration.yml` | CI matrix incl. live mosquitto Docker service |
|
||||
|
||||
## ADR cross-reference
|
||||
|
||||
| ADR | Scope |
|
||||
|-----|-------|
|
||||
| [118](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | Umbrella + invariants I1/I2/I3 |
|
||||
| [119](../../../docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md) | Wire format (86-byte header + payload sections + CRC-32/ISO-HDLC) |
|
||||
| [120](../../../docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md) | 4 privacy classes + per-site keyed hash with daily rotation |
|
||||
| [121](../../../docs/adr/ADR-121-bfld-identity-risk-scoring.md) | Multiplicative risk score + coherence-gate hysteresis + Soul Signature exemption |
|
||||
| [122](../../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | HA-DISCO + Matter cluster boundary + MQTT topic routing |
|
||||
| [123](../../../docs/adr/ADR-123-bfld-capture-path-nexmon-and-esp32.md) | Pi 5 / Nexmon capture adapter + ESP32 self-only mode |
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cargo test -p wifi-densepose-bfld --no-default-features # no_std-compatible core
|
||||
cargo test -p wifi-densepose-bfld # default std + serde-json
|
||||
cargo test -p wifi-densepose-bfld --features mqtt # incl. rumqttc smoke
|
||||
```
|
||||
|
||||
A `BFLD_MQTT_BROKER=tcp://localhost:1883` env var unlocks the live-broker `mosquitto_integration` test suite (see `tests/mosquitto_integration.rs`).
|
||||
|
||||
## License
|
||||
|
||||
MIT — same as the wifi-densepose workspace.
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Worker-thread BFLD example — the production-recommended pattern.
|
||||
//!
|
||||
//! Demonstrates the full operator lifecycle:
|
||||
//! 1. publish_availability_online (retained) → HA marks device online
|
||||
//! 2. publish_discovery (retained) → HA auto-creates 6 BFLD entities
|
||||
//! 3. BfldPipelineHandle::spawn → worker owns gate + ring + hasher
|
||||
//! 4. handle.send(input) per BFI frame → worker process + publish
|
||||
//! 5. handle.shutdown() → clean worker join
|
||||
//! 6. publish_availability_offline → HA marks device offline
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```sh
|
||||
//! cargo run -p wifi-densepose-bfld --example bfld_handle
|
||||
//! ```
|
||||
//!
|
||||
//! For a real broker, swap `CapturePublisher` for `RumqttPublisher::connect_with_lwt(...)`
|
||||
//! (requires `--features mqtt`).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
publish_availability_offline, publish_availability_online, publish_discovery, BfldConfig,
|
||||
BfldPipeline, BfldPipelineHandle, CapturePublisher, IdentityEmbedding, PipelineInput,
|
||||
PrivacyClass, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let node_id = "seed-handle-demo";
|
||||
let site_salt: [u8; SITE_SALT_LEN] = [0xC0; SITE_SALT_LEN];
|
||||
|
||||
// Shared publisher (CapturePublisher for demo; RumqttPublisher in prod).
|
||||
let publisher = Arc::new(Mutex::new(CapturePublisher::default()));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 1 — Bootstrap. Three messages land on the broker (or
|
||||
// capture log) BEFORE the worker starts: online + 6 discovery payloads.
|
||||
// In production these should be published with retain=true so HA picks
|
||||
// them up on reconnect.
|
||||
// ----------------------------------------------------------------
|
||||
publish_availability_online(&mut publisher.clone(), node_id)?;
|
||||
let discovery_count = publish_discovery(&mut publisher.clone(), node_id, PrivacyClass::Anonymous)?;
|
||||
println!("bootstrap: 1 availability + {discovery_count} discovery payloads");
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 2 — Spawn the worker thread. From this point on, the
|
||||
// operator only calls handle.send(...) per frame; the worker owns
|
||||
// every piece of pipeline state.
|
||||
// ----------------------------------------------------------------
|
||||
let pipeline = BfldPipeline::new(
|
||||
BfldConfig::new(node_id).with_signature_hasher(SignatureHasher::new(site_salt)),
|
||||
);
|
||||
let handle = BfldPipelineHandle::spawn(pipeline, publisher.clone());
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 3 — Drive 5 sensing frames. Each one becomes 5 MQTT state
|
||||
// messages (presence/motion/count/conf/identity_risk for Anonymous
|
||||
// class, no zone configured).
|
||||
// ----------------------------------------------------------------
|
||||
for i in 0..5u64 {
|
||||
let timestamp_ns = 1_700_000_000_000_000_000 + i * 200_000_000;
|
||||
let mut emb = [0.0f32; EMBEDDING_DIM];
|
||||
for (j, v) in emb.iter_mut().enumerate() {
|
||||
*v = (j as f32 + i as f32) * 0.005;
|
||||
}
|
||||
let input = PipelineInput {
|
||||
inputs: SensingInputs {
|
||||
timestamp_ns,
|
||||
presence: true,
|
||||
motion: 0.3 + (i as f32) * 0.1,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None,
|
||||
},
|
||||
embedding: Some(IdentityEmbedding::from_raw(emb)),
|
||||
};
|
||||
handle.send(input)?;
|
||||
}
|
||||
|
||||
// Give the worker time to drain the channel before shutdown.
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Phase 4 — Graceful shutdown. handle.shutdown() joins the worker;
|
||||
// publish_availability_offline then signals HA explicitly (the LWT
|
||||
// configured on RumqttPublisher::connect_with_lwt would handle the
|
||||
// crash case).
|
||||
// ----------------------------------------------------------------
|
||||
handle.shutdown();
|
||||
publish_availability_offline(&mut publisher.clone(), node_id)?;
|
||||
|
||||
// Print a summary so the example produces visible output.
|
||||
let log = publisher.lock().expect("publisher mutex");
|
||||
println!("total messages published: {}", log.published.len());
|
||||
println!("first three topics:");
|
||||
for msg in log.published.iter().take(3) {
|
||||
println!(" {}", msg.topic);
|
||||
}
|
||||
println!("last three topics:");
|
||||
for msg in log.published.iter().rev().take(3).collect::<Vec<_>>().iter().rev() {
|
||||
println!(" {}", msg.topic);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Minimal end-to-end BFLD pipeline example. Demonstrates the operator-facing
|
||||
//! flow: construct a `BfldPipeline` with a `SignatureHasher`, feed one
|
||||
//! `SensingInputs` + `IdentityEmbedding`, and print the resulting privacy-
|
||||
//! gated `BfldEvent` as JSON.
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```sh
|
||||
//! cargo run -p wifi-densepose-bfld --example bfld_minimal
|
||||
//! ```
|
||||
//!
|
||||
//! Expected output: one JSON line on stdout matching the BfldEvent schema
|
||||
//! (presence, motion, person_count, identity_risk_score, rf_signature_hash,
|
||||
//! privacy_class = "anonymous").
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
BfldConfig, BfldPipeline, IdentityEmbedding, SensingInputs, SignatureHasher, EMBEDDING_DIM,
|
||||
SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Per-site secret (in production: loaded from TPM / KMS / secret file).
|
||||
let site_salt: [u8; SITE_SALT_LEN] = [
|
||||
0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x07, 0x18, 0x29, 0x3A, 0x4B, 0x5C, 0x6D, 0x7E, 0x8F,
|
||||
0x90, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
|
||||
// 2. Build the pipeline. Default class = Anonymous, no zone, hasher
|
||||
// installed so rf_signature_hash gets derived from the embedding.
|
||||
let mut pipeline = BfldPipeline::new(
|
||||
BfldConfig::new("seed-example")
|
||||
.with_signature_hasher(SignatureHasher::new(site_salt)),
|
||||
);
|
||||
|
||||
// 3. One per-frame sensing observation. In production these come from
|
||||
// the BFI extractor + RuvSense feature engine.
|
||||
let inputs = SensingInputs {
|
||||
timestamp_ns: 1_700_000_000_000_000_000,
|
||||
presence: true,
|
||||
motion: 0.42,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.91,
|
||||
// Low risk — gate stays in Accept; event is published.
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: None, // hasher will derive
|
||||
};
|
||||
|
||||
// 4. Embedding from the AETHER encoder (ADR-024). For the example we
|
||||
// fill with a deterministic ramp; production uses real model output.
|
||||
let mut emb_values = [0.0f32; EMBEDDING_DIM];
|
||||
for (i, v) in emb_values.iter_mut().enumerate() {
|
||||
*v = (i as f32) * 0.0073;
|
||||
}
|
||||
let embedding = IdentityEmbedding::from_raw(emb_values);
|
||||
|
||||
// 5. Drive the pipeline. Returns Some(BfldEvent) when the gate permits;
|
||||
// None on Reject / Recalibrate.
|
||||
let event = pipeline
|
||||
.process(inputs, Some(embedding))
|
||||
.ok_or("gate dropped the event — should not happen at this risk level")?;
|
||||
|
||||
// 6. Publish JSON. Real deployments would feed this to MQTT via the
|
||||
// iter-22 publish_event(&publisher, &event) helper.
|
||||
let json = event.to_json()?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! `ruview/<node_id>/bfld/availability` topic helpers. ADR-122 §2.2.
|
||||
//!
|
||||
//! HA expects each device to publish an availability topic so the UI can grey
|
||||
//! out entities when the device is offline. Convention:
|
||||
//!
|
||||
//! - Publish `"online"` with `retain = true` immediately after broker CONNECT.
|
||||
//! - Configure the MQTT client's Last Will and Testament (LWT) to publish
|
||||
//! `"offline"` (also retained) so the broker auto-marks the device offline
|
||||
//! when the TCP session drops without a clean DISCONNECT.
|
||||
//!
|
||||
//! HA discovery payloads (iter 26) reference this same topic via the
|
||||
//! `availability_topic` field so every BFLD entity inherits the marker.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
|
||||
/// Payload string published when the node is healthy.
|
||||
pub const PAYLOAD_AVAILABLE: &str = "online";
|
||||
|
||||
/// Payload string published when the node has disconnected.
|
||||
pub const PAYLOAD_NOT_AVAILABLE: &str = "offline";
|
||||
|
||||
/// Build the canonical `ruview/<node_id>/bfld/availability` topic string.
|
||||
#[must_use]
|
||||
pub fn availability_topic(node_id: &str) -> String {
|
||||
let mut s = String::with_capacity(7 + node_id.len() + 19);
|
||||
s.push_str("ruview/");
|
||||
s.push_str(node_id);
|
||||
s.push_str("/bfld/availability");
|
||||
s
|
||||
}
|
||||
|
||||
/// Build the `(topic, "online")` pair to publish on broker connect.
|
||||
#[must_use]
|
||||
pub fn online_message(node_id: &str) -> TopicMessage {
|
||||
TopicMessage {
|
||||
topic: availability_topic(node_id),
|
||||
payload: PAYLOAD_AVAILABLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `(topic, "offline")` pair — usually configured as the broker LWT
|
||||
/// rather than published explicitly, but provided here for explicit-shutdown
|
||||
/// scenarios (graceful stop, planned maintenance) where the operator wants
|
||||
/// HA to update immediately rather than waiting for the LWT keep-alive timeout.
|
||||
#[must_use]
|
||||
pub fn offline_message(node_id: &str) -> TopicMessage {
|
||||
TopicMessage {
|
||||
topic: availability_topic(node_id),
|
||||
payload: PAYLOAD_NOT_AVAILABLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap helper: publish the `"online"` availability marker through
|
||||
/// `publisher`. Pairs with `publish_discovery` (iter 27) and `publish_event`
|
||||
/// (iter 22) for the full startup sequence:
|
||||
///
|
||||
/// ```ignore
|
||||
/// publish_availability_online(&mut retained_pub, "seed-01")?; // "online", retained
|
||||
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
/// // ... then BfldPipelineHandle::spawn(pipeline, state_pub) for the per-frame loop
|
||||
/// ```
|
||||
pub fn publish_availability_online<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
) -> Result<(), P::Error> {
|
||||
publisher.publish(&online_message(node_id))
|
||||
}
|
||||
|
||||
/// Bootstrap helper: publish the `"offline"` availability marker through
|
||||
/// `publisher`. Use during a graceful shutdown so HA reflects the state
|
||||
/// immediately instead of waiting for the broker LWT timeout.
|
||||
pub fn publish_availability_offline<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
) -> Result<(), P::Error> {
|
||||
publisher.publish(&offline_message(node_id))
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Stateful coherence gate with hysteresis + debounce. ADR-121 §2.4 + §2.5.
|
||||
//!
|
||||
//! Wraps the stateless [`crate::identity_risk::GateAction::from_score`] band
|
||||
//! classifier with two stabilizing mechanisms:
|
||||
//!
|
||||
//! - **Hysteresis (±0.05)** — a score must clear the current band's edge by
|
||||
//! `HYSTERESIS` before the gate considers the next band.
|
||||
//! - **Debounce (5 seconds)** — once a different action is "pending", it must
|
||||
//! persist for `DEBOUNCE_NS` of wall time before it becomes the current
|
||||
//! action. Returning to the current band cancels the pending action.
|
||||
//!
|
||||
//! Together these prevent the gate from flapping when the risk score
|
||||
//! oscillates near a boundary or spikes briefly on a single bad frame.
|
||||
|
||||
use crate::identity_risk::{
|
||||
GateAction, PREDICT_ONLY_THRESHOLD, RECALIBRATE_THRESHOLD, REJECT_THRESHOLD,
|
||||
};
|
||||
|
||||
/// Symmetric hysteresis band applied to every action boundary.
|
||||
pub const HYSTERESIS: f32 = 0.05;
|
||||
|
||||
/// Pending action must persist this long (in nanoseconds) before promotion.
|
||||
pub const DEBOUNCE_NS: u64 = 5_000_000_000;
|
||||
|
||||
/// Stateful gate. Construct with `CoherenceGate::new()` and call
|
||||
/// `evaluate(score, timestamp_ns)` per frame to obtain the active action.
|
||||
pub struct CoherenceGate {
|
||||
current: GateAction,
|
||||
pending: Option<(GateAction, u64)>,
|
||||
}
|
||||
|
||||
impl CoherenceGate {
|
||||
/// Build a fresh gate, starting in [`GateAction::Accept`] with no pending
|
||||
/// transition.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
current: GateAction::Accept,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current published action — does **not** advance any state.
|
||||
#[must_use]
|
||||
pub const fn current(&self) -> GateAction {
|
||||
self.current
|
||||
}
|
||||
|
||||
/// Pending action (if any) — useful for diagnostics / dashboards.
|
||||
#[must_use]
|
||||
pub const fn pending(&self) -> Option<GateAction> {
|
||||
match self.pending {
|
||||
Some((a, _)) => Some(a),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the gate with a fresh score reading and a monotonic timestamp.
|
||||
/// Returns the currently-active action after the update.
|
||||
pub fn evaluate(&mut self, score: f32, timestamp_ns: u64) -> GateAction {
|
||||
let target = effective_target(score, self.current);
|
||||
self.advance_state(target, timestamp_ns)
|
||||
}
|
||||
|
||||
/// Variant of [`Self::evaluate`] that consults a [`SoulMatchOracle`].
|
||||
/// When the gate would transition to [`GateAction::Recalibrate`] and the
|
||||
/// oracle reports a [`MatchOutcome::Match`], the target is downgraded to
|
||||
/// [`GateAction::PredictOnly`] — the high score is the *intended* outcome
|
||||
/// of a successful Soul Signature match and should not rotate `site_salt`.
|
||||
/// See ADR-121 §2.6.
|
||||
pub fn evaluate_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
score: f32,
|
||||
timestamp_ns: u64,
|
||||
oracle: &O,
|
||||
) -> GateAction {
|
||||
let mut target = effective_target(score, self.current);
|
||||
if target == GateAction::Recalibrate {
|
||||
if let MatchOutcome::Match { .. } = oracle.matches_enrolled() {
|
||||
target = GateAction::PredictOnly;
|
||||
}
|
||||
}
|
||||
self.advance_state(target, timestamp_ns)
|
||||
}
|
||||
|
||||
/// Shared hysteresis-debounce state-machine driver.
|
||||
fn advance_state(&mut self, target: GateAction, timestamp_ns: u64) -> GateAction {
|
||||
if target == self.current {
|
||||
self.pending = None;
|
||||
return self.current;
|
||||
}
|
||||
match self.pending {
|
||||
Some((pending, since)) if pending == target => {
|
||||
if timestamp_ns.saturating_sub(since) >= DEBOUNCE_NS {
|
||||
self.current = target;
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pending = Some((target, timestamp_ns));
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
// --- SoulMatchOracle -------------------------------------------------------
|
||||
//
|
||||
// The trait + MatchOutcome enum live here so the Recalibrate exemption is
|
||||
// addressable without pulling in any Soul Signature implementation crate.
|
||||
// Downstream crates compiled with `--features soul-signature` provide their
|
||||
// own oracle impl; otherwise `NullOracle` is the sensible default.
|
||||
|
||||
/// Result of an oracle lookup. ADR-121 §2.6.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchOutcome {
|
||||
/// The current high-separability cluster matches an enrolled subject —
|
||||
/// the gate must NOT recalibrate, because the match is the intended outcome.
|
||||
Match {
|
||||
/// Opaque per-deployment person identifier.
|
||||
person_id: u64,
|
||||
},
|
||||
/// No enrolled subject matches the cluster — proceed with normal gating.
|
||||
NotEnrolled,
|
||||
/// Soul Signature is disabled in this deployment (e.g., `privacy_class = 3`).
|
||||
/// Treated identically to `NotEnrolled` by the gate.
|
||||
Suppressed,
|
||||
}
|
||||
|
||||
/// Oracle hook consulted before the gate fires `Recalibrate`. Implementations
|
||||
/// live in the Soul Signature integration crate; this crate ships only the
|
||||
/// trait and a no-op fallback ([`NullOracle`]).
|
||||
pub trait SoulMatchOracle {
|
||||
/// Return the current match outcome. May be called once per evaluation
|
||||
/// when the gate is about to fire `Recalibrate`; implementations should
|
||||
/// be cheap (the iter-10 budget is < 1 ms via RaBitQ; see ADR-121 §2.7).
|
||||
fn matches_enrolled(&self) -> MatchOutcome;
|
||||
}
|
||||
|
||||
/// No-op oracle — always reports `NotEnrolled`. Used when Soul Signature is
|
||||
/// not enabled, so the gate behaves identically to [`CoherenceGate::evaluate`].
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NullOracle;
|
||||
|
||||
impl SoulMatchOracle for NullOracle {
|
||||
fn matches_enrolled(&self) -> MatchOutcome {
|
||||
MatchOutcome::NotEnrolled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CoherenceGate {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_target(score: f32, current: GateAction) -> GateAction {
|
||||
let raw = GateAction::from_score(score);
|
||||
if raw == current {
|
||||
return current;
|
||||
}
|
||||
if action_idx(raw) > action_idx(current) {
|
||||
// Crossing upward — score must clear current's upper edge + HYSTERESIS.
|
||||
if score >= upper_edge_of(current) + HYSTERESIS {
|
||||
raw
|
||||
} else {
|
||||
current
|
||||
}
|
||||
} else {
|
||||
// Crossing downward — score must fall below current's lower edge - HYSTERESIS.
|
||||
if score < lower_edge_of(current) - HYSTERESIS {
|
||||
raw
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_idx(a: GateAction) -> u8 {
|
||||
match a {
|
||||
GateAction::Accept => 0,
|
||||
GateAction::PredictOnly => 1,
|
||||
GateAction::Reject => 2,
|
||||
GateAction::Recalibrate => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn upper_edge_of(a: GateAction) -> f32 {
|
||||
match a {
|
||||
GateAction::Accept => PREDICT_ONLY_THRESHOLD,
|
||||
GateAction::PredictOnly => REJECT_THRESHOLD,
|
||||
GateAction::Reject => RECALIBRATE_THRESHOLD,
|
||||
GateAction::Recalibrate => f32::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_edge_of(a: GateAction) -> f32 {
|
||||
match a {
|
||||
GateAction::Accept => f32::NEG_INFINITY,
|
||||
GateAction::PredictOnly => PREDICT_ONLY_THRESHOLD,
|
||||
GateAction::Reject => REJECT_THRESHOLD,
|
||||
GateAction::Recalibrate => RECALIBRATE_THRESHOLD,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! `IdentityEmbedding` — structural enforcement of ADR-118 invariant I2.
|
||||
//!
|
||||
//! I2: the identity embedding is **in-RAM-only**. There is no `Serialize`
|
||||
//! impl on this type, no `Copy`, no `Clone`; the only way to extract a value
|
||||
//! is `as_slice()`, which returns a borrowed view, and the buffer is zeroized
|
||||
//! on `Drop`. A future PR cannot accidentally leak the embedding because:
|
||||
//!
|
||||
//! - The type lives in this crate; downstream crates see only the public API
|
||||
//! and the type's lack of `Serialize`/`Clone`/`Copy` makes accidental
|
||||
//! reflection impossible without explicitly bypassing the wrapper.
|
||||
//! - `Drop` overwrites the f32 storage with `0.0` before the allocation is
|
||||
//! freed, so a stale pointer reads zeros instead of the original values.
|
||||
//! - `Debug` redacts: only the L2 norm and the constant length are emitted.
|
||||
//!
|
||||
//! This is the type-system half of I2. The lifecycle half — a bounded ring
|
||||
//! buffer with FIFO replacement — lives in a subsequent iter.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
use static_assertions::{assert_impl_all, assert_not_impl_any};
|
||||
|
||||
/// Dimension of the AETHER contrastive embedding (ADR-024 §2.4).
|
||||
pub const EMBEDDING_DIM: usize = 128;
|
||||
|
||||
/// In-RAM-only identity embedding. **No serialization, no clone, no copy.**
|
||||
pub struct IdentityEmbedding {
|
||||
values: [f32; EMBEDDING_DIM],
|
||||
}
|
||||
|
||||
impl IdentityEmbedding {
|
||||
/// Wrap a freshly-computed embedding. The caller relinquishes the array;
|
||||
/// after this call the only safe accessor is `as_slice()`.
|
||||
#[must_use]
|
||||
pub const fn from_raw(values: [f32; EMBEDDING_DIM]) -> Self {
|
||||
Self { values }
|
||||
}
|
||||
|
||||
/// Borrow the embedding values for a read-only computation (similarity,
|
||||
/// risk scoring). Lifetime-bound to `&self` — the values cannot escape.
|
||||
#[must_use]
|
||||
pub fn as_slice(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
|
||||
/// L2 norm of the embedding. Useful for sanity-checking and for the
|
||||
/// redacted `Debug` output.
|
||||
#[must_use]
|
||||
pub fn l2_norm(&self) -> f32 {
|
||||
self.values.iter().map(|v| v * v).sum::<f32>().sqrt()
|
||||
}
|
||||
|
||||
/// Embedding dimension. Always `EMBEDDING_DIM`.
|
||||
#[must_use]
|
||||
pub const fn len(&self) -> usize {
|
||||
EMBEDDING_DIM
|
||||
}
|
||||
|
||||
/// Always `false` — embeddings are never empty.
|
||||
#[must_use]
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for IdentityEmbedding {
|
||||
/// Redacted: emits dimension + L2 norm only. Never logs raw values.
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("IdentityEmbedding")
|
||||
.field("dim", &EMBEDDING_DIM)
|
||||
.field("l2_norm", &self.l2_norm())
|
||||
.field("values", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IdentityEmbedding {
|
||||
/// Overwrite the embedding storage with `0.0` before deallocation.
|
||||
/// Used `core::hint::black_box` to prevent the compiler from eliding the
|
||||
/// write under DCE — the zeroization is observable on the heap/stack.
|
||||
fn drop(&mut self) {
|
||||
for v in &mut self.values {
|
||||
*v = 0.0;
|
||||
}
|
||||
// black_box forces the compiler to treat self.values as observed,
|
||||
// preventing the dead-store elimination pass from removing the loop.
|
||||
core::hint::black_box(&self.values);
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time structural assertions. If a future PR adds `Clone` or `Copy`,
|
||||
// or if a downstream crate tries to derive Serialize/Deserialize, the build
|
||||
// fails here. These constraints are what makes I2 *structural* rather than
|
||||
// merely documented.
|
||||
|
||||
assert_impl_all!(IdentityEmbedding: Drop);
|
||||
assert_not_impl_any!(IdentityEmbedding: Copy, Clone);
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `EmbeddingRing` — bounded FIFO of `IdentityEmbedding`s.
|
||||
//!
|
||||
//! Holds at most [`RING_CAPACITY`] (default 64) embeddings. When full, `push`
|
||||
//! evicts and returns the oldest entry so its `Drop` runs and the f32 storage
|
||||
//! is zeroized. `drain()` is the explicit "rotate site_salt" hook from the
|
||||
//! coherence-gate `Recalibrate` action (ADR-121 §2.4): it clears every slot
|
||||
//! at once. The ring is `no_std`-compatible; no heap allocation.
|
||||
|
||||
use crate::embedding::IdentityEmbedding;
|
||||
|
||||
/// Default ring capacity — matches ADR-120 §2.5 ("ring buffer of 64 entries").
|
||||
pub const RING_CAPACITY: usize = 64;
|
||||
|
||||
/// Fixed-capacity FIFO of identity embeddings. Insertion-ordered; oldest
|
||||
/// evicted first when full.
|
||||
pub struct EmbeddingRing {
|
||||
slots: [Option<IdentityEmbedding>; RING_CAPACITY],
|
||||
/// Index of the oldest slot — the next eviction target.
|
||||
head: usize,
|
||||
/// Number of currently-occupied slots (0..=RING_CAPACITY).
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl EmbeddingRing {
|
||||
/// Build an empty ring.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
slots: [const { None }; RING_CAPACITY],
|
||||
head: 0,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `emb`. If the ring is already full, evicts and returns the
|
||||
/// oldest entry (its `Drop` runs as the returned `Option` is dropped).
|
||||
pub fn push(&mut self, emb: IdentityEmbedding) -> Option<IdentityEmbedding> {
|
||||
if self.count < RING_CAPACITY {
|
||||
// Not full — write into the slot at head + count.
|
||||
let idx = (self.head + self.count) % RING_CAPACITY;
|
||||
self.slots[idx] = Some(emb);
|
||||
self.count += 1;
|
||||
None
|
||||
} else {
|
||||
// Full — overwrite the oldest slot, advance head.
|
||||
let evicted = self.slots[self.head].take();
|
||||
self.slots[self.head] = Some(emb);
|
||||
self.head = (self.head + 1) % RING_CAPACITY;
|
||||
evicted
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of occupied slots.
|
||||
#[must_use]
|
||||
pub const fn len(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// `true` iff `len() == 0`.
|
||||
#[must_use]
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.count == 0
|
||||
}
|
||||
|
||||
/// Maximum number of slots — always [`RING_CAPACITY`].
|
||||
#[must_use]
|
||||
pub const fn capacity(&self) -> usize {
|
||||
RING_CAPACITY
|
||||
}
|
||||
|
||||
/// `true` iff `len() == capacity()`.
|
||||
#[must_use]
|
||||
pub const fn is_full(&self) -> bool {
|
||||
self.count == RING_CAPACITY
|
||||
}
|
||||
|
||||
/// Iterate occupied slots in **insertion order** (oldest first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &IdentityEmbedding> + '_ {
|
||||
(0..self.count).map(move |i| {
|
||||
let idx = (self.head + i) % RING_CAPACITY;
|
||||
self.slots[idx].as_ref().expect("occupied slot")
|
||||
})
|
||||
}
|
||||
|
||||
/// Empty the ring. Every contained `IdentityEmbedding` is dropped, which
|
||||
/// zeroizes its storage. Returns the number of entries that were drained.
|
||||
pub fn drain(&mut self) -> usize {
|
||||
let drained = self.count;
|
||||
for slot in &mut self.slots {
|
||||
// Take() moves the embedding out; the temporary is dropped at the
|
||||
// end of this statement, running IdentityEmbedding::drop which
|
||||
// zeroes the f32 array.
|
||||
let _ = slot.take();
|
||||
}
|
||||
self.head = 0;
|
||||
self.count = 0;
|
||||
drained
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EmbeddingRing {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! `BfldEmitter` — end-to-end pipeline. ADR-118 §2.1.
|
||||
//!
|
||||
//! Wires the per-frame sensing inputs through:
|
||||
//!
|
||||
//! ```text
|
||||
//! risk = identity_risk::score(sep, stab, consist, conf_factor)
|
||||
//! -> gate.evaluate_with_oracle(risk, ts, &oracle) -> GateAction
|
||||
//! -> if Recalibrate: ring.drain()
|
||||
//! -> if action.drops_event(): return None
|
||||
//! -> else: BfldEvent::with_privacy_gating(...)
|
||||
//! ```
|
||||
//!
|
||||
//! The emitter owns the `CoherenceGate` and `EmbeddingRing` state so the
|
||||
//! caller only supplies per-frame inputs. Identity embeddings are pushed to
|
||||
//! the ring before the gate is consulted; on `Recalibrate` the ring is
|
||||
//! drained synchronously inside this function.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::coherence_gate::{CoherenceGate, NullOracle, SoulMatchOracle};
|
||||
use crate::embedding_ring::EmbeddingRing;
|
||||
use crate::identity_features::IdentityFeatures;
|
||||
use crate::identity_risk::{score, GateAction};
|
||||
use crate::signature_hasher::SignatureHasher;
|
||||
use crate::{BfldEvent, IdentityEmbedding, PrivacyClass};
|
||||
|
||||
/// Nanoseconds-per-second conversion factor for deriving unix_secs from
|
||||
/// `timestamp_ns`. The caller is responsible for using unix-epoch nanoseconds
|
||||
/// if it wants stable daily rotation; monotonic-only clocks won't anchor to
|
||||
/// UTC midnight.
|
||||
const NS_PER_SEC: u64 = 1_000_000_000;
|
||||
|
||||
/// Per-frame sensing inputs to [`BfldEmitter::emit`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensingInputs {
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// Whether an occupant is present in the zone.
|
||||
pub presence: bool,
|
||||
/// Normalized motion magnitude `[0,1]`.
|
||||
pub motion: f32,
|
||||
/// Estimated occupant count.
|
||||
pub person_count: u8,
|
||||
/// Sensing confidence (NOT the risk-score `conf` factor) — `[0,1]`.
|
||||
pub sensing_confidence: f32,
|
||||
|
||||
// --- Risk-score factors (ADR-121 §2.2) -------------------------------
|
||||
/// `identity_separability_score` — `[0,1]`.
|
||||
pub sep: f32,
|
||||
/// `temporal_stability` — `[0,1]`.
|
||||
pub stab: f32,
|
||||
/// `cross_perspective_consistency` — `[0,1]`.
|
||||
pub consist: f32,
|
||||
/// Risk-score sample confidence factor — `[0,1]`.
|
||||
pub risk_conf: f32,
|
||||
|
||||
// --- Optional identity-derived fields --------------------------------
|
||||
/// Per-day BLAKE3-keyed `rf_signature_hash`. Stripped at class 3 by the
|
||||
/// privacy-gated event constructor.
|
||||
pub rf_signature_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// End-to-end pipeline. Owns the gate state, the embedding ring, and the
|
||||
/// configured node identity. Defaults to `PrivacyClass::Anonymous`.
|
||||
pub struct BfldEmitter {
|
||||
node_id: String,
|
||||
default_zone_id: Option<String>,
|
||||
privacy_class: PrivacyClass,
|
||||
gate: CoherenceGate,
|
||||
ring: EmbeddingRing,
|
||||
signature_hasher: Option<SignatureHasher>,
|
||||
}
|
||||
|
||||
impl BfldEmitter {
|
||||
/// Build a new emitter in the production-default state: class Anonymous,
|
||||
/// empty gate/ring, no default zone.
|
||||
#[must_use]
|
||||
pub fn new(node_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
node_id: node_id.into(),
|
||||
default_zone_id: None,
|
||||
privacy_class: PrivacyClass::Anonymous,
|
||||
gate: CoherenceGate::new(),
|
||||
ring: EmbeddingRing::new(),
|
||||
signature_hasher: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a [`SignatureHasher`] so the emitter computes `rf_signature_hash`
|
||||
/// per ADR-120 §2.3 from the supplied embedding (preferred) or the risk
|
||||
/// factors (fallback when no embedding is supplied). When set, the derived
|
||||
/// hash overrides `SensingInputs::rf_signature_hash`.
|
||||
#[must_use]
|
||||
pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self {
|
||||
self.signature_hasher = Some(hasher);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the default zone ID emitted with each event (None = single-zone).
|
||||
#[must_use]
|
||||
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
||||
self.default_zone_id = Some(zone_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the privacy class (default `Anonymous`).
|
||||
#[must_use]
|
||||
pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self {
|
||||
self.privacy_class = class;
|
||||
self
|
||||
}
|
||||
|
||||
/// Read-only access to the current gate action — useful for diagnostics.
|
||||
#[must_use]
|
||||
pub const fn current_action(&self) -> GateAction {
|
||||
self.gate.current()
|
||||
}
|
||||
|
||||
/// Read-only access to the ring length (post any in-flight drain).
|
||||
#[must_use]
|
||||
pub const fn ring_len(&self) -> usize {
|
||||
self.ring.len()
|
||||
}
|
||||
|
||||
/// Run one pipeline step with the default [`NullOracle`]. Returns
|
||||
/// `Some(BfldEvent)` if the gate permitted publishing, `None` if the
|
||||
/// action was `Reject` or `Recalibrate`.
|
||||
pub fn emit(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
self.emit_with_oracle(inputs, embedding, &NullOracle)
|
||||
}
|
||||
|
||||
/// Same as [`Self::emit`] but consults a [`SoulMatchOracle`] before the
|
||||
/// gate fires `Recalibrate`. See ADR-121 §2.6.
|
||||
pub fn emit_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
oracle: &O,
|
||||
) -> Option<BfldEvent> {
|
||||
let risk = score(inputs.sep, inputs.stab, inputs.consist, inputs.risk_conf);
|
||||
|
||||
// Compute the derived rf_signature_hash BEFORE moving `embedding`
|
||||
// into the ring. The IdentityFeatures encoder (iter 18) consolidates
|
||||
// the embedding vs risk-factor selection behind a single canonical-
|
||||
// bytes path; same wire bytes as the iter-16 inline encoding.
|
||||
let derived_hash: Option<[u8; 32]> = self.signature_hasher.as_ref().map(|h| {
|
||||
let unix_secs = inputs.timestamp_ns / NS_PER_SEC;
|
||||
let day_epoch = SignatureHasher::day_epoch_from_unix_secs(unix_secs);
|
||||
let features = match &embedding {
|
||||
Some(emb) => IdentityFeatures::from_embedding(emb),
|
||||
None => IdentityFeatures::from_risk_factors(
|
||||
inputs.sep,
|
||||
inputs.stab,
|
||||
inputs.consist,
|
||||
inputs.risk_conf,
|
||||
),
|
||||
};
|
||||
features.compute_hash(h, day_epoch)
|
||||
});
|
||||
|
||||
if let Some(emb) = embedding {
|
||||
// Always push, regardless of action — the ring is the rolling
|
||||
// memory of recent identity embeddings, used for separability.
|
||||
self.ring.push(emb);
|
||||
}
|
||||
|
||||
let action = self
|
||||
.gate
|
||||
.evaluate_with_oracle(risk, inputs.timestamp_ns, oracle);
|
||||
|
||||
if action == GateAction::Recalibrate {
|
||||
self.ring.drain();
|
||||
}
|
||||
|
||||
if action.drops_event() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let identity_risk_score = match self.privacy_class {
|
||||
PrivacyClass::Anonymous => Some(risk),
|
||||
// Class 3 strips identity_risk; class 0/1 keep it (research modes).
|
||||
// The BfldEvent constructor enforces the class-3 strip again as a
|
||||
// defense-in-depth measure.
|
||||
_ => Some(risk),
|
||||
};
|
||||
|
||||
// Derived hash (when hasher installed) takes precedence over caller-
|
||||
// supplied; otherwise pass through whatever the caller provided.
|
||||
let rf_signature_hash = derived_hash.or(inputs.rf_signature_hash);
|
||||
|
||||
Some(BfldEvent::with_privacy_gating(
|
||||
self.node_id.clone(),
|
||||
inputs.timestamp_ns,
|
||||
inputs.presence,
|
||||
inputs.motion,
|
||||
inputs.person_count,
|
||||
inputs.sensing_confidence,
|
||||
self.default_zone_id.clone(),
|
||||
self.privacy_class,
|
||||
identity_risk_score,
|
||||
rf_signature_hash,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// canonical_risk_bytes removed in iter 18 — superseded by
|
||||
// IdentityFeatures::from_risk_factors().canonical_bytes() which uses the
|
||||
// same little-endian f32 layout.
|
||||
@@ -0,0 +1,170 @@
|
||||
//! `BfldEvent` — privacy-gated output event. ADR-121 §2.1, ADR-122 §2.1.
|
||||
//!
|
||||
//! Field exposure per privacy_class (ADR-122 §2.1):
|
||||
//!
|
||||
//! | Field | Raw(0) | Derived(1) | Anonymous(2) | Restricted(3) |
|
||||
//! |------------------------|--------|------------|--------------|---------------|
|
||||
//! | presence | y | y | y | y |
|
||||
//! | motion | y | y | y | y |
|
||||
//! | person_count | y | y | y | y |
|
||||
//! | confidence | y | y | y | y |
|
||||
//! | zone_id | y | y | y | y |
|
||||
//! | identity_risk_score | y | y | **y** | **n** |
|
||||
//! | rf_signature_hash | y | y | **y** | **n** |
|
||||
//!
|
||||
//! Construction defers to [`BfldEvent::with_privacy_gating`] which applies
|
||||
//! the policy by stripping disallowed fields to `None` based on the supplied
|
||||
//! `privacy_class`. Direct field access remains possible (for unit tests),
|
||||
//! but the JSON serializer always honors the gating because the dropped
|
||||
//! fields are `None` and the `Serialize` derive uses `skip_serializing_if`.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::PrivacyClass;
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
use serde::Serialize;
|
||||
|
||||
/// Privacy-gated output event published by the BFLD pipeline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[cfg_attr(feature = "serde-json", derive(Serialize))]
|
||||
pub struct BfldEvent {
|
||||
/// Always `"bfld_update"`. Tags the event type for downstream routers.
|
||||
#[cfg_attr(feature = "serde-json", serde(rename = "type"))]
|
||||
pub event_type: &'static str,
|
||||
|
||||
/// Originating BFLD node identifier.
|
||||
pub node_id: String,
|
||||
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
|
||||
/// Whether an occupant is present in the sensing zone.
|
||||
pub presence: bool,
|
||||
|
||||
/// Normalized motion magnitude in `[0.0, 1.0]`.
|
||||
pub motion: f32,
|
||||
|
||||
/// Estimated number of occupants.
|
||||
pub person_count: u8,
|
||||
|
||||
/// Sensing confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
|
||||
/// Optional zone identifier; absent if the deployment is single-zone.
|
||||
#[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub zone_id: Option<String>,
|
||||
|
||||
/// Privacy classification byte for this event.
|
||||
#[cfg_attr(feature = "serde-json", serde(serialize_with = "ser_privacy_class"))]
|
||||
pub privacy_class: PrivacyClass,
|
||||
|
||||
/// Identity-risk score, `[0.0, 1.0]`. Class 2 only; `None` at class 3.
|
||||
#[cfg_attr(feature = "serde-json", serde(skip_serializing_if = "Option::is_none"))]
|
||||
pub identity_risk_score: Option<f32>,
|
||||
|
||||
/// 256-bit BLAKE3 keyed hash of the current cluster. Class 2 only; `None` at class 3.
|
||||
/// Serializes as the JSON string `"blake3:<64-hex>"` per the BFLD wire spec.
|
||||
#[cfg_attr(
|
||||
feature = "serde-json",
|
||||
serde(skip_serializing_if = "Option::is_none", serialize_with = "ser_rf_signature_hash")
|
||||
)]
|
||||
pub rf_signature_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl BfldEvent {
|
||||
/// Build an event from sensing fields, applying the privacy_class policy
|
||||
/// to mask identity-derived fields. `identity_risk_score` and
|
||||
/// `rf_signature_hash` are nulled out at class `Restricted`.
|
||||
#[must_use]
|
||||
pub fn with_privacy_gating(
|
||||
node_id: String,
|
||||
timestamp_ns: u64,
|
||||
presence: bool,
|
||||
motion: f32,
|
||||
person_count: u8,
|
||||
confidence: f32,
|
||||
zone_id: Option<String>,
|
||||
privacy_class: PrivacyClass,
|
||||
identity_risk_score: Option<f32>,
|
||||
rf_signature_hash: Option<[u8; 32]>,
|
||||
) -> Self {
|
||||
let mut e = Self {
|
||||
event_type: "bfld_update",
|
||||
node_id,
|
||||
timestamp_ns,
|
||||
presence,
|
||||
motion,
|
||||
person_count,
|
||||
confidence,
|
||||
zone_id,
|
||||
privacy_class,
|
||||
identity_risk_score,
|
||||
rf_signature_hash,
|
||||
};
|
||||
e.apply_privacy_gating();
|
||||
e
|
||||
}
|
||||
|
||||
/// Idempotently mask fields disallowed at the current `privacy_class`.
|
||||
/// Called by [`Self::with_privacy_gating`]; exposed for callers that
|
||||
/// mutate the event in place before publication.
|
||||
pub fn apply_privacy_gating(&mut self) {
|
||||
if self.privacy_class.as_u8() >= PrivacyClass::Restricted.as_u8() {
|
||||
self.identity_risk_score = None;
|
||||
self.rf_signature_hash = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical JSON. Fields masked by privacy gating are omitted
|
||||
/// entirely (not emitted as `null`), so a privacy-gated event is
|
||||
/// observationally indistinguishable from one that never had the field set.
|
||||
#[cfg(feature = "serde-json")]
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_privacy_class<S: serde::Serializer>(
|
||||
class: &PrivacyClass,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
let name = match class {
|
||||
PrivacyClass::Raw => "raw",
|
||||
PrivacyClass::Derived => "derived",
|
||||
PrivacyClass::Anonymous => "anonymous",
|
||||
PrivacyClass::Restricted => "restricted",
|
||||
};
|
||||
s.serialize_str(name)
|
||||
}
|
||||
|
||||
/// Encode an `Option<[u8; 32]>` as the JSON string `"blake3:<64 lowercase hex chars>"`.
|
||||
/// Used for `rf_signature_hash` so consumers don't have to decode a 32-element JSON
|
||||
/// array of integers. Called only when the value is `Some(_)` because
|
||||
/// `skip_serializing_if = "Option::is_none"` short-circuits the `None` case.
|
||||
#[cfg(feature = "serde-json")]
|
||||
fn ser_rf_signature_hash<S: serde::Serializer>(
|
||||
hash: &Option<[u8; 32]>,
|
||||
s: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
// The unwrap is safe: skip_serializing_if guarantees we only run with Some.
|
||||
let bytes = hash.as_ref().expect("ser_rf_signature_hash called with None");
|
||||
let mut out = String::with_capacity(7 + 64); // "blake3:" + 32*2 hex chars
|
||||
out.push_str("blake3:");
|
||||
for b in bytes {
|
||||
// Manual lowercase-hex push — avoids pulling in the `hex` crate for 32 bytes.
|
||||
out.push(nibble_to_hex(b >> 4));
|
||||
out.push(nibble_to_hex(b & 0x0F));
|
||||
}
|
||||
s.serialize_str(&out)
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde-json")]
|
||||
const fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + (n - 10)) as char,
|
||||
_ => '?', // unreachable: input is masked with 0x0F
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! `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.
|
||||
//!
|
||||
//! CRC-32/ISO-HDLC (the same polynomial Ethernet uses) protects the payload.
|
||||
//! See [`crc32_of_payload`] for the canonical computation.
|
||||
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// CRC-32/ISO-HDLC algorithm used to checksum payload bytes. Poly 0xEDB88320,
|
||||
/// init 0xFFFFFFFF, xorout 0xFFFFFFFF, reflected — same as Ethernet / zlib.
|
||||
pub const CRC32_ALG: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
|
||||
|
||||
/// Compute the canonical CRC32 over `payload`. The header CRC field is **not**
|
||||
/// included in the digest (ADR-119 §2.2: "CRC32 covers all section bytes
|
||||
/// including length prefixes, but not the header").
|
||||
#[must_use]
|
||||
pub fn crc32_of_payload(payload: &[u8]) -> u32 {
|
||||
CRC32_ALG.checksum(payload)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Bitmask covering every named flag this version of the crate knows
|
||||
/// about. Useful for "did the wire form set any flags I don't recognize?"
|
||||
/// forward-compat checks.
|
||||
pub const KNOWN_FLAGS_MASK: u16 = HAS_CSI_DELTA | PRIVACY_MODE | SELF_ONLY;
|
||||
|
||||
/// Complement of [`KNOWN_FLAGS_MASK`] — every bit position not currently
|
||||
/// assigned a meaning. Bits set in this mask MUST round-trip unchanged
|
||||
/// per ADR-119 §2.1 ("Reserved flag bits 2-15 lock in future-extension
|
||||
/// order; any new bit assignment is a version bump"). A future protocol
|
||||
/// revision may light these up; today's parser preserves them so a node
|
||||
/// running iter N can forward unknown bits to a peer running iter N+M
|
||||
/// without losing information.
|
||||
pub const RESERVED_FLAGS_MASK: u16 = !KNOWN_FLAGS_MASK;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BfldFrame (header + payload) ------------------------------------------
|
||||
//
|
||||
// Gated on `std` because the payload is heap-allocated (`Vec<u8>`). ESP32-S3
|
||||
// self-only mode (ADR-123 §2.5) will need a separate `BfldFrameRef<'_>` API
|
||||
// that borrows a caller-provided buffer; that lands in a later iter.
|
||||
|
||||
/// Complete BFLD frame: header + payload bytes. The frame's wire form is
|
||||
/// `header.to_le_bytes() ‖ payload`, with the header's `payload_len` and
|
||||
/// `payload_crc32` fields kept consistent by `to_bytes`/`from_bytes`.
|
||||
#[cfg(feature = "std")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BfldFrame {
|
||||
/// Header — `payload_len` and `payload_crc32` reflect the payload below.
|
||||
pub header: BfldFrameHeader,
|
||||
/// Raw payload bytes. The internal section layout (compressed_angle_matrix,
|
||||
/// amplitude_proxy, ...) lives in a later iter; for now the byte buffer is
|
||||
/// opaque to this struct.
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl BfldFrame {
|
||||
/// Construct a frame, automatically syncing `header.payload_len` and
|
||||
/// `header.payload_crc32` to the supplied `payload`.
|
||||
#[must_use]
|
||||
pub fn new(mut header: BfldFrameHeader, payload: Vec<u8>) -> Self {
|
||||
let len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
|
||||
header.payload_len = len;
|
||||
header.payload_crc32 = crc32_of_payload(&payload);
|
||||
Self { header, payload }
|
||||
}
|
||||
|
||||
/// Construct a frame from a typed `BfldPayload`. The header `flags`
|
||||
/// `HAS_CSI_DELTA` bit is auto-synced from `payload.csi_delta.is_some()`,
|
||||
/// then the payload is serialized via [`crate::payload::BfldPayload::to_bytes`]
|
||||
/// and the resulting bytes feed [`BfldFrame::new`]. The CRC therefore covers
|
||||
/// the **section-prefixed** wire bytes per ADR-119 §2.2.
|
||||
#[must_use]
|
||||
pub fn from_payload(
|
||||
mut header: BfldFrameHeader,
|
||||
payload: &crate::payload::BfldPayload,
|
||||
) -> Self {
|
||||
let include_csi_delta = payload.csi_delta.is_some();
|
||||
if include_csi_delta {
|
||||
header.flags |= flags::HAS_CSI_DELTA;
|
||||
} else {
|
||||
header.flags &= !flags::HAS_CSI_DELTA;
|
||||
}
|
||||
let bytes = payload.to_bytes(include_csi_delta);
|
||||
Self::new(header, bytes)
|
||||
}
|
||||
|
||||
/// Parse the opaque payload bytes back into a typed [`crate::payload::BfldPayload`].
|
||||
/// Consults `header.flags & HAS_CSI_DELTA` so the parser matches the
|
||||
/// originating encoder's framing.
|
||||
pub fn parse_payload(&self) -> Result<crate::payload::BfldPayload, BfldError> {
|
||||
let expect_csi_delta = (self.header.flags & flags::HAS_CSI_DELTA) != 0;
|
||||
crate::payload::BfldPayload::from_bytes(&self.payload, expect_csi_delta)
|
||||
}
|
||||
|
||||
/// Serialize to wire form: 86 header bytes + `payload_len` payload bytes.
|
||||
/// Always recomputes `payload_crc32` so the returned bytes are internally
|
||||
/// consistent even if the caller mutated `header.payload_crc32` directly.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut header = self.header;
|
||||
header.payload_len = u32::try_from(self.payload.len()).unwrap_or(u32::MAX);
|
||||
header.payload_crc32 = crc32_of_payload(&self.payload);
|
||||
let header_bytes = header.to_le_bytes();
|
||||
let mut out = Vec::with_capacity(BFLD_HEADER_SIZE + self.payload.len());
|
||||
out.extend_from_slice(&header_bytes);
|
||||
out.extend_from_slice(&self.payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse from wire form. Validates magic, version, payload length, and CRC.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, BfldError> {
|
||||
if bytes.len() < BFLD_HEADER_SIZE {
|
||||
return Err(BfldError::TruncatedFrame {
|
||||
got: bytes.len(),
|
||||
need: BFLD_HEADER_SIZE,
|
||||
});
|
||||
}
|
||||
let header_bytes: &[u8; BFLD_HEADER_SIZE] =
|
||||
bytes[..BFLD_HEADER_SIZE].try_into().unwrap();
|
||||
let header = BfldFrameHeader::from_le_bytes(header_bytes)?;
|
||||
|
||||
let payload_len = header.payload_len as usize;
|
||||
let expected_total = BFLD_HEADER_SIZE.saturating_add(payload_len);
|
||||
if bytes.len() < expected_total {
|
||||
return Err(BfldError::TruncatedFrame {
|
||||
got: bytes.len(),
|
||||
need: expected_total,
|
||||
});
|
||||
}
|
||||
let payload = bytes[BFLD_HEADER_SIZE..expected_total].to_vec();
|
||||
|
||||
let actual = crc32_of_payload(&payload);
|
||||
let expected = header.payload_crc32;
|
||||
if actual != expected {
|
||||
return Err(BfldError::Crc { expected, actual });
|
||||
}
|
||||
Ok(Self { header, payload })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Home Assistant MQTT auto-discovery payload publisher. ADR-122 §2.1.
|
||||
//!
|
||||
//! Generates the JSON config messages HA expects on
|
||||
//! `homeassistant/<type>/<unique_id>/config` to auto-create the six BFLD
|
||||
//! entities. Class-gated identically to the state-topic router
|
||||
//! (`mqtt_topics.rs`): `identity_risk` discovery is only published at exactly
|
||||
//! `PrivacyClass::Anonymous`.
|
||||
//!
|
||||
//! Discovery payloads should be published **once per node session**, retained
|
||||
//! by the broker (`retain = true`) so HA finds them on next start. The
|
||||
//! `RumqttPublisher` exposes a `with_retain(true)` builder for this; the
|
||||
//! state-topic loop must keep `retain = false` to avoid stale-state flapping.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
use crate::PrivacyClass;
|
||||
|
||||
/// Bootstrap helper: render the per-node HA-DISCO config payloads and forward
|
||||
/// each through `publisher`. Returns the count published, or short-circuits
|
||||
/// on the first publisher error.
|
||||
///
|
||||
/// Typical bootstrap pattern combining iter 25's `Arc<Mutex<P>>` adapter and
|
||||
/// iter 23's retain-aware `RumqttPublisher`:
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::sync::{Arc, Mutex};
|
||||
/// use wifi_densepose_bfld::{
|
||||
/// publish_discovery, BfldConfig, BfldPipeline, BfldPipelineHandle,
|
||||
/// PrivacyClass, RumqttPublisher,
|
||||
/// };
|
||||
/// use rumqttc::MqttOptions;
|
||||
///
|
||||
/// let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
||||
/// let (retained_pub, _conn) = RumqttPublisher::connect(opts.clone(), 64);
|
||||
/// let mut retained_pub = retained_pub.with_retain(true);
|
||||
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||
///
|
||||
/// let (state_pub, _conn) = RumqttPublisher::connect(opts, 64);
|
||||
/// let pipeline = BfldPipeline::new(BfldConfig::new("seed-01"));
|
||||
/// let handle = BfldPipelineHandle::spawn(pipeline, state_pub);
|
||||
/// // handle.send(...) from now on
|
||||
/// # Ok::<(), rumqttc::ClientError>(())
|
||||
/// ```
|
||||
pub fn publish_discovery<P: Publish>(
|
||||
publisher: &mut P,
|
||||
node_id: &str,
|
||||
class: PrivacyClass,
|
||||
) -> Result<usize, P::Error> {
|
||||
let mut count = 0;
|
||||
for msg in render_discovery_payloads(node_id, class) {
|
||||
publisher.publish(&msg)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Render every HA-DISCO config message for the given node at `class`. Returns
|
||||
/// an empty `Vec` for classes < `Anonymous` (HA doesn't see raw / derived).
|
||||
#[must_use]
|
||||
pub fn render_discovery_payloads(node_id: &str, class: PrivacyClass) -> Vec<TopicMessage> {
|
||||
if class.as_u8() < PrivacyClass::Anonymous.as_u8() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(6);
|
||||
|
||||
out.push(config_message(
|
||||
"binary_sensor",
|
||||
node_id,
|
||||
"presence",
|
||||
"BFLD Presence",
|
||||
Some("occupancy"),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"motion",
|
||||
"BFLD Motion",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"person_count",
|
||||
"BFLD Person Count",
|
||||
None,
|
||||
Some("people"),
|
||||
None,
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"zone_activity",
|
||||
"BFLD Zone Activity",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"confidence",
|
||||
"BFLD Confidence",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
|
||||
// identity_risk discovery only at class 2. Class 3 computes but doesn't
|
||||
// publish — therefore HA should not even see the entity exist.
|
||||
if class == PrivacyClass::Anonymous {
|
||||
out.push(config_message(
|
||||
"sensor",
|
||||
node_id,
|
||||
"identity_risk",
|
||||
"BFLD Identity Risk",
|
||||
None,
|
||||
None,
|
||||
Some("diagnostic"),
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn config_message(
|
||||
ha_type: &str,
|
||||
node_id: &str,
|
||||
entity: &str,
|
||||
name: &str,
|
||||
device_class: Option<&str>,
|
||||
unit_of_measurement: Option<&str>,
|
||||
entity_category: Option<&str>,
|
||||
) -> TopicMessage {
|
||||
let unique_id = format!("{node_id}_bfld_{entity}");
|
||||
let topic = format!("homeassistant/{ha_type}/{unique_id}/config");
|
||||
let state_topic = format!("ruview/{node_id}/bfld/{entity}/state");
|
||||
let availability_topic_str = crate::availability::availability_topic(node_id);
|
||||
|
||||
let mut payload = String::with_capacity(384);
|
||||
payload.push('{');
|
||||
push_str_field(&mut payload, "name", name, true);
|
||||
push_str_field(&mut payload, "unique_id", &unique_id, false);
|
||||
push_str_field(&mut payload, "state_topic", &state_topic, false);
|
||||
// Availability — every entity inherits the device-level offline marker.
|
||||
push_str_field(&mut payload, "availability_topic", &availability_topic_str, false);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"payload_available",
|
||||
crate::availability::PAYLOAD_AVAILABLE,
|
||||
false,
|
||||
);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"payload_not_available",
|
||||
crate::availability::PAYLOAD_NOT_AVAILABLE,
|
||||
false,
|
||||
);
|
||||
if let Some(dc) = device_class {
|
||||
push_str_field(&mut payload, "device_class", dc, false);
|
||||
}
|
||||
if let Some(unit) = unit_of_measurement {
|
||||
push_str_field(&mut payload, "unit_of_measurement", unit, false);
|
||||
}
|
||||
if let Some(cat) = entity_category {
|
||||
push_str_field(&mut payload, "entity_category", cat, false);
|
||||
}
|
||||
payload.push_str(",\"device\":{");
|
||||
push_str_field(&mut payload, "identifiers", node_id, true);
|
||||
push_str_field(
|
||||
&mut payload,
|
||||
"name",
|
||||
&format!("RuView Seed {node_id}"),
|
||||
false,
|
||||
);
|
||||
push_str_field(&mut payload, "model", "BFLD", false);
|
||||
push_str_field(&mut payload, "manufacturer", "RuView", false);
|
||||
payload.push('}');
|
||||
payload.push('}');
|
||||
|
||||
TopicMessage { topic, payload }
|
||||
}
|
||||
|
||||
fn push_str_field(out: &mut String, key: &str, value: &str, first: bool) {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('"');
|
||||
out.push_str(key);
|
||||
out.push_str("\":\"");
|
||||
// Minimal JSON escaping for the values BFLD controls — node_id is ASCII
|
||||
// alphanumeric + dash by convention, names are operator-controlled. A
|
||||
// future iter can swap to serde_json::to_string for full escape coverage.
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
let escape = format!("\\u{:04x}", c as u32);
|
||||
out.push_str(&escape);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! `IdentityFeatures` — typed canonical-bytes encoder for `SignatureHasher`.
|
||||
//!
|
||||
//! Wraps the two possible feature sources (a borrowed [`IdentityEmbedding`] or
|
||||
//! the four-tuple of risk factors) behind a single API so callers don't need
|
||||
//! to know which one ultimately feeds the BLAKE3 keyed hash. Replaces the
|
||||
//! ad-hoc `canonical_risk_bytes` + inline embedding-flatten paths that lived
|
||||
//! in `emitter.rs` through iter 17.
|
||||
//!
|
||||
//! Borrowing semantics:
|
||||
//! - `IdentityFeatures::Embedding(&IdentityEmbedding)` is the **preferred**
|
||||
//! source — it carries the AETHER cluster identity directly.
|
||||
//! - `IdentityFeatures::RiskFactors { .. }` is the fallback used when the
|
||||
//! per-frame embedding is unavailable.
|
||||
//!
|
||||
//! Both variants emit canonical little-endian f32 bytes. Embedding produces
|
||||
//! `EMBEDDING_DIM * 4` bytes (512 by default); risk factors produce
|
||||
//! [`RISK_FACTOR_BYTES`] bytes (16).
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN};
|
||||
use crate::{IdentityEmbedding, EMBEDDING_DIM};
|
||||
|
||||
/// Wire-form length for the `RiskFactors` variant (4 × f32 little-endian).
|
||||
pub const RISK_FACTOR_BYTES: usize = 16;
|
||||
|
||||
/// Borrowed feature source for the signature hasher.
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityFeatures<'a> {
|
||||
/// Preferred: a borrowed identity embedding. The embedding stays in-RAM
|
||||
/// (invariant I2) — this enum holds only a reference.
|
||||
Embedding(&'a IdentityEmbedding),
|
||||
/// Fallback: the four risk-score factors. Less identity-stable than the
|
||||
/// embedding, but always available even when the encoder is offline.
|
||||
RiskFactors {
|
||||
/// `identity_separability_score`.
|
||||
sep: f32,
|
||||
/// `temporal_stability`.
|
||||
stab: f32,
|
||||
/// `cross_perspective_consistency`.
|
||||
consist: f32,
|
||||
/// Risk-score sample confidence factor.
|
||||
conf: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> IdentityFeatures<'a> {
|
||||
/// Build from a borrowed embedding (preferred path).
|
||||
#[must_use]
|
||||
pub const fn from_embedding(emb: &'a IdentityEmbedding) -> Self {
|
||||
Self::Embedding(emb)
|
||||
}
|
||||
|
||||
/// Build from the risk-factor four-tuple (fallback path).
|
||||
#[must_use]
|
||||
pub const fn from_risk_factors(sep: f32, stab: f32, consist: f32, conf: f32) -> Self {
|
||||
Self::RiskFactors {
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
conf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Predicted wire length without allocating.
|
||||
#[must_use]
|
||||
pub const fn canonical_byte_len(&self) -> usize {
|
||||
match self {
|
||||
Self::Embedding(_) => EMBEDDING_DIM * 4,
|
||||
Self::RiskFactors { .. } => RISK_FACTOR_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append canonical little-endian bytes to `out`. Useful for callers that
|
||||
/// already own a buffer (avoids the `canonical_bytes` allocation).
|
||||
pub fn write_canonical_bytes(&self, out: &mut Vec<u8>) {
|
||||
out.reserve(self.canonical_byte_len());
|
||||
match self {
|
||||
Self::Embedding(emb) => {
|
||||
for f in emb.as_slice() {
|
||||
out.extend_from_slice(&f.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Self::RiskFactors {
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
conf,
|
||||
} => {
|
||||
out.extend_from_slice(&sep.to_le_bytes());
|
||||
out.extend_from_slice(&stab.to_le_bytes());
|
||||
out.extend_from_slice(&consist.to_le_bytes());
|
||||
out.extend_from_slice(&conf.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocating convenience wrapper around [`Self::write_canonical_bytes`].
|
||||
#[must_use]
|
||||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(self.canonical_byte_len());
|
||||
self.write_canonical_bytes(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Drive `hasher` with this feature source at the given `day_epoch`. The
|
||||
/// returned hash is what the emitter publishes as `rf_signature_hash`.
|
||||
#[must_use]
|
||||
pub fn compute_hash(
|
||||
&self,
|
||||
hasher: &SignatureHasher,
|
||||
day_epoch: u32,
|
||||
) -> [u8; RF_SIGNATURE_LEN] {
|
||||
hasher.compute(day_epoch, &self.canonical_bytes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Identity-risk scoring and coherence-gate action mapping. ADR-121 §2.2–§2.4.
|
||||
//!
|
||||
//! The risk score is a multiplicative combination of four bounded factors:
|
||||
//!
|
||||
//! ```text
|
||||
//! identity_risk_score = clamp(sep × stab × consist × conf, 0.0, 1.0)
|
||||
//! ```
|
||||
//!
|
||||
//! Multiplicative combination is **conservative under uncertainty**: any single
|
||||
//! near-zero factor (e.g., very low sample confidence) collapses the score
|
||||
//! toward 0. This biases the system toward "report low risk when unsure",
|
||||
//! which is the privacy-preferred default.
|
||||
//!
|
||||
//! The score maps deterministically to a [`GateAction`]:
|
||||
//!
|
||||
//! | Score range | Action | Effect |
|
||||
//! |------------------------|-----------------|-------------------------------------------|
|
||||
//! | `score < 0.5` | `Accept` | Publish normally |
|
||||
//! | `0.5 <= score < 0.7` | `PredictOnly` | Publish with `confidence` flag lowered |
|
||||
//! | `0.7 <= score < 0.9` | `Reject` | Drop the event entirely |
|
||||
//! | `score >= 0.9` | `Recalibrate` | Drop AND rotate `site_salt` (per ADR-120) |
|
||||
//!
|
||||
//! This iter ships the **stateless** mapping. Hysteresis (±0.05) and the
|
||||
//! 5-second debounce land in the `CoherenceGate` struct in a subsequent iter.
|
||||
|
||||
/// Lower edge of `PredictOnly` (inclusive).
|
||||
pub const PREDICT_ONLY_THRESHOLD: f32 = 0.5;
|
||||
/// Lower edge of `Reject` (inclusive).
|
||||
pub const REJECT_THRESHOLD: f32 = 0.7;
|
||||
/// Lower edge of `Recalibrate` (inclusive). Triggers `site_salt` rotation.
|
||||
pub const RECALIBRATE_THRESHOLD: f32 = 0.9;
|
||||
|
||||
/// Compute the identity-risk score from its four factors.
|
||||
///
|
||||
/// Each input is clamped to `[0.0, 1.0]`; the result is always in that range
|
||||
/// even if the inputs include NaN (treated as 0.0 by `clamp` per its contract).
|
||||
#[must_use]
|
||||
pub fn score(sep: f32, stab: f32, consist: f32, conf: f32) -> f32 {
|
||||
let s = clamp01(sep);
|
||||
let t = clamp01(stab);
|
||||
let p = clamp01(consist);
|
||||
let c = clamp01(conf);
|
||||
clamp01(s * t * p * c)
|
||||
}
|
||||
|
||||
/// `clamp01` — handles NaN by mapping it to 0.0, matching the
|
||||
/// privacy-conservative bias documented in ADR-121 §2.2.
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Coherence-gate decision derived from the current risk score. ADR-121 §2.4.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GateAction {
|
||||
/// Publish the event normally.
|
||||
Accept,
|
||||
/// Publish but mark the event as "predicted-only" — downstream consumers
|
||||
/// (HA, Matter) should display reduced confidence.
|
||||
PredictOnly,
|
||||
/// Drop the event entirely; do not publish on any sink.
|
||||
Reject,
|
||||
/// Drop the event AND rotate the site-keyed BLAKE3 salt so future
|
||||
/// `rf_signature_hash` values cannot correlate with past ones.
|
||||
Recalibrate,
|
||||
}
|
||||
|
||||
impl GateAction {
|
||||
/// Map a risk score to the corresponding gate action.
|
||||
///
|
||||
/// Boundary semantics: thresholds are **inclusive of the lower edge**.
|
||||
/// `score = 0.7` is `Reject`; `score = 0.9` is `Recalibrate`.
|
||||
#[must_use]
|
||||
pub fn from_score(score: f32) -> Self {
|
||||
if score.is_nan() {
|
||||
// Conservative: an undefined score should not trigger anything
|
||||
// beyond a normal publish — the gate-runner is responsible for
|
||||
// logging the NaN as an upstream data-quality issue.
|
||||
return Self::Accept;
|
||||
}
|
||||
if score < PREDICT_ONLY_THRESHOLD {
|
||||
Self::Accept
|
||||
} else if score < REJECT_THRESHOLD {
|
||||
Self::PredictOnly
|
||||
} else if score < RECALIBRATE_THRESHOLD {
|
||||
Self::Reject
|
||||
} else {
|
||||
Self::Recalibrate
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for `Accept` and `PredictOnly` — both produce a published event.
|
||||
#[must_use]
|
||||
pub const fn allows_publish(self) -> bool {
|
||||
matches!(self, Self::Accept | Self::PredictOnly)
|
||||
}
|
||||
|
||||
/// `true` for `Reject` and `Recalibrate` — both drop the current event.
|
||||
#[must_use]
|
||||
pub const fn drops_event(self) -> bool {
|
||||
matches!(self, Self::Reject | Self::Recalibrate)
|
||||
}
|
||||
|
||||
/// `true` only for `Recalibrate` — the gate-runner must rotate `site_salt`
|
||||
/// and `drain()` the `EmbeddingRing` (per ADR-120 §2.5 + ADR-121 §2.4).
|
||||
#[must_use]
|
||||
pub const fn requires_recalibrate(self) -> bool {
|
||||
matches!(self, Self::Recalibrate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! # 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 coherence_gate;
|
||||
pub mod embedding;
|
||||
pub mod embedding_ring;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod emitter;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod availability;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod event;
|
||||
pub mod frame;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod ha_discovery;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod mqtt_topics;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod identity_features;
|
||||
pub mod identity_risk;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod payload;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod pipeline;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod pipeline_handle;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod privacy_gate;
|
||||
#[cfg(feature = "mqtt")]
|
||||
pub mod rumqttc_publisher;
|
||||
pub mod signature_hasher;
|
||||
pub mod sink;
|
||||
|
||||
pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle};
|
||||
#[cfg(feature = "std")]
|
||||
pub use emitter::{BfldEmitter, SensingInputs};
|
||||
#[cfg(feature = "std")]
|
||||
pub use event::BfldEvent;
|
||||
#[cfg(feature = "std")]
|
||||
pub use availability::{
|
||||
availability_topic, offline_message, online_message, publish_availability_offline,
|
||||
publish_availability_online, PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE,
|
||||
};
|
||||
#[cfg(feature = "std")]
|
||||
pub use ha_discovery::{publish_discovery, render_discovery_payloads};
|
||||
#[cfg(feature = "std")]
|
||||
pub use mqtt_topics::{publish_event, render_events, CapturePublisher, Publish, TopicMessage};
|
||||
#[cfg(feature = "mqtt")]
|
||||
pub use rumqttc_publisher::{with_lwt, RumqttPublisher};
|
||||
pub use embedding::{IdentityEmbedding, EMBEDDING_DIM};
|
||||
pub use embedding_ring::{EmbeddingRing, RING_CAPACITY};
|
||||
#[cfg(feature = "std")]
|
||||
pub use identity_features::{IdentityFeatures, RISK_FACTOR_BYTES};
|
||||
pub use identity_risk::{score as identity_risk_score, GateAction};
|
||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||||
#[cfg(feature = "std")]
|
||||
pub use frame::BfldFrame;
|
||||
#[cfg(feature = "std")]
|
||||
pub use payload::BfldPayload;
|
||||
#[cfg(feature = "std")]
|
||||
pub use pipeline::{BfldConfig, BfldPipeline};
|
||||
#[cfg(feature = "std")]
|
||||
pub use pipeline_handle::{BfldPipelineHandle, PipelineInput};
|
||||
#[cfg(feature = "std")]
|
||||
pub use privacy_gate::PrivacyGate;
|
||||
pub use signature_hasher::{SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN};
|
||||
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 {
|
||||
/// CRC value the header declared.
|
||||
expected: u32,
|
||||
/// CRC value computed over the received payload.
|
||||
actual: u32,
|
||||
},
|
||||
|
||||
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
|
||||
/// Enforces structural invariant I1.
|
||||
#[error("privacy violation: {reason}")]
|
||||
PrivacyViolation {
|
||||
/// `Sink::KIND` of the sink that rejected the frame.
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||||
#[error("invalid PrivacyClass byte: {0}")]
|
||||
InvalidPrivacyClass(u8),
|
||||
|
||||
/// Buffer too short for header (86 bytes) or header + declared payload.
|
||||
#[error("truncated frame: got {got} bytes, need at least {need}")]
|
||||
TruncatedFrame {
|
||||
/// Bytes available in the input buffer.
|
||||
got: usize,
|
||||
/// Bytes the header indicates are required.
|
||||
need: usize,
|
||||
},
|
||||
|
||||
/// Payload section length-prefix decoding failed or trailing bytes left over.
|
||||
#[error("malformed payload section at offset {offset}: {reason}")]
|
||||
MalformedSection {
|
||||
/// Byte offset within the payload where parsing failed.
|
||||
offset: usize,
|
||||
/// Human-readable reason for the failure.
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// Attempted to demote a frame to a class with MORE information than the
|
||||
/// current class (lower numerical value). `demote` is monotonic; the only
|
||||
/// way to add information back is to receive a fresh frame.
|
||||
#[error("invalid demote: cannot move from class {from} to class {to}")]
|
||||
InvalidDemote {
|
||||
/// Source class byte value.
|
||||
from: u8,
|
||||
/// Refused target class byte value.
|
||||
to: u8,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! MQTT topic router. ADR-122 §2.2.
|
||||
//!
|
||||
//! Pure-function module that maps a [`BfldEvent`] into a list of per-entity
|
||||
//! MQTT topic + payload pairs. No broker dependency lives here — the actual
|
||||
//! `publish` call is a thin wrapper around `Client::publish(topic, payload)`
|
||||
//! once a broker integration lands (deferred to a follow-up iter).
|
||||
//!
|
||||
//! Topic shape (ADR-122 §2.2):
|
||||
//!
|
||||
//! ```text
|
||||
//! 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 (when zone_id set)
|
||||
//! ruview/<node_id>/bfld/confidence/state # class >= 2
|
||||
//! ruview/<node_id>/bfld/identity_risk/state # class == 2 only
|
||||
//! ```
|
||||
//!
|
||||
//! `raw` (class-1) and `availability` topics are intentionally not yet emitted
|
||||
//! by this router; they belong to the broker-connection lifecycle, not to the
|
||||
//! per-event publish loop.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::{BfldEvent, PrivacyClass};
|
||||
|
||||
/// Per-topic MQTT message ready to feed into `Client::publish(topic, payload)`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TopicMessage {
|
||||
/// Full MQTT topic, e.g. `ruview/seed-01/bfld/presence/state`.
|
||||
pub topic: String,
|
||||
/// UTF-8 payload bytes — single JSON scalar (`true`, `0.72`, `"living_room"`)
|
||||
/// or a compact JSON object for diagnostics.
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl TopicMessage {
|
||||
/// Build a topic of the form `ruview/<node_id>/bfld/<suffix>/state`.
|
||||
#[must_use]
|
||||
pub fn ruview_topic(node_id: &str, entity: &str) -> String {
|
||||
let mut s = String::with_capacity(7 + node_id.len() + 6 + entity.len() + 6);
|
||||
s.push_str("ruview/");
|
||||
s.push_str(node_id);
|
||||
s.push_str("/bfld/");
|
||||
s.push_str(entity);
|
||||
s.push_str("/state");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract MQTT publisher boundary. The crate ships only the trait + a
|
||||
/// capture-impl for tests; the production rumqttc-backed impl lands in a
|
||||
/// follow-up iter behind a `mqtt` feature gate.
|
||||
///
|
||||
/// `publish` is synchronous so callers can hold a `&mut self` without an
|
||||
/// async runtime; the rumqttc wrapper drives a tokio task internally.
|
||||
pub trait Publish {
|
||||
/// Error type — typically the broker's transport error.
|
||||
type Error;
|
||||
/// Publish a single rendered message. Implementations may buffer.
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
/// Capture-impl for unit tests. Stores every published message in order.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CapturePublisher {
|
||||
/// Every `publish()` call appends to this vec.
|
||||
pub published: Vec<TopicMessage>,
|
||||
}
|
||||
|
||||
impl Publish for CapturePublisher {
|
||||
type Error = core::convert::Infallible;
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.published.push(msg.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward `Publish` through a shared `Arc<Mutex<P>>` so a publisher owned by
|
||||
/// a worker thread can still be inspected by the test or operator after the
|
||||
/// fact. Lock-poisoning is treated as a panic — there is no recovery story.
|
||||
impl<P: Publish> Publish for std::sync::Arc<std::sync::Mutex<P>> {
|
||||
type Error = P::Error;
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.lock()
|
||||
.expect("BFLD publish: inner publisher Mutex poisoned")
|
||||
.publish(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish every topic message rendered from `event`. Returns the number of
|
||||
/// messages actually published (zero for Raw / Derived class events). Errors
|
||||
/// short-circuit — the publisher state at error time may have partial output.
|
||||
pub fn publish_event<P: Publish>(
|
||||
publisher: &mut P,
|
||||
event: &BfldEvent,
|
||||
) -> Result<usize, P::Error> {
|
||||
let mut count = 0;
|
||||
for msg in render_events(event) {
|
||||
publisher.publish(&msg)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Render an event into the per-entity MQTT messages it should publish. Returns
|
||||
/// an empty vec for events that fail the class gate (e.g., raw class 0).
|
||||
#[must_use]
|
||||
pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
|
||||
let class_byte = event.privacy_class.as_u8();
|
||||
if class_byte < PrivacyClass::Anonymous.as_u8() {
|
||||
// Raw + Derived stay local — never published on the public topic tree.
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(6);
|
||||
let node = &event.node_id;
|
||||
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "presence"),
|
||||
payload: if event.presence { "true".into() } else { "false".into() },
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "motion"),
|
||||
payload: format!("{:.6}", event.motion),
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "person_count"),
|
||||
payload: format!("{}", event.person_count),
|
||||
});
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "confidence"),
|
||||
payload: format!("{:.6}", event.confidence),
|
||||
});
|
||||
|
||||
if let Some(zone) = &event.zone_id {
|
||||
// Emit a JSON string so consumers can distinguish "no zone" (omitted)
|
||||
// from "single-zone deployment" (always the same zone string).
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "zone_activity"),
|
||||
payload: format!("\"{zone}\""),
|
||||
});
|
||||
}
|
||||
|
||||
// Identity risk is only published at exactly class 2 (Anonymous). Class 3
|
||||
// (Restricted) computes the score internally but never emits it.
|
||||
if class_byte == PrivacyClass::Anonymous.as_u8() {
|
||||
if let Some(score) = event.identity_risk_score {
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "identity_risk"),
|
||||
payload: format!("{score:.6}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! BFLD payload section parser. See ADR-119 §2.2.
|
||||
//!
|
||||
//! The payload is a length-prefixed sequence of typed sections in this fixed
|
||||
//! order:
|
||||
//!
|
||||
//! ```text
|
||||
//! payload = compressed_angle_matrix
|
||||
//! ‖ amplitude_proxy
|
||||
//! ‖ phase_proxy
|
||||
//! ‖ snr_vector
|
||||
//! ‖ csi_delta (present iff flags.bit0 set)
|
||||
//! ‖ vendor_extension (length 0 allowed)
|
||||
//! ```
|
||||
//!
|
||||
//! Each section is encoded as `[u32 len_le][bytes...]`. Vendor extension is
|
||||
//! always present in the wire form (length may be zero); CSI delta is gated by
|
||||
//! the header `flags::HAS_CSI_DELTA` bit and is omitted entirely when off.
|
||||
//!
|
||||
//! Gated on `std` because the parser hands the caller owned `Vec<u8>` sections.
|
||||
//! A future zero-copy `BfldPayloadRef<'_>` variant will land alongside the
|
||||
//! ESP32-S3 self-only adapter (ADR-123 §2.5).
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// Length-prefix size in bytes for each section.
|
||||
pub const SECTION_PREFIX_LEN: usize = 4;
|
||||
|
||||
/// Parsed payload sections.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BfldPayload {
|
||||
/// Compressed beamforming angle matrix (Φ/ψ Givens rotations).
|
||||
pub compressed_angle_matrix: Vec<u8>,
|
||||
/// Per-subcarrier amplitude proxy.
|
||||
pub amplitude_proxy: Vec<u8>,
|
||||
/// Per-subcarrier phase proxy.
|
||||
pub phase_proxy: Vec<u8>,
|
||||
/// Per-subcarrier SNR vector.
|
||||
pub snr_vector: Vec<u8>,
|
||||
/// Optional CSI delta fusion section (present iff header `flags.bit0` set).
|
||||
pub csi_delta: Option<Vec<u8>>,
|
||||
/// Vendor-extension bytes outside the witness hash. Length 0 is permitted.
|
||||
pub vendor_extension: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BfldPayload {
|
||||
/// Serialize to canonical wire form.
|
||||
///
|
||||
/// `include_csi_delta` must match the header `flags::HAS_CSI_DELTA` bit
|
||||
/// the resulting payload will be paired with. When `true`, the `csi_delta`
|
||||
/// section is emitted (using an empty section if `self.csi_delta` is `None`).
|
||||
/// When `false`, the section is omitted entirely.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self, include_csi_delta: bool) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.wire_len(include_csi_delta));
|
||||
push_section(&mut out, &self.compressed_angle_matrix);
|
||||
push_section(&mut out, &self.amplitude_proxy);
|
||||
push_section(&mut out, &self.phase_proxy);
|
||||
push_section(&mut out, &self.snr_vector);
|
||||
if include_csi_delta {
|
||||
let csi = self.csi_delta.as_deref().unwrap_or(&[]);
|
||||
push_section(&mut out, csi);
|
||||
}
|
||||
push_section(&mut out, &self.vendor_extension);
|
||||
out
|
||||
}
|
||||
|
||||
/// Predict the wire size of a future `to_bytes` call without serializing.
|
||||
#[must_use]
|
||||
pub fn wire_len(&self, include_csi_delta: bool) -> usize {
|
||||
let mut n = SECTION_PREFIX_LEN * 5 // 4 mandatory + vendor
|
||||
+ self.compressed_angle_matrix.len()
|
||||
+ self.amplitude_proxy.len()
|
||||
+ self.phase_proxy.len()
|
||||
+ self.snr_vector.len()
|
||||
+ self.vendor_extension.len();
|
||||
if include_csi_delta {
|
||||
n += SECTION_PREFIX_LEN + self.csi_delta.as_deref().map_or(0, <[u8]>::len);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Parse from canonical wire form.
|
||||
///
|
||||
/// `expect_csi_delta` must reflect the paired header's `flags::HAS_CSI_DELTA`
|
||||
/// bit. Returns `MalformedSection` if a section length runs past the buffer
|
||||
/// end, or if trailing bytes remain after the vendor-extension section.
|
||||
pub fn from_bytes(bytes: &[u8], expect_csi_delta: bool) -> Result<Self, BfldError> {
|
||||
let mut cursor = 0usize;
|
||||
let compressed_angle_matrix = read_section(bytes, &mut cursor)?;
|
||||
let amplitude_proxy = read_section(bytes, &mut cursor)?;
|
||||
let phase_proxy = read_section(bytes, &mut cursor)?;
|
||||
let snr_vector = read_section(bytes, &mut cursor)?;
|
||||
let csi_delta = if expect_csi_delta {
|
||||
Some(read_section(bytes, &mut cursor)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let vendor_extension = read_section(bytes, &mut cursor)?;
|
||||
|
||||
if cursor != bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: cursor,
|
||||
reason: "trailing bytes after vendor_extension",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
compressed_angle_matrix,
|
||||
amplitude_proxy,
|
||||
phase_proxy,
|
||||
snr_vector,
|
||||
csi_delta,
|
||||
vendor_extension,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn push_section(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn read_section(bytes: &[u8], cursor: &mut usize) -> Result<Vec<u8>, BfldError> {
|
||||
let start = *cursor;
|
||||
if start + SECTION_PREFIX_LEN > bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section length prefix runs past buffer end",
|
||||
});
|
||||
}
|
||||
let len_bytes: [u8; 4] = bytes[start..start + SECTION_PREFIX_LEN].try_into().unwrap();
|
||||
let len = u32::from_le_bytes(len_bytes) as usize;
|
||||
let data_start = start + SECTION_PREFIX_LEN;
|
||||
let data_end = data_start
|
||||
.checked_add(len)
|
||||
.ok_or(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section length overflows usize",
|
||||
})?;
|
||||
if data_end > bytes.len() {
|
||||
return Err(BfldError::MalformedSection {
|
||||
offset: start,
|
||||
reason: "section body runs past buffer end",
|
||||
});
|
||||
}
|
||||
*cursor = data_end;
|
||||
Ok(bytes[data_start..data_end].to_vec())
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! `BfldPipeline` — public entry point. ADR-118 §2.1.
|
||||
//!
|
||||
//! Thin facade over [`crate::BfldEmitter`] that adds:
|
||||
//!
|
||||
//! - A configuration struct ([`BfldConfig`]) for ergonomic construction.
|
||||
//! - A `privacy_mode` toggle that flips the active class to
|
||||
//! [`PrivacyClass::Restricted`] (and back to the configured baseline)
|
||||
//! without rebuilding the underlying emitter state.
|
||||
//! - A single named consumer call ([`Self::process`]) so callers don't have
|
||||
//! to navigate the lower-level emitter API.
|
||||
//!
|
||||
//! Future iters add `process_to_frame()` (BfldFrame production) and a `tokio`
|
||||
//! MQTT loop wrapper on top of this same facade.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::coherence_gate::SoulMatchOracle;
|
||||
use crate::emitter::{BfldEmitter, SensingInputs};
|
||||
use crate::identity_risk::GateAction;
|
||||
use crate::signature_hasher::SignatureHasher;
|
||||
use crate::{BfldEvent, BfldFrame, BfldFrameHeader, BfldPayload, IdentityEmbedding, PrivacyClass};
|
||||
|
||||
/// Construction parameters for [`BfldPipeline`]. Matches the ADR-118 default-
|
||||
/// secure posture: `class = Anonymous`, no zone, no signature hasher.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BfldConfig {
|
||||
/// Node identifier published in every `BfldEvent.node_id`.
|
||||
pub node_id: String,
|
||||
/// Optional default zone; passed through to every event.
|
||||
pub default_zone_id: Option<String>,
|
||||
/// Baseline privacy class. `privacy_mode = true` overrides to Restricted.
|
||||
pub privacy_class: PrivacyClass,
|
||||
/// Optional signature hasher; when present, the pipeline derives
|
||||
/// `rf_signature_hash` via [`crate::IdentityFeatures`].
|
||||
pub signature_hasher: Option<SignatureHasher>,
|
||||
}
|
||||
|
||||
impl BfldConfig {
|
||||
/// Build a minimal config: node_id only, class defaulted to Anonymous.
|
||||
#[must_use]
|
||||
pub fn new(node_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
node_id: node_id.into(),
|
||||
default_zone_id: None,
|
||||
privacy_class: PrivacyClass::Anonymous,
|
||||
signature_hasher: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default zone.
|
||||
#[must_use]
|
||||
pub fn with_zone(mut self, zone_id: impl Into<String>) -> Self {
|
||||
self.default_zone_id = Some(zone_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the baseline privacy class.
|
||||
#[must_use]
|
||||
pub const fn with_privacy_class(mut self, class: PrivacyClass) -> Self {
|
||||
self.privacy_class = class;
|
||||
self
|
||||
}
|
||||
|
||||
/// Install a signature hasher.
|
||||
#[must_use]
|
||||
pub fn with_signature_hasher(mut self, hasher: SignatureHasher) -> Self {
|
||||
self.signature_hasher = Some(hasher);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Public BFLD entry point. Owns the configured emitter and the
|
||||
/// `privacy_mode` toggle.
|
||||
pub struct BfldPipeline {
|
||||
/// Baseline class — the class to which `disable_privacy_mode()` returns.
|
||||
baseline_class: PrivacyClass,
|
||||
privacy_mode: bool,
|
||||
emitter: BfldEmitter,
|
||||
}
|
||||
|
||||
impl BfldPipeline {
|
||||
/// Build a pipeline from `config`. The underlying emitter is initialized
|
||||
/// with the configured class; `privacy_mode` is initially `false`.
|
||||
#[must_use]
|
||||
pub fn new(config: BfldConfig) -> Self {
|
||||
let mut emitter = BfldEmitter::new(config.node_id);
|
||||
if let Some(zone) = config.default_zone_id {
|
||||
emitter = emitter.with_zone(zone);
|
||||
}
|
||||
emitter = emitter.with_privacy_class(config.privacy_class);
|
||||
if let Some(hasher) = config.signature_hasher {
|
||||
emitter = emitter.with_signature_hasher(hasher);
|
||||
}
|
||||
Self {
|
||||
baseline_class: config.privacy_class,
|
||||
privacy_mode: false,
|
||||
emitter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single sensing frame. Delegates to the underlying emitter,
|
||||
/// then post-processes the resulting event to honor `privacy_mode`. When
|
||||
/// privacy mode is engaged the published event is demoted to Restricted
|
||||
/// (identity-derived fields stripped) regardless of the configured baseline.
|
||||
pub fn process(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldEvent> {
|
||||
let mut event = self.emitter.emit(inputs, embedding)?;
|
||||
if self.privacy_mode {
|
||||
event.privacy_class = PrivacyClass::Restricted;
|
||||
event.apply_privacy_gating();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Variant of [`Self::process`] that consults a [`SoulMatchOracle`] before
|
||||
/// the coherence gate fires `Recalibrate`. See ADR-121 §2.6 and ADR-118
|
||||
/// §1.4. The privacy_mode post-processing still applies; the oracle only
|
||||
/// affects whether the gate transitions to Recalibrate at all.
|
||||
pub fn process_with_oracle<O: SoulMatchOracle>(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
oracle: &O,
|
||||
) -> Option<BfldEvent> {
|
||||
let mut event = self.emitter.emit_with_oracle(inputs, embedding, oracle)?;
|
||||
if self.privacy_mode {
|
||||
event.privacy_class = PrivacyClass::Restricted;
|
||||
event.apply_privacy_gating();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Wire-bytes variant of [`Self::process`]: returns a [`BfldFrame`] ready
|
||||
/// to serialize via `BfldFrame::to_bytes()`. Caller supplies a
|
||||
/// `header_template` carrying AP / STA / session identity fields and a
|
||||
/// `payload` typed via [`BfldPayload`]. The pipeline overrides the
|
||||
/// template's `timestamp_ns` and `privacy_class` from its own state, then
|
||||
/// builds the frame via [`BfldFrame::from_payload`] so the CRC covers the
|
||||
/// section-prefixed bytes.
|
||||
///
|
||||
/// Returns `None` whenever the gate drops the underlying event (Reject or
|
||||
/// Recalibrate), so `process_to_frame` is a strict subset of `process`.
|
||||
pub fn process_to_frame(
|
||||
&mut self,
|
||||
inputs: SensingInputs,
|
||||
header_template: BfldFrameHeader,
|
||||
payload: BfldPayload,
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldFrame> {
|
||||
let timestamp_ns = inputs.timestamp_ns;
|
||||
let _gate_signal = self.process(inputs, embedding)?;
|
||||
let mut header = header_template;
|
||||
header.timestamp_ns = timestamp_ns;
|
||||
header.privacy_class = self.current_privacy_class().as_u8();
|
||||
Some(BfldFrame::from_payload(header, &payload))
|
||||
}
|
||||
|
||||
/// `true` if `enable_privacy_mode()` has been called more recently than
|
||||
/// `disable_privacy_mode()`.
|
||||
#[must_use]
|
||||
pub const fn is_privacy_mode_enabled(&self) -> bool {
|
||||
self.privacy_mode
|
||||
}
|
||||
|
||||
/// Read the currently active class. Returns Restricted if privacy mode is
|
||||
/// engaged, otherwise the baseline.
|
||||
#[must_use]
|
||||
pub const fn current_privacy_class(&self) -> PrivacyClass {
|
||||
if self.privacy_mode {
|
||||
PrivacyClass::Restricted
|
||||
} else {
|
||||
self.baseline_class
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only access to the current gate action — for diagnostics.
|
||||
#[must_use]
|
||||
pub const fn current_gate_action(&self) -> GateAction {
|
||||
self.emitter.current_action()
|
||||
}
|
||||
|
||||
/// Engage privacy mode: future `process()` calls return events demoted
|
||||
/// to Restricted (identity_risk_score + rf_signature_hash stripped)
|
||||
/// regardless of the configured baseline.
|
||||
///
|
||||
/// The override is applied post-emission so the underlying gate / ring /
|
||||
/// hasher state remains unchanged and recoverable when privacy mode is
|
||||
/// later disabled.
|
||||
pub fn enable_privacy_mode(&mut self) {
|
||||
self.privacy_mode = true;
|
||||
}
|
||||
|
||||
/// Disengage privacy mode: future events return to the configured baseline.
|
||||
pub fn disable_privacy_mode(&mut self) {
|
||||
self.privacy_mode = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! `BfldPipelineHandle` — worker-thread wrapper around [`BfldPipeline`] and a
|
||||
//! [`Publish`]er. ADR-118 §2.1 single-call operator surface.
|
||||
//!
|
||||
//! `spawn()` returns a handle owning the inbound channel sender. The worker
|
||||
//! thread loops on `recv()`, drives one `pipeline.process()` per input, and
|
||||
//! forwards any emitted `BfldEvent` through `publish_event()`. `shutdown()`
|
||||
//! closes the channel and joins the thread.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use std::sync::mpsc::{channel, RecvError, SendError, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::coherence_gate::SoulMatchOracle;
|
||||
use crate::mqtt_topics::{publish_event, Publish};
|
||||
use crate::pipeline::BfldPipeline;
|
||||
use crate::{IdentityEmbedding, SensingInputs};
|
||||
|
||||
/// Frame-level input to the spawned worker. The pipeline state — gate,
|
||||
/// embedding ring, hasher — lives behind the worker thread; callers only
|
||||
/// send the per-frame sensing data.
|
||||
pub struct PipelineInput {
|
||||
/// Sensing fields fed to `pipeline.process`.
|
||||
pub inputs: SensingInputs,
|
||||
/// Optional embedding for the iter-15 hasher input + iter-8 ring.
|
||||
pub embedding: Option<IdentityEmbedding>,
|
||||
}
|
||||
|
||||
/// Handle to the spawned worker. Drop or `shutdown()` to stop. `send()`
|
||||
/// returns an error after shutdown.
|
||||
pub struct BfldPipelineHandle {
|
||||
sender: Sender<PipelineInput>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BfldPipelineHandle {
|
||||
/// Spawn a worker that owns `pipeline` and `publisher`. Returns a handle
|
||||
/// whose `send()` enqueues sensing inputs into the worker thread.
|
||||
///
|
||||
/// Publish errors are logged to stderr and the worker continues — single
|
||||
/// frame failures should not kill the long-running pipeline.
|
||||
#[must_use]
|
||||
pub fn spawn<P>(mut pipeline: BfldPipeline, mut publisher: P) -> Self
|
||||
where
|
||||
P: Publish + Send + 'static,
|
||||
P::Error: core::fmt::Debug,
|
||||
{
|
||||
let (sender, receiver) = channel::<PipelineInput>();
|
||||
let worker = thread::spawn(move || loop {
|
||||
match receiver.recv() {
|
||||
Ok(PipelineInput { inputs, embedding }) => {
|
||||
if let Some(event) = pipeline.process(inputs, embedding) {
|
||||
if let Err(e) = publish_event(&mut publisher, &event) {
|
||||
eprintln!("BFLD publish error: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvError) => break, // channel closed by shutdown / drop
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant of [`Self::spawn`] that installs a long-lived
|
||||
/// [`SoulMatchOracle`] used on every per-frame `process` call. The oracle
|
||||
/// must be `Send + Sync + 'static` because the worker thread consults it
|
||||
/// on every recv. Pairs with ADR-121 §2.6: when the oracle reports a
|
||||
/// `Match`, a would-be Recalibrate gate transition is downgraded to
|
||||
/// `PredictOnly` (high score is the *intended* outcome of a known-enrolled
|
||||
/// person match, not an attacker-grade sniffer arrival).
|
||||
#[must_use]
|
||||
pub fn spawn_with_oracle<P, O>(
|
||||
mut pipeline: BfldPipeline,
|
||||
mut publisher: P,
|
||||
oracle: O,
|
||||
) -> Self
|
||||
where
|
||||
P: Publish + Send + 'static,
|
||||
P::Error: core::fmt::Debug,
|
||||
O: SoulMatchOracle + Send + Sync + 'static,
|
||||
{
|
||||
let (sender, receiver) = channel::<PipelineInput>();
|
||||
let worker = thread::spawn(move || loop {
|
||||
match receiver.recv() {
|
||||
Ok(PipelineInput { inputs, embedding }) => {
|
||||
if let Some(event) =
|
||||
pipeline.process_with_oracle(inputs, embedding, &oracle)
|
||||
{
|
||||
if let Err(e) = publish_event(&mut publisher, &event) {
|
||||
eprintln!("BFLD publish error: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvError) => break,
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue an input. Returns `SendError<PipelineInput>` (carrying the
|
||||
/// rejected input) if the worker has already shut down.
|
||||
pub fn send(&self, input: PipelineInput) -> Result<(), SendError<PipelineInput>> {
|
||||
self.sender.send(input)
|
||||
}
|
||||
|
||||
/// Close the input channel and join the worker. Panics from the worker
|
||||
/// thread propagate here; otherwise returns cleanly.
|
||||
pub fn shutdown(mut self) {
|
||||
if let Some(worker) = self.worker.take() {
|
||||
drop(std::mem::replace(&mut self.sender, channel().0));
|
||||
worker
|
||||
.join()
|
||||
.expect("BFLD pipeline worker panicked during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BfldPipelineHandle {
|
||||
/// Best-effort cleanup if `shutdown()` was not called explicitly.
|
||||
fn drop(&mut self) {
|
||||
if let Some(worker) = self.worker.take() {
|
||||
// Replace the sender with a fresh disconnected one so the worker
|
||||
// recv() returns Err(RecvError) and the loop exits.
|
||||
drop(std::mem::replace(&mut self.sender, channel().0));
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! `PrivacyGate` — monotonic class transitions for `BfldFrame`. ADR-120 §2.4.
|
||||
//!
|
||||
//! The only way a higher-information frame becomes a lower-information frame
|
||||
//! is through [`PrivacyGate::demote`]. This function:
|
||||
//!
|
||||
//! 1. Asserts the target class is **strictly higher in numerical value** (or
|
||||
//! equal) to the current class — going from Derived(1) to Anonymous(2) is
|
||||
//! a demote; going from Anonymous(2) back to Derived(1) is forbidden.
|
||||
//! 2. Zeroes payload sections that are not permitted at the target class,
|
||||
//! using a `black_box`-guarded loop to defeat dead-store elimination.
|
||||
//! 3. Re-syncs `header.privacy_class` and `header.payload_crc32`.
|
||||
//! 4. Returns the new frame.
|
||||
//!
|
||||
//! There is no `promote` operation by design — once a section is zeroed, the
|
||||
//! original bytes are unrecoverable.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use crate::frame::crc32_of_payload;
|
||||
use crate::{BfldError, BfldFrame, BfldPayload, PrivacyClass};
|
||||
|
||||
/// Monotonic class transformer. See module docs.
|
||||
pub struct PrivacyGate;
|
||||
|
||||
impl PrivacyGate {
|
||||
/// Apply a class demotion in-place: returns a new `BfldFrame` whose
|
||||
/// `privacy_class`, payload sections, and CRC match `target`.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidDemote`] when `target` would *increase*
|
||||
/// the information density (lower class number than the source).
|
||||
pub fn demote(
|
||||
mut frame: BfldFrame,
|
||||
target: PrivacyClass,
|
||||
) -> Result<BfldFrame, BfldError> {
|
||||
let current = PrivacyClass::try_from(frame.header.privacy_class)?;
|
||||
if target.as_u8() < current.as_u8() {
|
||||
return Err(BfldError::InvalidDemote {
|
||||
from: current.as_u8(),
|
||||
to: target.as_u8(),
|
||||
});
|
||||
}
|
||||
|
||||
// Strip payload sections not permitted at the target class. We only do
|
||||
// this when the payload parses cleanly; a malformed payload remains
|
||||
// untouched in the bytes (the class byte and CRC still get re-synced).
|
||||
if let Ok(mut payload) = frame.parse_payload() {
|
||||
if target.as_u8() >= PrivacyClass::Anonymous.as_u8() {
|
||||
// Anonymous: drop the compressed angle matrix (identity surface).
|
||||
zeroize_then_clear(&mut payload.compressed_angle_matrix);
|
||||
// Also drop optional sections that may carry identity-leaky
|
||||
// signal under high-separability conditions.
|
||||
if let Some(csi) = payload.csi_delta.as_mut() {
|
||||
zeroize_then_clear(csi);
|
||||
}
|
||||
}
|
||||
if target.as_u8() >= PrivacyClass::Restricted.as_u8() {
|
||||
// Restricted: also drop amplitude + phase proxies.
|
||||
zeroize_then_clear(&mut payload.amplitude_proxy);
|
||||
zeroize_then_clear(&mut payload.phase_proxy);
|
||||
}
|
||||
// Note: csi_delta dropped above implies the flag bit should clear.
|
||||
// from_payload re-derives the flag from csi_delta.is_some(), so
|
||||
// taking the Option out below ensures the bit is cleared.
|
||||
if target.as_u8() >= PrivacyClass::Anonymous.as_u8() {
|
||||
payload.csi_delta = None;
|
||||
}
|
||||
frame = BfldFrame::from_payload(frame.header, &payload);
|
||||
}
|
||||
|
||||
frame.header.privacy_class = target.as_u8();
|
||||
// from_payload already recomputed CRC, but recompute again so the
|
||||
// path that skipped payload parsing still produces a consistent frame.
|
||||
frame.header.payload_crc32 = crc32_of_payload(&frame.payload);
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `v` with zeros, then truncate. The `black_box` call defeats
|
||||
/// dead-store elimination so the writes are observable.
|
||||
fn zeroize_then_clear(v: &mut Vec<u8>) {
|
||||
for b in v.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
core::hint::black_box(v.as_ptr());
|
||||
v.clear();
|
||||
}
|
||||
|
||||
// Convenience constructor: the gate is a unit type, but keeping a Default
|
||||
// makes downstream injection sites (PrivacyGate.demote(...) vs static call)
|
||||
// straightforward.
|
||||
impl Default for PrivacyGate {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard the rest of an unused (#[allow(dead_code)]) — placeholder so
|
||||
/// `BfldPayload` import isn't unused in builds that strip the implementation.
|
||||
#[allow(dead_code)]
|
||||
fn _unused_payload_marker(_: BfldPayload) {}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `RumqttPublisher` — production [`Publish`] impl backed by `rumqttc`.
|
||||
//! ADR-122 §2.2 broker integration.
|
||||
//!
|
||||
//! Gated on `feature = "mqtt"`. The sync `rumqttc::Client` is used so the
|
||||
//! `Publish` trait's sync method signature is honored without a tokio runtime.
|
||||
//! The companion `rumqttc::Connection` returned by [`RumqttPublisher::connect`]
|
||||
//! must be pumped by the caller (typically on a dedicated thread) to drive
|
||||
//! the MQTT protocol — published messages remain queued until the connection
|
||||
//! sends them.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use std::thread;
|
||||
//! use wifi_densepose_bfld::{publish_event, RumqttPublisher};
|
||||
//! use rumqttc::MqttOptions;
|
||||
//!
|
||||
//! let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
||||
//! let (mut publisher, mut connection) = RumqttPublisher::connect(opts, 100);
|
||||
//! thread::spawn(move || for _ in connection.iter() { /* drain */ });
|
||||
//! // ... build BfldEvent ...
|
||||
//! publish_event(&mut publisher, &event).expect("mqtt publish");
|
||||
//! ```
|
||||
|
||||
#![cfg(feature = "mqtt")]
|
||||
|
||||
use rumqttc::{Client, Connection, LastWill, MqttOptions, QoS};
|
||||
|
||||
use crate::availability::{availability_topic, PAYLOAD_NOT_AVAILABLE};
|
||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||
|
||||
/// Sync MQTT publisher wrapping [`rumqttc::Client`].
|
||||
pub struct RumqttPublisher {
|
||||
client: Client,
|
||||
qos: QoS,
|
||||
retain: bool,
|
||||
}
|
||||
|
||||
impl RumqttPublisher {
|
||||
/// Wrap an existing `Client` at the supplied QoS. `retain = false` matches
|
||||
/// HA-DISCO state-topic semantics (retained payloads cause stale-state
|
||||
/// flapping on broker reconnect). For availability-style topics callers
|
||||
/// should construct a separate publisher with `retain = true`.
|
||||
#[must_use]
|
||||
pub const fn new(client: Client, qos: QoS) -> Self {
|
||||
Self {
|
||||
client,
|
||||
qos,
|
||||
retain: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the per-publisher `retain` flag.
|
||||
#[must_use]
|
||||
pub const fn with_retain(mut self, retain: bool) -> Self {
|
||||
self.retain = retain;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build a publisher + an unpumped `Connection`. Caller is responsible
|
||||
/// for spawning a thread that iterates the connection (typical pattern
|
||||
/// shown in the module-level doc example).
|
||||
#[must_use]
|
||||
pub fn connect(opts: MqttOptions, capacity: usize) -> (Self, Connection) {
|
||||
let (client, connection) = Client::new(opts, capacity);
|
||||
(Self::new(client, QoS::AtLeastOnce), connection)
|
||||
}
|
||||
|
||||
/// Like [`Self::connect`] but also configures the MQTT Last Will and
|
||||
/// Testament so the broker auto-publishes `"offline"` on
|
||||
/// `ruview/<node_id>/bfld/availability` (retained, QoS 1) when the
|
||||
/// publisher's TCP session drops without a clean DISCONNECT.
|
||||
///
|
||||
/// Pairs with [`crate::publish_availability_online`] — call that on first
|
||||
/// CONNECT to set `"online"`; the LWT covers the disconnect path.
|
||||
#[must_use]
|
||||
pub fn connect_with_lwt(
|
||||
node_id: &str,
|
||||
opts: MqttOptions,
|
||||
capacity: usize,
|
||||
) -> (Self, Connection) {
|
||||
let opts = with_lwt(opts, node_id);
|
||||
Self::connect(opts, capacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutate `opts` to attach the BFLD availability LWT. Public so callers that
|
||||
/// build their own `MqttOptions` (custom tls, credentials, etc.) can still
|
||||
/// opt in to the LWT without using `connect_with_lwt`.
|
||||
#[must_use]
|
||||
pub fn with_lwt(mut opts: MqttOptions, node_id: &str) -> MqttOptions {
|
||||
// rumqttc 0.24 LastWill::new takes (topic, message, qos, retain).
|
||||
// retain = true so HA sees "offline" on next start even if the session
|
||||
// dropped while HA was down.
|
||||
let will = LastWill::new(
|
||||
availability_topic(node_id),
|
||||
PAYLOAD_NOT_AVAILABLE,
|
||||
QoS::AtLeastOnce,
|
||||
true,
|
||||
);
|
||||
opts.set_last_will(will);
|
||||
opts
|
||||
}
|
||||
|
||||
impl Publish for RumqttPublisher {
|
||||
type Error = rumqttc::ClientError;
|
||||
|
||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||
self.client
|
||||
.publish(&msg.topic, self.qos, self.retain, msg.payload.as_bytes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `SignatureHasher` — BLAKE3 keyed-hash for `rf_signature_hash`. ADR-120 §2.3.
|
||||
//!
|
||||
//! Computes a per-site, per-day, identity-features digest that **structurally
|
||||
//! prevents** cross-site identity correlation (BFLD invariant I3):
|
||||
//!
|
||||
//! ```text
|
||||
//! rf_signature_hash = BLAKE3-keyed(site_salt, day_epoch || features)
|
||||
//! ```
|
||||
//!
|
||||
//! - **Site isolation**: `site_salt` is a 256-bit secret unique to each node
|
||||
//! and never transmitted. Two nodes observing the same physical person
|
||||
//! produce uncorrelated hashes — there is no key an operator (or an
|
||||
//! attacker who compromises one node) can use to bridge sites.
|
||||
//! - **Daily rotation**: `day_epoch = floor(unix_time_utc / 86_400)` flips at
|
||||
//! UTC midnight, so the same person's hash changes once per day.
|
||||
//!
|
||||
//! See ADR-120 §2.7 AC2 for the cross-site Hamming-distance acceptance
|
||||
//! criterion. `tests/signature_hasher.rs` exercises it directly.
|
||||
|
||||
use blake3::Hasher;
|
||||
|
||||
/// Number of seconds in a UTC day; the daily-rotation modulus.
|
||||
pub const SECONDS_PER_DAY: u64 = 86_400;
|
||||
|
||||
/// Length of the keyed `site_salt`, fixed by BLAKE3 keyed mode at 32 bytes.
|
||||
pub const SITE_SALT_LEN: usize = 32;
|
||||
|
||||
/// Output length — always 32 bytes (BLAKE3 default).
|
||||
pub const RF_SIGNATURE_LEN: usize = 32;
|
||||
|
||||
/// Per-node hasher carrying the secret `site_salt`. Construct once at boot
|
||||
/// from the persistent secret store (TPM, KMS, or strict-mode file).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SignatureHasher {
|
||||
site_salt: [u8; SITE_SALT_LEN],
|
||||
}
|
||||
|
||||
impl SignatureHasher {
|
||||
/// Build a hasher from an existing `site_salt`. The salt is **never
|
||||
/// transmitted** from this point on; callers must keep it in secure storage.
|
||||
#[must_use]
|
||||
pub const fn new(site_salt: [u8; SITE_SALT_LEN]) -> Self {
|
||||
Self { site_salt }
|
||||
}
|
||||
|
||||
/// Compute the daily epoch from a UTC unix-seconds timestamp.
|
||||
#[must_use]
|
||||
pub const fn day_epoch_from_unix_secs(unix_secs: u64) -> u32 {
|
||||
(unix_secs / SECONDS_PER_DAY) as u32
|
||||
}
|
||||
|
||||
/// Compute the `rf_signature_hash` for the supplied (day, features) pair.
|
||||
/// `features` is the canonical-bytes representation of the current
|
||||
/// identity-features tuple — the caller is responsible for deterministic
|
||||
/// serialization (e.g., `bincode` with sorted keys, or a hand-rolled
|
||||
/// fixed-order byte layout).
|
||||
#[must_use]
|
||||
pub fn compute(&self, day_epoch: u32, features: &[u8]) -> [u8; RF_SIGNATURE_LEN] {
|
||||
let mut hasher = Hasher::new_keyed(&self.site_salt);
|
||||
hasher.update(&day_epoch.to_le_bytes());
|
||||
hasher.update(features);
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// Convenience: compute from a unix-seconds timestamp instead of an
|
||||
/// explicit `day_epoch`.
|
||||
#[must_use]
|
||||
pub fn compute_at(
|
||||
&self,
|
||||
unix_secs: u64,
|
||||
features: &[u8],
|
||||
) -> [u8; RF_SIGNATURE_LEN] {
|
||||
self.compute(Self::day_epoch_from_unix_secs(unix_secs), features)
|
||||
}
|
||||
}
|
||||
@@ -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,117 @@
|
||||
//! Acceptance tests for ADR-122 §2.2 availability topic + LWT integration.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
availability_topic, offline_message, online_message, publish_availability_offline,
|
||||
publish_availability_online, render_discovery_payloads, CapturePublisher, PrivacyClass,
|
||||
PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn availability_topic_format_matches_documented_path() {
|
||||
assert_eq!(
|
||||
availability_topic("seed-01"),
|
||||
"ruview/seed-01/bfld/availability",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_message_is_retained_friendly_payload() {
|
||||
let msg = online_message("seed-99");
|
||||
assert_eq!(msg.topic, "ruview/seed-99/bfld/availability");
|
||||
assert_eq!(msg.payload, "online");
|
||||
assert_eq!(msg.payload, PAYLOAD_AVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_message_is_retained_friendly_payload() {
|
||||
let msg = offline_message("seed-99");
|
||||
assert_eq!(msg.payload, "offline");
|
||||
assert_eq!(msg.payload, PAYLOAD_NOT_AVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_online_lands_one_message() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 1);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_offline_lands_one_message() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_offline(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 1);
|
||||
assert_eq!(p.published[0].payload, "offline");
|
||||
}
|
||||
|
||||
// --- discovery payload integration --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn discovery_payload_includes_availability_topic_field() {
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
|
||||
for msg in &msgs {
|
||||
assert!(
|
||||
msg.payload
|
||||
.contains("\"availability_topic\":\"ruview/seed-01/bfld/availability\""),
|
||||
"discovery payload must reference availability_topic, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_payload_includes_payload_available_and_not_available_strings() {
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous);
|
||||
for msg in &msgs {
|
||||
assert!(
|
||||
msg.payload.contains("\"payload_available\":\"online\""),
|
||||
"discovery payload missing payload_available, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
assert!(
|
||||
msg.payload.contains("\"payload_not_available\":\"offline\""),
|
||||
"discovery payload missing payload_not_available, got: {}",
|
||||
msg.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_class_discovery_still_carries_availability_fields() {
|
||||
// Availability isn't an identity field — class 3 retains it.
|
||||
let msgs = render_discovery_payloads("seed-01", PrivacyClass::Restricted);
|
||||
assert_eq!(msgs.len(), 5);
|
||||
for msg in &msgs {
|
||||
assert!(msg.payload.contains("\"availability_topic\":"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- bootstrap composition ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bootstrap_sequence_online_then_discovery_lands_in_order() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").expect("online");
|
||||
let count =
|
||||
wifi_densepose_bfld::publish_discovery(&mut p, "seed-01", PrivacyClass::Anonymous)
|
||||
.expect("discovery");
|
||||
assert_eq!(count, 6);
|
||||
assert_eq!(p.published.len(), 1 + 6);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
for msg in p.published.iter().skip(1) {
|
||||
assert!(msg.topic.starts_with("homeassistant/"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graceful_shutdown_sequence_publishes_offline_message_last() {
|
||||
let mut p = CapturePublisher::default();
|
||||
publish_availability_online(&mut p, "seed-01").unwrap();
|
||||
publish_availability_offline(&mut p, "seed-01").unwrap();
|
||||
assert_eq!(p.published.len(), 2);
|
||||
assert_eq!(p.published[0].payload, "online");
|
||||
assert_eq!(p.published[1].payload, "offline");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! `BfldError` Display format pinning. Operators grep log lines for these
|
||||
//! strings; format drift between minor versions breaks monitoring queries.
|
||||
//! Each variant gets a test that asserts the documented substrings appear.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::BfldError;
|
||||
|
||||
#[test]
|
||||
fn invalid_magic_displays_both_expected_and_actual_in_hex() {
|
||||
let err = BfldError::InvalidMagic(0xDEAD_BEEF);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid BFLD magic"), "got: {s}");
|
||||
assert!(s.contains("0xBF1D0001"), "expected magic missing: {s}");
|
||||
assert!(s.contains("0xDEADBEEF"), "actual magic missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_displays_the_offending_version() {
|
||||
let err = BfldError::UnsupportedVersion(99);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("unsupported BFLD version"), "got: {s}");
|
||||
assert!(s.contains("99"), "version number missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_mismatch_displays_both_values_in_hex() {
|
||||
let err = BfldError::Crc {
|
||||
expected: 0xCAFEBABE,
|
||||
actual: 0xDEADBEEF,
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("payload CRC mismatch"), "got: {s}");
|
||||
assert!(s.contains("0xCAFEBABE"), "expected missing: {s}");
|
||||
assert!(s.contains("0xDEADBEEF"), "actual missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_violation_displays_the_sink_reason() {
|
||||
let err = BfldError::PrivacyViolation {
|
||||
reason: "NetworkKind",
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("privacy violation"), "got: {s}");
|
||||
assert!(s.contains("NetworkKind"), "reason missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_privacy_class_displays_the_offending_byte() {
|
||||
let err = BfldError::InvalidPrivacyClass(7);
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid PrivacyClass byte"), "got: {s}");
|
||||
assert!(s.contains("7"), "byte value missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_frame_displays_got_and_need_byte_counts() {
|
||||
let err = BfldError::TruncatedFrame { got: 50, need: 86 };
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("truncated frame"), "got: {s}");
|
||||
assert!(s.contains("50"), "got count missing: {s}");
|
||||
assert!(s.contains("86"), "need count missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_section_displays_offset_and_reason() {
|
||||
let err = BfldError::MalformedSection {
|
||||
offset: 1234,
|
||||
reason: "section body runs past buffer end",
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("malformed payload section"), "got: {s}");
|
||||
assert!(s.contains("1234"), "offset missing: {s}");
|
||||
assert!(s.contains("buffer end"), "reason missing: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_demote_displays_both_from_and_to_class_bytes() {
|
||||
let err = BfldError::InvalidDemote { from: 2, to: 1 };
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("invalid demote"), "got: {s}");
|
||||
assert!(s.contains("from class 2"), "from missing: {s}");
|
||||
assert!(s.contains("to class 1"), "to missing: {s}");
|
||||
}
|
||||
|
||||
// --- meta: error implements std::error::Error (for ? + dyn use) -------
|
||||
|
||||
#[test]
|
||||
fn bfld_error_implements_std_error_trait() {
|
||||
fn assert_error_trait<E: std::error::Error>() {}
|
||||
assert_error_trait::<BfldError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bfld_error_is_debug_so_panic_unwrap_messages_carry_diagnostics() {
|
||||
let err = BfldError::Crc {
|
||||
expected: 0xAA,
|
||||
actual: 0xBB,
|
||||
};
|
||||
let debug = format!("{err:?}");
|
||||
assert!(debug.contains("Crc"), "Debug must show variant name: {debug}");
|
||||
}
|
||||
|
||||
// --- catch-all: every variant has a non-empty Display -----------------
|
||||
|
||||
#[test]
|
||||
fn every_variant_has_a_non_empty_display_string() {
|
||||
let cases: Vec<BfldError> = vec![
|
||||
BfldError::InvalidMagic(0),
|
||||
BfldError::UnsupportedVersion(0),
|
||||
BfldError::Crc {
|
||||
expected: 0,
|
||||
actual: 0,
|
||||
},
|
||||
BfldError::PrivacyViolation { reason: "X" },
|
||||
BfldError::InvalidPrivacyClass(0),
|
||||
BfldError::TruncatedFrame { got: 0, need: 0 },
|
||||
BfldError::MalformedSection {
|
||||
offset: 0,
|
||||
reason: "X",
|
||||
},
|
||||
BfldError::InvalidDemote { from: 0, to: 0 },
|
||||
];
|
||||
for err in cases {
|
||||
let s = err.to_string();
|
||||
assert!(!s.is_empty(), "Display for {err:?} returned empty string");
|
||||
assert!(
|
||||
s.len() >= 5,
|
||||
"Display for {err:?} suspiciously short: {s:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Validate the BFLD entry exists in the workspace-root CHANGELOG.md.
|
||||
//! `cog-ha-matter`, `wifi-densepose-sensing-server`, and the pip wheel
|
||||
//! ship under their own release cadence; the workspace CHANGELOG is the
|
||||
//! one canonical record an operator scans when upgrading a Cognitum Seed.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const CHANGELOG: &str = include_str!("../../../../CHANGELOG.md");
|
||||
|
||||
#[test]
|
||||
fn changelog_documents_bfld_entry_under_unreleased() {
|
||||
// Find the position of the [Unreleased] header.
|
||||
let unreleased = CHANGELOG
|
||||
.find("## [Unreleased]")
|
||||
.expect("CHANGELOG must have an [Unreleased] section");
|
||||
// The first numbered version header marks the end of [Unreleased].
|
||||
let after_unreleased = CHANGELOG[unreleased..]
|
||||
.find("\n## [0")
|
||||
.or_else(|| CHANGELOG[unreleased..].find("\n## [1"))
|
||||
.map(|off| unreleased + off)
|
||||
.unwrap_or(CHANGELOG.len());
|
||||
let unreleased_block = &CHANGELOG[unreleased..after_unreleased];
|
||||
assert!(
|
||||
unreleased_block.contains("BFLD"),
|
||||
"[Unreleased] must mention BFLD",
|
||||
);
|
||||
assert!(unreleased_block.contains("wifi-densepose-bfld"));
|
||||
assert!(
|
||||
unreleased_block.contains("#787"),
|
||||
"[Unreleased] BFLD entry must link tracking issue #787",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_cites_companion_adrs() {
|
||||
for adr in ["ADR-118", "ADR-119", "ADR-120", "ADR-121", "ADR-122", "ADR-123"] {
|
||||
assert!(
|
||||
CHANGELOG.contains(adr),
|
||||
"CHANGELOG BFLD entry must cite {adr}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_names_three_structural_invariants() {
|
||||
let needles = ["**I1**", "**I2**", "**I3**"];
|
||||
for n in needles {
|
||||
assert!(CHANGELOG.contains(n), "CHANGELOG must call out invariant {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_documents_a_runnable_example() {
|
||||
assert!(
|
||||
CHANGELOG.contains("cargo run -p wifi-densepose-bfld --example"),
|
||||
"CHANGELOG entry should give operators a copy-pasteable try-it command",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_bfld_entry_references_research_bundle() {
|
||||
assert!(CHANGELOG.contains("docs/research/BFLD/"));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Structural validation for `.github/workflows/bfld-mqtt-integration.yml`.
|
||||
//! Same pattern as iter-30's HA blueprint tests: embed via `include_str!`,
|
||||
//! string-check the key fields. Avoids adding a serde_yaml dep just to lint
|
||||
//! a CI workflow.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const WORKFLOW: &str = include_str!(
|
||||
"../../../../.github/workflows/bfld-mqtt-integration.yml"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn workflow_declares_mosquitto_service_container() {
|
||||
assert!(
|
||||
WORKFLOW.contains("image: eclipse-mosquitto:2"),
|
||||
"workflow must declare eclipse-mosquitto:2 as a service container",
|
||||
);
|
||||
assert!(
|
||||
WORKFLOW.contains("- 1883:1883"),
|
||||
"workflow must expose port 1883 from the mosquitto service",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_exports_broker_env_for_iter_24_and_29_tests() {
|
||||
assert!(
|
||||
WORKFLOW.contains("BFLD_MQTT_BROKER: tcp://localhost:1883"),
|
||||
"BFLD_MQTT_BROKER env var must point at the service container so the \
|
||||
iter-24 mosquitto_integration test exits skip mode",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_runs_three_cargo_test_invocations() {
|
||||
// Regression guard for the default + no-default-features + mqtt matrix.
|
||||
// Each one catches a different class of bug:
|
||||
// --no-default-features: catches std-feature leakage
|
||||
// default: catches the everyday surface
|
||||
// --features mqtt: catches the live-broker integration path
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld --no-default-features"));
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld"));
|
||||
assert!(WORKFLOW.contains("cargo test -p wifi-densepose-bfld --features mqtt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_waits_for_mosquitto_readiness_before_testing() {
|
||||
assert!(
|
||||
WORKFLOW.contains("nc -z localhost 1883"),
|
||||
"workflow must port-poll for mosquitto readiness — a service container \
|
||||
can take a few seconds to bind even with healthcheck",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_uses_health_check_on_the_service() {
|
||||
assert!(
|
||||
WORKFLOW.contains("--health-cmd"),
|
||||
"service container should declare a health-check for stable startup",
|
||||
);
|
||||
assert!(
|
||||
WORKFLOW.contains("mosquitto_pub"),
|
||||
"health-check should attempt a real publish, not just process liveness",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_only_triggers_on_bfld_paths() {
|
||||
assert!(
|
||||
WORKFLOW.contains("v2/crates/wifi-densepose-bfld/**"),
|
||||
"path filter must scope the workflow to BFLD changes, not run on every push",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_pins_runner_to_ubuntu_latest_for_docker_service_support() {
|
||||
assert!(
|
||||
WORKFLOW.contains("runs-on: ubuntu-latest"),
|
||||
"GitHub Actions Docker service containers require linux; macOS and \
|
||||
Windows runners don't support `services:`.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_has_timeout_guard() {
|
||||
// The integration tests have 10-second recv timeouts but the matrix runs
|
||||
// three cargo invocations + cache + warmup; a top-level timeout-minutes
|
||||
// guards against a stuck broker or rumqttc handshake hanging the runner.
|
||||
assert!(
|
||||
WORKFLOW.contains("timeout-minutes:"),
|
||||
"workflow must declare a top-level timeout-minutes to bound runner cost",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Acceptance tests for ADR-121 §2.5 — `CoherenceGate` hysteresis + debounce.
|
||||
|
||||
use wifi_densepose_bfld::coherence_gate::{DEBOUNCE_NS, HYSTERESIS};
|
||||
use wifi_densepose_bfld::{CoherenceGate, GateAction};
|
||||
|
||||
#[test]
|
||||
fn fresh_gate_starts_in_accept_with_no_pending() {
|
||||
let g = CoherenceGate::new();
|
||||
assert_eq!(g.current(), GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_score_stays_in_accept_with_no_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.3, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_just_past_boundary_but_within_hysteresis_does_not_pend() {
|
||||
// current = Accept, upper edge = 0.5, hysteresis = 0.05 → need >= 0.55 to start pending.
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.52, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None, "0.52 must not start a pending transition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_clearly_past_hysteresis_starts_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.6, 0);
|
||||
assert_eq!(out, GateAction::Accept, "still Accept until debounce elapses");
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_action_promotes_after_full_debounce() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0);
|
||||
assert_eq!(g.current(), GateAction::Accept);
|
||||
let out = g.evaluate(0.6, DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::PredictOnly);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_action_does_not_promote_before_debounce() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0);
|
||||
let out = g.evaluate(0.6, DEBOUNCE_NS - 1);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_to_current_band_cancels_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0); // pending PredictOnly
|
||||
let out = g.evaluate(0.4, 1_000_000_000); // 1s later, back in Accept band
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None, "returning to current band cancels pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_pending_target_resets_the_debounce_clock() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(0.6, 0); // pending PredictOnly at t=0
|
||||
g.evaluate(0.95, 1_000_000_000); // pending Recalibrate at t=1s (clock reset)
|
||||
// At t=1s + DEBOUNCE_NS - 1, still not promoted (Recalibrate pending since 1s)
|
||||
let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS - 1);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
// At t=1s + DEBOUNCE_NS, promoted to Recalibrate
|
||||
let out = g.evaluate(0.95, 1_000_000_000 + DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::Recalibrate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downward_transitions_also_require_hysteresis() {
|
||||
let mut g = CoherenceGate::new();
|
||||
// Force gate into PredictOnly state.
|
||||
g.evaluate(0.6, 0);
|
||||
g.evaluate(0.6, DEBOUNCE_NS);
|
||||
assert_eq!(g.current(), GateAction::PredictOnly);
|
||||
|
||||
// 0.48 is below 0.5 but only by 0.02 — within hysteresis envelope.
|
||||
let out = g.evaluate(0.48, 2 * DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::PredictOnly);
|
||||
assert_eq!(g.pending(), None, "0.48 is within downward hysteresis");
|
||||
|
||||
// 0.44 is below 0.5 - 0.05 = 0.45 → starts pending Accept.
|
||||
g.evaluate(0.44, 3 * DEBOUNCE_NS);
|
||||
assert_eq!(g.pending(), Some(GateAction::Accept));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_to_one_then_back_to_zero_never_promotes_to_recalibrate() {
|
||||
let mut g = CoherenceGate::new();
|
||||
g.evaluate(1.0, 0); // pending Recalibrate at t=0
|
||||
// 1 second later score is back to 0 — cancel pending.
|
||||
let out = g.evaluate(0.0, 1_000_000_000);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
// Even waiting longer, the gate stays in Accept.
|
||||
let out = g.evaluate(0.0, 100 * DEBOUNCE_NS);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_with_hysteresis_does_not_promote() {
|
||||
// Edge: current=Accept, score = upper_edge + HYSTERESIS - epsilon (just below).
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.5 + HYSTERESIS - 0.0001, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_at_hysteresis_exact_does_pend() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(0.5 + HYSTERESIS, 0);
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), Some(GateAction::PredictOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_score_stays_in_current_action_with_no_pending() {
|
||||
let mut g = CoherenceGate::new();
|
||||
let out = g.evaluate(f32::NAN, 0);
|
||||
// NaN maps to Accept via from_score; gate stays in Accept and clears pending.
|
||||
assert_eq!(out, GateAction::Accept);
|
||||
assert_eq!(g.pending(), None);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Validate the crate README. Same `include_str!` pattern iter-30/47/48 used
|
||||
//! for HA blueprints / examples. crates.io renders this file, so doc drift
|
||||
//! against the actual public API is operator-visible.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
const README: &str = include_str!("../README.md");
|
||||
|
||||
#[test]
|
||||
fn readme_documents_three_structural_invariants() {
|
||||
for needle in [
|
||||
"**I1**",
|
||||
"**I2**",
|
||||
"**I3**",
|
||||
"Raw BFI never exits the node",
|
||||
"Identity embedding is in-RAM-only",
|
||||
"Cross-site identity correlation",
|
||||
] {
|
||||
assert!(README.contains(needle), "README missing invariant text: {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_feature_flag_matrix() {
|
||||
for needle in ["`std`", "`serde-json`", "`mqtt`", "`soul-signature`"] {
|
||||
assert!(README.contains(needle), "feature flag {needle} missing from README");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_both_runnable_examples() {
|
||||
assert!(README.contains("cargo run -p wifi-densepose-bfld --example bfld_minimal"));
|
||||
assert!(README.contains("cargo run -p wifi-densepose-bfld --example bfld_handle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_three_test_invocations() {
|
||||
assert!(README.contains("cargo test -p wifi-densepose-bfld --no-default-features"));
|
||||
assert!(README.contains("cargo test -p wifi-densepose-bfld --features mqtt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_references_companion_adrs_118_through_123() {
|
||||
for adr in ["118", "119", "120", "121", "122", "123"] {
|
||||
assert!(README.contains(adr), "README must cite ADR-{adr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_quickstart_uses_canonical_public_api() {
|
||||
// The quickstart snippets must reference the actual operator-facing
|
||||
// surface — drift here would mislead first-time users.
|
||||
for needle in [
|
||||
"BfldPipeline::new",
|
||||
"BfldConfig::new",
|
||||
"SignatureHasher::new",
|
||||
"SensingInputs",
|
||||
"IdentityEmbedding::from_raw",
|
||||
"pipeline\n .process",
|
||||
"publish_availability_online",
|
||||
"publish_discovery",
|
||||
"BfldPipelineHandle::spawn",
|
||||
"PipelineInput",
|
||||
] {
|
||||
assert!(README.contains(needle), "quickstart missing canonical API: {needle}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_points_at_research_bundle_and_blueprints() {
|
||||
assert!(README.contains("docs/research/BFLD/"));
|
||||
assert!(README.contains("cog-ha-matter/blueprints/bfld/"));
|
||||
assert!(README.contains("bfld-mqtt-integration.yml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_documents_env_gated_mosquitto_integration() {
|
||||
assert!(README.contains("BFLD_MQTT_BROKER=tcp://localhost:1883"));
|
||||
assert!(README.contains("mosquitto_integration"));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Pin the CRC-32/ISO-HDLC polynomial used by `crc32_of_payload`. ADR-119 §2.4.
|
||||
//!
|
||||
//! BFLD picks **CRC-32/ISO-HDLC** specifically (same as Ethernet / zlib),
|
||||
//! NOT CRC-32C (Castagnoli) or any other CRC-32 variant. The polynomial
|
||||
//! choice is part of the wire-format contract — two implementations that
|
||||
//! disagree on the polynomial will treat every other's frame as corrupt.
|
||||
//!
|
||||
//! These tests use the standard "123456789" check string (CRC reference
|
||||
//! https://reveng.sourceforge.io/crc-catalogue/all.htm) plus a few targeted
|
||||
//! vectors. If a future PR swaps `CRC_32_ISO_HDLC` for `CRC_32_CKSUM` or
|
||||
//! similar, every test below fires.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::frame::crc32_of_payload;
|
||||
|
||||
/// CRC-32/ISO-HDLC check vector — "123456789" must produce 0xCBF43926.
|
||||
const CHECK_VALUE: u32 = 0xCBF4_3926;
|
||||
|
||||
#[test]
|
||||
fn check_string_matches_canonical_iso_hdlc_value() {
|
||||
assert_eq!(
|
||||
crc32_of_payload(b"123456789"),
|
||||
CHECK_VALUE,
|
||||
"CRC-32/ISO-HDLC of the standard \"123456789\" check string must be 0xCBF43926. \
|
||||
If this test fires, someone likely swapped the polynomial — verify the \
|
||||
crc::CRC_32_ISO_HDLC binding in src/frame.rs.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_payload_yields_zero_crc() {
|
||||
// Per CRC-32/ISO-HDLC: init = 0xFFFFFFFF, xorout = 0xFFFFFFFF. Empty
|
||||
// input passes init through xorout, yielding 0x00000000.
|
||||
assert_eq!(crc32_of_payload(b""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_zero_byte_has_a_specific_value() {
|
||||
// Pins the algorithm — CRC-32/ISO-HDLC of a single 0x00 byte is
|
||||
// 0xD202EF8D (well-known constant).
|
||||
assert_eq!(crc32_of_payload(&[0x00]), 0xD202_EF8D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipping_a_single_payload_byte_changes_the_crc() {
|
||||
// CRC is sensitive to every bit. A 256-byte payload with one bit flip
|
||||
// must produce a different CRC.
|
||||
let mut payload = vec![0xAA; 256];
|
||||
let crc_before = crc32_of_payload(&payload);
|
||||
payload[42] ^= 0x01;
|
||||
let crc_after = crc32_of_payload(&payload);
|
||||
assert_ne!(crc_before, crc_after, "single bit flip must change CRC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_hdlc_distinguishes_from_castagnoli_for_same_input() {
|
||||
// CRC-32C ("Castagnoli", poly 0x1EDC6F41) of "123456789" is 0xE3069283.
|
||||
// CRC-32/ISO-HDLC of "123456789" is 0xCBF43926.
|
||||
// If anyone swaps polynomials, the test above already catches it — this
|
||||
// test makes the failure mode explicit by asserting the inequality
|
||||
// between the values, so reading the test source explains WHY.
|
||||
let our_crc = crc32_of_payload(b"123456789");
|
||||
let castagnoli = 0xE306_9283u32;
|
||||
assert_ne!(
|
||||
our_crc, castagnoli,
|
||||
"if our_crc equals CRC-32C/Castagnoli, the polynomial was swapped",
|
||||
);
|
||||
assert_eq!(our_crc, CHECK_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_short_inputs_have_documented_crcs() {
|
||||
// Computed via crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(...)
|
||||
// and captured here to lock the API surface. If a different crc crate
|
||||
// version or a different polynomial slips in, these constants fire.
|
||||
assert_eq!(crc32_of_payload(b"a"), 0xE8B7_BE43);
|
||||
assert_eq!(crc32_of_payload(b"abc"), 0x3524_41C2);
|
||||
assert_eq!(crc32_of_payload(b"hello world"), 0x0D4A_1185);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_is_deterministic_across_repeated_calls() {
|
||||
let payload = b"deterministic check payload";
|
||||
let a = crc32_of_payload(payload);
|
||||
let b = crc32_of_payload(payload);
|
||||
let c = crc32_of_payload(payload);
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(b, c);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Acceptance tests for ADR-120 §2.5 `EmbeddingRing` lifecycle.
|
||||
|
||||
use wifi_densepose_bfld::{EmbeddingRing, IdentityEmbedding, EMBEDDING_DIM, RING_CAPACITY};
|
||||
|
||||
fn embedding_with_first(v: f32) -> IdentityEmbedding {
|
||||
let mut arr = [0.0f32; EMBEDDING_DIM];
|
||||
arr[0] = v;
|
||||
IdentityEmbedding::from_raw(arr)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ring_is_empty() {
|
||||
let r = EmbeddingRing::new();
|
||||
assert_eq!(r.len(), 0);
|
||||
assert!(r.is_empty());
|
||||
assert!(!r.is_full());
|
||||
assert_eq!(r.capacity(), RING_CAPACITY);
|
||||
assert_eq!(r.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_constructor_matches_new() {
|
||||
let r = EmbeddingRing::default();
|
||||
assert_eq!(r.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_below_capacity_returns_none() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..5 {
|
||||
let evicted = r.push(embedding_with_first(i as f32));
|
||||
assert!(evicted.is_none(), "no eviction expected at i={i}");
|
||||
}
|
||||
assert_eq!(r.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_in_insertion_order() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..5 {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let firsts: Vec<f32> = r.iter().map(|e| e.as_slice()[0]).collect();
|
||||
assert_eq!(firsts, vec![0.0, 1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_at_capacity_evicts_oldest_and_returns_it() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..RING_CAPACITY {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
assert!(r.is_full());
|
||||
let evicted = r
|
||||
.push(embedding_with_first(999.0))
|
||||
.expect("must evict when full");
|
||||
// The evicted slot held the very first push (first = 0.0).
|
||||
assert_eq!(evicted.as_slice()[0], 0.0);
|
||||
assert_eq!(r.len(), RING_CAPACITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_beyond_capacity_keeps_last_n_entries() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
// Push capacity + 10 entries; the first 10 must have been evicted.
|
||||
for i in 0..(RING_CAPACITY + 10) {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let firsts: Vec<f32> = r.iter().map(|e| e.as_slice()[0]).collect();
|
||||
let expected: Vec<f32> = (10..(RING_CAPACITY + 10) as i32)
|
||||
.map(|i| i as f32)
|
||||
.collect();
|
||||
assert_eq!(firsts, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empties_the_ring_and_returns_count() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
for i in 0..7 {
|
||||
r.push(embedding_with_first(i as f32));
|
||||
}
|
||||
let drained = r.drain();
|
||||
assert_eq!(drained, 7);
|
||||
assert!(r.is_empty());
|
||||
assert_eq!(r.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_on_empty_ring_returns_zero() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
assert_eq!(r.drain(), 0);
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_can_be_refilled_after_drain() {
|
||||
let mut r = EmbeddingRing::new();
|
||||
r.push(embedding_with_first(1.0));
|
||||
r.push(embedding_with_first(2.0));
|
||||
r.drain();
|
||||
r.push(embedding_with_first(42.0));
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r.iter().next().unwrap().as_slice()[0], 42.0);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Acceptance tests for ADR-120 §2.3 ↔ ADR-118 §2.1 wiring — `SignatureHasher`
|
||||
//! derives `rf_signature_hash` end-to-end through `BfldEmitter`.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::{
|
||||
BfldEmitter, IdentityEmbedding, SensingInputs, SignatureHasher, EMBEDDING_DIM, SITE_SALT_LEN,
|
||||
};
|
||||
|
||||
fn salt(seed: u8) -> [u8; SITE_SALT_LEN] {
|
||||
let mut s = [0u8; SITE_SALT_LEN];
|
||||
for (i, b) in s.iter_mut().enumerate() {
|
||||
*b = seed.wrapping_add(i as u8);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn embedding(seed: u8) -> IdentityEmbedding {
|
||||
let mut a = [0.0f32; EMBEDDING_DIM];
|
||||
for (i, v) in a.iter_mut().enumerate() {
|
||||
*v = (i as f32 + seed as f32) * 0.001;
|
||||
}
|
||||
IdentityEmbedding::from_raw(a)
|
||||
}
|
||||
|
||||
fn inputs(seed: u8) -> SensingInputs {
|
||||
SensingInputs {
|
||||
timestamp_ns: 1_700_000_000_000_000_000 + (seed as u64) * 1_000_000_000,
|
||||
presence: true,
|
||||
motion: 0.5,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep: 0.2,
|
||||
stab: 0.2,
|
||||
consist: 0.2,
|
||||
risk_conf: 0.2,
|
||||
rf_signature_hash: Some([0xFF; 32]), // caller-supplied "wrong" hash
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_hasher_passes_caller_supplied_hash_through() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let out = e.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_eq!(out.rf_signature_hash, Some([0xFF; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_hasher_overrides_caller_supplied_hash() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let out = e.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let hash = out.rf_signature_hash.unwrap();
|
||||
assert_ne!(hash, [0xFF; 32], "derived hash must override caller-supplied");
|
||||
assert_ne!(hash, [0x00; 32], "derived hash must be non-trivial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_emitter_same_inputs_produce_same_hash() {
|
||||
let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let mut e_b = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(7)));
|
||||
let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_eq!(a.rf_signature_hash, b.rf_signature_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_site_salts_produce_different_hashes_end_to_end() {
|
||||
let mut e_a = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(1)));
|
||||
let mut e_b = BfldEmitter::new("seed-02").with_signature_hasher(SignatureHasher::new(salt(2)));
|
||||
// Same embedding, same inputs → different sites must produce different hashes.
|
||||
let a = e_a.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let b = e_b.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
assert_ne!(
|
||||
a.rf_signature_hash, b.rf_signature_hash,
|
||||
"cross-site emit must produce uncorrelated hashes",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_embedding_falls_back_to_risk_factor_bytes() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(5)));
|
||||
let out = e.emit(inputs(0), None).unwrap();
|
||||
let hash = out.rf_signature_hash.unwrap();
|
||||
assert_ne!(hash, [0xFF; 32]); // still derived (fallback path), not caller-supplied
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_hash_differs_from_embedding_hash() {
|
||||
let mut e_with = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9)));
|
||||
let mut e_without = BfldEmitter::new("seed-01").with_signature_hasher(SignatureHasher::new(salt(9)));
|
||||
let with_emb = e_with.emit(inputs(0), Some(embedding(0))).unwrap();
|
||||
let no_emb = e_without.emit(inputs(0), None).unwrap();
|
||||
assert_ne!(
|
||||
with_emb.rf_signature_hash, no_emb.rf_signature_hash,
|
||||
"embedding bytes and risk-factor bytes should hash to different values",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! End-to-end pipeline tests for `BfldEmitter`. ADR-118 §2.1.
|
||||
|
||||
#![cfg(feature = "std")]
|
||||
|
||||
use wifi_densepose_bfld::coherence_gate::DEBOUNCE_NS;
|
||||
use wifi_densepose_bfld::{
|
||||
BfldEmitter, GateAction, IdentityEmbedding, PrivacyClass, SensingInputs, EMBEDDING_DIM,
|
||||
};
|
||||
|
||||
fn inputs(ts_ns: u64, risk_factors: [f32; 4]) -> SensingInputs {
|
||||
let [sep, stab, consist, risk_conf] = risk_factors;
|
||||
SensingInputs {
|
||||
timestamp_ns: ts_ns,
|
||||
presence: true,
|
||||
motion: 0.5,
|
||||
person_count: 1,
|
||||
sensing_confidence: 0.9,
|
||||
sep,
|
||||
stab,
|
||||
consist,
|
||||
risk_conf,
|
||||
rf_signature_hash: Some([0xCD; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_embedding() -> IdentityEmbedding {
|
||||
IdentityEmbedding::from_raw([0.1; EMBEDDING_DIM])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_emits_event_under_low_risk() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let out = e
|
||||
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
|
||||
.expect("low risk should produce an event");
|
||||
assert_eq!(out.node_id, "seed-01");
|
||||
assert!(out.presence);
|
||||
assert!(out.identity_risk_score.is_some());
|
||||
assert_eq!(e.current_action(), GateAction::Accept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_drops_event_under_sustained_high_risk() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// First call: score ~ 0.7 pending Reject. Event still emits this turn
|
||||
// because the gate hasn't promoted yet (current is still Accept).
|
||||
let first = e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
|
||||
assert!(first.is_some(), "first high-risk call still emits");
|
||||
// After debounce: current becomes Reject -> event dropped.
|
||||
let after = e.emit(
|
||||
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert!(after.is_none(), "post-debounce Reject drops the event");
|
||||
assert_eq!(e.current_action(), GateAction::Reject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emitter_drains_ring_on_recalibrate() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// Pump 5 embeddings under a slow rising score so the ring fills.
|
||||
for i in 0..5 {
|
||||
let _ = e.emit(
|
||||
inputs(i * 1_000_000, [0.3, 0.3, 0.3, 0.3]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
}
|
||||
assert_eq!(e.ring_len(), 5);
|
||||
|
||||
// Now push a Recalibrate-grade score and run past debounce.
|
||||
e.emit(inputs(10_000_000, [1.0, 1.0, 1.0, 1.0]), Some(dummy_embedding()));
|
||||
let _ = e.emit(
|
||||
inputs(10_000_000 + DEBOUNCE_NS, [1.0, 1.0, 1.0, 1.0]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert_eq!(e.current_action(), GateAction::Recalibrate);
|
||||
assert_eq!(e.ring_len(), 0, "Recalibrate must drain the embedding ring");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_class_strips_identity_fields_in_emitted_event() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_privacy_class(PrivacyClass::Restricted);
|
||||
let out = e
|
||||
.emit(inputs(0, [0.2, 0.2, 0.2, 0.2]), Some(dummy_embedding()))
|
||||
.expect("Accept should emit");
|
||||
assert!(
|
||||
out.identity_risk_score.is_none(),
|
||||
"class 3 must strip identity_risk_score",
|
||||
);
|
||||
assert!(
|
||||
out.rf_signature_hash.is_none(),
|
||||
"class 3 must strip rf_signature_hash",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_zone_sets_default_zone_id_on_event() {
|
||||
let mut e = BfldEmitter::new("seed-01").with_zone("kitchen");
|
||||
let out = e
|
||||
.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), Some(dummy_embedding()))
|
||||
.unwrap();
|
||||
assert_eq!(out.zone_id.as_deref(), Some("kitchen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_is_pushed_to_ring_even_when_event_dropped() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
// Drive into Reject state.
|
||||
e.emit(inputs(0, [1.0, 1.0, 1.0, 0.8]), Some(dummy_embedding()));
|
||||
e.emit(
|
||||
inputs(DEBOUNCE_NS, [1.0, 1.0, 1.0, 0.8]),
|
||||
Some(dummy_embedding()),
|
||||
);
|
||||
assert_eq!(e.current_action(), GateAction::Reject);
|
||||
// Even though the gate dropped events, the embeddings landed in the ring.
|
||||
assert_eq!(e.ring_len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_unchanged_when_no_embedding_supplied() {
|
||||
let mut e = BfldEmitter::new("seed-01");
|
||||
let _ = e.emit(inputs(0, [0.1, 0.1, 0.1, 0.1]), None);
|
||||
assert_eq!(e.ring_len(), 0);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user