mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
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:
parent
8a84748a83
commit
a2d1739c07
21 changed files with 3067 additions and 35 deletions
234
docs/adr/ADR-065-happiness-scoring-seed-bridge.md
Normal file
234
docs/adr/ADR-065-happiness-scoring-seed-bridge.md
Normal 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).
|
||||
274
docs/adr/ADR-066-esp32-swarm-seed-coordinator.md
Normal file
274
docs/adr/ADR-066-esp32-swarm-seed-coordinator.md
Normal 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
|
||||
99
examples/happiness-vector/happiness_vector_schema.json
Normal file
99
examples/happiness-vector/happiness_vector_schema.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
examples/happiness-vector/provision_swarm.sh
Normal file
60
examples/happiness-vector/provision_swarm.sh
Normal 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 "========================================"
|
||||
260
examples/happiness-vector/seed_query.py
Normal file
260
examples/happiness-vector/seed_query.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
327
firmware/esp32-csi-node/main/swarm_bridge.c
Normal file
327
firmware/esp32-csi-node/main/swarm_bridge.c
Normal 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);
|
||||
}
|
||||
67
firmware/esp32-csi-node/main/swarm_bridge.h
Normal file
67
firmware/esp32-csi-node/main/swarm_bridge.h
Normal 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 */
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
5
firmware/esp32-hello-world/CMakeLists.txt
Normal file
5
firmware/esp32-hello-world/CMakeLists.txt
Normal 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)
|
||||
4
firmware/esp32-hello-world/main/CMakeLists.txt
Normal file
4
firmware/esp32-hello-world/main/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
idf_component_register(
|
||||
SRCS "main.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
437
firmware/esp32-hello-world/main/main.c
Normal file
437
firmware/esp32-hello-world/main/main.c
Normal 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));
|
||||
}
|
||||
}
|
||||
18
firmware/esp32-hello-world/sdkconfig.defaults
Normal file
18
firmware/esp32-hello-world/sdkconfig.defaults
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
&litudes[..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");
|
||||
}
|
||||
|
|
@ -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, &s, &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, &s, &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, &s, &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, &s, &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, &s, &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, &s, &var, 1, 0.3, 14.0, 70.0);
|
||||
}
|
||||
let events = det.process_frame(&phases, &s, &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, &s, &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, &s, &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, &s, &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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue