feat: happiness scoring pipeline with ESP32 swarm + Cognitum Seed coordinator

ADR-065: Hotel guest happiness scoring from WiFi CSI physiological proxies.
ADR-066: ESP32 swarm with Cognitum Seed as coordinator for multi-zone analytics.

Firmware:
- swarm_bridge.c/h: FreeRTOS task on Core 0, HTTP client with Bearer auth,
  registers with Seed, sends heartbeats (30s) and happiness vectors (5s)
- nvs_config: seed_url, seed_token, zone_name, swarm intervals
- provision.py: --seed-url, --seed-token, --zone CLI args
- esp32-hello-world: capability discovery firmware for 4MB ESP32-S3 variant

WASM edge modules:
- exo_happiness_score.rs: 8-dim happiness vector from gait speed, stride
  regularity, movement fluidity, breathing calm, posture, dwell time
  (events 690-694, 11 tests, ESP32-optimized buffers + event decimation)
- ghost_hunter.rs standalone binary: 5.7 KB WASM, feature-gated default pipeline

RuView Live:
- --mode happiness dashboard with bar visualization
- --seed flag for Cognitum Seed bridge (urllib, background POST)
- HappinessScorer + SeedBridge classes (stdlib only, no deps)

Examples:
- seed_query.py: CLI tool (status, search, witness, monitor, report)
- provision_swarm.sh: batch provisioning for multi-node deployment
- happiness_vector_schema.json: 8-dim vector format documentation

Verified live: ESP32 on COM5 (4MB flash) registered with Seed at 10.1.10.236,
vectors flowing, witness chain growing (epoch 455, chain 1108).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-20 18:32:10 -04:00
parent 8a84748a83
commit a2d1739c07
21 changed files with 3067 additions and 35 deletions

View file

@ -0,0 +1,234 @@
# ADR-065: Hotel Guest Happiness Scoring -- WiFi CSI + Cognitum Seed Bridge
**Status:** Proposed
**Date:** 2026-03-20
**Deciders:** @ruvnet
**Related:** ADR-040 (WASM edge modules), ADR-039 (edge intelligence), ADR-042 (CHCI), ADR-064 (multimodal ambient intelligence), ADR-060 (multi-node aggregation)
## Context
Hotels lack objective, privacy-preserving methods to measure guest satisfaction in real time. Current approaches (post-stay surveys, NPS scores) are delayed, biased toward extremes, and capture less than 10% of guests. Meanwhile, ambient RF sensing can infer behavioral cues that correlate with comfort and well-being -- without cameras, wearables, or any guest interaction.
### Hardware
Two ESP32-S3 variants are deployed:
| Device | Flash | PSRAM | MAC | Port | Notes |
|--------|-------|-------|-----|------|-------|
| ESP32-S3 (QFN56 rev 0.2) | 4 MB | 2 MB | 1C:DB:D4:83:D2:40 | COM5 | Budget node, uses `sdkconfig.defaults.4mb` + `partitions_4mb.csv` |
| ESP32-S3 | 8 MB | 8 MB | -- | COM7 | Full-featured node, existing deployment |
Both run the Tier 2 DSP firmware with presence detection, vitals extraction, fall detection, and gait analysis.
### Cognitum Seed Device
A Cognitum Seed unit is deployed on the same network segment:
- **Address:** 169.254.42.1 (link-local)
- **Hardware:** Raspberry Pi Zero 2 W
- **Firmware:** 0.7.0
- **Vector store:** 398 vectors, dim=8
- **API endpoints:** 98 (REST, fully documented)
- **Sensors:** PIR, reed switch (door), vibration, ADS1115 ADC (4-ch analog), BME280 (temp/humidity/pressure)
- **Security:** Ed25519 custody chain with tamper-evident witness log
The Seed's 8-dimensional vector store and drift detection engine make it a natural aggregation point for behavioral feature vectors extracted from CSI data.
### Existing WASM Edge Modules
The following modules already run on-device and produce features relevant to happiness scoring:
| Module | Event IDs | Outputs |
|--------|-----------|---------|
| `exo_emotion_detect.rs` | 610-613 | Arousal level, stress index |
| `med_gait_analysis.rs` | 130-134 | Cadence, stride length, regularity |
| `ret_customer_flow.rs` | 410-413 | Entry/exit count, direction |
| `ret_dwell_heatmap.rs` | 420-423 | Dwell time per zone |
## Decision
### 1. New WASM Module: `exo_happiness_score.rs`
Create a new WASM edge module that fuses outputs from existing modules into an 8-dimensional happiness vector, matching the Seed's vector dimensionality (dim=8).
**Event ID registry (690-694):**
| Event ID | Name | Description |
|----------|------|-------------|
| 690 | `HAPPINESS_VECTOR` | Full 8-dim happiness vector emitted per scoring window |
| 691 | `HAPPINESS_TREND` | Windowed trend (rising/falling/stable) over last N vectors |
| 692 | `HAPPINESS_ALERT` | Score crossed a configured threshold (low satisfaction) |
| 693 | `HAPPINESS_GROUP` | Aggregate score for multi-person zone |
| 694 | `HAPPINESS_CALIBRATION` | Baseline recalibration event (new guest check-in) |
### 2. Happiness Vector Schema (8 Dimensions)
Each dimension is normalized to [0.0, 1.0] where 1.0 = maximal positive signal:
| Dim | Name | Source | Derivation |
|-----|------|--------|------------|
| 0 | `gait_speed` | `med_gait_analysis` (130) | Normalized walking velocity. Brisk = positive. |
| 1 | `stride_regularity` | `med_gait_analysis` (131) | Low stride-to-stride variance = relaxed gait. |
| 2 | `movement_fluidity` | CSI phase jerk (d3/dt3) | Low jerk = smooth, unhurried movement. |
| 3 | `breathing_calm` | Vitals BR extraction | BR 12-18 at rest = calm. Deviation penalized. |
| 4 | `posture_openness` | CSI subcarrier spread | Wide phase spread across subcarriers = open posture. |
| 5 | `dwell_comfort` | `ret_dwell_heatmap` (420) | Moderate dwell in amenity zones = engagement. |
| 6 | `direction_entropy` | `ret_customer_flow` (410) | Low entropy = purposeful movement. Wandering penalized. |
| 7 | `group_energy` | Multi-target CSI clustering | Synchronized movement of 2+ people = social engagement. |
The composite scalar happiness score is the weighted L2 norm:
```
score = sum(w[i] * v[i] for i in 0..7) / sum(w[i])
```
Default weights are uniform (all 1.0), configurable via NVS or Seed API.
### 3. ESP32 to Seed Bridge
```
ESP32-S3 (CSI) Cognitum Seed (169.254.42.1)
+------------------+ +----------------------------+
| Tier 2 DSP | | |
| + WASM modules | UDP 5555 | /api/v1/store/ingest |
| exo_happiness |──────────────| (POST, 8-dim vector) |
| _score.rs | | |
| | | /api/v1/drift/check |
| |◄─────────────| (drift alerts via webhook) |
| | | |
| | | /api/v1/witness/append |
| | | (Ed25519 audit trail) |
+------------------+ +----------------------------+
```
**Data flow:**
1. ESP32 runs CSI capture at 20+ Hz and feeds subcarrier data through existing WASM modules.
2. `exo_happiness_score.rs` collects outputs from emotion, gait, flow, and dwell modules every scoring window (default: 30 seconds).
3. The 8-dim happiness vector is packed as a 32-byte payload (8x float32) and sent via UDP to port 5555 on 169.254.42.1.
4. A lightweight bridge task on the Seed receives the UDP packet and POSTs it to `/api/v1/store/ingest` with metadata (room ID, timestamp, MAC).
5. The Seed's drift detection engine monitors the happiness vector stream and flags anomalies (sudden drops, sustained low scores).
6. Every ingested vector is appended to the Seed's Ed25519 witness chain, providing a tamper-proof audit trail.
### 4. Seed Drift Detection for Happiness Trends
The Seed's built-in drift detection compares incoming vectors against a rolling baseline:
- **Check-in calibration:** When a new guest checks in, event 694 resets the baseline.
- **Drift threshold:** Configurable (default: cosine distance > 0.3 from baseline triggers alert).
- **Trend window:** Last 20 vectors (~10 minutes at 30s intervals).
- **Alert routing:** Seed webhook notifies hotel management system when happiness trend is declining.
### 5. RuView Live Dashboard Update
`ruview_live.py` gains a `--seed` flag:
```bash
python ruview_live.py --port COM5 --seed 169.254.42.1 --mode happiness
```
This mode displays:
- Real-time 8-dim radar chart of the happiness vector
- Scalar happiness score (0-100) with color coding (red/yellow/green)
- Trend sparkline over the last hour
- Seed witness chain status (last hash, chain length)
- Room-level aggregate when multiple ESP32 nodes report
### 6. Architecture
```
+------------------------------------------+
| Hotel Room |
| |
| [ESP32-S3] [Cognitum Seed] |
| COM5 or COM7 169.254.42.1 |
| 4MB or 8MB flash Pi Zero 2 W |
| | | |
| | WiFi CSI | PIR, reed, |
| | 20+ Hz | BME280, |
| v | vibration |
| +-----------+ | |
| | Tier 2 DSP| v |
| | presence | +-------------+ |
| | vitals | | Seed API | |
| | gait | | 98 endpoints| |
| | fall det | | 398 vectors | |
| +-----------+ | dim=8 | |
| | +-------------+ |
| v ^ |
| +-----------+ UDP 5555 | |
| | WASM edge |─────────────┘ |
| | happiness | |
| | score | Drift alerts |
| | (690-694) |◄────────────── |
| +-----------+ /api/v1/drift/check |
| |
+------------------------------------------+
|
| MQTT / HTTP
v
+------------------+
| Hotel Management |
| System / RuView |
| Live Dashboard |
+------------------+
```
### 7. 4MB Flash Support
The 4MB ESP32-S3 variant (COM5) is officially supported for happiness scoring. The existing `partitions_4mb.csv` and `sdkconfig.defaults.4mb` from ADR-265 provide dual OTA slots (1.856 MB each), sufficient for the full Tier 2 DSP firmware plus `exo_happiness_score.wasm` (estimated < 40 KB).
Build for 4MB variant:
```bash
cp sdkconfig.defaults.4mb sdkconfig.defaults
idf.py build
```
The WASM module loader selects which modules to instantiate based on available heap. On the 4MB/2MB PSRAM variant, happiness scoring runs with a reduced scoring window (60s instead of 30s) to conserve memory.
### 8. Privacy Considerations
- **No cameras.** All sensing is RF-based (WiFi subcarrier amplitude/phase).
- **No facial recognition.** Happiness is inferred from movement patterns, not expressions.
- **No audio capture.** Breathing rate is extracted from chest wall displacement via RF, not microphone.
- **No PII stored on device.** Vectors are anonymous; room-to-guest mapping lives only in the hotel PMS.
- **Seed witness chain** provides auditable proof of what data was collected and when, satisfying GDPR Article 30 record-keeping requirements.
- **Guest opt-out:** A physical switch on the ESP32 node (GPIO connected to a toggle) disables CSI capture entirely. The Seed's reed switch can also serve as a "privacy mode" trigger (door-mounted magnet removed = sensing paused).
- **Data retention:** Vectors are retained on the Seed for the duration of the stay plus 24 hours, then purged. The witness chain retains hashes (not vectors) indefinitely for audit.
### 9. API Integration
Key Cognitum Seed endpoints used:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/store/ingest` | POST | Ingest 8-dim happiness vector |
| `/api/v1/store/query` | POST | Retrieve vectors by room/time range |
| `/api/v1/drift/check` | GET | Check if current vector drifts from baseline |
| `/api/v1/drift/configure` | PUT | Set drift threshold and window size |
| `/api/v1/witness/append` | POST | Append event to Ed25519 custody chain |
| `/api/v1/witness/verify` | GET | Verify chain integrity |
| `/api/v1/sensors/bme280` | GET | Room temperature/humidity (comfort correlation) |
| `/api/v1/sensors/pir` | GET | PIR presence (cross-validate with CSI) |
## Consequences
### Positive
- Provides real-time, objective guest satisfaction measurement without surveys or wearables.
- Reuses four existing WASM modules -- the happiness module is a fusion layer, not a rewrite.
- The Seed's 8-dim vector store is a natural fit; no schema changes needed.
- Ed25519 witness chain satisfies hospitality industry audit requirements and GDPR record-keeping.
- Both 4MB and 8MB ESP32-S3 variants are supported, enabling low-cost deployment at scale (~$8 per room for the 4MB node).
- Seed's environmental sensors (BME280, PIR) provide complementary context (room temperature, humidity) that can be correlated with happiness scores.
- No cloud dependency -- all processing is local (ESP32 edge + Seed link-local network).
### Negative
- Happiness inference from movement patterns is a proxy, not a direct measurement. Correlation with actual guest satisfaction must be validated empirically.
- The 4MB variant has reduced scoring frequency (60s vs 30s) due to memory constraints.
- UDP transport between ESP32 and Seed is unreliable; packets may be lost. Mitigation: sequence numbers and a small retry buffer on the ESP32 side.
- Link-local addressing (169.254.x.x) limits the Seed to the same network segment as the ESP32. Multi-room deployments need one Seed per subnet or a routed bridge.
- Drift detection thresholds require per-property tuning; a luxury resort has different movement patterns than a budget hotel.
- The system cannot distinguish between guests in a multi-occupancy room without additional multi-target CSI clustering, which is experimental (ADR-064, Tier 3).

View file

@ -0,0 +1,274 @@
# ADR-066: ESP32 CSI Swarm with Cognitum Seed Coordinator
**Status:** Proposed
**Date:** 2026-03-20
**Deciders:** @ruvnet
**Related:** ADR-065 (happiness scoring + Seed bridge), ADR-039 (edge intelligence), ADR-060 (provisioning), ADR-018 (CSI binary protocol), ADR-040 (WASM runtime)
## Context
ADR-065 established a single ESP32-S3 node pushing happiness vectors to a Cognitum Seed at `169.254.42.1` (Pi Zero 2 W, firmware 0.7.0). The Seed is now on the same WiFi network (`RedCloverWifi`, `10.1.10.236`) as the ESP32 node (`10.1.10.168`).
The Seed already exposes REST APIs for:
- Peer discovery (`/api/v1/peers`) — 0 peers currently registered
- Delta sync (`/api/v1/delta/pull`, `/api/v1/delta/push`) — epoch-based replication
- Reflex rules (`/api/v1/sensor/reflex/rules`) — 3 rules (fragility alarm, drift cutoff, HD anomaly indicator)
- Actuators (`/api/v1/sensor/actuators`) — relay + PWM outputs
- Cognitive engine (`/api/v1/cognitive/tick`) — periodic inference loop
- Witness chain (`/api/v1/custody/epoch`) — epoch 316, cryptographically signed
- kNN search (`/api/v1/store/search`) — similarity queries across the full vector store
A hotel deployment requires multiple ESP32 nodes (lobby, hallway, restaurant, rooms) coordinated as a swarm with centralized analytics on the Seed.
## Decision
Implement a Seed-coordinated ESP32 swarm where each node operates autonomously for CSI sensing and edge processing, while the Seed serves as the swarm coordinator for registration, aggregation, drift detection, cross-zone inference, and actuator control.
### Architecture
```
ESP32 Node A ESP32 Node B ESP32 Node C
(Lobby) (Hallway) (Restaurant)
node_id=1 node_id=2 node_id=3
10.1.10.168 10.1.10.xxx 10.1.10.xxx
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ WiFi CSI │ │ WiFi CSI │ │ WiFi CSI │
│ Tier 2 DSP │ │ Tier 2 DSP │ │ Tier 2 DSP │
│ WASM Tier 3 │ │ WASM Tier 3 │ │ WASM Tier 3 │
│ Swarm Bridge │ │ Swarm Bridge │ │ Swarm Bridge │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ HTTP POST │ HTTP POST │ HTTP POST
│ (happiness vectors, │ │
│ heartbeat, events) │ │
└──────────┬───────────────┴──────────────────────────┘
┌───────────────┐
│ Cognitum Seed │
│ (Coordinator) │
│ 10.1.10.236 │
├───────────────┤
│ Vector Store │ ← 8-dim vectors tagged with node_id + zone
│ kNN Search │ ← Cross-zone similarity ("which room matches?")
│ Drift Detect │ ← Global mood trend across all zones
│ Witness Chain │ ← Tamper-proof audit trail per node
│ Reflex Rules │ ← Trigger actuators on swarm-wide patterns
│ Cognitive Eng │ ← Periodic cross-zone inference
│ Peer Registry │ ← Node health, last-seen, capabilities
└───────────────┘
```
### Swarm Protocol
#### 1. Node Registration (on boot)
Each ESP32 registers with the Seed via HTTP POST on startup. The Seed's peer discovery API tracks active nodes.
```
POST /api/v1/store/ingest
{
"vectors": [{
"id": "node-1-reg",
"values": [0,0,0,0,0,0,0,0],
"metadata": {
"type": "registration",
"node_id": 1,
"zone": "lobby",
"mac": "1C:DB:D4:83:D2:40",
"ip": "10.1.10.168",
"firmware": "0.5.0",
"capabilities": ["csi", "tier2", "presence", "vitals", "happiness"],
"flash_mb": 4,
"psram_mb": 2
}
}]
}
```
#### 2. Heartbeat (every 30 seconds)
```
POST /api/v1/store/ingest
{
"vectors": [{
"id": "node-1-hb-{epoch}",
"values": [happiness, gait, stride, fluidity, calm, posture, dwell, social],
"metadata": {
"type": "heartbeat",
"node_id": 1,
"zone": "lobby",
"uptime_s": 3600,
"csi_frames": 72000,
"free_heap": 317140,
"presence_now": true,
"persons": 2,
"rssi": -60
}
}]
}
```
#### 3. Happiness Vector Ingestion (every 5 seconds when presence detected)
```
POST /api/v1/store/ingest
{
"vectors": [{
"id": "node-1-h-{epoch}-{ts}",
"values": [0.72, 0.65, 0.80, 0.71, 0.55, 0.60, 0.85, 0.45],
"metadata": {
"type": "happiness",
"node_id": 1,
"zone": "lobby",
"timestamp_ms": 1742486400000,
"persons": 2,
"direction": "entering"
}
}]
}
```
#### 4. Cross-Zone Queries (Seed-side)
The Seed can answer questions across the entire swarm:
```
POST /api/v1/store/search
{"vector": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5], "k": 5}
Response: nearest neighbors across all zones, showing which
rooms had the most similar mood to a "happy" reference vector.
```
#### 5. Reflex Rules for Swarm Patterns
Configure the Seed's reflex engine to act on swarm-wide patterns:
| Rule | Trigger | Action | Use Case |
|------|---------|--------|----------|
| `low_happiness_alert` | Mean happiness < 0.3 across 3+ nodes for 5 min | Activate `alarm` relay | Staff alert: guest dissatisfaction |
| `crowd_surge` | Presence count > 10 across lobby + hallway | PWM indicator brightness 100% | Lobby congestion warning |
| `zone_drift` | Drift score > 0.5 on any node | Log to witness chain | Trend change documentation |
| `ghost_anomaly` | Event 650 (anomaly) from any node | Notify + log | Security: unexpected RF disturbance |
### ESP32 Firmware: Swarm Bridge Module
New module `swarm_bridge.c` added to the CSI firmware, activated via NVS config:
```c
typedef struct {
char seed_url[64]; // e.g. "http://10.1.10.236"
char zone_name[16]; // e.g. "lobby"
uint16_t heartbeat_sec; // Default: 30
uint16_t ingest_sec; // Default: 5
uint8_t enabled; // 0 = disabled, 1 = enabled
} swarm_config_t;
```
NVS keys (provisioned via `provision.py --seed-url http://10.1.10.236 --zone lobby`):
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `seed_url` | string | (empty) | Seed base URL; empty = swarm disabled |
| `zone_name` | string | `"default"` | Zone identifier for this node |
| `swarm_hb` | u16 | 30 | Heartbeat interval (seconds) |
| `swarm_ingest` | u16 | 5 | Vector ingest interval (seconds) |
The swarm bridge runs as a FreeRTOS task on Core 0 (separate from DSP on Core 1):
```
swarm_bridge_task (Core 0, priority 3, stack 4096)
├── On boot: POST registration to Seed
├── Every 30s: POST heartbeat with latest happiness vector
├── Every 5s (if presence): POST happiness vector
└── On event 650+ (anomaly): POST immediately
```
HTTP client uses `esp_http_client` (already in ESP-IDF, no extra dependencies). JSON is formatted with `snprintf` (no cJSON dependency needed for the small payloads).
### Node Discovery and Addressing
Nodes find the Seed via:
1. **NVS provisioned URL** (primary) — `provision.py --seed-url http://10.1.10.236`
2. **mDNS fallback** — Seed advertises `_cognitum._tcp.local`; ESP32 resolves `cognitum.local`
3. **Link-local fallback**`http://169.254.42.1` when connected via USB
### Vector ID Scheme
```
{node_id}-{type}-{epoch}-{timestamp_ms}
```
Examples:
- `1-reg` — Node 1 registration
- `1-hb-316` — Node 1 heartbeat at epoch 316
- `1-h-316-1742486400000` — Node 1 happiness vector at epoch 316, timestamp T
- `2-h-316-1742486401000` — Node 2 happiness vector at same epoch
### Witness Chain Integration
Every vector ingested into the Seed increments the epoch and extends the witness chain. The chain provides:
- **Per-node audit trail** — filter by node_id metadata to get one node's history
- **Tamper detection** — Ed25519 signed, hash-chained; break = detectable
- **Regulatory compliance** — prove "sensor X reported Y at time Z" for disputes
- **Cross-node ordering** — Seed epoch gives total order across all nodes
### Scaling Considerations
| Nodes | Vectors/hour | Seed storage/day | kNN latency |
|-------|---|---|---|
| 1 | 720 | ~1.5 MB | < 1 ms |
| 5 | 3,600 | ~7.5 MB | < 2 ms |
| 10 | 7,200 | ~15 MB | < 5 ms |
| 20 | 14,400 | ~30 MB | < 10 ms |
The Seed's Pi Zero 2 W has 512 MB RAM and typically an 8-32 GB SD card. At 30 MB/day for 20 nodes, storage lasts 250+ days before compaction is needed. The Seed's optimizer runs automatic compaction in the background.
### Provisioning for Swarm
```bash
# Node 1: Lobby (COM5, existing)
python provision.py --port COM5 \
--ssid "RedCloverWifi" --password "redclover2.4" \
--node-id 1 --seed-url "http://10.1.10.236" --zone "lobby"
# Node 2: Hallway (future device)
python provision.py --port COM6 \
--ssid "RedCloverWifi" --password "redclover2.4" \
--node-id 2 --seed-url "http://10.1.10.236" --zone "hallway"
# Node 3: Restaurant (future device)
python provision.py --port COM8 \
--ssid "RedCloverWifi" --password "redclover2.4" \
--node-id 3 --seed-url "http://10.1.10.236" --zone "restaurant"
```
## Consequences
### Positive
- **Zero infrastructure** — no cloud, no server, no database. Seed + ESP32s + WiFi router is the entire stack
- **Autonomous nodes** — each ESP32 runs full Tier 2 DSP independently; Seed loss degrades gracefully to local-only operation
- **Cryptographic audit** — witness chain gives tamper-proof history for every observation across all nodes
- **Real-time cross-zone analytics** — Seed kNN search answers "which zones are happy/stressed right now" in < 5 ms
- **Physical actuators** — Seed's relay/PWM outputs can trigger real-world actions (lights, alarms, displays) based on swarm-wide patterns
- **Horizontal scaling** — add ESP32 nodes by flashing firmware + running provision.py; no Seed reconfiguration needed
- **Privacy-preserving** — no cameras, no audio, no PII; only 8-dimensional feature vectors stored
### Negative
- **Single point of aggregation** — Seed failure loses cross-zone analytics (nodes continue autonomously)
- **WiFi dependency** — nodes must be on the same network as the Seed; no mesh/LoRa fallback yet
- **HTTP overhead** — REST/JSON adds ~200 bytes overhead per vector vs raw binary UDP; acceptable at 5-second intervals
- **Pi Zero 2 W limits** — 512 MB RAM, single-core ARM; adequate for 20 nodes but not 100+
- **No WASM OTA via Seed** — currently WASM modules are uploaded per-node; future work could use Seed as WASM distribution hub
### Future Work
- **Seed-initiated WASM push** — Seed distributes WASM modules to all nodes via their OTA endpoints
- **mDNS auto-discovery** — nodes find Seed without provisioned URL
- **Mesh fallback** — ESP-NOW peer-to-peer when WiFi is down
- **Multi-Seed federation** — multiple Seeds for multi-floor/multi-building deployments
- **Seed dashboard** — web UI on the Seed showing live swarm map with per-zone happiness

View file

@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Happiness Vector",
"description": "8-dimensional happiness feature vector for Cognitum Seed ingestion (ADR-065). Each dimension is normalized to [0, 1] where higher values indicate more positive affect.",
"type": "object",
"properties": {
"vectors": {
"type": "array",
"items": {
"type": "array",
"prefixItems": [
{
"type": "integer",
"description": "Vector ID: node_id * 1000000 + type_offset + timestamp_component. Type offsets: 0=registration, 100000=heartbeat, 200000=happiness."
},
{
"type": "array",
"items": { "type": "number", "minimum": 0, "maximum": 1 },
"minItems": 8,
"maxItems": 8,
"description": "8-dim happiness vector: [happiness_score, gait_speed, stride_regularity, movement_fluidity, breathing_calm, posture_score, dwell_factor, social_energy]"
}
],
"minItems": 2,
"maxItems": 2
}
}
},
"required": ["vectors"],
"$defs": {
"dimensions": {
"type": "object",
"description": "Happiness vector dimension definitions",
"properties": {
"dim_0_happiness_score": {
"description": "Composite happiness [0=sad, 0.5=neutral, 1=happy]. Weighted sum of dims 1-6.",
"weights": "gait=0.25, stride=0.15, fluidity=0.20, calm=0.20, posture=0.10, dwell=0.10"
},
"dim_1_gait_speed": {
"description": "Walking speed from CSI phase rate-of-change. Happy people walk ~12% faster.",
"source": "Phase Doppler shift",
"units": "normalized phase delta / MAX_GAIT_SPEED"
},
"dim_2_stride_regularity": {
"description": "Step interval consistency. Regular strides indicate confidence/positive affect.",
"source": "Variance coefficient of step intervals (inverted)",
"interpretation": "1.0=perfectly regular, 0.0=erratic/stumbling"
},
"dim_3_movement_fluidity": {
"description": "Smoothness of body movement trajectory. Jerky motion indicates anxiety.",
"source": "Phase second derivative (acceleration), inverted",
"interpretation": "1.0=smooth/flowing, 0.0=jerky/hesitant"
},
"dim_4_breathing_calm": {
"description": "Breathing rate mapped to calmness. Slow deep breathing = relaxed.",
"source": "0.15-0.5 Hz phase oscillation (breathing proxy)",
"interpretation": "1.0=calm (6-14 BPM), 0.0=rapid/stressed (>22 BPM)"
},
"dim_5_posture_score": {
"description": "Upright vs slouched posture from RF scattering cross-section.",
"source": "Amplitude coefficient of variation across subcarrier groups",
"interpretation": "1.0=upright (wide spread), 0.0=slouched (narrow spread)"
},
"dim_6_dwell_factor": {
"description": "How long the person stays in the sensing zone.",
"source": "Fraction of recent frames with presence detected",
"interpretation": "1.0=lingering (happy guests browse), 0.0=rushing through"
},
"dim_7_social_energy": {
"description": "Group animation and interaction level.",
"source": "Motion energy + dwell + heart rate proxy",
"interpretation": "1.0=animated group interaction, 0.0=solitary/withdrawn"
}
}
},
"event_ids": {
"type": "object",
"description": "WASM edge module event IDs (690-694)",
"properties": {
"690_HAPPINESS_SCORE": "Composite happiness [0, 1] — emitted every frame",
"691_GAIT_ENERGY": "Gait speed + stride regularity composite — emitted every 4th frame",
"692_AFFECT_VALENCE": "Breathing calm + fluidity + posture composite — emitted every 4th frame",
"693_SOCIAL_ENERGY": "Group animation level — emitted every 4th frame",
"694_TRANSIT_DIRECTION": "1.0=entering, 0.0=exiting — emitted every 4th frame"
}
},
"seed_id_scheme": {
"type": "object",
"description": "Vector ID encoding for Cognitum Seed",
"properties": {
"format": "node_id * 1000000 + type_offset + timestamp_component",
"registration": "offset 0 (e.g. node 1 = 1000000)",
"heartbeat": "offset 100000 + uptime_sec % 100000 (e.g. 1100042)",
"happiness": "offset 200000 + ms_timestamp / 1000 % 100000 (e.g. 1212345)"
}
}
}
}

View file

@ -0,0 +1,60 @@
#!/bin/bash
# ESP32 Swarm Provisioning — ADR-065/066
#
# Provisions multiple ESP32-S3 nodes for a hotel happiness sensing deployment.
# Each node gets WiFi credentials, a unique node_id, zone name, and Seed token.
#
# Prerequisites:
# - ESP-IDF Python venv with esptool and nvs_partition_gen
# - Firmware already flashed to each ESP32
# - Seed paired (obtain token via: curl -X POST http://169.254.42.1/api/v1/pair)
#
# Usage:
# bash provision_swarm.sh
set -euo pipefail
# ---- Configuration ----
SSID="RedCloverWifi"
PASSWORD="redclover2.4"
SEED_URL="http://10.1.10.236"
SEED_TOKEN="hyHVY4Ux6uBAh8FaQzF_9OwWCWMFB-YuM2OJ3Dcwdm8" # Replace with your token
PROVISION="../../firmware/esp32-csi-node/provision.py"
# ---- Node definitions: PORT NODE_ID ZONE ----
NODES=(
"COM5 1 lobby"
"COM6 2 hallway"
"COM8 3 restaurant"
"COM9 4 pool"
"COM10 5 conference"
)
echo "========================================"
echo " ESP32 Swarm Provisioning"
echo " Seed: $SEED_URL"
echo " WiFi: $SSID"
echo " Nodes: ${#NODES[@]}"
echo "========================================"
echo
for entry in "${NODES[@]}"; do
read -r port node_id zone <<< "$entry"
echo "--- Node $node_id: $zone ($port) ---"
python "$PROVISION" \
--port "$port" \
--ssid "$SSID" \
--password "$PASSWORD" \
--node-id "$node_id" \
--seed-url "$SEED_URL" \
--seed-token "$SEED_TOKEN" \
--zone "$zone" \
&& echo " OK" || echo " FAILED (device not connected?)"
echo
done
echo "========================================"
echo " Provisioning complete."
echo " Monitor with: python seed_query.py monitor --seed $SEED_URL --token $SEED_TOKEN"
echo "========================================"

View file

@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Cognitum Seed Happiness Vector Query Tool
Query the Seed's vector store for happiness patterns across ESP32 swarm nodes.
Demonstrates kNN search, drift monitoring, and witness chain verification.
Usage:
python seed_query.py --seed http://10.1.10.236 --token <bearer_token>
python seed_query.py --seed http://169.254.42.1 # USB link-local (no token needed)
Requirements:
Python 3.7+ (stdlib only, no dependencies)
"""
import argparse
import json
import sys
import time
import urllib.request
import urllib.error
def api(base, path, token=None, method="GET", data=None):
"""Make an API request to the Seed."""
url = f"{base}{path}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}", "detail": e.read().decode()[:200]}
except Exception as e:
return {"error": str(e)}
def print_header(title):
print(f"\n{'=' * 60}")
print(f" {title}")
print(f"{'=' * 60}")
def cmd_status(args):
"""Show Seed and swarm status."""
print_header("Seed Status")
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" Error: {s['error']}")
return
print(f" Device: {s['device_id'][:8]}...")
print(f" Vectors: {s['total_vectors']} (dim={s['dimension']})")
print(f" Epoch: {s['epoch']}")
print(f" Store: {s['file_size_bytes'] / 1024:.1f} KB")
print(f" Uptime: {s['uptime_secs'] // 3600}h {(s['uptime_secs'] % 3600) // 60}m")
print(f" Witness: {s['witness_chain_length']} entries")
print_header("Drift Detection")
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
if "error" not in d:
print(f" Drifting: {d.get('drifting', False)}")
print(f" Score: {d.get('current_drift_score', 0):.4f}")
print(f" Detectors: {d.get('detectors_active', 0)} active")
print(f" Total: {d.get('detections_total', 0)} detections")
def cmd_search(args):
"""Search for similar happiness vectors."""
print_header("Happiness kNN Search")
# Reference vectors for common moods
refs = {
"happy": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5],
"neutral": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
"stressed":[0.2, 0.3, 0.2, 0.2, 0.3, 0.3, 0.2, 0.7],
}
query = refs.get(args.mood, refs["happy"])
print(f" Query mood: {args.mood}")
print(f" Vector: [{', '.join(f'{v:.1f}' for v in query)}]")
print(f" k: {args.k}")
print()
result = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": query, "k": args.k})
if "error" in result:
print(f" Error: {result['error']}")
return
neighbors = result.get("neighbors", result.get("results", []))
if not neighbors:
print(" No results found.")
return
print(f" {'ID':>10} {'Distance':>10} {'Vector'}")
print(f" {'-'*10} {'-'*10} {'-'*40}")
for n in neighbors:
vid = n.get("id", "?")
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
vec_str = "[" + ", ".join(f"{v:.2f}" for v in vec[:4]) + ", ...]" if len(vec) > 4 else str(vec)
print(f" {vid:>10} {dist:>10.4f} {vec_str}")
def cmd_witness(args):
"""Show the witness chain for audit trail."""
print_header("Witness Chain (Audit Trail)")
epoch = api(args.seed, "/api/v1/custody/epoch", args.token)
if "error" not in epoch:
print(f" Current epoch: {epoch.get('epoch', '?')}")
head = epoch.get("witness_head", "?")
print(f" Chain head: {head[:16]}..." if len(head) > 16 else f" Chain head: {head}")
chain = api(args.seed, "/api/v1/cognitive/status", args.token)
if "error" not in chain:
cv = chain.get("chain_valid", {})
print(f" Chain valid: {cv.get('valid', '?')}")
print(f" Chain length: {cv.get('chain_length', '?')}")
print(f" Epoch range: {cv.get('first_epoch', '?')} - {cv.get('last_epoch', '?')}")
def cmd_monitor(args):
"""Live monitor happiness vectors flowing into the Seed."""
print_header("Live Happiness Monitor")
print(f" Polling every {args.interval}s (Ctrl+C to stop)")
print()
prev_epoch = 0
prev_vectors = 0
try:
while True:
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" [{time.strftime('%H:%M:%S')}] Error: {s['error']}")
time.sleep(args.interval)
continue
epoch = s["epoch"]
vectors = s["total_vectors"]
new_v = vectors - prev_vectors if prev_vectors > 0 else 0
new_e = epoch - prev_epoch if prev_epoch > 0 else 0
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
drift = d.get("current_drift_score", 0) if "error" not in d else 0
drifting = d.get("drifting", False) if "error" not in d else False
ts = time.strftime("%H:%M:%S")
drift_str = f" DRIFT!" if drifting else ""
print(f" [{ts}] epoch={epoch} vectors={vectors} (+{new_v}) "
f"drift={drift:.4f} chain={s['witness_chain_length']}{drift_str}")
prev_epoch = epoch
prev_vectors = vectors
time.sleep(args.interval)
except KeyboardInterrupt:
print("\n Stopped.")
def cmd_happiness_report(args):
"""Generate a happiness report from stored vectors."""
print_header("Happiness Report")
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" Error: {s['error']}")
return
print(f" Total vectors: {s['total_vectors']}")
print(f" Store epoch: {s['epoch']}")
print()
# Search for happiest and saddest vectors
happy_ref = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5]
sad_ref = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]
print(" Happiest moments (closest to ideal happy):")
happy = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": happy_ref, "k": 3})
for n in happy.get("neighbors", happy.get("results", [])):
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
score = vec[0] if vec else 0
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
print()
print(" Most stressed moments (closest to stressed reference):")
sad = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": sad_ref, "k": 3})
for n in sad.get("neighbors", sad.get("results", [])):
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
score = vec[0] if vec else 0
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
# Drift status
print()
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
if "error" not in d:
if d.get("drifting"):
print(f" WARNING: Mood drift detected (score={d['current_drift_score']:.4f})")
print(f" This may indicate a change in guest satisfaction.")
else:
print(f" Mood stable (drift score={d.get('current_drift_score', 0):.4f})")
def main():
parser = argparse.ArgumentParser(
description="Happiness Vector Query Tool for Cognitum Seed",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s status --seed http://169.254.42.1
%(prog)s search --seed http://10.1.10.236 --token TOKEN --mood happy
%(prog)s monitor --seed http://10.1.10.236 --token TOKEN
%(prog)s report --seed http://10.1.10.236 --token TOKEN
%(prog)s witness --seed http://10.1.10.236 --token TOKEN
"""
)
parser.add_argument("--seed", default="http://169.254.42.1",
help="Seed base URL (default: USB link-local)")
parser.add_argument("--token", default=None,
help="Bearer token for WiFi access (not needed for USB)")
sub = parser.add_subparsers(dest="command")
sub.add_parser("status", help="Show Seed and swarm status")
sub.add_parser("witness", help="Show witness chain audit trail")
p_search = sub.add_parser("search", help="kNN search for mood patterns")
p_search.add_argument("--mood", default="happy",
choices=["happy", "neutral", "stressed"])
p_search.add_argument("--k", type=int, default=5)
p_monitor = sub.add_parser("monitor", help="Live monitor incoming vectors")
p_monitor.add_argument("--interval", type=int, default=5)
sub.add_parser("report", help="Generate happiness report")
args = parser.parse_args()
if not args.command:
args.command = "status"
cmds = {
"status": cmd_status,
"search": cmd_search,
"witness": cmd_witness,
"monitor": cmd_monitor,
"report": cmd_happiness_report,
}
cmds[args.command](args)
if __name__ == "__main__":
main()

View file

@ -17,12 +17,15 @@ Usage:
import argparse
import collections
import json
import math
import re
import serial
import sys
import threading
import time
import urllib.request
import urllib.error
try:
import numpy as np
@ -224,12 +227,153 @@ class BPEstimator:
return round(max(80, min(200, sbp))), round(max(50, min(130, dbp)))
class HappinessScorer:
"""Multimodal happiness estimator fusing gait, breathing, and social signals."""
def __init__(self):
self.gait_speed = WelfordStats()
self.stride_regularity = WelfordStats()
self.movement_fluidity = 0.5
self.breathing_calm = 0.5
self.posture_score = 0.5
self.dwell_frames = 0
self._prev_motion = 0.0
self._motion_deltas = collections.deque(maxlen=30)
self._br_baseline = WelfordStats()
self._rssi_baseline = WelfordStats()
def update(self, motion_energy, br, hr, rssi):
# Gait speed proxy from motion energy
self.gait_speed.update(motion_energy)
# Stride regularity from motion delta consistency
delta = abs(motion_energy - self._prev_motion)
self._motion_deltas.append(delta)
self._prev_motion = motion_energy
if len(self._motion_deltas) >= 5:
deltas = list(self._motion_deltas)
mean_d = sum(deltas) / len(deltas)
var_d = sum((x - mean_d) ** 2 for x in deltas) / len(deltas)
self.stride_regularity.update(1.0 / (1.0 + math.sqrt(var_d)))
# Movement fluidity — smooth transitions score higher
if len(self._motion_deltas) >= 3:
recent = list(self._motion_deltas)[-3:]
jerk = abs(recent[-1] - recent[-2]) - abs(recent[-2] - recent[-3]) if len(recent) == 3 else 0
self.movement_fluidity = 0.9 * self.movement_fluidity + 0.1 * (1.0 / (1.0 + abs(jerk)))
# Breathing calm — low BR variance means relaxed
if br > 0:
self._br_baseline.update(br)
if self._br_baseline.count >= 5:
br_z = self._br_baseline.z_score(br)
self.breathing_calm = 0.9 * self.breathing_calm + 0.1 * max(0.0, 1.0 - br_z / 3.0)
# Posture proxy from RSSI stability
if rssi != 0:
self._rssi_baseline.update(rssi)
if self._rssi_baseline.count >= 5:
rssi_z = self._rssi_baseline.z_score(rssi)
self.posture_score = 0.9 * self.posture_score + 0.1 * max(0.0, 1.0 - rssi_z / 3.0)
# Dwell — presence accumulation
if motion_energy > 0.01 or br > 0:
self.dwell_frames += 1
def compute(self):
# Normalize gait energy to 0-1 range
gait_e = min(1.0, self.gait_speed.mean / 5.0) if self.gait_speed.count > 0 else 0.0
# Stride regularity average
stride_r = min(1.0, self.stride_regularity.mean) if self.stride_regularity.count > 0 else 0.5
# Dwell factor — saturates after ~300 frames (~5 min at 1 Hz)
dwell_factor = min(1.0, self.dwell_frames / 300.0)
# Weighted happiness score
happiness = (
0.25 * gait_e
+ 0.15 * stride_r
+ 0.20 * self.movement_fluidity
+ 0.20 * self.breathing_calm
+ 0.10 * self.posture_score
+ 0.10 * dwell_factor
)
happiness = max(0.0, min(1.0, happiness))
# Affect valence: breathing_calm and fluidity dominant
affect_valence = 0.5 * self.breathing_calm + 0.3 * self.movement_fluidity + 0.2 * stride_r
# Social energy: gait + dwell
social_energy = 0.6 * gait_e + 0.4 * dwell_factor
vector = [
happiness, gait_e, stride_r, self.movement_fluidity,
self.breathing_calm, self.posture_score, dwell_factor, affect_valence,
]
return {
"happiness": happiness,
"gait_energy": gait_e,
"affect_valence": affect_valence,
"social_energy": social_energy,
"vector": vector,
}
class SeedBridge:
"""HTTP bridge to Cognitum Seed for happiness vector ingestion."""
def __init__(self, base_url):
self.base_url = base_url.rstrip("/")
self._last_drift = None
self._drift_lock = threading.Lock()
def ingest(self, vector, metadata=None):
"""POST happiness vector to Seed in a background thread."""
payload = json.dumps({"vector": vector, "metadata": metadata or {}}).encode()
def _post():
try:
req = urllib.request.Request(
f"{self.base_url}/api/v1/store/ingest",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
except Exception:
pass # silently ignore connection errors
threading.Thread(target=_post, daemon=True).start()
def get_drift(self):
"""GET drift status from Seed. Returns dict or None."""
try:
req = urllib.request.Request(
f"{self.base_url}/api/v1/sensor/drift/status",
method="GET",
)
resp = urllib.request.urlopen(req, timeout=3)
data = json.loads(resp.read().decode())
with self._drift_lock:
self._last_drift = data
return data
except Exception:
return None
@property
def last_drift(self):
with self._drift_lock:
return self._last_drift
# ====================================================================
# Sensor Hub
# ====================================================================
class SensorHub:
def __init__(self):
def __init__(self, seed_url=None):
self.lock = threading.Lock()
self.mw_hr = 0.0
self.mw_br = 0.0
@ -254,6 +398,10 @@ class SensorHub:
self.coherence_mw = CoherenceScorer()
self.coherence_csi = CoherenceScorer()
self.bp = BPEstimator()
# Happiness + Seed
self.happiness = HappinessScorer()
self.seed = SeedBridge(seed_url) if seed_url else None
self._last_seed_ingest = 0.0
def update_mw(self, **kw):
with self.lock:
@ -283,6 +431,13 @@ class SensorHub:
if rssi != 0:
self.longitudinal.observe("rssi", rssi)
self.coherence_csi.update(min(1.0, max(0.0, (rssi + 90) / 50)))
# Feed happiness scorer
self.happiness.update(
motion_energy=kw.get("motion", self.csi_motion),
br=kw.get("br", self.csi_br),
hr=kw.get("hr", self.csi_hr),
rssi=rssi,
)
def add_event(self, msg):
with self.lock:
@ -337,6 +492,18 @@ class SensorHub:
if d:
drifts.append(d)
# Happiness
happy = self.happiness.compute()
# Seed ingestion every 5 seconds
now = time.time()
if self.seed and now - self._last_seed_ingest >= 5.0:
self._last_seed_ingest = now
self.seed.ingest(happy["vector"], {
"hr": fused_hr, "br": fused_br, "rssi": self.csi_rssi,
"presence": self.mw_presence or self.csi_presence,
})
return {
"hr": fused_hr, "hr_src": hr_src,
"br": fused_br, "sbp": sbp, "dbp": dbp,
@ -350,6 +517,11 @@ class SensorHub:
"fall": self.csi_fall, "drifts": drifts,
"events": list(self.events),
"longitudinal": self.longitudinal.summary(),
"happiness": happy["happiness"],
"gait_energy": happy["gait_energy"],
"affect_valence": happy["affect_valence"],
"social_energy": happy["social_energy"],
"happiness_vector": happy["vector"],
}
@ -426,21 +598,40 @@ def reader_csi(port, baud, hub, stop):
# Display
# ====================================================================
def run_display(hub, duration, interval):
def _happiness_bar(value, width=10):
"""Render a bar like [====------] 0.62"""
filled = int(round(value * width))
return "[" + "=" * filled + "-" * (width - filled) + "]"
def run_display(hub, duration, interval, mode="vitals"):
start = time.time()
last = 0
print()
print("=" * 80)
print(" RuView Live — Ambient Intelligence + RuVector Signal Processing")
if mode == "happiness":
print(" RuView Live — Happiness + Cognitum Seed Dashboard")
else:
print(" RuView Live — Ambient Intelligence + RuVector Signal Processing")
print("=" * 80)
print()
hdr = (f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} "
f"{'SDNN':>5} {'RMSSD':>5} {'LF/HF':>5} "
f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} "
f"{'Coh':>4} {'CSI#':>5}")
print(hdr)
print("-" * 80)
if mode == "happiness":
hdr = (f"{'s':>4} {'Happy':>16} {'Gait':>5} {'Calm':>5} "
f"{'Social':>6} {'Pres':>4} {'RSSI':>5} {'Seed':>6} {'CSI#':>5}")
print(hdr)
print("-" * 80)
else:
hdr = (f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} "
f"{'SDNN':>5} {'RMSSD':>5} {'LF/HF':>5} "
f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} "
f"{'Coh':>4} {'CSI#':>5}")
print(hdr)
print("-" * 80)
# Periodic Seed drift check (every 15s)
_last_drift_check = 0.0
while time.time() - start < duration:
time.sleep(0.5)
@ -451,23 +642,52 @@ def run_display(hub, duration, interval):
d = hub.compute()
hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else ""
br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else ""
bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— "
sdnn_s = f"{d['sdnn']:>5.0f}" if d["sdnn"] > 0 else ""
rmssd_s = f"{d['rmssd']:>5.0f}" if d["rmssd"] > 0 else ""
lfhf_s = f"{d['lf_hf']:>5.2f}" if d["sdnn"] > 0 else ""
pres_s = "YES" if d["presence"] else " no"
dist_s = f"{d['distance']:>4.0f}cm" if d["distance"] > 0 else ""
lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else ""
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else ""
coh = max(d["coh_mw"], d["coh_csi"])
coh_s = f"{coh:>.2f}"
if mode == "happiness":
h = d["happiness"]
bar = _happiness_bar(h)
gait_s = f"{d['gait_energy']:>5.2f}"
calm_s = f"{d['affect_valence']:>5.2f}"
social_s = f"{d['social_energy']:>6.2f}"
pres_s = "YES" if d["presence"] else " no"
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else ""
print(f"{elapsed:>3}s {hr_s} {br_s} {bp_s} {d['stress']:>8} "
f"{sdnn_s} {rmssd_s} {lfhf_s} "
f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} "
f"{coh_s:>4} {d['csi_frames']:>5}")
# Seed status
seed_s = ""
if hub.seed:
now = time.time()
if now - _last_drift_check >= 15.0:
_last_drift_check = now
hub.seed.get_drift()
drift = hub.seed.last_drift
if drift:
seed_s = f"{'OK' if not drift.get('drifting') else 'DRIFT':>6}"
else:
seed_s = " conn?"
print(f"{elapsed:>3}s {bar} {h:.2f} {gait_s} {calm_s} "
f"{social_s} {pres_s:>4} {rssi_s} {seed_s} {d['csi_frames']:>5}")
# Show drift detail if drifting
if hub.seed and hub.seed.last_drift and hub.seed.last_drift.get("drifting"):
print(f" SEED DRIFT: {hub.seed.last_drift.get('message', 'unknown')}")
else:
hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else ""
br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else ""
bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— "
sdnn_s = f"{d['sdnn']:>5.0f}" if d["sdnn"] > 0 else ""
rmssd_s = f"{d['rmssd']:>5.0f}" if d["rmssd"] > 0 else ""
lfhf_s = f"{d['lf_hf']:>5.2f}" if d["sdnn"] > 0 else ""
pres_s = "YES" if d["presence"] else " no"
dist_s = f"{d['distance']:>4.0f}cm" if d["distance"] > 0 else ""
lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else ""
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else ""
coh = max(d["coh_mw"], d["coh_csi"])
coh_s = f"{coh:>.2f}"
print(f"{elapsed:>3}s {hr_s} {br_s} {bp_s} {d['stress']:>8} "
f"{sdnn_s} {rmssd_s} {lfhf_s} "
f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} "
f"{coh_s:>4} {d['csi_frames']:>5}")
for drift in d["drifts"]:
print(f" DRIFT: {drift}")
@ -506,6 +726,9 @@ def run_display(hub, duration, interval):
print(f" Baselines ({len(longi)} metrics tracked):")
for name, stats in sorted(longi.items()):
print(f" {name}: mean={stats['mean']:.1f} std={stats['std']:.1f} n={stats['n']}")
# Happiness
if d.get("happiness", 0) > 0:
print(f" Happiness: {d['happiness']:.2f} (gait={d['gait_energy']:.2f} affect={d['affect_valence']:.2f} social={d['social_energy']:.2f})")
# Signal coherence
print(f" Coherence: mmWave={d['coh_mw']:.2f} CSI={d['coh_csi']:.2f}")
events = d["events"]
@ -518,13 +741,21 @@ def run_display(hub, duration, interval):
def main():
parser = argparse.ArgumentParser(description="RuView Live + RuVector Analysis")
parser.add_argument("--csi", default="COM7", help="CSI port (or 'none')")
parser.add_argument("--csi", default=None, help="CSI port (or 'none'); defaults to COM5 for happiness mode, COM7 otherwise")
parser.add_argument("--mmwave", default="COM4", help="mmWave port (or 'none')")
parser.add_argument("--duration", type=int, default=120)
parser.add_argument("--interval", type=int, default=3)
parser.add_argument("--seed", default="none", help="Cognitum Seed HTTP base URL (e.g. 'http://169.254.42.1')")
parser.add_argument("--mode", default="vitals", choices=["vitals", "happiness"],
help="Dashboard mode: vitals (default) or happiness")
args = parser.parse_args()
hub = SensorHub()
# Default CSI port depends on mode
if args.csi is None:
args.csi = "COM5" if args.mode == "happiness" else "COM7"
seed_url = args.seed if args.seed.lower() != "none" else None
hub = SensorHub(seed_url=seed_url)
stop = threading.Event()
if args.mmwave.lower() != "none":
@ -535,7 +766,7 @@ def main():
time.sleep(2)
try:
run_display(hub, args.duration, args.interval)
run_display(hub, args.duration, args.interval, mode=args.mode)
except KeyboardInterrupt:
print("\nStopping...")
stop.set()

View file

@ -3,6 +3,7 @@ set(SRCS
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
"swarm_bridge.c"
)
set(REQUIRES "")

View file

@ -28,6 +28,7 @@
#include "wasm_upload.h"
#include "display_task.h"
#include "mmwave_sensor.h"
#include "swarm_bridge.h"
#ifdef CONFIG_CSI_MOCK_ENABLED
#include "mock_csi.h"
#endif
@ -240,6 +241,29 @@ void app_main(void)
ESP_LOGI(TAG, "No mmWave sensor detected (CSI-only mode)");
}
/* ADR-066: Initialize swarm bridge to Cognitum Seed (if configured). */
esp_err_t swarm_ret = ESP_ERR_INVALID_ARG;
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
if (g_nvs_config.seed_url[0] != '\0') {
swarm_config_t swarm_cfg = {
.heartbeat_sec = g_nvs_config.swarm_heartbeat_sec,
.ingest_sec = g_nvs_config.swarm_ingest_sec,
.enabled = 1,
};
strncpy(swarm_cfg.seed_url, g_nvs_config.seed_url, sizeof(swarm_cfg.seed_url) - 1);
strncpy(swarm_cfg.seed_token, g_nvs_config.seed_token, sizeof(swarm_cfg.seed_token) - 1);
strncpy(swarm_cfg.zone_name, g_nvs_config.zone_name, sizeof(swarm_cfg.zone_name) - 1);
swarm_ret = swarm_bridge_init(&swarm_cfg, g_nvs_config.node_id);
if (swarm_ret != ESP_OK) {
ESP_LOGW(TAG, "Swarm bridge init failed: %s", esp_err_to_name(swarm_ret));
}
} else {
ESP_LOGI(TAG, "Swarm bridge disabled (no seed_url configured)");
}
#else
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
#endif
/* Initialize power management. */
power_mgmt_init(g_nvs_config.power_duty);
@ -251,12 +275,13 @@ void app_main(void)
}
#endif
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s)",
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)",
g_nvs_config.target_ip, g_nvs_config.target_port,
g_nvs_config.edge_tier,
(ota_ret == ESP_OK) ? "ready" : "off",
(wasm_ret == ESP_OK) ? "ready" : "off",
(mmwave_ret == ESP_OK) ? "active" : "off");
(mmwave_ret == ESP_OK) ? "active" : "off",
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off");
/* Main loop — keep alive */
while (1) {

View file

@ -302,6 +302,26 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
}
/* ADR-066: Swarm bridge */
len = sizeof(cfg->seed_url);
if (nvs_get_str(handle, "seed_url", cfg->seed_url, &len) != ESP_OK) {
cfg->seed_url[0] = '\0'; /* Disabled by default */
}
len = sizeof(cfg->seed_token);
if (nvs_get_str(handle, "seed_token", cfg->seed_token, &len) != ESP_OK) {
cfg->seed_token[0] = '\0';
}
len = sizeof(cfg->zone_name);
if (nvs_get_str(handle, "zone_name", cfg->zone_name, &len) != ESP_OK) {
strncpy(cfg->zone_name, "default", sizeof(cfg->zone_name) - 1);
}
if (nvs_get_u16(handle, "swarm_hb", &cfg->swarm_heartbeat_sec) != ESP_OK) {
cfg->swarm_heartbeat_sec = 30;
}
if (nvs_get_u16(handle, "swarm_ingest", &cfg->swarm_ingest_sec) != ESP_OK) {
cfg->swarm_ingest_sec = 5;
}
/* Validate tdm_slot_index < tdm_node_count */
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",

View file

@ -55,6 +55,13 @@ typedef struct {
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
/* ADR-066: Swarm bridge configuration */
char seed_url[64]; /**< Cognitum Seed base URL (empty = disabled). */
char seed_token[64]; /**< Seed Bearer token (from pairing). */
char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */
uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */
uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */
} nvs_config_t;
/**

View file

@ -0,0 +1,327 @@
/**
* @file swarm_bridge.c
* @brief ADR-066: ESP32 Swarm Bridge Cognitum Seed coordinator client.
*
* Runs a FreeRTOS task on Core 0 that periodically POSTs registration,
* heartbeat, and happiness vectors to a Cognitum Seed ingest endpoint.
*/
#include "swarm_bridge.h"
#include <string.h>
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_app_desc.h"
#include "esp_netif.h"
#include "esp_http_client.h"
static const char *TAG = "swarm";
/* ---- Task parameters ---- */
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
#define SWARM_TASK_PRIO 3
#define SWARM_TASK_CORE 0
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
/* ---- Ingest endpoint path ---- */
#define SWARM_INGEST_PATH "/api/v1/store/ingest"
/* ---- JSON buffer size (Seed tuple format: max ~120 bytes per vector) ---- */
#define SWARM_JSON_BUF 256
/* ---- Module state ---- */
static swarm_config_t s_cfg;
static uint8_t s_node_id;
static SemaphoreHandle_t s_mutex;
static TaskHandle_t s_task_handle;
/* ---- Protected shared data ---- */
static edge_vitals_pkt_t s_vitals;
static float s_happiness[SWARM_VECTOR_DIM];
static bool s_vitals_valid;
/* ---- Counters ---- */
static uint32_t s_cnt_regs;
static uint32_t s_cnt_heartbeats;
static uint32_t s_cnt_ingests;
static uint32_t s_cnt_errors;
/* ---- Forward declarations ---- */
static void swarm_task(void *arg);
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
const char *json, int json_len);
static void swarm_get_ip_str(char *buf, size_t buf_len);
/* ------------------------------------------------------------------ */
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id)
{
if (cfg == NULL || cfg->seed_url[0] == '\0') {
ESP_LOGW(TAG, "seed_url is empty — swarm bridge disabled");
return ESP_ERR_INVALID_ARG;
}
memcpy(&s_cfg, cfg, sizeof(s_cfg));
s_node_id = node_id;
/* Apply defaults for zero-valued intervals. */
if (s_cfg.heartbeat_sec == 0) {
s_cfg.heartbeat_sec = 30;
}
if (s_cfg.ingest_sec == 0) {
s_cfg.ingest_sec = 5;
}
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == NULL) {
ESP_LOGE(TAG, "failed to create mutex");
return ESP_ERR_NO_MEM;
}
s_vitals_valid = false;
memset(s_happiness, 0, sizeof(s_happiness));
s_cnt_regs = 0;
s_cnt_heartbeats = 0;
s_cnt_ingests = 0;
s_cnt_errors = 0;
BaseType_t ret = xTaskCreatePinnedToCore(
swarm_task, "swarm", SWARM_TASK_STACK, NULL,
SWARM_TASK_PRIO, &s_task_handle, SWARM_TASK_CORE);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create swarm task");
vSemaphoreDelete(s_mutex);
s_mutex = NULL;
return ESP_FAIL;
}
ESP_LOGI(TAG, "bridge init OK — seed=%s zone=%s hb=%us ingest=%us",
s_cfg.seed_url, s_cfg.zone_name,
s_cfg.heartbeat_sec, s_cfg.ingest_sec);
return ESP_OK;
}
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals)
{
if (vitals == NULL || s_mutex == NULL) {
return;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(&s_vitals, vitals, sizeof(s_vitals));
s_vitals_valid = true;
xSemaphoreGive(s_mutex);
}
void swarm_bridge_update_happiness(const float *vector, uint8_t dim)
{
if (vector == NULL || s_mutex == NULL) {
return;
}
uint8_t n = (dim < SWARM_VECTOR_DIM) ? dim : SWARM_VECTOR_DIM;
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(s_happiness, vector, n * sizeof(float));
/* Zero-fill remaining dimensions. */
for (uint8_t i = n; i < SWARM_VECTOR_DIM; i++) {
s_happiness[i] = 0.0f;
}
xSemaphoreGive(s_mutex);
}
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
uint32_t *ingests, uint32_t *errors)
{
if (regs) *regs = s_cnt_regs;
if (heartbeats) *heartbeats = s_cnt_heartbeats;
if (ingests) *ingests = s_cnt_ingests;
if (errors) *errors = s_cnt_errors;
}
/* ---- HTTP POST helper ---- */
static esp_err_t swarm_post_json(esp_http_client_handle_t client,
const char *json, int json_len)
{
esp_http_client_set_post_field(client, json, json_len);
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) {
/* Connection may have been closed by Seed between requests.
* Close our end and let the next perform() reconnect. */
esp_http_client_close(client);
/* Retry once. */
err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGW(TAG, "HTTP POST failed: %s", esp_err_to_name(err));
s_cnt_errors++;
esp_http_client_close(client);
return err;
}
}
int status = esp_http_client_get_status_code(client);
/* Close connection after each request to avoid stale keep-alive. */
esp_http_client_close(client);
if (status < 200 || status >= 300) {
ESP_LOGW(TAG, "HTTP POST status %d", status);
s_cnt_errors++;
return ESP_FAIL;
}
return ESP_OK;
}
/* ---- Get local IP address as string ---- */
static void swarm_get_ip_str(char *buf, size_t buf_len)
{
esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif == NULL) {
snprintf(buf, buf_len, "0.0.0.0");
return;
}
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) {
snprintf(buf, buf_len, "0.0.0.0");
return;
}
snprintf(buf, buf_len, IPSTR, IP2STR(&ip_info.ip));
}
/* ---- Swarm bridge task ---- */
static void swarm_task(void *arg)
{
(void)arg;
/* Build the full ingest URL once. */
char url[128];
snprintf(url, sizeof(url), "%s%s", s_cfg.seed_url, SWARM_INGEST_PATH);
/* Create a reusable HTTP client. */
esp_http_client_config_t http_cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = SWARM_HTTP_TIMEOUT,
};
esp_http_client_handle_t client = esp_http_client_init(&http_cfg);
if (client == NULL) {
ESP_LOGE(TAG, "failed to create HTTP client — task exiting");
vTaskDelete(NULL);
return;
}
esp_http_client_set_header(client, "Content-Type", "application/json");
/* ADR-066: Set Bearer token for Seed WiFi auth (from pairing). */
if (s_cfg.seed_token[0] != '\0') {
char auth_hdr[80];
snprintf(auth_hdr, sizeof(auth_hdr), "Bearer %s", s_cfg.seed_token);
esp_http_client_set_header(client, "Authorization", auth_hdr);
ESP_LOGI(TAG, "Bearer token configured for Seed auth");
}
/* Get firmware version string. */
const esp_app_desc_t *app = esp_app_get_description();
const char *fw_ver = app ? app->version : "unknown";
/* Get local IP. */
char ip_str[16];
swarm_get_ip_str(ip_str, sizeof(ip_str));
/* ---- Registration POST ---- */
/* Seed ingest format: {"vectors":[[u64_id, [f32; dim]]]} */
{
/* ID scheme: node_id * 1000000 + type_code (0=reg, 1=hb, 2=happiness) */
uint32_t reg_id = (uint32_t)s_node_id * 1000000U;
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[0,0,0,0,0,0,0,0]]]}",
(unsigned long)reg_id);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_regs++;
ESP_LOGI(TAG, "registered node %u with seed (id=%lu)", s_node_id, (unsigned long)reg_id);
} else {
ESP_LOGW(TAG, "registration failed — will retry on next heartbeat");
}
}
/* ---- Main loop ---- */
TickType_t last_heartbeat = xTaskGetTickCount();
TickType_t last_ingest = xTaskGetTickCount();
const TickType_t poll_interval = pdMS_TO_TICKS(1000); /* Wake every 1 s. */
for (;;) {
vTaskDelay(poll_interval);
TickType_t now = xTaskGetTickCount();
/* Snapshot shared data under mutex. */
float hv[SWARM_VECTOR_DIM];
edge_vitals_pkt_t vit;
bool vit_valid;
xSemaphoreTake(s_mutex, portMAX_DELAY);
memcpy(hv, s_happiness, sizeof(hv));
memcpy(&vit, &s_vitals, sizeof(vit));
vit_valid = s_vitals_valid;
xSemaphoreGive(s_mutex);
uint32_t uptime_s = (uint32_t)(esp_timer_get_time() / 1000000ULL);
uint32_t free_heap = esp_get_free_heap_size();
uint32_t ts = (uint32_t)(esp_timer_get_time() / 1000ULL);
/* ---- Heartbeat ---- */
if ((now - last_heartbeat) >= pdMS_TO_TICKS(s_cfg.heartbeat_sec * 1000U)) {
last_heartbeat = now;
bool presence = vit_valid && (vit.flags & 0x01);
/* Heartbeat ID: node_id * 1000000 + 100000 + ts_sec */
uint32_t hb_id = (uint32_t)s_node_id * 1000000U + 100000U + (uptime_s % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)hb_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_heartbeats++;
}
}
/* ---- Happiness ingest (only when presence detected) ---- */
if ((now - last_ingest) >= pdMS_TO_TICKS(s_cfg.ingest_sec * 1000U)) {
last_ingest = now;
bool presence = vit_valid && (vit.flags & 0x01);
if (presence) {
/* Happiness ID: node_id * 1000000 + 200000 + ts_sec */
uint32_t h_id = (uint32_t)s_node_id * 1000000U + 200000U + (ts / 1000U % 100000U);
char json[SWARM_JSON_BUF];
int len = snprintf(json, sizeof(json),
"{\"vectors\":[[%lu,[%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f]]]}",
(unsigned long)h_id,
hv[0], hv[1], hv[2], hv[3], hv[4], hv[5], hv[6], hv[7]);
if (swarm_post_json(client, json, len) == ESP_OK) {
s_cnt_ingests++;
}
}
}
}
/* Unreachable, but clean up for completeness. */
esp_http_client_cleanup(client);
vTaskDelete(NULL);
}

View file

@ -0,0 +1,67 @@
/**
* @file swarm_bridge.h
* @brief ADR-066: ESP32 Swarm Bridge Cognitum Seed coordinator client.
*
* Registers this node with a Cognitum Seed, sends periodic heartbeats,
* and pushes happiness vectors for cross-zone analytics.
* Runs as a FreeRTOS task on Core 0.
*/
#ifndef SWARM_BRIDGE_H
#define SWARM_BRIDGE_H
#include <stdint.h>
#include "esp_err.h"
#include "edge_processing.h"
/** Happiness vector dimension. */
#define SWARM_VECTOR_DIM 8
/** Swarm bridge configuration. */
typedef struct {
char seed_url[64]; /**< Cognitum Seed base URL (e.g. "http://192.168.1.10:8080"). */
char seed_token[64]; /**< Bearer token for Seed WiFi API auth (from pairing). */
char zone_name[16]; /**< Zone name for this node (e.g. "bedroom"). */
uint16_t heartbeat_sec; /**< Heartbeat interval in seconds (default 30). */
uint16_t ingest_sec; /**< Happiness ingest interval in seconds (default 5). */
uint8_t enabled; /**< 1 = bridge active, 0 = disabled. */
} swarm_config_t;
/**
* Initialize the swarm bridge and start the background task.
* Registers this node with the Cognitum Seed on first successful POST.
*
* @param cfg Swarm bridge configuration.
* @param node_id This node's identifier (from NVS).
* @return ESP_OK on success, ESP_ERR_INVALID_ARG if seed_url is empty.
*/
esp_err_t swarm_bridge_init(const swarm_config_t *cfg, uint8_t node_id);
/**
* Feed the latest vitals packet into the swarm bridge.
* Called from the main loop whenever new vitals are available.
*
* @param vitals Pointer to the latest vitals packet.
*/
void swarm_bridge_update_vitals(const edge_vitals_pkt_t *vitals);
/**
* Update the happiness vector to be pushed at the next ingest cycle.
*
* @param vector Float array of happiness values.
* @param dim Number of elements (clamped to SWARM_VECTOR_DIM).
*/
void swarm_bridge_update_happiness(const float *vector, uint8_t dim);
/**
* Get cumulative bridge statistics.
*
* @param regs Output: number of successful registrations.
* @param heartbeats Output: number of successful heartbeats sent.
* @param ingests Output: number of successful happiness ingests sent.
* @param errors Output: number of HTTP errors encountered.
*/
void swarm_bridge_get_stats(uint32_t *regs, uint32_t *heartbeats,
uint32_t *ingests, uint32_t *errors);
#endif /* SWARM_BRIDGE_H */

View file

@ -71,6 +71,17 @@ def build_nvs_csv(args):
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
# NVS blob: write as hex-encoded string for CSV compatibility
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
# ADR-066: Swarm bridge configuration
if args.seed_url is not None:
writer.writerow(["seed_url", "data", "string", args.seed_url])
if args.seed_token is not None:
writer.writerow(["seed_token", "data", "string", args.seed_token])
if args.zone is not None:
writer.writerow(["zone_name", "data", "string", args.zone])
if args.swarm_hb is not None:
writer.writerow(["swarm_hb", "data", "u16", str(args.swarm_hb)])
if args.swarm_ingest is not None:
writer.writerow(["swarm_ingest", "data", "u16", str(args.swarm_ingest)])
return buf.getvalue()
@ -170,6 +181,12 @@ def main():
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
"Overrides auto-detection from connected AP.")
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
# ADR-066: Swarm bridge
parser.add_argument("--seed-url", type=str, help="Cognitum Seed base URL (e.g. http://10.1.10.236)")
parser.add_argument("--seed-token", type=str, help="Seed Bearer token (from pairing)")
parser.add_argument("--zone", type=str, help="Zone name for this node (e.g. lobby, hallway)")
parser.add_argument("--swarm-hb", type=int, help="Swarm heartbeat interval in seconds (default 30)")
parser.add_argument("--swarm-ingest", type=int, help="Swarm vector ingest interval in seconds (default 5)")
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
args = parser.parse_args()
@ -182,6 +199,7 @@ def main():
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
args.seed_url is not None, args.zone is not None,
])
if not has_value:
parser.error("At least one config value must be specified")
@ -238,6 +256,14 @@ def main():
print(f" CSI Channel: {args.channel}")
if args.filter_mac is not None:
print(f" Filter MAC: {args.filter_mac}")
if args.seed_url is not None:
print(f" Seed URL: {args.seed_url}")
if args.zone is not None:
print(f" Zone: {args.zone}")
if args.swarm_hb is not None:
print(f" Swarm HB: {args.swarm_hb}s")
if args.swarm_ingest is not None:
print(f" Swarm Ingest: {args.swarm_ingest}s")
csv_content = build_nvs_csv(args)

View file

@ -0,0 +1,5 @@
# ESP32-S3 Hello World — Capability Discovery
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32-hello-world)

View file

@ -0,0 +1,4 @@
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
)

View file

@ -0,0 +1,437 @@
/**
* @file main.c
* @brief ESP32-S3 Hello World Full Capability Discovery
*
* Boots up, prints "Hello World!", then probes and reports every major
* hardware/software capability of the ESP32-S3: chip info, flash, PSRAM,
* WiFi (including CSI), Bluetooth, GPIOs, peripherals, FreeRTOS stats,
* and power management features. No WiFi connection required.
*/
#include <stdio.h>
#include <string.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_mac.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "esp_partition.h"
#include "esp_ota_ops.h"
#include "esp_efuse.h"
#include "esp_pm.h"
#include "nvs_flash.h"
#include "soc/soc_caps.h"
#include "driver/gpio.h"
#include "driver/temperature_sensor.h"
#include "sdkconfig.h"
static const char *TAG = "hello";
/* ── Helpers ─────────────────────────────────────────────────────────── */
static const char *chip_model_str(esp_chip_model_t model)
{
switch (model) {
case CHIP_ESP32: return "ESP32";
case CHIP_ESP32S2: return "ESP32-S2";
case CHIP_ESP32S3: return "ESP32-S3";
case CHIP_ESP32C3: return "ESP32-C3";
case CHIP_ESP32H2: return "ESP32-H2";
case CHIP_ESP32C2: return "ESP32-C2";
default: return "Unknown";
}
}
static void print_separator(const char *title)
{
printf("\n╔══════════════════════════════════════════════════════════╗\n");
printf("║ %-55s ║\n", title);
printf("╚══════════════════════════════════════════════════════════╝\n");
}
/* ── Capability Probes ───────────────────────────────────────────────── */
static void probe_chip_info(void)
{
print_separator("CHIP INFO");
esp_chip_info_t info;
esp_chip_info(&info);
printf(" Model: %s (rev %d.%d)\n",
chip_model_str(info.model),
info.revision / 100, info.revision % 100);
printf(" Cores: %d\n", info.cores);
printf(" Features: ");
if (info.features & CHIP_FEATURE_WIFI_BGN) printf("WiFi ");
if (info.features & CHIP_FEATURE_BLE) printf("BLE ");
if (info.features & CHIP_FEATURE_BT) printf("BT-Classic ");
if (info.features & CHIP_FEATURE_IEEE802154) printf("802.15.4 ");
if (info.features & CHIP_FEATURE_EMB_FLASH) printf("EmbFlash ");
if (info.features & CHIP_FEATURE_EMB_PSRAM) printf("EmbPSRAM ");
printf("\n");
/* MAC addresses */
uint8_t mac[6];
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) == ESP_OK) {
printf(" WiFi STA MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
if (esp_read_mac(mac, ESP_MAC_BT) == ESP_OK) {
printf(" BT MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
printf(" IDF Version: %s\n", esp_get_idf_version());
printf(" Reset Reason: %d\n", esp_reset_reason());
}
static void probe_memory(void)
{
print_separator("MEMORY");
/* Internal RAM */
printf(" Internal DRAM:\n");
printf(" Total: %"PRIu32" bytes\n",
(uint32_t)heap_caps_get_total_size(MALLOC_CAP_INTERNAL));
printf(" Free: %"PRIu32" bytes\n",
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
printf(" Min Free: %"PRIu32" bytes\n",
(uint32_t)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL));
/* PSRAM */
size_t psram_total = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
if (psram_total > 0) {
printf(" External PSRAM:\n");
printf(" Total: %"PRIu32" bytes (%.1f MB)\n",
(uint32_t)psram_total, psram_total / (1024.0 * 1024.0));
printf(" Free: %"PRIu32" bytes\n",
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
} else {
printf(" External PSRAM: Not available\n");
}
/* DMA-capable */
printf(" DMA-capable: %"PRIu32" bytes free\n",
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_DMA));
}
static void probe_flash(void)
{
print_separator("FLASH STORAGE");
uint32_t flash_size = 0;
if (esp_flash_get_size(NULL, &flash_size) == ESP_OK) {
printf(" Flash Size: %"PRIu32" bytes (%.0f MB)\n",
flash_size, flash_size / (1024.0 * 1024.0));
}
/* Partition table */
printf(" Partitions:\n");
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY,
ESP_PARTITION_SUBTYPE_ANY, NULL);
while (it != NULL) {
const esp_partition_t *p = esp_partition_get(it);
printf(" %-16s type=0x%02x sub=0x%02x offset=0x%06"PRIx32" size=%"PRIu32" KB\n",
p->label, p->type, p->subtype, p->address, p->size / 1024);
it = esp_partition_next(it);
}
esp_partition_iterator_release(it);
/* Running partition */
const esp_partition_t *running = esp_ota_get_running_partition();
if (running) {
printf(" Running from: %s (0x%06"PRIx32")\n", running->label, running->address);
}
}
static void probe_wifi_capabilities(void)
{
print_separator("WiFi CAPABILITIES");
/* Init WiFi just enough to query capabilities (no connection) */
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_start());
/* Protocol capabilities */
printf(" Protocols: 802.11 b/g/n\n");
/* CSI (Channel State Information) */
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
printf(" CSI: ENABLED (Channel State Information)\n");
printf(" - Subcarrier amplitude & phase data\n");
printf(" - Per-packet callback available\n");
printf(" - Use for: presence detection, gesture recognition,\n");
printf(" breathing/heart rate, indoor positioning\n");
#else
printf(" CSI: DISABLED (enable CONFIG_ESP_WIFI_CSI_ENABLED)\n");
#endif
/* Scan to show what's visible */
printf(" WiFi Scan: Scanning nearby APs...\n");
wifi_scan_config_t scan_cfg = {
.show_hidden = true,
.scan_type = WIFI_SCAN_TYPE_ACTIVE,
.scan_time.active.min = 100,
.scan_time.active.max = 300,
};
esp_wifi_scan_start(&scan_cfg, true); /* blocking scan */
uint16_t ap_count = 0;
esp_wifi_scan_get_ap_num(&ap_count);
printf(" APs Found: %d\n", ap_count);
if (ap_count > 0) {
uint16_t max_show = (ap_count > 10) ? 10 : ap_count;
wifi_ap_record_t *ap_list = malloc(sizeof(wifi_ap_record_t) * max_show);
if (ap_list) {
esp_wifi_scan_get_ap_records(&max_show, ap_list);
printf(" %-32s CH RSSI Auth\n", " SSID");
printf(" %-32s -- ---- ----\n", " ----");
for (int i = 0; i < max_show; i++) {
const char *auth_str = "OPEN";
switch (ap_list[i].authmode) {
case WIFI_AUTH_WEP: auth_str = "WEP"; break;
case WIFI_AUTH_WPA_PSK: auth_str = "WPA"; break;
case WIFI_AUTH_WPA2_PSK: auth_str = "WPA2"; break;
case WIFI_AUTH_WPA_WPA2_PSK: auth_str = "WPA/2"; break;
case WIFI_AUTH_WPA3_PSK: auth_str = "WPA3"; break;
case WIFI_AUTH_WPA2_WPA3_PSK: auth_str = "WPA2/3"; break;
default: break;
}
printf(" %-30s %2d %4d %s\n",
(char *)ap_list[i].ssid,
ap_list[i].primary,
ap_list[i].rssi,
auth_str);
}
free(ap_list);
if (ap_count > max_show)
printf(" ... and %d more\n", ap_count - max_show);
}
}
/* WiFi modes supported */
printf("\n Supported Modes:\n");
printf(" - STA (Station / Client)\n");
printf(" - AP (Access Point / Soft-AP)\n");
printf(" - STA+AP (Concurrent)\n");
printf(" - Promiscuous (raw 802.11 frame capture)\n");
printf(" - ESP-NOW (peer-to-peer, no router needed)\n");
printf(" - WiFi Aware / NAN (Neighbor Awareness)\n");
esp_wifi_stop();
esp_wifi_deinit();
}
static void probe_bluetooth(void)
{
print_separator("BLUETOOTH CAPABILITIES");
esp_chip_info_t info;
esp_chip_info(&info);
if (info.features & CHIP_FEATURE_BLE) {
printf(" BLE: Supported (Bluetooth 5.0 LE)\n");
printf(" - GATT Server/Client\n");
printf(" - Advertising & Scanning\n");
printf(" - Mesh Networking\n");
printf(" - Long Range (Coded PHY)\n");
printf(" - 2 Mbps PHY\n");
} else {
printf(" BLE: Not supported on this chip\n");
}
if (info.features & CHIP_FEATURE_BT) {
printf(" BT Classic: Supported (A2DP, SPP, HFP)\n");
} else {
printf(" BT Classic: Not available (ESP32-S3 is BLE-only)\n");
}
}
static void probe_peripherals(void)
{
print_separator("PERIPHERAL CAPABILITIES");
printf(" GPIOs: %d total\n", SOC_GPIO_PIN_COUNT);
printf(" ADC:\n");
printf(" - ADC1: %d channels (12-bit SAR)\n", SOC_ADC_CHANNEL_NUM(0));
printf(" - ADC2: %d channels (shared with WiFi)\n", SOC_ADC_CHANNEL_NUM(1));
printf(" DAC: Not available on ESP32-S3\n");
printf(" Touch Sensors: %d channels (capacitive)\n", SOC_TOUCH_SENSOR_NUM);
printf(" SPI: %d controllers (SPI2/SPI3 for user)\n", SOC_SPI_PERIPH_NUM);
printf(" I2C: %d controllers\n", SOC_I2C_NUM);
printf(" I2S: %d controllers (audio/PDM/TDM)\n", SOC_I2S_NUM);
printf(" UART: %d controllers\n", SOC_UART_NUM);
printf(" USB: USB-OTG 1.1 (Host & Device)\n");
printf(" USB-Serial: Built-in USB-JTAG/Serial (this console)\n");
printf(" TWAI (CAN): 1 controller (CAN 2.0B compatible)\n");
printf(" RMT: %d channels (IR/WS2812/NeoPixel)\n", SOC_RMT_TX_CANDIDATES_PER_GROUP + SOC_RMT_RX_CANDIDATES_PER_GROUP);
printf(" LEDC (PWM): %d channels\n", SOC_LEDC_CHANNEL_NUM);
printf(" MCPWM: %d groups (motor control)\n", SOC_MCPWM_GROUPS);
printf(" PCNT: %d units (pulse counter / encoder)\n", SOC_PCNT_UNITS_PER_GROUP);
printf(" LCD: Parallel 8/16-bit + SPI + I2C interfaces\n");
printf(" Camera: DVP 8/16-bit parallel interface\n");
printf(" SDMMC: SD/MMC host controller (1-bit / 4-bit)\n");
}
static void probe_security(void)
{
print_separator("SECURITY & CRYPTO");
printf(" AES: 128/256-bit hardware accelerator\n");
printf(" SHA: SHA-1/224/256 hardware accelerator\n");
printf(" RSA: Up to 4096-bit hardware accelerator\n");
printf(" HMAC: Hardware HMAC (eFuse key)\n");
printf(" Digital Sig: Hardware digital signature (RSA)\n");
printf(" Flash Encrypt: AES-256-XTS (eFuse controlled)\n");
printf(" Secure Boot: V2 (RSA-3072 / ECDSA)\n");
printf(" eFuse: %d bits (MAC, keys, config)\n", 256 * 11);
printf(" World Ctrl: Dual-world isolation (TEE)\n");
printf(" Random: Hardware TRNG available\n");
}
static void probe_power(void)
{
print_separator("POWER MANAGEMENT");
printf(" Clock Modes:\n");
printf(" - 240 MHz (max performance)\n");
printf(" - 160 MHz (balanced)\n");
printf(" - 80 MHz (low power)\n");
printf(" Sleep Modes:\n");
printf(" - Modem Sleep (WiFi off, CPU active)\n");
printf(" - Light Sleep (CPU paused, fast wake)\n");
printf(" - Deep Sleep (RTC only, ~10 uA)\n");
printf(" - Hibernation (RTC timer only, ~5 uA)\n");
printf(" Wake Sources: GPIO, timer, touch, ULP, UART\n");
printf(" ULP Coprocessor: RISC-V + FSM (runs in deep sleep)\n");
}
static void probe_temperature(void)
{
print_separator("TEMPERATURE SENSOR");
temperature_sensor_handle_t tsens = NULL;
temperature_sensor_config_t tsens_cfg = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80);
esp_err_t ret = temperature_sensor_install(&tsens_cfg, &tsens);
if (ret == ESP_OK) {
temperature_sensor_enable(tsens);
float temp_c = 0;
temperature_sensor_get_celsius(tsens, &temp_c);
printf(" Chip Temp: %.1f °C (%.1f °F)\n", temp_c, temp_c * 9.0 / 5.0 + 32.0);
temperature_sensor_disable(tsens);
temperature_sensor_uninstall(tsens);
} else {
printf(" Chip Temp: Sensor not available (%s)\n", esp_err_to_name(ret));
}
}
static void probe_freertos(void)
{
print_separator("FreeRTOS / SYSTEM");
printf(" FreeRTOS: v%s\n", tskKERNEL_VERSION_NUMBER);
printf(" Tick Rate: %d Hz\n", configTICK_RATE_HZ);
printf(" Task Count: %"PRIu32"\n", (uint32_t)uxTaskGetNumberOfTasks());
printf(" Main Stack: %d bytes\n", CONFIG_ESP_MAIN_TASK_STACK_SIZE);
printf(" Uptime: %lld ms\n", esp_timer_get_time() / 1000LL);
}
static void probe_csi_details(void)
{
print_separator("CSI (Channel State Information) DETAILS");
#ifdef CONFIG_ESP_WIFI_CSI_ENABLED
printf(" Status: ENABLED in this build\n");
printf("\n What is CSI?\n");
printf(" WiFi CSI captures the amplitude and phase of each OFDM\n");
printf(" subcarrier in received WiFi frames. This gives a detailed\n");
printf(" view of how radio signals propagate through a space.\n");
printf("\n Subcarriers: 52 (20 MHz) / 114 (40 MHz) per frame\n");
printf(" Data Rate: Up to ~100 frames/sec\n");
printf(" Data per Frame: ~200-500 bytes (amplitude + phase)\n");
printf("\n Applications:\n");
printf(" 1. Presence Detection — detect humans in a room\n");
printf(" 2. Gesture Recognition — classify hand gestures\n");
printf(" 3. Activity Recognition — walking, sitting, falling\n");
printf(" 4. Breathing/Heart Rate — contactless vital signs\n");
printf(" 5. Indoor Positioning — sub-meter localization\n");
printf(" 6. Fall Detection — elderly safety monitoring\n");
printf(" 7. People Counting — crowd estimation\n");
printf(" 8. Sleep Monitoring — non-contact sleep staging\n");
printf("\n How to use:\n");
printf(" esp_wifi_set_csi_config(&csi_config);\n");
printf(" esp_wifi_set_csi_rx_cb(my_callback, NULL);\n");
printf(" esp_wifi_set_csi(true);\n");
#else
printf(" Status: DISABLED\n");
printf(" To enable: Set CONFIG_ESP_WIFI_CSI_ENABLED=y in sdkconfig\n");
#endif
}
/* ── Main ────────────────────────────────────────────────────────────── */
void app_main(void)
{
/* NVS required for WiFi */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase();
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
/* ── Hello World! ── */
printf("\n");
printf(" ╭─────────────────────────────────────────────────╮\n");
printf(" │ │\n");
printf(" │ HELLO WORLD from ESP32-S3! │\n");
printf(" │ │\n");
printf(" │ WiFi-DensePose Capability Discovery v1.0 │\n");
printf(" │ │\n");
printf(" ╰─────────────────────────────────────────────────╯\n");
printf("\n");
/* Run all probes */
probe_chip_info();
probe_memory();
probe_flash();
probe_temperature();
probe_peripherals();
probe_security();
probe_power();
probe_freertos();
probe_wifi_capabilities();
probe_bluetooth();
probe_csi_details();
print_separator("DONE — ALL CAPABILITIES REPORTED");
printf("\n This ESP32-S3 is ready for WiFi-DensePose!\n");
printf(" Flash the full firmware (esp32-csi-node) to begin CSI sensing.\n\n");
/* Keep alive — blink a status message every 10 seconds */
int tick = 0;
while (1) {
vTaskDelay(pdMS_TO_TICKS(10000));
tick++;
printf("[hello] Still running... uptime=%lld sec, free_heap=%"PRIu32"\n",
esp_timer_get_time() / 1000000LL,
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
}
}

View file

@ -0,0 +1,18 @@
# ESP32-S3 Hello World — SDK Configuration
CONFIG_IDF_TARGET="esp32s3"
# Flash: 4MB (this chip has Embedded Flash 4MB)
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# Enable WiFi CSI so we can probe it
CONFIG_ESP_WIFI_CSI_ENABLED=y
# Verbose logging so user sees everything
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# Bigger main task stack for printf-heavy capability dump
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# Enable temperature sensor driver
CONFIG_SOC_TEMP_SENSOR_SUPPORTED=y

View file

@ -19,9 +19,12 @@ libm = "0.2"
sha2 = { version = "0.10", optional = true, default-features = false }
[features]
default = []
default = ["default-pipeline"]
# Enable std for testing on host + RVF builder
std = ["sha2/std"]
# Include the default combined pipeline (gesture+coherence+adversarial) entry points.
# Disable this when building standalone module binaries (ghost_hunter, etc.)
default-pipeline = []
[profile.release]
opt-level = "s" # Optimize for size

View file

@ -0,0 +1,108 @@
//! Standalone Ghost Hunter WASM module for ESP32-S3.
//!
//! Compiles to a self-contained .wasm binary that runs the
//! GhostHunterDetector as a hot-loadable Tier 3 edge module.
//!
//! Build:
//! cargo build --bin ghost_hunter --target wasm32-unknown-unknown --release
//!
//! The resulting .wasm file can be uploaded to an ESP32 running the
//! CSI firmware via the HTTP /api/wasm/upload endpoint.
#![cfg_attr(target_arch = "wasm32", no_std)]
#![cfg_attr(target_arch = "wasm32", no_main)]
// The lib crate already provides the panic handler for wasm32.
// We use its host API bindings and the GhostHunterDetector.
#[cfg(target_arch = "wasm32")]
use wifi_densepose_wasm_edge::{
host_get_phase, host_get_amplitude, host_get_variance,
host_get_presence, host_get_motion_energy,
host_emit_event, host_log,
exo_ghost_hunter::GhostHunterDetector,
};
#[cfg(target_arch = "wasm32")]
static mut DETECTOR: GhostHunterDetector = GhostHunterDetector::new();
// ── Helpers ────────────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
fn log_str(s: &str) {
unsafe { host_log(s.as_ptr() as i32, s.len() as i32) }
}
#[cfg(target_arch = "wasm32")]
fn emit(event_type: i32, value: f32) {
unsafe { host_emit_event(event_type, value) }
}
// ── WASM entry points (exported to host) ───────────────────────────────────
/// Called once when the module is loaded onto the ESP32.
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_init() {
log_str("ghost-hunter v1.0: anomaly detector active");
}
/// Called per CSI frame (~20 Hz) by the WASM3 runtime.
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_frame(n_subcarriers: i32) {
let n_sc = if n_subcarriers < 0 { 0 } else { n_subcarriers as usize };
let max_sc = if n_sc > 32 { 32 } else { n_sc };
if max_sc < 8 {
return;
}
// Read CSI data from host
let mut phases = [0.0f32; 32];
let mut amplitudes = [0.0f32; 32];
let mut variances = [0.0f32; 32];
for i in 0..max_sc {
unsafe {
phases[i] = host_get_phase(i as i32);
amplitudes[i] = host_get_amplitude(i as i32);
variances[i] = host_get_variance(i as i32);
}
}
let presence = unsafe { host_get_presence() };
let motion_energy = unsafe { host_get_motion_energy() };
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
let events = detector.process_frame(
&phases[..max_sc],
&amplitudes[..max_sc],
&variances[..max_sc],
presence,
motion_energy,
);
for &(event_id, value) in events {
emit(event_id, value);
}
}
/// Called at configurable interval (default 1 second).
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_timer() {
let detector = unsafe { &*core::ptr::addr_of!(DETECTOR) };
let energy = detector.anomaly_energy();
if energy > 0.001 {
emit(650, energy);
}
}
// ── Non-WASM main (for native host builds) ─────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
fn main() {
println!("Ghost Hunter WASM module");
println!("Build: cargo build --bin ghost_hunter --target wasm32-unknown-unknown --release");
println!("Upload: POST the .wasm to http://<esp32-ip>/api/wasm/upload");
}

View file

@ -0,0 +1,812 @@
//! Happiness score from WiFi CSI physiological proxies -- ADR-041 exotic module.
//!
//! # Algorithm
//!
//! Combines six physiological proxies extracted from CSI into a composite
//! happiness score [0, 1]:
//!
//! 1. **Gait speed** -- Doppler proxy from phase rate-of-change. Happy people
//! walk approximately 12% faster than neutral baseline.
//!
//! 2. **Stride regularity** -- Variance of step intervals from successive phase
//! differences. Regular strides correlate with confidence and positive affect.
//!
//! 3. **Movement fluidity** -- Smoothness of phase trajectory (second derivative).
//! Jerky motion indicates anxiety; smooth motion indicates relaxation.
//!
//! 4. **Breathing calm** -- Inverse of breathing rate, extracted from 0.15-0.5 Hz
//! phase oscillation. Slow, deep breathing correlates with positive mood.
//!
//! 5. **Posture score** -- Amplitude spread across subcarrier groups. Upright
//! posture scatters signal across more subcarriers than slouched.
//!
//! 6. **Dwell time** -- Fraction of recent frames with presence in the sensing
//! zone. Longer dwell in social spaces correlates with engagement.
//!
//! The composite happiness score is a weighted sum of these six features,
//! EMA-smoothed for temporal stability.
//!
//! An 8-dimensional "happiness vector" is also produced for ingestion into a
//! Cognitum Seed vector store (dim=8).
//!
//! # Events (690-694: Exotic / Research)
//!
//! - `HAPPINESS_SCORE` (690): Composite happiness [0.0 = sad, 0.5 = neutral, 1.0 = happy].
//! - `GAIT_ENERGY` (691): Normalized gait speed/stride score [0, 1].
//! - `AFFECT_VALENCE` (692): Emotional valence from breathing + motion [0, 1].
//! - `SOCIAL_ENERGY` (693): Group animation/interaction level [0, 1].
//! - `TRANSIT_DIRECTION` (694): 1.0 = entering, 0.0 = exiting (from motion trend).
//!
//! # Budget
//!
//! H (heavy, < 10 ms) -- rolling statistics + weighted scoring.
use crate::vendor_common::{CircularBuffer, Ema, WelfordStats};
use libm::fabsf;
// ── Constants ────────────────────────────────────────────────────────────────
/// Rolling window for phase rate-of-change (gait speed proxy).
/// ESP32: 16 frames at 20 Hz = 0.8s — sufficient for step detection.
const PHASE_ROC_LEN: usize = 16;
/// Rolling window for step interval detection.
const STEP_INTERVAL_LEN: usize = 16;
/// Rolling window for movement fluidity (second derivative of phase).
/// ESP32: 16 frames captures 2-3 stride cycles at walking cadence.
const FLUIDITY_BUF_LEN: usize = 16;
/// Rolling window for breathing rate history.
/// ESP32: 16 samples at 1 Hz timer rate = 16 seconds of breathing data.
const BREATH_HIST_LEN: usize = 16;
/// Rolling window for amplitude spread (posture).
/// ESP32: 8 samples is enough for posture averaging.
const AMP_SPREAD_LEN: usize = 8;
/// Rolling window for presence/dwell tracking.
/// ESP32: 32 frames at 20 Hz = 1.6s dwell window (was 3.2s).
const DWELL_BUF_LEN: usize = 32;
/// Rolling window for motion energy trend (transit direction).
/// ESP32: 16 frames gives clear entering/exiting gradient.
const MOTION_TREND_LEN: usize = 16;
/// EMA smoothing for happiness output.
const HAPPINESS_ALPHA: f32 = 0.10;
/// EMA smoothing for gait speed.
const GAIT_ALPHA: f32 = 0.12;
/// EMA smoothing for fluidity.
const FLUIDITY_ALPHA: f32 = 0.12;
/// EMA smoothing for social energy.
const SOCIAL_ALPHA: f32 = 0.10;
/// Minimum frames before emitting events.
const MIN_WARMUP: u32 = 20;
/// Maximum subcarriers from host API.
/// ESP32 CSI provides up to 52 subcarriers; host caps at 32.
const MAX_SC: usize = 32;
/// Event emission decimation: emit full event set every Nth frame.
/// At 20 Hz, N=4 means events at 5 Hz — reduces UDP packet rate by 75%.
const EVENT_DECIMATION: u32 = 4;
/// Baseline gait speed (phase rate-of-change, arbitrary units).
/// Happy gait is ~12% above this.
const BASELINE_GAIT_SPEED: f32 = 0.5;
/// Maximum expected gait speed for normalization.
const MAX_GAIT_SPEED: f32 = 2.0;
/// Calm breathing range: 6-14 BPM (slow = calm = happier).
const CALM_BREATH_LOW: f32 = 6.0;
const CALM_BREATH_HIGH: f32 = 14.0;
/// Stressed breathing threshold.
const STRESS_BREATH_THRESH: f32 = 22.0;
// ── Weights for composite happiness score ────────────────────────────────────
const W_GAIT_SPEED: f32 = 0.25;
const W_STRIDE_REG: f32 = 0.15;
const W_FLUIDITY: f32 = 0.20;
const W_BREATH_CALM: f32 = 0.20;
const W_POSTURE: f32 = 0.10;
const W_DWELL: f32 = 0.10;
// ── Event IDs (690-694: Exotic) ──────────────────────────────────────────────
pub const EVENT_HAPPINESS_SCORE: i32 = 690;
pub const EVENT_GAIT_ENERGY: i32 = 691;
pub const EVENT_AFFECT_VALENCE: i32 = 692;
pub const EVENT_SOCIAL_ENERGY: i32 = 693;
pub const EVENT_TRANSIT_DIRECTION: i32 = 694;
/// Dimension of the happiness vector for Cognitum Seed ingestion.
pub const HAPPINESS_VECTOR_DIM: usize = 8;
// ── Happiness Score Detector ─────────────────────────────────────────────────
/// Computes a composite happiness score from WiFi CSI physiological proxies.
///
/// Outputs a scalar happiness score [0, 1] and an 8-dim happiness vector
/// suitable for ingestion into a Cognitum Seed vector store.
pub struct HappinessScoreDetector {
/// Phase rate-of-change history (gait speed proxy).
phase_roc: CircularBuffer<PHASE_ROC_LEN>,
/// Step interval variance tracking.
step_stats: WelfordStats,
/// Movement fluidity buffer (phase second derivative).
fluidity_buf: CircularBuffer<FLUIDITY_BUF_LEN>,
/// Breathing rate history.
breath_hist: CircularBuffer<BREATH_HIST_LEN>,
/// Amplitude spread history (posture proxy).
amp_spread_hist: CircularBuffer<AMP_SPREAD_LEN>,
/// Dwell buffer: 1.0 if presence, 0.0 if not.
dwell_buf: CircularBuffer<DWELL_BUF_LEN>,
/// Motion energy trend buffer (for transit direction).
motion_trend: CircularBuffer<MOTION_TREND_LEN>,
/// EMA-smoothed happiness score.
happiness_ema: Ema,
/// EMA-smoothed gait energy.
gait_ema: Ema,
/// EMA-smoothed fluidity.
fluidity_ema: Ema,
/// EMA-smoothed social energy.
social_ema: Ema,
/// Previous frame mean phase (for rate-of-change).
prev_mean_phase: f32,
/// Previous phase rate-of-change (for second derivative).
prev_phase_roc: f32,
/// Current happiness score [0, 1].
happiness: f32,
/// 8-dim happiness vector for Cognitum Seed ingestion.
///
/// Layout:
/// [0] = happiness_score
/// [1] = gait_speed_norm
/// [2] = stride_regularity
/// [3] = movement_fluidity
/// [4] = breathing_calm
/// [5] = posture_score
/// [6] = dwell_factor
/// [7] = social_energy
pub happiness_vector: [f32; HAPPINESS_VECTOR_DIM],
/// Total frames processed.
frame_count: u32,
}
impl HappinessScoreDetector {
pub const fn new() -> Self {
Self {
phase_roc: CircularBuffer::new(),
step_stats: WelfordStats::new(),
fluidity_buf: CircularBuffer::new(),
breath_hist: CircularBuffer::new(),
amp_spread_hist: CircularBuffer::new(),
dwell_buf: CircularBuffer::new(),
motion_trend: CircularBuffer::new(),
happiness_ema: Ema::new(HAPPINESS_ALPHA),
gait_ema: Ema::new(GAIT_ALPHA),
fluidity_ema: Ema::new(FLUIDITY_ALPHA),
social_ema: Ema::new(SOCIAL_ALPHA),
prev_mean_phase: 0.0,
prev_phase_roc: 0.0,
happiness: 0.5,
happiness_vector: [0.0; HAPPINESS_VECTOR_DIM],
frame_count: 0,
}
}
/// Process one CSI frame.
///
/// # Arguments
/// - `phases` -- subcarrier phase values.
/// - `amplitudes` -- subcarrier amplitude values.
/// - `variance` -- subcarrier phase variance values.
/// - `presence` -- 1 if person present, 0 if not.
/// - `motion_energy` -- host-reported motion energy.
/// - `breathing_bpm` -- breathing rate from Tier 2 DSP.
/// - `heart_rate_bpm` -- heart rate from Tier 2 DSP.
///
/// Returns events as `(event_id, value)` pairs.
pub fn process_frame(
&mut self,
phases: &[f32],
amplitudes: &[f32],
variance: &[f32],
presence: i32,
motion_energy: f32,
breathing_bpm: f32,
heart_rate_bpm: f32,
) -> &[(i32, f32)] {
static mut EVENTS: [(i32, f32); 5] = [(0, 0.0); 5];
let mut n_ev = 0usize;
self.frame_count += 1;
let present = presence > 0;
// ── Update dwell buffer ──
self.dwell_buf.push(if present { 1.0 } else { 0.0 });
// ── Update motion trend ──
self.motion_trend.push(motion_energy);
// If nobody is present, emit nothing.
if !present {
return &[];
}
// ── 1. Gait speed: phase rate-of-change ──
let mean_phase = mean_slice(phases);
let phase_roc = fabsf(mean_phase - self.prev_mean_phase);
self.phase_roc.push(phase_roc);
self.prev_mean_phase = mean_phase;
// ── 2. Stride regularity: step interval variance from successive diffs ──
// Use variance across subcarriers as a step-impact proxy.
let var_mean = mean_slice(variance);
self.step_stats.update(var_mean);
// ── 3. Movement fluidity: second derivative of phase ──
let phase_accel = fabsf(phase_roc - self.prev_phase_roc);
self.fluidity_buf.push(phase_accel);
self.prev_phase_roc = phase_roc;
// ── 4. Breathing calm ──
self.breath_hist.push(breathing_bpm);
// ── 5. Posture: amplitude spread across subcarrier groups ──
let amp_spread = compute_amplitude_spread(amplitudes);
self.amp_spread_hist.push(amp_spread);
// ── Warmup period ──
if self.frame_count < MIN_WARMUP {
return &[];
}
// ── Feature extraction ──
// Feature 1: Gait speed score [0, 1].
let gait_speed = self.compute_gait_speed();
let gait_speed_norm = clamp01(gait_speed / MAX_GAIT_SPEED);
let gait_score = clamp01(self.gait_ema.update(gait_speed_norm));
// Feature 2: Stride regularity [0, 1] (low CV = regular = higher score).
let stride_regularity = self.compute_stride_regularity();
// Feature 3: Movement fluidity [0, 1] (low jerk = fluid = higher score).
let fluidity_raw = self.compute_fluidity();
let fluidity = clamp01(self.fluidity_ema.update(fluidity_raw));
// Feature 4: Breathing calm [0, 1] (slow breathing = calm = higher score).
let breath_calm = self.compute_breath_calm(breathing_bpm);
// Feature 5: Posture score [0, 1] (wide spread = upright = higher score).
let posture_score = self.compute_posture_score();
// Feature 6: Dwell factor [0, 1] (fraction of recent frames with presence).
let dwell_factor = self.compute_dwell_factor();
// ── Composite happiness score ──
let raw_happiness = W_GAIT_SPEED * gait_score
+ W_STRIDE_REG * stride_regularity
+ W_FLUIDITY * fluidity
+ W_BREATH_CALM * breath_calm
+ W_POSTURE * posture_score
+ W_DWELL * dwell_factor;
self.happiness = clamp01(self.happiness_ema.update(raw_happiness));
// ── Derived outputs ──
// Gait energy: combination of gait speed + stride regularity.
let gait_energy = clamp01(0.6 * gait_score + 0.4 * stride_regularity);
// Affect valence: breathing calm + fluidity (emotional valence).
let affect_valence = clamp01(0.5 * breath_calm + 0.3 * fluidity + 0.2 * posture_score);
// Social energy: motion energy + dwell + heart rate proxy.
let hr_factor = clamp01((heart_rate_bpm - 60.0) / 60.0);
let raw_social = 0.4 * clamp01(motion_energy) + 0.3 * dwell_factor + 0.3 * hr_factor;
let social_energy = clamp01(self.social_ema.update(raw_social));
// Transit direction: motion energy trend (increasing = entering, decreasing = exiting).
let transit = self.compute_transit_direction();
// ── Update happiness vector ──
self.happiness_vector[0] = self.happiness;
self.happiness_vector[1] = gait_score;
self.happiness_vector[2] = stride_regularity;
self.happiness_vector[3] = fluidity;
self.happiness_vector[4] = breath_calm;
self.happiness_vector[5] = posture_score;
self.happiness_vector[6] = dwell_factor;
self.happiness_vector[7] = social_energy;
// ── Emit events (decimated for ESP32 bandwidth) ──
// Always emit happiness score; other events only every Nth frame.
unsafe {
EVENTS[n_ev] = (EVENT_HAPPINESS_SCORE, self.happiness);
}
n_ev += 1;
if self.frame_count % EVENT_DECIMATION == 0 {
unsafe {
EVENTS[n_ev] = (EVENT_GAIT_ENERGY, gait_energy);
}
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_AFFECT_VALENCE, affect_valence);
}
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_SOCIAL_ENERGY, social_energy);
}
n_ev += 1;
unsafe {
EVENTS[n_ev] = (EVENT_TRANSIT_DIRECTION, transit);
}
n_ev += 1;
}
unsafe { &EVENTS[..n_ev] }
}
/// Average phase rate-of-change over the rolling window.
fn compute_gait_speed(&self) -> f32 {
let n = self.phase_roc.len();
if n == 0 {
return 0.0;
}
let mut sum = 0.0f32;
for i in 0..n {
sum += self.phase_roc.get(i);
}
sum / n as f32
}
/// Stride regularity: inverse of step interval CV, mapped to [0, 1].
/// Low CV (regular) -> high score.
fn compute_stride_regularity(&self) -> f32 {
if self.step_stats.count() < 4 {
return 0.5;
}
let mean = self.step_stats.mean();
if mean < 1e-6 {
return 0.5;
}
let cv = self.step_stats.std_dev() / mean;
// CV of 0 -> score 1.0, CV of 1.0 -> score 0.0.
clamp01(1.0 - cv)
}
/// Movement fluidity: inverse of mean phase acceleration, mapped to [0, 1].
/// Low jerk -> high fluidity.
fn compute_fluidity(&self) -> f32 {
let n = self.fluidity_buf.len();
if n == 0 {
return 0.5;
}
let mut sum = 0.0f32;
for i in 0..n {
sum += self.fluidity_buf.get(i);
}
let mean_accel = sum / n as f32;
// Mean acceleration of 0 -> fluidity 1.0, > 1.0 -> fluidity 0.0.
clamp01(1.0 - mean_accel)
}
/// Breathing calm score [0, 1].
/// Slow breathing (6-14 BPM) -> high calm, fast breathing (>22) -> low calm.
fn compute_breath_calm(&self, bpm: f32) -> f32 {
if bpm >= CALM_BREATH_LOW && bpm <= CALM_BREATH_HIGH {
return 1.0;
}
if bpm < CALM_BREATH_LOW {
// Very slow -- still fairly calm.
return 0.7;
}
// Linear ramp from calm to stressed.
let score = 1.0 - (bpm - CALM_BREATH_HIGH) / (STRESS_BREATH_THRESH - CALM_BREATH_HIGH);
clamp01(score)
}
/// Posture score [0, 1] from amplitude spread across subcarriers.
/// Wide spread = upright posture.
fn compute_posture_score(&self) -> f32 {
let n = self.amp_spread_hist.len();
if n == 0 {
return 0.5;
}
let mut sum = 0.0f32;
for i in 0..n {
sum += self.amp_spread_hist.get(i);
}
let mean_spread = sum / n as f32;
// Normalize: typical spread range is [0, 1].
clamp01(mean_spread)
}
/// Dwell factor [0, 1]: fraction of recent frames with presence.
fn compute_dwell_factor(&self) -> f32 {
let n = self.dwell_buf.len();
if n == 0 {
return 0.0;
}
let mut sum = 0.0f32;
for i in 0..n {
sum += self.dwell_buf.get(i);
}
sum / n as f32
}
/// Transit direction from motion energy trend.
/// Returns 1.0 for entering (increasing trend), 0.0 for exiting (decreasing).
fn compute_transit_direction(&self) -> f32 {
let n = self.motion_trend.len();
if n < 4 {
return 0.5;
}
// Compare recent half to older half.
let half = n / 2;
let mut old_sum = 0.0f32;
let mut new_sum = 0.0f32;
for i in 0..half {
old_sum += self.motion_trend.get(i);
}
for i in half..n {
new_sum += self.motion_trend.get(i);
}
let old_avg = old_sum / half as f32;
let new_avg = new_sum / (n - half) as f32;
// Increasing -> entering (1.0), decreasing -> exiting (0.0).
if new_avg > old_avg + 0.01 {
1.0
} else if new_avg < old_avg - 0.01 {
0.0
} else {
0.5
}
}
/// Get current happiness score [0, 1].
pub fn happiness(&self) -> f32 {
self.happiness
}
/// Get the 8-dim happiness vector.
pub fn happiness_vector(&self) -> &[f32; HAPPINESS_VECTOR_DIM] {
&self.happiness_vector
}
/// Total frames processed.
pub fn frame_count(&self) -> u32 {
self.frame_count
}
/// Reset to initial state.
pub fn reset(&mut self) {
*self = Self::new();
}
}
/// Compute mean of a slice. Returns 0.0 if empty.
/// ESP32-optimized: caps at MAX_SC to avoid processing more subcarriers
/// than the host provides, and uses `#[inline]` for WASM3 interpreter.
#[inline]
fn mean_slice(s: &[f32]) -> f32 {
let n = s.len();
if n == 0 {
return 0.0;
}
let n_use = if n > MAX_SC { MAX_SC } else { n };
let mut sum = 0.0f32;
for i in 0..n_use {
sum += s[i];
}
sum / n_use as f32
}
/// Compute amplitude spread: normalized variance across subcarriers.
/// Higher spread means signal is distributed across more subcarriers (upright posture).
/// ESP32-optimized: uses variance/mean^2 (CV^2) to avoid sqrtf.
#[inline]
fn compute_amplitude_spread(amplitudes: &[f32]) -> f32 {
let n = amplitudes.len();
if n < 2 {
return 0.0;
}
let n_use = if n > MAX_SC { MAX_SC } else { n };
// Single-pass mean + variance (Welford online, unrolled for speed).
let mut sum = 0.0f32;
for i in 0..n_use {
sum += amplitudes[i];
}
let mean = sum / n_use as f32;
if mean < 1e-6 {
return 0.0;
}
let mut var_sum = 0.0f32;
for i in 0..n_use {
let d = amplitudes[i] - mean;
var_sum += d * d;
}
// CV^2 = variance / mean^2 — avoids sqrtf on ESP32.
// Typical CV range [0, 2] -> CV^2 range [0, 4].
// Map CV^2 to [0, 1] with saturating scale at 1.0.
let cv_sq = var_sum / (n_use as f32 * mean * mean);
clamp01(cv_sq)
}
/// Clamp a value to [0, 1].
#[inline(always)]
fn clamp01(x: f32) -> f32 {
if x < 0.0 {
0.0
} else if x > 1.0 {
1.0
} else {
x
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use libm::fabsf;
/// Helper: feed N frames with presence and reasonable CSI data.
fn feed_frames(
det: &mut HappinessScoreDetector,
n: u32,
phases: &[f32],
amplitudes: &[f32],
variance: &[f32],
presence: i32,
motion_energy: f32,
breathing_bpm: f32,
heart_rate_bpm: f32,
) {
for _ in 0..n {
det.process_frame(
phases,
amplitudes,
variance,
presence,
motion_energy,
breathing_bpm,
heart_rate_bpm,
);
}
}
#[test]
fn test_const_new() {
let det = HappinessScoreDetector::new();
assert_eq!(det.frame_count(), 0);
assert!(fabsf(det.happiness() - 0.5) < 1e-6);
assert_eq!(det.happiness_vector().len(), HAPPINESS_VECTOR_DIM);
}
#[test]
fn test_no_presence_no_score() {
let mut det = HappinessScoreDetector::new();
let phases = [0.1, 0.2, 0.3, 0.4];
let amps = [1.0, 1.0, 1.0, 1.0];
let var = [0.1, 0.1, 0.1, 0.1];
// Feed 100 frames with no presence.
for _ in 0..100 {
let events = det.process_frame(&phases, &amps, &var, 0, 0.5, 14.0, 70.0);
assert!(events.is_empty(), "should not emit events without presence");
}
}
#[test]
fn test_happy_gait() {
let mut det = HappinessScoreDetector::new();
// Simulate happy gait: fast phase changes (high gait speed), regular variance,
// smooth trajectory, calm breathing, good posture.
let amps = [1.0, 0.8, 1.2, 0.9, 1.1, 0.7, 1.3, 0.85];
let var = [0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3];
for i in 0..200u32 {
// Steadily increasing phase = fast gait (0.8 rad/frame is brisk walking).
let phase_val = (i as f32) * 0.8;
let phases = [phase_val; 8];
det.process_frame(&phases, &amps, &var, 1, 0.6, 10.0, 72.0);
}
// Gait energy should be moderate-to-high due to consistent phase changes.
let vec = det.happiness_vector();
let gait_score = vec[1];
assert!(
gait_score > 0.2,
"fast regular gait should yield moderate+ gait score, got {}",
gait_score
);
}
#[test]
fn test_calm_breathing() {
let mut det = HappinessScoreDetector::new();
let phases = [0.1, 0.2, 0.15, 0.18];
let amps = [1.0, 1.0, 1.0, 1.0];
let var = [0.2, 0.2, 0.2, 0.2];
// Feed with calm breathing (10 BPM, in calm range).
feed_frames(&mut det, 200, &phases, &amps, &var, 1, 0.3, 10.0, 68.0);
let vec = det.happiness_vector();
let breath_calm = vec[4];
assert!(
breath_calm > 0.7,
"slow calm breathing should yield high calm score, got {}",
breath_calm
);
}
#[test]
fn test_score_bounds() {
let mut det = HappinessScoreDetector::new();
// Feed extreme values.
let phases = [10.0, -10.0, 5.0, -5.0];
let amps = [100.0, 0.0, 50.0, 200.0];
let var = [5.0, 5.0, 5.0, 5.0];
feed_frames(&mut det, 100, &phases, &amps, &var, 1, 5.0, 40.0, 150.0);
assert!(
det.happiness() >= 0.0 && det.happiness() <= 1.0,
"happiness must be in [0,1], got {}",
det.happiness()
);
let vec = det.happiness_vector();
for (i, &v) in vec.iter().enumerate() {
assert!(
v >= 0.0 && v <= 1.0,
"happiness_vector[{}] must be in [0,1], got {}",
i,
v
);
}
}
#[test]
fn test_happiness_vector_dim() {
let det = HappinessScoreDetector::new();
assert_eq!(
det.happiness_vector().len(),
8,
"happiness vector must be exactly 8 dimensions"
);
assert_eq!(HAPPINESS_VECTOR_DIM, 8);
}
#[test]
fn test_event_ids_emitted() {
let mut det = HappinessScoreDetector::new();
let phases = [0.1, 0.2, 0.3, 0.4];
let amps = [1.0, 1.0, 1.0, 1.0];
let var = [0.1, 0.1, 0.1, 0.1];
// Past warmup — feed enough frames so next one lands on decimation boundary.
// EVENT_DECIMATION=4, MIN_WARMUP=20, so frame 24 is first full-emit after warmup.
// We need frame_count % EVENT_DECIMATION == 0 for full event set.
let warmup_frames = MIN_WARMUP + (EVENT_DECIMATION - (MIN_WARMUP % EVENT_DECIMATION)) % EVENT_DECIMATION;
for _ in 0..warmup_frames {
det.process_frame(&phases, &amps, &var, 1, 0.3, 14.0, 70.0);
}
// Next frame should land on decimation boundary and emit all 5 events.
// Feed (EVENT_DECIMATION - 1) more frames that emit only happiness score.
for _ in 0..EVENT_DECIMATION - 1 {
det.process_frame(&phases, &amps, &var, 1, 0.3, 14.0, 70.0);
}
let events = det.process_frame(&phases, &amps, &var, 1, 0.3, 14.0, 70.0);
// On non-decimation frames: 1 event (happiness only).
// On decimation frames: 5 events (all).
// Check that we get either 1 or 5; full event set when on boundary.
assert!(events.len() == 1 || events.len() == 5,
"should emit 1 or 5 events, got {}", events.len());
assert_eq!(events[0].0, EVENT_HAPPINESS_SCORE);
// Verify all 5 on a decimation frame.
if events.len() == 5 {
assert_eq!(events[1].0, EVENT_GAIT_ENERGY);
assert_eq!(events[2].0, EVENT_AFFECT_VALENCE);
assert_eq!(events[3].0, EVENT_SOCIAL_ENERGY);
assert_eq!(events[4].0, EVENT_TRANSIT_DIRECTION);
}
}
#[test]
fn test_clamp01() {
assert!(fabsf(clamp01(-1.0)) < 1e-6);
assert!(fabsf(clamp01(0.5) - 0.5) < 1e-6);
assert!(fabsf(clamp01(2.0) - 1.0) < 1e-6);
}
#[test]
fn test_transit_direction() {
let mut det = HappinessScoreDetector::new();
let phases = [0.1, 0.2, 0.3, 0.4];
let amps = [1.0, 1.0, 1.0, 1.0];
let var = [0.1, 0.1, 0.1, 0.1];
// Feed increasing motion energy -> entering.
// Use enough frames so we land on a decimation boundary with transit event.
for i in 0..64u32 {
let energy = (i as f32) * 0.02;
det.process_frame(&phases, &amps, &var, 1, energy, 14.0, 70.0);
}
// Collect events across EVENT_DECIMATION frames to catch the transit event.
let mut found_transit = false;
let mut transit_val = 0.0f32;
for _ in 0..EVENT_DECIMATION {
let events = det.process_frame(&phases, &amps, &var, 1, 1.5, 14.0, 70.0);
if let Some(ev) = events.iter().find(|e| e.0 == EVENT_TRANSIT_DIRECTION) {
found_transit = true;
transit_val = ev.1;
}
}
assert!(found_transit, "should emit transit direction within decimation window");
assert!(
transit_val >= 0.5,
"increasing motion should indicate entering, got {}",
transit_val
);
}
#[test]
fn test_reset() {
let mut det = HappinessScoreDetector::new();
let phases = [0.1, 0.2, 0.3, 0.4];
let amps = [1.0, 1.0, 1.0, 1.0];
let var = [0.1, 0.1, 0.1, 0.1];
feed_frames(&mut det, 100, &phases, &amps, &var, 1, 0.3, 14.0, 70.0);
assert!(det.frame_count() > 0);
det.reset();
assert_eq!(det.frame_count(), 0);
assert!(fabsf(det.happiness() - 0.5) < 1e-6);
}
#[test]
fn test_amplitude_spread() {
// Uniform amplitudes -> low spread.
let uniform = [1.0, 1.0, 1.0, 1.0];
let s1 = compute_amplitude_spread(&uniform);
assert!(s1 < 0.01, "uniform amps should have near-zero spread, got {}", s1);
// Varied amplitudes -> higher spread.
let varied = [0.1, 2.0, 0.5, 3.0, 0.2, 1.5];
let s2 = compute_amplitude_spread(&varied);
assert!(s2 > 0.3, "varied amps should have significant spread, got {}", s2);
}
}

View file

@ -139,6 +139,7 @@ pub mod exo_plant_growth;
pub mod exo_ghost_hunter;
pub mod exo_rain_detect;
pub mod exo_breathing_sync;
pub mod exo_happiness_score;
// ── Host API FFI bindings ────────────────────────────────────────────────────
@ -382,6 +383,13 @@ pub mod event_types {
pub const HIDDEN_PRESENCE: i32 = 652;
pub const ENVIRONMENTAL_DRIFT: i32 = 653;
// exo_happiness_score (690-694)
pub const HAPPINESS_SCORE: i32 = 690;
pub const GAIT_ENERGY: i32 = 691;
pub const AFFECT_VALENCE: i32 = 692;
pub const SOCIAL_ENERGY: i32 = 693;
pub const TRANSIT_DIRECTION: i32 = 694;
// exo_rain_detect (660-662)
pub const RAIN_ONSET: i32 = 660;
pub const RAIN_INTENSITY: i32 = 661;
@ -569,10 +577,15 @@ fn panic(_info: &core::panic::PanicInfo) -> ! {
// Individual modules (gesture, coherence, adversarial) can define their own
// on_init/on_frame/on_timer. This default implementation demonstrates the
// combined pipeline: gesture detection + coherence monitoring + anomaly check.
//
// Gated behind the "default-pipeline" feature so that standalone module
// binaries (ghost_hunter, etc.) can define their own on_frame without
// symbol collisions.
#[cfg(target_arch = "wasm32")]
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
static mut STATE: CombinedState = CombinedState::new();
#[cfg(feature = "default-pipeline")]
struct CombinedState {
gesture: gesture::GestureDetector,
coherence: coherence::CoherenceMonitor,
@ -580,6 +593,7 @@ struct CombinedState {
frame_count: u32,
}
#[cfg(feature = "default-pipeline")]
impl CombinedState {
const fn new() -> Self {
Self {
@ -591,13 +605,13 @@ impl CombinedState {
}
}
#[cfg(target_arch = "wasm32")]
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
#[no_mangle]
pub extern "C" fn on_init() {
log_msg("wasm-edge: combined pipeline init");
}
#[cfg(target_arch = "wasm32")]
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
#[no_mangle]
pub extern "C" fn on_frame(n_subcarriers: i32) {
// M-01 fix: treat negative host values as 0 instead of wrapping to usize::MAX.
@ -634,7 +648,7 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
}
}
#[cfg(target_arch = "wasm32")]
#[cfg(all(target_arch = "wasm32", feature = "default-pipeline"))]
#[no_mangle]
pub extern "C" fn on_timer() {
// Periodic summary.