mirror of
https://github.com/ruvnet/RuView
synced 2026-06-16 11:23:19 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f52c5c94f6 | |||
| 6b2c97eb10 | |||
| ad013902fb | |||
| 4a48564c37 | |||
| 1fd2f68497 | |||
| e75a3acacb | |||
| cab98df34a | |||
| 79aaf2d217 | |||
| df7f9a5e02 | |||
| a5049d3b35 | |||
| 50a82165c9 | |||
| 2ba8b3b93d | |||
| 7659b0bbe2 | |||
| 75d4685d25 | |||
| 45c15b77a5 | |||
| 47223a98be | |||
| c45690ed4e | |||
| fb782e0d71 | |||
| 944076733e | |||
| a8f48a7897 | |||
| 7df316f13e | |||
| da54ea07d2 | |||
| bf4d64ad4b | |||
| 8b57a6f64c | |||
| 5fa61ba7ea | |||
| f771cf8461 | |||
| c257e9a215 | |||
| 6e76578dcf |
@@ -0,0 +1,50 @@
|
||||
name: Update vendor submodules
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update submodules to latest main
|
||||
run: git submodule update --remote --merge
|
||||
|
||||
- name: Check for changes
|
||||
id: check
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create PR with updates
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH="chore/update-submodules-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
git add vendor/
|
||||
git commit -m "chore: update vendor submodules to latest main"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update vendor submodules" \
|
||||
--body "Automated submodule update to latest upstream main." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+10
@@ -8,6 +8,16 @@ firmware/esp32-csi-node/sdkconfig.defaults
|
||||
firmware/esp32-csi-node/sdkconfig.old
|
||||
# Downloaded WASM3 source (fetched at configure time)
|
||||
firmware/esp32-csi-node/components/wasm3/wasm3-src/
|
||||
# ESP-IDF managed components (downloaded at build time)
|
||||
firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
# CSI recordings (local training data, machine-specific)
|
||||
rust-port/wifi-densepose-rs/data/recordings/
|
||||
|
||||
# NVS partition images and CSVs (contain WiFi credentials)
|
||||
nvs.bin
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
[submodule "vendor/midstream"]
|
||||
path = vendor/midstream
|
||||
url = https://github.com/ruvnet/midstream
|
||||
branch = main
|
||||
[submodule "vendor/ruvector"]
|
||||
path = vendor/ruvector
|
||||
url = https://github.com/ruvnet/ruvector
|
||||
branch = main
|
||||
[submodule "vendor/sublinear-time-solver"]
|
||||
path = vendor/sublinear-time-solver
|
||||
url = https://github.com/ruvnet/sublinear-time-solver
|
||||
branch = main
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# π RuView
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/ruview-small-gemini.jpg" alt="RuView - WiFi DensePose" width="100%">
|
||||
<a href="https://ruvnet.github.io/RuView/">
|
||||
<img src="assets/ruview-small-gemini.jpg" alt="RuView - WiFi DensePose" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**See through walls with WiFi.** No cameras. No wearables. No Internet. Just radio waves.
|
||||
@@ -57,15 +59,20 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
|----------|-------------|
|
||||
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
|
||||
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 44 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 48 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 7 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [Desktop App](rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
|
||||
---
|
||||
|
||||
|
||||
<img src="assets/screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800">
|
||||
<a href="https://ruvnet.github.io/RuView/">
|
||||
<img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800">
|
||||
</a>
|
||||
<br>
|
||||
<em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables</em>
|
||||
<br>
|
||||
<a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a>
|
||||
|
||||
> The [server](#-quick-start) is optional for visualization and aggregation — the ESP32 [runs independently](#esp32-s3-hardware-pipeline) for presence detection, vital signs, and fall alerts.
|
||||
|
||||
@@ -98,6 +105,7 @@ The system learns on its own and gets smarter over time — no hand-tuning, no l
|
||||
| 👁️ | **Cross-Viewpoint Fusion** | AI combines what each sensor sees from its own angle — fills in blind spots and depth ambiguity that no single viewpoint can resolve on its own ([ADR-031](docs/adr/ADR-031-ruview-sensing-first-rf-mode.md)) |
|
||||
| 🔮 | **Signal-Line Protocol** | A 6-stage processing pipeline transforms raw WiFi signals into structured body representations — from signal cleanup through graph-based spatial reasoning to final pose output ([ADR-033](docs/adr/ADR-033-crv-signal-line-sensing-integration.md)) |
|
||||
| 🔒 | **QUIC Mesh Security** | All sensor-to-sensor communication is encrypted end-to-end with tamper detection, replay protection, and seamless reconnection if a node moves or drops offline ([ADR-032](docs/adr/ADR-032-multistatic-mesh-security-hardening.md)) |
|
||||
| 🎯 | **Adaptive Classifier** | Records labeled CSI sessions, trains a 15-feature logistic regression model in pure Rust, and learns your room's unique signal characteristics — replaces hand-tuned thresholds with data-driven classification ([ADR-048](docs/adr/ADR-048-adaptive-csi-classifier.md)) |
|
||||
|
||||
### Performance & Deployment
|
||||
|
||||
@@ -110,6 +118,8 @@ Fast enough for real-time use, small enough for edge devices, simple enough for
|
||||
| 🐳 | **One-Command Setup** | `docker pull ruvnet/wifi-densepose:latest` — live sensing in 30 seconds, no toolchain needed (amd64 + arm64 / Apple Silicon) |
|
||||
| 📡 | **Fully Local** | Runs completely on a $9 ESP32 — no internet connection, no cloud account, no recurring fees. Detects presence, vital signs, and falls on-device with instant response |
|
||||
| 📦 | **Portable Models** | Trained models package into a single `.rvf` file — runs on edge, cloud, or browser (WASM) |
|
||||
| 🔭 | **Observatory Visualization** | Cinematic Three.js dashboard with 5 holographic panels — subcarrier manifold, vital signs oracle, presence heatmap, phase constellation, convergence engine — all driven by live or demo CSI data ([ADR-047](docs/adr/ADR-047-psychohistory-observatory-visualization.md)) |
|
||||
| 📟 | **AMOLED Display** | ESP32-S3 boards with built-in AMOLED screens show real-time presence, vital signs, and room status directly on the sensor — no phone or PC needed ([ADR-045](docs/adr/ADR-045-amoled-display-support.md)) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,110 @@
|
||||
# ADR-045: AMOLED Display Support for ESP32-S3 CSI Node
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The ESP32-S3 board (LilyGO T-Display-S3 AMOLED) has an integrated RM67162 QSPI AMOLED display (536x240) and 8MB octal PSRAM that were unused by the CSI firmware. Users want real-time on-device visualization of CSI statistics, vital signs, and system health without relying on an external server.
|
||||
|
||||
### Constraints
|
||||
|
||||
- Binary was 947 KB in a 1 MB partition — needed 8MB flash + custom partition table
|
||||
- SPIRAM was disabled in sdkconfig despite hardware having 8MB PSRAM
|
||||
- Core 1 is pinned to DSP (edge processing) — display must use Core 0
|
||||
- Existing CSI pipeline must not be affected
|
||||
|
||||
### Available APIs
|
||||
|
||||
Thread-safe edge APIs already exist (`edge_get_vitals()`, `edge_get_multi_person()`) — the display task only reads from these, no new synchronization needed.
|
||||
|
||||
## Decision
|
||||
|
||||
Add optional AMOLED display support with the following architecture:
|
||||
|
||||
### Hardware Abstraction Layer
|
||||
|
||||
- `display_hal.c/h`: RM67162 QSPI panel driver + CST816S capacitive touch via I2C
|
||||
- Auto-detect at boot: probe RM67162 and check SPIRAM; log warning and skip if absent
|
||||
|
||||
### UI Layer
|
||||
|
||||
- `display_ui.c/h`: LVGL 8.3 with 4 swipeable views via tileview widget
|
||||
- Dark theme (#0a0a0f) with cyan (#00d4ff) accent for three.js-like aesthetic
|
||||
- Views: Dashboard (CSI amplitude chart + stats), Vitals (breathing + HR line graphs), Presence (4x4 occupancy grid), System (CPU, heap, PSRAM, WiFi, uptime, FPS)
|
||||
|
||||
### Task Layer
|
||||
|
||||
- `display_task.c/h`: FreeRTOS task on Core 0, priority 1 (lowest)
|
||||
- LVGL pump loop at configurable FPS (default 30)
|
||||
- Double-buffered draw buffers allocated in SPIRAM
|
||||
|
||||
### Compile-Time Control
|
||||
|
||||
- `CONFIG_DISPLAY_ENABLE=y` (default): compiles display code, auto-detects hardware at boot
|
||||
- `CONFIG_DISPLAY_ENABLE=n`: zero-cost — no display code compiled
|
||||
- `CONFIG_SPIRAM_IGNORE_NOTFOUND=y`: boots fine on boards without PSRAM
|
||||
|
||||
### Flash Layout
|
||||
|
||||
8MB partition table (`partitions_display.csv`):
|
||||
- Dual OTA partitions: 2 x 2MB (supports larger binaries with LVGL)
|
||||
- SPIFFS: 1.9MB (for future font/asset storage)
|
||||
- NVS + otadata + phy: standard sizes
|
||||
|
||||
### Core/Task Layout
|
||||
|
||||
| Task | Core | Priority | Impact |
|
||||
|------|------|----------|--------|
|
||||
| WiFi/LwIP | 0 | 18-23 | unchanged |
|
||||
| OTA httpd | 0 | 5 | unchanged |
|
||||
| **display_task** | **0** | **1** | **NEW — lowest priority** |
|
||||
| edge_task (DSP) | 1 | 5 | unchanged |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- LVGL ~8.3 (via ESP-IDF managed components)
|
||||
- espressif/esp_lcd_touch_cst816s ^1.0
|
||||
- espressif/esp_lcd_touch ^1.0
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Real-time on-device stats without network dependency
|
||||
- Zero impact on CSI pipeline (display reads thread-safe APIs, runs at lowest priority)
|
||||
- Graceful degradation: works on boards without display or PSRAM
|
||||
- SPIRAM enabled for all boards (benefits WASM runtime too)
|
||||
- 8MB flash + dual OTA 2MB partitions give headroom for future features
|
||||
|
||||
### Negative
|
||||
|
||||
- Binary size increase (~200-300 KB with LVGL)
|
||||
- SPIRAM + 8MB flash config is specific to T-Display-S3 AMOLED boards
|
||||
- Boards with only 4MB flash need `CONFIG_DISPLAY_ENABLE=n` and the old partition table
|
||||
|
||||
### Risks
|
||||
|
||||
- RM67162 init sequence is board-specific; other AMOLED panels may need different commands
|
||||
- QSPI bus conflicts if other peripherals use SPI2_HOST (currently unused)
|
||||
|
||||
## New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main/display_hal.c/h` | RM67162 QSPI + CST816S touch HAL |
|
||||
| `main/display_ui.c/h` | LVGL 4-view UI |
|
||||
| `main/display_task.c/h` | FreeRTOS task, LVGL pump |
|
||||
| `main/lv_conf.h` | LVGL compile config |
|
||||
| `partitions_display.csv` | 8MB partition table |
|
||||
| `idf_component.yml` | Managed component deps |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sdkconfig.defaults` | 8MB flash, SPIRAM, custom partitions |
|
||||
| `main/CMakeLists.txt` | Conditional display sources + deps |
|
||||
| `main/main.c` | +1 include, +5 lines guarded init |
|
||||
| `main/Kconfig.projbuild` | "AMOLED Display" menu |
|
||||
@@ -0,0 +1,263 @@
|
||||
# ADR-046: Android TV Box / Armbian Deployment Target
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Issue [#138](https://github.com/ruvnet/wifi-densepose/issues/138) requests ESP8266 and mobile device support. The ESP8266 lacks CSI capability and sufficient resources, but the discussion revealed a compelling deployment target: **Android TV boxes** (Amlogic/Allwinner/Rockchip SoCs) running **Armbian** (Debian for ARM).
|
||||
|
||||
These devices cost $15–35, are always-on mains-powered, include 802.11ac WiFi, 2–4 GB RAM, quad-core ARM Cortex-A53/A55 CPUs, and HDMI output. They are widely available as consumer "IPTV boxes" (T95, H96 Max, X96, MXQ Pro, etc.) and can boot Armbian from SD card without modifying the factory Android installation.
|
||||
|
||||
### Current deployment model
|
||||
|
||||
```
|
||||
[ESP32-S3 nodes] --UDP CSI--> [Laptop/PC running sensing-server] --browser--> [UI]
|
||||
```
|
||||
|
||||
This requires a general-purpose computer ($300+) to run the Rust sensing server, NN inference, and web dashboard. For permanent installations (elder care, smart home, security), dedicating a laptop is impractical.
|
||||
|
||||
### Proposed deployment model
|
||||
|
||||
```
|
||||
[ESP32-S3 nodes] --UDP CSI--> [TV Box running Armbian + sensing-server] --HDMI--> [Display]
|
||||
$25, always-on, fanless
|
||||
```
|
||||
|
||||
### Future: custom WiFi firmware for standalone operation
|
||||
|
||||
Many TV box WiFi chipsets (Realtek RTL8822CS, MediaTek MT7661, Broadcom BCM43455) can potentially be patched for CSI extraction when running under Linux with custom drivers. This would eliminate the ESP32 dependency entirely for basic sensing:
|
||||
|
||||
```
|
||||
[TV Box with patched WiFi driver] --CSI extraction--> [sensing-server on same box] --HDMI--> [Display]
|
||||
$25 total, single device
|
||||
```
|
||||
|
||||
This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware for CSI). Phase 2 is speculative and requires per-chipset R&D.
|
||||
|
||||
## Decision
|
||||
|
||||
### Phase 1: TV Box as Aggregator (Armbian)
|
||||
|
||||
1. **Cross-compile the sensing server** for `aarch64-unknown-linux-gnu` using `cross` or Docker-based cross-compilation.
|
||||
|
||||
2. **Create an Armbian deployment package** containing:
|
||||
- Pre-built `wifi-densepose-sensing-server` binary (aarch64)
|
||||
- systemd service file for auto-start on boot
|
||||
- Kiosk-mode Chromium configuration for HDMI dashboard display
|
||||
- Network configuration for ESP32 UDP reception (port 5005)
|
||||
- Optional: `hostapd` config to create a dedicated WiFi AP for the ESP32 mesh
|
||||
|
||||
3. **Define minimum hardware requirements:**
|
||||
|
||||
| Component | Minimum | Recommended |
|
||||
|-----------|---------|-------------|
|
||||
| SoC | Amlogic S905W (A53 quad) | Amlogic S905X3 (A55 quad) |
|
||||
| RAM | 2 GB | 4 GB |
|
||||
| Storage | 8 GB eMMC + 8 GB SD | 16 GB eMMC + 16 GB SD |
|
||||
| WiFi | 802.11n 2.4 GHz | 802.11ac dual-band |
|
||||
| Ethernet | 100 Mbps | Gigabit |
|
||||
| USB | 1x USB 2.0 | 2x USB 3.0 |
|
||||
| HDMI | 1.4 | 2.0 |
|
||||
|
||||
4. **Tested reference devices** (initial target list):
|
||||
|
||||
| Device | SoC | WiFi Chip | Price | Armbian Support |
|
||||
|--------|-----|-----------|-------|-----------------|
|
||||
| T95 Max+ | S905X3 | RTL8822CS | ~$30 | Good (meson-sm1) |
|
||||
| H96 Max X3 | S905X3 | RTL8822CS | ~$35 | Good (meson-sm1) |
|
||||
| X96 Max+ | S905X3 | RTL8822CS | ~$28 | Good (meson-sm1) |
|
||||
| Tanix TX6S | H616 | MT7668 | ~$25 | Moderate (sun50i-h616) |
|
||||
|
||||
5. **New Rust compilation target** in workspace CI:
|
||||
- Add `aarch64-unknown-linux-gnu` to cross-compilation matrix
|
||||
- Binary size target: <15 MB stripped (fits easily in SD card)
|
||||
- No GPU dependency — CPU-only inference using `candle` or ONNX Runtime for ARM
|
||||
|
||||
### Phase 2: Custom WiFi Firmware for CSI Extraction (Future)
|
||||
|
||||
1. **CSI extraction feasibility by chipset:**
|
||||
|
||||
| Chipset | Driver | CSI Support | Monitor Mode | Effort |
|
||||
|---------|--------|-------------|--------------|--------|
|
||||
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
|
||||
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
|
||||
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
|
||||
|
||||
2. **CSI extraction architecture** (Linux kernel driver modification):
|
||||
|
||||
```
|
||||
[WiFi chipset firmware] → [Modified kernel driver] → [Netlink/procfs CSI export]
|
||||
↓
|
||||
[userspace CSI reader]
|
||||
↓
|
||||
[sensing-server UDP input]
|
||||
```
|
||||
|
||||
The CSI data would be reformatted into the existing ESP32 binary protocol (ADR-018 header, magic `0xC5100001`) so the sensing server treats it identically to ESP32 frames. This means zero changes to the ingestion context.
|
||||
|
||||
3. **Hybrid mode**: When the TV box has both patched WiFi CSI and ESP32 UDP input, the sensing server's multi-node architecture (already supporting multiple `node_id` values) handles both sources transparently. The TV box's own WiFi becomes an additional viewpoint in the multistatic array.
|
||||
|
||||
### Phase 3: Android Companion App (Optional)
|
||||
|
||||
For users who want mobile monitoring without Armbian:
|
||||
|
||||
1. **PWA (Progressive Web App)**: The sensing server already serves a web UI. Adding a PWA manifest with offline caching makes it installable on any Android device. No native app needed.
|
||||
|
||||
2. **Native Android app** (future): Only if PWA proves insufficient. Would use Kotlin + Jetpack Compose, consuming the existing REST API and WebSocket endpoints.
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Single-Room Deployment (Phase 1)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Room │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ ESP32-S3 │ │ ESP32-S3 │ │ ESP32-S3 │ CSI sensor mesh │
|
||||
│ │ Node 1 │ │ Node 2 │ │ Node 3 │ ($10 each) │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────┼──────────────┘ │
|
||||
│ │ UDP port 5005 │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ Android TV Box (Armbian) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────┐ │ │
|
||||
│ │ │ wifi-densepose-sensing- │ │ │
|
||||
│ │ │ server (aarch64 binary) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ • CSI ingestion (UDP) │ │ │
|
||||
│ │ │ • Feature extraction │ │ │
|
||||
│ │ │ • NN inference (CPU) │ │ │
|
||||
│ │ │ • WebSocket streaming │ │ │
|
||||
│ │ │ • REST API │ │ │
|
||||
│ │ │ • Web UI (:3000) │ │ │
|
||||
│ │ └──────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────┐ │ │
|
||||
│ │ │ Chromium Kiosk Mode │───│──→ HDMI out │
|
||||
│ │ │ (localhost:3000) │ │ to display │
|
||||
│ │ └──────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Cost: $25-35 │ │
|
||||
│ │ Power: 5-10W (USB-C or barrel) │ │
|
||||
│ │ Form: fits behind TV/monitor │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Total system cost: $55-65 (3 ESP32 nodes + 1 TV box)
|
||||
```
|
||||
|
||||
### Multi-Room Deployment
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Router │
|
||||
│ (WiFi AP) │
|
||||
└──────┬───────┘
|
||||
│ LAN
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌───────▼───────┐ ┌───▼────────┐ ┌──▼──────────┐
|
||||
│ Room A │ │ Room B │ │ Room C │
|
||||
│ TV Box + │ │ TV Box + │ │ TV Box + │
|
||||
│ 3x ESP32 │ │ 3x ESP32 │ │ 3x ESP32 │
|
||||
│ HDMI display │ │ HDMI │ │ HDMI │
|
||||
└───────────────┘ └────────────┘ └─────────────┘
|
||||
|
||||
Each room: self-contained sensing + display
|
||||
Central dashboard: aggregate all rooms via REST API
|
||||
```
|
||||
|
||||
### Standalone Mode (Phase 2 — Custom WiFi FW)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Android TV Box (Armbian) │
|
||||
│ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ Patched WiFi │ │
|
||||
│ │ Driver │ │
|
||||
│ │ (CSI extraction) │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ │ CSI frames │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ sensing-server │──→ HDMI out │
|
||||
│ │ (inference + │ │
|
||||
│ │ dashboard) │ │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
│ Single device: $25 │
|
||||
│ No ESP32 nodes needed │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **10x cost reduction** for aggregator: $25 TV box vs $300+ laptop/PC
|
||||
- **Always-on deployment**: Mains-powered, fanless, designed for 24/7 operation
|
||||
- **HDMI output**: Direct connection to TV/monitor for wall-mounted dashboards
|
||||
- **Familiar hardware**: Available globally, no specialized ordering required
|
||||
- **Armbian ecosystem**: Mature Debian-based distro with package management, systemd, SSH
|
||||
- **Path to standalone**: Custom WiFi firmware could eliminate ESP32 dependency entirely
|
||||
- **PWA for mobile**: No native app development needed for mobile monitoring
|
||||
- **Multi-room scaling**: One TV box per room, each self-contained
|
||||
|
||||
### Negative
|
||||
|
||||
- **ARM cross-compilation**: Adds CI complexity; `candle`/ONNX Runtime ARM builds need testing
|
||||
- **Armbian compatibility**: Not all TV boxes are well-supported; need a tested device list
|
||||
- **Performance uncertainty**: ARM A53 cores are ~3-5x slower than x86 for NN inference; may need model quantization (INT8) for real-time operation
|
||||
- **Phase 2 risk**: Custom WiFi firmware is chipset-specific, may require kernel patches per driver version, and CSI quality varies by chipset
|
||||
- **Support burden**: Different hardware = more configurations to support
|
||||
- **No GPU**: TV boxes lack discrete GPU; inference is CPU-only (but our models are small enough)
|
||||
|
||||
### Neutral
|
||||
|
||||
- **No changes to existing ESP32 firmware** — TV box receives the same UDP frames
|
||||
- **No changes to sensing server protocol** — Phase 2 CSI output uses same binary format
|
||||
- **Existing web UI works as-is** — Chromium kiosk mode or any browser on the LAN
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 (2-3 weeks)
|
||||
|
||||
1. Add `aarch64-unknown-linux-gnu` cross-compilation target using `cross`
|
||||
2. Build and test sensing-server binary on reference TV box (T95 Max+ / S905X3)
|
||||
3. Create systemd service + Armbian deployment script
|
||||
4. Benchmark: measure inference latency, memory usage, thermal throttling
|
||||
5. Create `docs/deployment/armbian-tv-box.md` setup guide
|
||||
6. Add HDMI kiosk mode configuration (Chromium autostart)
|
||||
|
||||
### Phase 2 (4-8 weeks, R&D)
|
||||
|
||||
1. Acquire TV box with BCM43455 (proven Nexmon CSI support)
|
||||
2. Build Armbian with Nexmon CSI patches for BCM43455
|
||||
3. Write userspace CSI reader → ESP32 binary protocol converter
|
||||
4. Test CSI quality comparison: ESP32 vs BCM43455
|
||||
5. If viable: add RTL8822CS CSI extraction via rtw88 driver modification
|
||||
|
||||
### Phase 3 (1 week)
|
||||
|
||||
1. Add PWA manifest to sensing server web UI
|
||||
2. Test on Android Chrome, iOS Safari
|
||||
3. Add service worker for offline dashboard caching
|
||||
|
||||
## References
|
||||
|
||||
- [Nexmon CSI](https://github.com/seemoo-lab/nexmon_csi) — Broadcom WiFi CSI extraction (BCM43455, BCM4339, BCM4358)
|
||||
- [Armbian](https://www.armbian.com/) — Debian/Ubuntu for ARM SBCs and TV boxes
|
||||
- [rtw88 driver](https://github.com/torvalds/linux/tree/master/drivers/net/wireless/realtek/rtw88) — Mainline Linux driver for Realtek 802.11ac chips
|
||||
- [mt76 driver](https://github.com/torvalds/linux/tree/master/drivers/net/wireless/mediatek/mt76) — Mainline Linux driver for MediaTek WiFi chips
|
||||
- [cross](https://github.com/cross-rs/cross) — Zero-setup Rust cross-compilation
|
||||
- [ADR-018: ESP32 CSI Binary Protocol](ADR-018-dev-implementation.md) — Binary frame format reused for Phase 2 CSI extraction
|
||||
- [ADR-039: Edge Intelligence](ADR-039-esp32-edge-intelligence.md) — On-device processing tiers
|
||||
- [ADR-043: Sensing Server](ADR-043-sensing-server-ui-api-completion.md) — Single-binary deployment target
|
||||
@@ -0,0 +1,152 @@
|
||||
# ADR-047: RuView Observatory — Immersive Three.js WiFi Sensing Visualization
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (Implemented)
|
||||
|
||||
## Date
|
||||
|
||||
2026-03-04
|
||||
|
||||
## Context
|
||||
|
||||
The project has a functional tabbed dashboard UI (`ui/index.html`) with existing Three.js components (body model, gaussian splats, signal visualization, environment). While effective for monitoring, it lacks a cinematic, immersive visualization suitable for demonstrations and stakeholder presentations.
|
||||
|
||||
We need an immersive Three.js room-based visualization with practical WiFi sensing data overlays — human wireframe pose, dot-matrix body mass, vital signs HUD, signal field heatmap — powered by ESP32 CSI data (demo mode with live WebSocket path).
|
||||
|
||||
## Decision
|
||||
|
||||
### Standalone Page Architecture
|
||||
|
||||
`ui/observatory.html` is a standalone full-screen entry point, separate from the tabbed dashboard. Linked via "Observatory" nav tab in `ui/index.html`. No build step — vanilla JS modules with Three.js r160 via CDN importmap.
|
||||
|
||||
### Room-Based Visualization
|
||||
|
||||
Instead of abstract holographic panels, the observatory renders a practical room scene with:
|
||||
|
||||
| Element | Implementation | Data Source |
|
||||
|---------|---------------|-------------|
|
||||
| Human wireframe | COCO 17-keypoint skeleton, CylinderGeometry tube bones, SphereGeometry joints with glow halos | `persons[].position`, `vital_signs.breathing_rate_bpm` |
|
||||
| Dot-matrix mist | 800 Points with per-particle alpha ShaderMaterial, body-shaped distribution | `persons[].position`, `persons[].motion_score` |
|
||||
| Particle trail | 200 Points with age-based fade, emitted from moving person | `persons[].position`, `persons[].motion_score` |
|
||||
| Signal field | 400 floor-level Points with green→amber color ramp | `signal_field.values` (20×20 grid) |
|
||||
| WiFi waves | 5 wireframe SphereGeometry shells, AdditiveBlending, pulsing outward | Always-on animation from router position |
|
||||
| Router | BoxGeometry body, 3 CylinderGeometry antennas, pulsing LED, PointLight | Static scene element |
|
||||
| Room | GridHelper floor, BoxGeometry wireframe boundary, reflective MeshStandardMaterial floor, furniture (table, bed) | Static scene element |
|
||||
|
||||
### HUD Overlay
|
||||
|
||||
Glass-morphism HTML panels overlaid on the 3D canvas:
|
||||
|
||||
- **Left panel (Vital Signs):** Heart rate (BPM), respiration (RPM), confidence (%) with animated bars
|
||||
- **Right panel (WiFi Signal):** RSSI, variance, motion power, person count, 2D RSSI sparkline, presence state badge, fall alert
|
||||
- **Top-right:** Data source badge (DEMO/LIVE), scenario badge, FPS counter, settings gear
|
||||
- **Bottom:** Capability bar (Pose Estimation, Vital Monitoring, Presence Detection)
|
||||
- **Bottom-right:** Keyboard shortcut hints
|
||||
|
||||
### Settings Dialog (4 Tabs)
|
||||
|
||||
Full customization with localStorage persistence and JSON export:
|
||||
|
||||
| Tab | Controls |
|
||||
|-----|----------|
|
||||
| **Rendering** | Bloom strength/radius/threshold, exposure, vignette, film grain, chromatic aberration |
|
||||
| **Wireframe** | Bone thickness, joint size, glow intensity, particle trail, wireframe color, joint color, aura opacity |
|
||||
| **Scene** | Signal field opacity, WiFi wave intensity, room brightness, floor reflection, FOV, orbit speed, grid toggle, room boundary toggle |
|
||||
| **Data** | Scenario selector (auto-cycle or fixed), cycle speed, data source (demo/WebSocket), WS URL, reset camera, export settings |
|
||||
|
||||
### Demo-First with Live Data Path
|
||||
|
||||
Four auto-cycling scenarios (30s default, configurable) with 2s cosine crossfade:
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| `empty_room` | Low variance, no presence, flat amplitude, stable RSSI -45dBm |
|
||||
| `single_breathing` | 1 person, breathing 16 BPM, HR 72 BPM, sinusoidal subcarrier modulation |
|
||||
| `two_walking` | 2 persons, high motion, Doppler-like shifts, moving signal field peaks |
|
||||
| `fall_event` | 2s variance spike at t=5s, then stillness, fall flag, confidence drop |
|
||||
|
||||
Data contract matches `SensingUpdate` struct from the Rust sensing server. Live WebSocket connection configurable in settings dialog.
|
||||
|
||||
### Post-Processing Pipeline
|
||||
|
||||
EffectComposer chain: RenderPass → UnrealBloomPass → custom VignetteShader
|
||||
|
||||
- **UnrealBloom:** strength 1.0, radius 0.5, threshold 0.25 (configurable)
|
||||
- **VignetteShader:** warm shadow shift, edge chromatic aberration, film grain
|
||||
- **Adaptive quality:** Auto-degrades when FPS < 25, restores when FPS > 55
|
||||
|
||||
### RuView Foundation Color Palette
|
||||
|
||||
| Role | Color | Hex |
|
||||
|------|-------|-----|
|
||||
| Background | Deep dark | `#080c14` |
|
||||
| Primary wireframe | Green glow | `#00d878` |
|
||||
| Warm accent | Amber | `#ffb020` |
|
||||
| Signal | Blue | `#2090ff` |
|
||||
| Heart / joints | Red | `#ff4060` |
|
||||
| Alert | Crimson | `#ff3040` |
|
||||
|
||||
### Technology Choices
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Standalone page vs tab | Full-screen immersion, independent loading |
|
||||
| Room-based vs abstract panels | Practical spatial context for WiFi sensing data |
|
||||
| Vanilla JS + CDN, no build step | Matches existing `ui/` pattern, served as static files by Axum |
|
||||
| Custom ShaderMaterial for mist | Per-particle alpha, body-shaped distribution, AdditiveBlending |
|
||||
| CylinderGeometry tube bones | Visible at any zoom vs thin Line geometry |
|
||||
| COCO 17-keypoint skeleton | Standard pose format, 16 bone connections |
|
||||
| localStorage settings | Persistent customization without server round-trip |
|
||||
| Adaptive quality | 3 levels, auto-switches based on FPS measurement |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `A` | Toggle autopilot orbit |
|
||||
| `D` | Cycle demo scenario |
|
||||
| `F` | Toggle FPS counter |
|
||||
| `S` | Open/close settings |
|
||||
| `Space` | Pause/resume data |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ui/observatory.html` | Full-screen entry point with HUD overlay + settings dialog |
|
||||
| `ui/observatory/js/main.js` | Scene orchestrator (~1,100 lines): room, wireframe, mist, trails, settings, HUD, animation loop |
|
||||
| `ui/observatory/js/demo-data.js` | 4 scenarios with cosine crossfade, setScenario/setCycleDuration API |
|
||||
| `ui/observatory/js/nebula-background.js` | Procedural fBM nebula + star field background sphere |
|
||||
| `ui/observatory/js/post-processing.js` | EffectComposer: UnrealBloom + VignetteShader (chromatic, grain, warmth) |
|
||||
| `ui/observatory/css/observatory.css` | Foundation color scheme, glass-morphism panels, settings dialog, responsive |
|
||||
| `ui/index.html` | Modified: added Observatory nav link |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Standalone page does not affect existing dashboard stability
|
||||
- Demo-first allows offline presentations without hardware
|
||||
- Same `SensingUpdate` contract enables seamless live WebSocket switch
|
||||
- Room-based visualization provides intuitive spatial context for WiFi sensing
|
||||
- Dot-matrix mist gives visual body mass without occluding wireframe
|
||||
- Full settings customization without code changes (localStorage + JSON export)
|
||||
- Adaptive quality ensures usability on weaker hardware
|
||||
- ~20 draw calls keeps performance well within budget
|
||||
|
||||
### Negative
|
||||
- Additional static files served by Axum (minimal overhead)
|
||||
- Three.js r160 loaded from CDN (no build step, matches existing pattern)
|
||||
- Settings persistence is per-browser (localStorage, not synced)
|
||||
|
||||
### Risks
|
||||
- CDN dependency for Three.js (mitigated: can vendor locally if needed)
|
||||
- Post-processing may not work on very old GPUs (mitigated: adaptive quality disables bloom)
|
||||
|
||||
## References
|
||||
|
||||
- ADR-045: AMOLED display support
|
||||
- ADR-046: Android TV / Armbian deployment
|
||||
- Existing `ui/components/scene.js` — Three.js scene pattern
|
||||
- Existing `ui/components/gaussian-splats.js` — ShaderMaterial pattern
|
||||
- Existing `ui/services/sensing.service.js` — WebSocket data contract
|
||||
@@ -0,0 +1,140 @@
|
||||
# ADR-048: Adaptive CSI Activity Classifier
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-03-05 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-024 (AETHER Embeddings), ADR-039 (Edge Processing), ADR-045 (AMOLED Display) |
|
||||
|
||||
## Context
|
||||
|
||||
WiFi-based activity classification using ESP32 Channel State Information (CSI) relies on hand-tuned thresholds to distinguish between activity states (absent, present_still, present_moving, active). These static thresholds are brittle — they don't account for:
|
||||
|
||||
- **Environment-specific signal patterns**: Room geometry, furniture, wall materials, and ESP32 placement all affect how CSI signals respond to human activity.
|
||||
- **Temporal noise characteristics**: Real ESP32 CSI data at ~10 FPS has significant frame-to-frame jitter that causes classification to jump between states.
|
||||
- **Vital signs estimation noise**: Heart rate and breathing rate estimates from Goertzel filter banks produce large swings (50+ BPM frame-to-frame) at low confidence levels.
|
||||
|
||||
The existing threshold-based approach produces noisy, unstable classifications that degrade the user experience in the Observatory visualization and the main dashboard.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Three-Stage Signal Smoothing Pipeline
|
||||
|
||||
All CSI-derived metrics pass through a three-stage pipeline before reaching the UI:
|
||||
|
||||
#### Stage 1: Adaptive Baseline Subtraction
|
||||
- EMA with α=0.003 (~30s time constant) tracks the "quiet room" noise floor
|
||||
- Only updates during low-motion periods to avoid inflating baseline during activity
|
||||
- 50-frame warm-up period for initial baseline learning
|
||||
- Subtracts 70% of baseline from raw motion score to remove environmental drift
|
||||
|
||||
#### Stage 2: EMA + Median Filtering
|
||||
- **Motion score**: Blended from 4 signals (temporal diff 40%, variance 20%, motion band power 25%, change points 15%), then EMA-smoothed with α=0.15
|
||||
- **Vital signs**: 21-frame sliding window → trimmed mean (drop top/bottom 25%) → EMA with α=0.02 (~5s time constant)
|
||||
- **Dead-band**: HR won't update unless trimmed mean differs by >2 BPM; BR needs >0.5 BPM
|
||||
- **Outlier rejection**: HR jumps >8 BPM/frame and BR jumps >2 BPM/frame are discarded
|
||||
|
||||
#### Stage 3: Hysteresis Debounce
|
||||
- Activity state transitions require 4 consecutive frames (~0.4s) of agreement before committing
|
||||
- Prevents rapid flickering between states
|
||||
- Independent candidate tracking resets on new direction changes
|
||||
|
||||
### 2. Adaptive Classifier Module (`adaptive_classifier.rs`)
|
||||
|
||||
A Rust-native environment-tuned classifier that learns from labeled JSONL recordings:
|
||||
|
||||
#### Feature Extraction (15 features)
|
||||
| # | Feature | Source | Discriminative Power |
|
||||
|---|---------|--------|---------------------|
|
||||
| 0 | variance | Server | Medium — temporal CSI spread |
|
||||
| 1 | motion_band_power | Server | Medium — high-frequency subcarrier energy |
|
||||
| 2 | breathing_band_power | Server | Low — respiratory band energy |
|
||||
| 3 | spectral_power | Server | Low — mean squared amplitude |
|
||||
| 4 | dominant_freq_hz | Server | Low — peak subcarrier index |
|
||||
| 5 | change_points | Server | Medium — threshold crossing count |
|
||||
| 6 | mean_rssi | Server | Low — received signal strength |
|
||||
| 7 | amp_mean | Subcarrier | Medium — mean amplitude across 56 subcarriers |
|
||||
| 8 | amp_std | Subcarrier | **High** — amplitude spread (motion increases spread) |
|
||||
| 9 | amp_skew | Subcarrier | Medium — asymmetry of amplitude distribution |
|
||||
| 10 | amp_kurt | Subcarrier | **High** — peakedness (presence creates peaks) |
|
||||
| 11 | amp_iqr | Subcarrier | Medium — inter-quartile range |
|
||||
| 12 | amp_entropy | Subcarrier | **High** — spectral entropy (motion increases disorder) |
|
||||
| 13 | amp_max | Subcarrier | Medium — peak amplitude value |
|
||||
| 14 | amp_range | Subcarrier | Medium — amplitude dynamic range |
|
||||
|
||||
#### Training Algorithm
|
||||
- **Multiclass logistic regression** with softmax output
|
||||
- **Mini-batch SGD** (batch size 32, 200 epochs, linear learning rate decay)
|
||||
- **Z-score normalisation** using global mean/stddev computed from all training data
|
||||
- Per-class statistics (mean, stddev) stored for Mahalanobis distance fallback
|
||||
- Deterministic shuffling (LCG PRNG, seed 42) for reproducible results
|
||||
|
||||
#### Training Data Pipeline
|
||||
1. Record labeled CSI sessions via `POST /api/v1/recording/start {"id":"train_<label>"}`
|
||||
2. Filename-based label assignment: `*empty*`→absent, `*still*`→present_still, `*walking*`→present_moving, `*active*`→active
|
||||
3. Train via `POST /api/v1/adaptive/train`
|
||||
4. Model saved to `data/adaptive_model.json`, auto-loaded on server restart
|
||||
|
||||
#### Inference Pipeline
|
||||
1. Extract 15-feature vector from current CSI frame
|
||||
2. Z-score normalise using stored global mean/stddev
|
||||
3. Compute softmax probabilities across 4 classes
|
||||
4. Blend adaptive model confidence (70%) with smoothed threshold confidence (30%)
|
||||
5. Override classification only when adaptive model is loaded
|
||||
|
||||
### 3. API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/adaptive/train` | Train classifier from `train_*` recordings |
|
||||
| GET | `/api/v1/adaptive/status` | Check model status, accuracy, class stats |
|
||||
| POST | `/api/v1/adaptive/unload` | Revert to threshold-based classification |
|
||||
| POST | `/api/v1/recording/start` | Start recording CSI frames (JSONL) |
|
||||
| POST | `/api/v1/recording/stop` | Stop recording |
|
||||
| GET | `/api/v1/recording/list` | List available recordings |
|
||||
|
||||
### 4. Vital Signs Smoothing
|
||||
|
||||
| Parameter | Value | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Median window | 21 frames | ~2s of history, robust to transients |
|
||||
| Aggregation | Trimmed mean (middle 50%) | More stable than pure median, less noisy than raw mean |
|
||||
| EMA alpha | 0.02 | ~5s time constant — readings change very slowly |
|
||||
| HR dead-band | ±2 BPM | Prevents display creep from micro-fluctuations |
|
||||
| BR dead-band | ±0.5 BPM | Same for breathing rate |
|
||||
| HR max jump | 8 BPM/frame | Outlier rejection threshold |
|
||||
| BR max jump | 2 BPM/frame | Outlier rejection threshold |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
- **Stable UI**: Vital signs readings hold steady for 5-10+ seconds instead of jumping every frame
|
||||
- **Environment adaptation**: Classifier learns the specific room's signal characteristics
|
||||
- **Graceful fallback**: If no adaptive model is loaded, threshold-based classification with smoothing still works
|
||||
- **No external dependencies**: Pure Rust implementation, no Python/ML frameworks needed
|
||||
- **Fast training**: 3,000+ frames train in <1 second on commodity hardware
|
||||
- **Portable model**: JSON serialisation, loadable on any platform
|
||||
|
||||
### Limitations
|
||||
- **Single-link**: With one ESP32, the feature space is limited. Multi-AP setups (ADR-029) would dramatically improve separability.
|
||||
- **No temporal features**: Current frame-level classification doesn't use sequence models (LSTM/Transformer). Could be added later.
|
||||
- **Label quality**: Training accuracy depends heavily on recording quality (distinct activities, actual room vacancy for "empty").
|
||||
- **Linear classifier**: Logistic regression may underfit non-linear decision boundaries. Could upgrade to 2-layer MLP if needed.
|
||||
|
||||
### Future Work
|
||||
- **Online learning**: Continuously update model weights from user corrections
|
||||
- **Sequence models**: Use sliding window of N frames as input for temporal pattern recognition
|
||||
- **Contrastive pretraining**: Leverage ADR-024 AETHER embeddings for self-supervised feature learning
|
||||
- **Multi-AP fusion**: Use ADR-029 multistatic sensing for richer feature space
|
||||
- **Edge deployment**: Export learned thresholds to ESP32 firmware (ADR-039 Tier 2) for on-device classification
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs` | Adaptive classifier module (feature extraction, training, inference) |
|
||||
| `crates/wifi-densepose-sensing-server/src/main.rs` | Smoothing pipeline, API endpoints, integration |
|
||||
| `ui/observatory/js/hud-controller.js` | UI-side lerp smoothing (4% per frame) |
|
||||
| `data/adaptive_model.json` | Trained model (auto-created by training endpoint) |
|
||||
| `data/recordings/train_*.jsonl` | Labeled training recordings |
|
||||
@@ -0,0 +1,122 @@
|
||||
# ADR-049: Cross-Platform WiFi Interface Detection and Graceful Degradation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Proposed |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-013 (Feature-Level Sensing), ADR-025 (macOS CoreWLAN) |
|
||||
| Issue | [#148](https://github.com/ruvnet/wifi-densepose/issues/148) |
|
||||
|
||||
## Context
|
||||
|
||||
Users report `RuntimeError: Cannot read /proc/net/wireless` when running WiFi DensePose in environments where the Linux wireless proc filesystem is unavailable:
|
||||
|
||||
- **Docker containers** on macOS/Windows (Linux kernel detected, but no wireless subsystem)
|
||||
- **WSL2** without USB WiFi passthrough
|
||||
- **Headless Linux servers** without WiFi hardware
|
||||
- **Embedded Linux** boards without wireless-extensions support
|
||||
|
||||
The current architecture has two layers of defense:
|
||||
|
||||
1. **`ws_server.py`** (line 345-355) checks `os.path.exists("/proc/net/wireless")` before instantiating `LinuxWifiCollector` and falls back to `SimulatedCollector` if missing.
|
||||
2. **`rssi_collector.py`** `LinuxWifiCollector._validate_interface()` (line 178-196) raises a hard `RuntimeError` if `/proc/net/wireless` is missing or the interface isn't listed.
|
||||
|
||||
However, there are gaps:
|
||||
|
||||
- **Direct usage**: Any code that instantiates `LinuxWifiCollector` directly (outside `ws_server.py`) hits the unguarded `RuntimeError` with no fallback.
|
||||
- **Error message**: The RuntimeError message tells users to "use SimulatedCollector instead" but doesn't explain how.
|
||||
- **No auto-detection**: The collector selection logic is duplicated between `ws_server.py` and `install.sh` with no shared platform-detection utility.
|
||||
- **Partial `/proc/net/wireless`**: The file may exist (e.g., kernel module loaded) but contain no interfaces, producing a confusing "interface not found" error instead of a clean fallback.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Platform-Aware Collector Factory
|
||||
|
||||
Introduce a `create_collector()` factory function in `rssi_collector.py` that encapsulates the platform detection and fallback chain:
|
||||
|
||||
```python
|
||||
def create_collector(
|
||||
preferred: str = "auto",
|
||||
interface: str = "wlan0",
|
||||
sample_rate_hz: float = 10.0,
|
||||
) -> BaseCollector:
|
||||
"""
|
||||
Create the best available WiFi collector for the current platform.
|
||||
|
||||
Resolution order (when preferred="auto"):
|
||||
1. ESP32 CSI (if UDP port 5005 is receiving frames)
|
||||
2. Platform-native WiFi:
|
||||
- Linux: LinuxWifiCollector (requires /proc/net/wireless + active interface)
|
||||
- Windows: WindowsWifiCollector (netsh wlan)
|
||||
- macOS: MacosWifiCollector (CoreWLAN)
|
||||
3. SimulatedCollector (always available)
|
||||
|
||||
Raises nothing — always returns a usable collector.
|
||||
"""
|
||||
```
|
||||
|
||||
### 2. Soft Validation in LinuxWifiCollector
|
||||
|
||||
Replace the hard `RuntimeError` in `_validate_interface()` with a class method that returns availability status without raising:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def is_available(cls, interface: str = "wlan0") -> tuple[bool, str]:
|
||||
"""Check if Linux WiFi collection is possible. Returns (available, reason)."""
|
||||
if not os.path.exists("/proc/net/wireless"):
|
||||
return False, "/proc/net/wireless not found (Docker, WSL, or no wireless subsystem)"
|
||||
with open("/proc/net/wireless") as f:
|
||||
content = f.read()
|
||||
if interface not in content:
|
||||
names = cls._parse_interface_names(content)
|
||||
return False, f"Interface '{interface}' not in /proc/net/wireless. Available: {names}"
|
||||
return True, "ok"
|
||||
```
|
||||
|
||||
The existing `_validate_interface()` continues to raise `RuntimeError` for direct callers who need fail-fast behavior, but `create_collector()` uses `is_available()` to probe without exceptions.
|
||||
|
||||
### 3. Structured Fallback Logging
|
||||
|
||||
When auto-detection skips a collector, log at `WARNING` level with actionable context:
|
||||
|
||||
```
|
||||
WiFi collector: LinuxWifiCollector unavailable (/proc/net/wireless not found — likely Docker/WSL).
|
||||
WiFi collector: Falling back to SimulatedCollector. For real sensing, connect ESP32 nodes via UDP:5005.
|
||||
```
|
||||
|
||||
### 4. Consolidate Platform Detection
|
||||
|
||||
Remove duplicated platform-detection logic from `ws_server.py` and `install.sh`. Both should use `create_collector()` (Python) or a shared `detect_wifi_platform()` shell function.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero-crash startup**: `create_collector("auto")` never raises — Docker, WSL, and headless users get `SimulatedCollector` automatically with a clear log message.
|
||||
- **Single detection path**: Platform logic lives in one place (`rssi_collector.py`), reducing drift between `ws_server.py`, `install.sh`, and future entry points.
|
||||
- **Better DX**: Error messages explain *why* a collector is unavailable and *what to do* (connect ESP32, install WiFi driver, etc.).
|
||||
|
||||
### Negative
|
||||
|
||||
- **SimulatedCollector may mask hardware issues**: Users with real WiFi hardware that fails detection might unknowingly run on simulated data. Mitigated by the `WARNING`-level log.
|
||||
- **Breaking change for direct `LinuxWifiCollector` callers**: Code that catches `RuntimeError` from `_validate_interface()` as a signal needs to migrate to `is_available()` or `create_collector()`. This is a minor change — there are no known external consumers.
|
||||
|
||||
### Neutral
|
||||
|
||||
- `_validate_interface()` behavior is unchanged for existing direct callers — this is additive.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Add `create_collector()` and `BaseCollector.is_available()` to `v1/src/sensing/rssi_collector.py`
|
||||
2. Refactor `ws_server.py` `_init_collector()` to call `create_collector()`
|
||||
3. Update `install.sh` `detect_wifi_hardware()` to use shared detection logic
|
||||
4. Add unit tests for each platform path (mock `/proc/net/wireless` presence/absence)
|
||||
5. Comment on issue #148 with the fix
|
||||
|
||||
## References
|
||||
|
||||
- Issue #148: RuntimeError: Cannot read /proc/net/wireless
|
||||
- ADR-013: Feature-Level Sensing on Commodity Gear
|
||||
- ADR-025: macOS CoreWLAN WiFi Sensing
|
||||
- [Linux /proc/net/wireless documentation](https://www.kernel.org/doc/html/latest/networking/statistics.html)
|
||||
@@ -0,0 +1,100 @@
|
||||
# ADR-050: Quality Engineering Response — Security Hardening & Code Quality
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-032 (Multistatic Mesh Security) |
|
||||
| Issue | [#170](https://github.com/ruvnet/wifi-densepose/issues/170) |
|
||||
|
||||
## Context
|
||||
|
||||
An independent quality engineering analysis ([issue #170](https://github.com/ruvnet/wifi-densepose/issues/170)) identified 7 critical findings across the Rust codebase. After verification against the source code, the following findings are confirmed and require action:
|
||||
|
||||
### Confirmed Critical Findings
|
||||
|
||||
| # | Finding | Location | Verified |
|
||||
|---|---------|----------|----------|
|
||||
| 1 | Fake HMAC in `secure_tdm.rs` — XOR fold with hardcoded key | `hardware/src/esp32/secure_tdm.rs:253` | YES — comments say "sufficient for testing" |
|
||||
| 2 | `sensing-server/main.rs` is 3,741 lines — CC=65, god object | `sensing-server/src/main.rs` | YES — confirmed 3,741 lines |
|
||||
| 3 | WebSocket server has zero authentication | Rust WS codebase | YES — no auth/token checks found |
|
||||
| 4 | Zero security tests in Rust codebase | Entire workspace | YES — no auth/injection/tampering tests |
|
||||
| 5 | 54K fps claim has no supporting benchmark | No criterion benchmarks | YES — no benchmarks exist |
|
||||
|
||||
### Findings Requiring Further Investigation
|
||||
|
||||
| # | Finding | Status |
|
||||
|---|---------|--------|
|
||||
| 6 | Unauthenticated OTA firmware endpoint | Not found in Rust code — may be ESP32 C firmware level |
|
||||
| 7 | WASM upload without mandatory signatures | Needs review of WASM loader |
|
||||
| 8 | O(n^2) autocorrelation in heart rate detection | Needs profiling to confirm impact |
|
||||
|
||||
## Decision
|
||||
|
||||
Address findings in 3 priority sprints as recommended by the report.
|
||||
|
||||
### Sprint 1: Security (Blocks Deployment)
|
||||
|
||||
1. **Replace fake HMAC with real HMAC-SHA256** in `secure_tdm.rs`
|
||||
- Use the `hmac` + `sha2` crates (already in `Cargo.lock`)
|
||||
- Remove XOR fold implementation
|
||||
- Add key derivation (no more hardcoded keys)
|
||||
|
||||
2. **Add WebSocket authentication**
|
||||
- Token-based auth on WS upgrade handshake
|
||||
- Optional API key for local-network deployments
|
||||
- Configurable via environment variable
|
||||
|
||||
3. **Add security test suite**
|
||||
- Auth bypass attempts
|
||||
- Malformed CSI frame injection
|
||||
- Protocol tampering (TDM beacon replay, nonce reuse)
|
||||
|
||||
### Sprint 2: Code Quality & Testability
|
||||
|
||||
4. **Decompose `main.rs`** (3,741 lines -> ~14 focused modules)
|
||||
- Extract HTTP routes, WebSocket handler, CSI pipeline, config, state
|
||||
- Target: no file over 500 lines
|
||||
|
||||
5. **Add criterion benchmarks**
|
||||
- CSI frame parsing throughput
|
||||
- Signal processing pipeline latency
|
||||
- WebSocket broadcast fanout
|
||||
|
||||
### Sprint 3: Functional Verification
|
||||
|
||||
6. **Vital sign accuracy verification**
|
||||
- Reference signal tests with known BPM
|
||||
- False-negative rate measurement
|
||||
|
||||
7. **Fix O(n^2) autocorrelation** (if confirmed by profiling)
|
||||
- Replace brute-force lag with FFT-based autocorrelation
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Addresses all critical security findings before any production deployment
|
||||
- `main.rs` decomposition enables unit testing of server components
|
||||
- Criterion benchmarks provide verifiable performance claims
|
||||
- Security test suite prevents regression
|
||||
|
||||
### Negative
|
||||
|
||||
- Sprint 1 security changes are breaking for any existing TDM mesh deployments (fake HMAC -> real HMAC requires firmware update)
|
||||
- `main.rs` decomposition is a large refactor with merge conflict risk
|
||||
|
||||
### Neutral
|
||||
|
||||
- The report correctly identifies that life-safety claims (disaster detection, vital signs) require rigorous verification — this is an ongoing process, not a single sprint
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
Thanks to [@proffesor-for-testing](https://github.com/proffesor-for-testing) for the thorough 10-report analysis. The full report is archived at the [original gist](https://gist.github.com/proffesor-for-testing/02321e3f272720aa94484fffec6ab19b).
|
||||
|
||||
## References
|
||||
|
||||
- Issue #170: Quality Engineering Analysis
|
||||
- ADR-032: Multistatic Mesh Security Hardening
|
||||
- ADR-028: ESP32 Capability Audit
|
||||
@@ -0,0 +1,621 @@
|
||||
# ADR-052 Appendix: DDD Bounded Contexts — Tauri Desktop Frontend
|
||||
|
||||
This document maps out the domain model for the RuView Tauri desktop application
|
||||
described in ADR-052. It defines bounded contexts, their aggregates, entities,
|
||||
value objects, and the domain events flowing between them.
|
||||
|
||||
## Context Map
|
||||
|
||||
```
|
||||
+-------------------+ +---------------------+ +--------------------+
|
||||
| | | | | |
|
||||
| Device Discovery |------>| Firmware Management |------>| Configuration / |
|
||||
| | | | | Provisioning |
|
||||
+-------------------+ +---------------------+ +--------------------+
|
||||
| | |
|
||||
| | |
|
||||
v v v
|
||||
+-------------------+ +---------------------+ +--------------------+
|
||||
| | | | | |
|
||||
| Sensing Pipeline |<------| Edge Module | | Visualization |
|
||||
| | | (WASM) | | |
|
||||
+-------------------+ +---------------------+ +--------------------+
|
||||
|
||||
Relationship types:
|
||||
-----> Upstream/Downstream (upstream publishes events, downstream consumes)
|
||||
<----- Conformist (downstream conforms to upstream's model)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Device Discovery Context
|
||||
|
||||
**Purpose**: Find, identify, and monitor ESP32 CSI nodes on the local network.
|
||||
|
||||
**Upstream of**: Firmware Management, Configuration, Sensing Pipeline, Visualization
|
||||
|
||||
### Aggregates
|
||||
|
||||
#### `NodeRegistry` (Aggregate Root)
|
||||
|
||||
Maintains the authoritative list of all known nodes. Merges discovery results
|
||||
from multiple strategies (mDNS, UDP probe, HTTP sweep) and deduplicates by MAC
|
||||
address.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `nodes` | `Map<MacAddress, Node>` | All discovered nodes keyed by MAC |
|
||||
| `scan_state` | `ScanState` | Idle, Scanning, Error |
|
||||
| `last_scan` | `DateTime<Utc>` | Timestamp of last completed scan |
|
||||
|
||||
**Invariant**: No two nodes may share the same MAC address. If a node is
|
||||
discovered via multiple strategies, the most recent data wins.
|
||||
|
||||
**Persistence**: The registry is persisted to `~/.ruview/nodes.db` (SQLite via
|
||||
`rusqlite`). On startup, all previously known nodes are loaded as `Offline` and
|
||||
reconciled against a fresh discovery scan. This means the app **remembers the
|
||||
mesh** across restarts — critical for field deployments where nodes may be
|
||||
temporarily powered off.
|
||||
|
||||
#### `Node` (Entity)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mac` | `MacAddress` (VO) | IEEE 802.11 MAC address (unique identity) |
|
||||
| `ip` | `IpAddr` | Current IP address (may change on DHCP renewal) |
|
||||
| `hostname` | `Option<String>` | mDNS hostname |
|
||||
| `node_id` | `u8` | NVS-provisioned node ID |
|
||||
| `firmware_version` | `Option<SemVer>` | Firmware version string |
|
||||
| `health` | `HealthStatus` (VO) | Online / Offline / Degraded |
|
||||
| `discovery_method` | `DiscoveryMethod` (VO) | How this node was found |
|
||||
| `last_seen` | `DateTime<Utc>` | Last successful contact |
|
||||
| `tdm_config` | `Option<TdmConfig>` (VO) | TDM slot assignment |
|
||||
| `edge_tier` | `Option<u8>` | Edge processing tier (0/1/2) |
|
||||
|
||||
### Value Objects
|
||||
|
||||
- `MacAddress` — 6-byte hardware address, formatted as `AA:BB:CC:DD:EE:FF`
|
||||
- `HealthStatus` — enum: `Online`, `Offline`, `Degraded(reason: String)`
|
||||
- `DiscoveryMethod` — enum: `Mdns`, `UdpProbe`, `HttpSweep`, `Manual`
|
||||
- `TdmConfig` — `{ slot_index: u8, total_nodes: u8 }`
|
||||
- `SemVer` — semantic version `major.minor.patch`
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Payload | Consumers |
|
||||
|-------|---------|-----------|
|
||||
| `NodeDiscovered` | `{ node: Node }` | Firmware Mgmt (check for updates), Visualization (add to mesh graph) |
|
||||
| `NodeWentOffline` | `{ mac: MacAddress, last_seen: DateTime }` | Visualization (gray out node), Sensing Pipeline (remove from active set) |
|
||||
| `NodeCameOnline` | `{ node: Node }` | Visualization (restore node), Sensing Pipeline (re-add) |
|
||||
| `NodeHealthChanged` | `{ mac: MacAddress, old: HealthStatus, new: HealthStatus }` | Visualization (update indicator) |
|
||||
| `ScanCompleted` | `{ found: usize, new: usize, lost: usize }` | Dashboard (update summary) |
|
||||
|
||||
### Anti-Corruption Layer
|
||||
|
||||
When receiving data from the ESP32 OTA status endpoint (`GET /ota/status`), the
|
||||
response format is owned by the firmware and may change across firmware versions.
|
||||
The ACL translates the raw JSON response into `Node` entity fields:
|
||||
|
||||
```rust
|
||||
/// ACL: Translate ESP32 OTA status response to Node fields.
|
||||
fn translate_ota_status(raw: &serde_json::Value) -> Result<NodePatch, AclError> {
|
||||
NodePatch {
|
||||
firmware_version: raw["version"].as_str().map(SemVer::parse).transpose()?,
|
||||
uptime_secs: raw["uptime_s"].as_u64(),
|
||||
free_heap: raw["free_heap"].as_u64(),
|
||||
// Firmware may add fields in future versions — unknown fields are ignored
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Firmware Management Context
|
||||
|
||||
**Purpose**: Flash, update, and verify firmware on ESP32 nodes.
|
||||
|
||||
**Upstream of**: Configuration (a fresh flash triggers provisioning)
|
||||
**Downstream of**: Device Discovery (needs node list and serial port info)
|
||||
|
||||
### Aggregates
|
||||
|
||||
#### `FlashSession` (Aggregate Root)
|
||||
|
||||
Represents a single firmware flashing operation from start to completion. Each
|
||||
session has a lifecycle: Created -> Connecting -> Erasing -> Writing -> Verifying ->
|
||||
Completed | Failed.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `Uuid` | Session identifier |
|
||||
| `port` | `SerialPort` (VO) | Target serial port |
|
||||
| `firmware` | `FirmwareBinary` (Entity) | The binary being flashed |
|
||||
| `chip` | `ChipType` (VO) | Target chip (ESP32, ESP32-S3, ESP32-C3) |
|
||||
| `phase` | `FlashPhase` (VO) | Current phase of the flash operation |
|
||||
| `progress` | `Progress` (VO) | Bytes written / total, speed |
|
||||
| `started_at` | `DateTime<Utc>` | When the session started |
|
||||
| `error` | `Option<String>` | Error message if failed |
|
||||
|
||||
**Invariant**: Only one `FlashSession` may be active per serial port at a time.
|
||||
|
||||
#### `FirmwareBinary` (Entity)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `path` | `PathBuf` | Filesystem path to the `.bin` file |
|
||||
| `size_bytes` | `u64` | Binary size |
|
||||
| `version` | `Option<SemVer>` | Extracted from ESP32 image header |
|
||||
| `chip_type` | `Option<ChipType>` | Detected from image magic bytes |
|
||||
| `checksum` | `Sha256Hash` (VO) | SHA-256 of the binary |
|
||||
|
||||
#### `OtaSession` (Aggregate Root)
|
||||
|
||||
Represents an over-the-air firmware update to a running node.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `Uuid` | Session identifier |
|
||||
| `target_node` | `MacAddress` | Target node MAC |
|
||||
| `target_ip` | `IpAddr` | Target node IP |
|
||||
| `firmware` | `FirmwareBinary` | The binary being pushed |
|
||||
| `psk` | `Option<SecureString>` | PSK for authentication (ADR-050) |
|
||||
| `phase` | `OtaPhase` | Uploading / Rebooting / Verifying / Done / Failed |
|
||||
| `progress` | `Progress` | Upload progress |
|
||||
|
||||
#### `BatchOtaSession` (Aggregate Root)
|
||||
|
||||
Coordinates rolling firmware updates across multiple mesh nodes. Prevents all
|
||||
nodes from rebooting simultaneously, which would collapse the sensing network.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `Uuid` | Batch session identifier |
|
||||
| `firmware` | `FirmwareBinary` | The binary being deployed |
|
||||
| `strategy` | `OtaStrategy` | `Sequential`, `TdmSafe`, `Parallel` |
|
||||
| `max_concurrent` | `usize` | Max nodes updating at once |
|
||||
| `batch_delay_secs` | `u64` | Delay between batches |
|
||||
| `fail_fast` | `bool` | Abort remaining on first failure |
|
||||
| `node_states` | `Map<MacAddress, BatchNodeState>` | Per-node progress |
|
||||
|
||||
**Invariant**: In `TdmSafe` mode, adjacent TDM slots are never updated
|
||||
concurrently. Even-slot nodes update first, then odd-slot nodes.
|
||||
|
||||
**Lifecycle**: `Planning → InProgress → Completed | PartialFailure | Aborted`
|
||||
|
||||
- `BatchNodeState` — enum: `Queued`, `Uploading(Progress)`, `Rebooting`, `Verifying`, `Done`, `Failed(String)`, `Skipped`
|
||||
- `OtaStrategy` — enum:
|
||||
- `Sequential` — one node at a time, wait for rejoin
|
||||
- `TdmSafe` — update non-adjacent slots to maintain sensing coverage
|
||||
- `Parallel` — all at once (development only)
|
||||
|
||||
### Value Objects
|
||||
|
||||
- `SerialPort` — `{ name: String, vid: u16, pid: u16, manufacturer: Option<String> }`
|
||||
- `ChipType` — enum: `Esp32`, `Esp32s3`, `Esp32c3`
|
||||
- `FlashPhase` — enum: `Connecting`, `Erasing`, `Writing`, `Verifying`, `Completed`, `Failed`
|
||||
- `OtaPhase` — enum: `Uploading`, `Rebooting`, `Verifying`, `Completed`, `Failed`
|
||||
- `Progress` — `{ bytes_done: u64, bytes_total: u64, speed_bps: u64 }`
|
||||
- `Sha256Hash` — 32-byte hash
|
||||
- `SecureString` — zeroized-on-drop string for PSK tokens
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Payload | Consumers |
|
||||
|-------|---------|-----------|
|
||||
| `FlashStarted` | `{ session_id, port, firmware_version }` | UI (show progress) |
|
||||
| `FlashProgress` | `{ session_id, phase, progress }` | UI (update progress bar) |
|
||||
| `FlashCompleted` | `{ session_id, duration_secs }` | Configuration (trigger provisioning prompt) |
|
||||
| `FlashFailed` | `{ session_id, error }` | UI (show error) |
|
||||
| `OtaStarted` | `{ session_id, target_mac, firmware_version }` | Discovery (mark node as updating) |
|
||||
| `OtaCompleted` | `{ session_id, target_mac, new_version }` | Discovery (refresh node info) |
|
||||
| `OtaFailed` | `{ session_id, target_mac, error }` | UI (show error) |
|
||||
| `BatchOtaStarted` | `{ batch_id, strategy, node_count }` | UI (show batch progress) |
|
||||
| `BatchNodeUpdated` | `{ batch_id, mac, state }` | UI (update per-node status), Discovery (refresh) |
|
||||
| `BatchOtaCompleted` | `{ batch_id, succeeded, failed, skipped }` | UI (show summary), Discovery (full rescan) |
|
||||
|
||||
### Anti-Corruption Layer
|
||||
|
||||
The `espflash` crate has its own error types and progress reporting model. The
|
||||
ACL translates these into domain events:
|
||||
|
||||
```rust
|
||||
/// ACL: Translate espflash progress callbacks to domain FlashProgress events.
|
||||
impl From<espflash::ProgressCallbackMessage> for FlashProgress {
|
||||
fn from(msg: espflash::ProgressCallbackMessage) -> Self {
|
||||
match msg {
|
||||
espflash::ProgressCallbackMessage::Connecting => FlashProgress {
|
||||
phase: FlashPhase::Connecting,
|
||||
progress: Progress::indeterminate(),
|
||||
},
|
||||
espflash::ProgressCallbackMessage::Erasing { addr, total } => FlashProgress {
|
||||
phase: FlashPhase::Erasing,
|
||||
progress: Progress::new(addr as u64, total as u64),
|
||||
},
|
||||
// ... etc
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration / Provisioning Context
|
||||
|
||||
**Purpose**: Manage NVS configuration for ESP32 nodes — WiFi credentials, network
|
||||
targets, TDM mesh settings, edge intelligence parameters, WASM security keys.
|
||||
|
||||
**Downstream of**: Device Discovery (needs serial port), Firmware Management (post-flash provisioning)
|
||||
|
||||
### Aggregates
|
||||
|
||||
#### `ProvisioningSession` (Aggregate Root)
|
||||
|
||||
Represents a single NVS write or read operation on a connected ESP32.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `Uuid` | Session identifier |
|
||||
| `port` | `SerialPort` (VO) | Target serial port |
|
||||
| `config` | `NodeConfig` (Entity) | Configuration to write |
|
||||
| `direction` | `Direction` | Read or Write |
|
||||
| `phase` | `ProvisionPhase` | Generating / Flashing / Verifying / Done |
|
||||
|
||||
#### `NodeConfig` (Entity)
|
||||
|
||||
The full set of NVS key-value pairs for a single node. Maps directly to the
|
||||
firmware's `nvs_config_t` struct (see `firmware/esp32-csi-node/main/nvs_config.h`).
|
||||
|
||||
| Field | Type | NVS Key | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `wifi_ssid` | `Option<String>` | `ssid` | WiFi SSID |
|
||||
| `wifi_password` | `Option<SecureString>` | `password` | WiFi password |
|
||||
| `target_ip` | `Option<IpAddr>` | `target_ip` | Aggregator IP |
|
||||
| `target_port` | `Option<u16>` | `target_port` | Aggregator UDP port |
|
||||
| `node_id` | `Option<u8>` | `node_id` | Node identifier |
|
||||
| `tdm_slot` | `Option<u8>` | `tdm_slot` | TDM slot index |
|
||||
| `tdm_total` | `Option<u8>` | `tdm_nodes` | Total TDM nodes |
|
||||
| `edge_tier` | `Option<u8>` | `edge_tier` | Processing tier |
|
||||
| `hop_count` | `Option<u8>` | `hop_count` | Channel hop count |
|
||||
| `channel_list` | `Option<Vec<u8>>` | `chan_list` | Channel sequence |
|
||||
| `dwell_ms` | `Option<u32>` | `dwell_ms` | Hop dwell time |
|
||||
| `power_duty` | `Option<u8>` | `power_duty` | Power duty cycle |
|
||||
| `presence_thresh` | `Option<u16>` | `pres_thresh` | Presence threshold |
|
||||
| `fall_thresh` | `Option<u16>` | `fall_thresh` | Fall detection threshold |
|
||||
| `vital_window` | `Option<u16>` | `vital_win` | Vital sign window |
|
||||
| `vital_interval_ms` | `Option<u16>` | `vital_int` | Vital sign interval |
|
||||
| `top_k_count` | `Option<u8>` | `subk_count` | Top-K subcarriers |
|
||||
| `wasm_max_modules` | `Option<u8>` | `wasm_max` | Max WASM modules |
|
||||
| `wasm_verify` | `Option<bool>` | `wasm_verify` | Require WASM signature |
|
||||
| `wasm_pubkey` | `Option<[u8; 32]>` | `wasm_pubkey` | Ed25519 public key |
|
||||
| `ota_psk` | `Option<SecureString>` | `ota_psk` | OTA pre-shared key |
|
||||
|
||||
**Invariant**: `tdm_slot < tdm_total` when both are set.
|
||||
**Invariant**: `channel_list.len() == hop_count` when both are set.
|
||||
**Invariant**: `10 <= power_duty <= 100`.
|
||||
|
||||
#### `MeshConfig` (Entity)
|
||||
|
||||
A mesh-level configuration that generates per-node `NodeConfig` instances.
|
||||
Corresponds to ADR-044 Phase 2 (config file provisioning).
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `common` | `NodeConfig` | Shared settings (WiFi, target IP, edge tier) |
|
||||
| `nodes` | `Vec<MeshNodeEntry>` | Per-node overrides (port, node_id, tdm_slot) |
|
||||
|
||||
```rust
|
||||
pub struct MeshNodeEntry {
|
||||
pub port: String,
|
||||
pub node_id: u8,
|
||||
pub tdm_slot: u8,
|
||||
// All other fields inherited from common
|
||||
}
|
||||
```
|
||||
|
||||
**Invariant**: `tdm_total` is automatically computed as `nodes.len()`.
|
||||
|
||||
### Value Objects
|
||||
|
||||
- `ProvisionPhase` — enum: `Generating`, `Flashing`, `Verifying`, `Completed`, `Failed`
|
||||
- `Direction` — enum: `Read`, `Write`
|
||||
- `Preset` — enum: `Basic`, `Vitals`, `Mesh3`, `Mesh6Vitals` (ADR-044 Phase 3)
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Payload | Consumers |
|
||||
|-------|---------|-----------|
|
||||
| `NodeProvisioned` | `{ port, node_id, config_summary }` | Discovery (trigger re-scan), UI (show success) |
|
||||
| `NvsReadCompleted` | `{ port, config: NodeConfig }` | UI (populate form) |
|
||||
| `ProvisionFailed` | `{ port, error }` | UI (show error) |
|
||||
| `MeshProvisionStarted` | `{ node_count }` | UI (show batch progress) |
|
||||
| `MeshProvisionCompleted` | `{ success_count, fail_count }` | UI (show summary) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Sensing Pipeline Context
|
||||
|
||||
**Purpose**: Control the sensing server process, receive real-time CSI data, and
|
||||
manage the signal processing pipeline.
|
||||
|
||||
**Downstream of**: Device Discovery (needs node IPs for data attribution)
|
||||
|
||||
### Aggregates
|
||||
|
||||
#### `SensingServer` (Aggregate Root)
|
||||
|
||||
Represents the managed sensing server child process.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `state` | `ServerState` (VO) | Stopped / Starting / Running / Stopping / Crashed |
|
||||
| `config` | `ServerConfig` (VO) | Port configuration, log level, model paths |
|
||||
| `pid` | `Option<u32>` | OS process ID when running |
|
||||
| `started_at` | `Option<DateTime<Utc>>` | Start timestamp |
|
||||
| `log_buffer` | `RingBuffer<LogEntry>` | Last N log lines |
|
||||
| `ws_url` | `Option<Url>` | WebSocket URL for live data |
|
||||
|
||||
**Invariant**: Only one `SensingServer` process may be managed at a time.
|
||||
|
||||
#### `SensingSession` (Entity)
|
||||
|
||||
An active connection to the sensing server's WebSocket for receiving real-time data.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `connection_state` | `WsState` | Connecting / Connected / Disconnected |
|
||||
| `frames_received` | `u64` | Total CSI frames received this session |
|
||||
| `last_frame_at` | `Option<DateTime<Utc>>` | Timestamp of last received frame |
|
||||
| `subscriptions` | `HashSet<DataChannel>` | Which data streams are active |
|
||||
|
||||
### Value Objects
|
||||
|
||||
- `ServerState` — enum: `Stopped`, `Starting`, `Running`, `Stopping`, `Crashed(exit_code: i32)`
|
||||
- `ServerConfig` — `{ http_port: u16, ws_port: u16, udp_port: u16, model_dir: PathBuf, log_level: Level }`
|
||||
- `LogEntry` — `{ timestamp: DateTime, level: Level, target: String, message: String }`
|
||||
- `DataChannel` — enum: `CsiFrames`, `PoseUpdates`, `VitalSigns`, `ActivityClassification`
|
||||
- `WsState` — enum: `Connecting`, `Connected`, `Disconnected(reason: String)`
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Payload | Consumers |
|
||||
|-------|---------|-----------|
|
||||
| `ServerStarted` | `{ pid, ports: ServerConfig }` | UI (enable sensing view), Discovery (start health polling via WS) |
|
||||
| `ServerStopped` | `{ exit_code, uptime_secs }` | UI (disable sensing view) |
|
||||
| `ServerCrashed` | `{ exit_code, last_log_lines }` | UI (show crash report) |
|
||||
| `CsiFrameReceived` | `{ node_id, timestamp, subcarrier_count }` | Visualization (update charts) |
|
||||
| `PoseUpdated` | `{ persons: Vec<PersonPose> }` | Visualization (draw skeletons) |
|
||||
| `VitalSignUpdate` | `{ node_id, bpm, breath_rate }` | Visualization (update vitals chart) |
|
||||
| `ActivityDetected` | `{ label, confidence }` | Visualization (show activity) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Edge Module (WASM) Context
|
||||
|
||||
**Purpose**: Upload, manage, and monitor WASM edge processing modules running
|
||||
on ESP32 nodes.
|
||||
|
||||
**Downstream of**: Device Discovery (needs node IPs and WASM capability info)
|
||||
**Upstream of**: Sensing Pipeline (WASM modules emit edge-processed events)
|
||||
|
||||
### Aggregates
|
||||
|
||||
#### `ModuleRegistry` (Aggregate Root)
|
||||
|
||||
Tracks all WASM modules across all nodes.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `modules` | `Map<(MacAddress, ModuleId), WasmModule>` | Per-node module inventory |
|
||||
|
||||
#### `WasmModule` (Entity)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `ModuleId` (VO) | Node-assigned module identifier |
|
||||
| `name` | `String` | Filename of the uploaded `.wasm` |
|
||||
| `size_bytes` | `u64` | Module size |
|
||||
| `status` | `ModuleStatus` (VO) | Loaded / Running / Stopped / Error |
|
||||
| `node_mac` | `MacAddress` | Which node this module runs on |
|
||||
| `uploaded_at` | `DateTime<Utc>` | Upload timestamp |
|
||||
| `signed` | `bool` | Whether the module has an Ed25519 signature |
|
||||
|
||||
### Value Objects
|
||||
|
||||
- `ModuleId` — string identifier assigned by the node firmware
|
||||
- `ModuleStatus` — enum: `Loaded`, `Running`, `Stopped`, `Error(String)`
|
||||
|
||||
### Domain Events
|
||||
|
||||
| Event | Payload | Consumers |
|
||||
|-------|---------|-----------|
|
||||
| `ModuleUploaded` | `{ node_mac, module_id, name, size }` | UI (refresh list) |
|
||||
| `ModuleStarted` | `{ node_mac, module_id }` | UI (update status) |
|
||||
| `ModuleStopped` | `{ node_mac, module_id }` | UI (update status) |
|
||||
| `ModuleUnloaded` | `{ node_mac, module_id }` | UI (remove from list) |
|
||||
| `ModuleError` | `{ node_mac, module_id, error }` | UI (show error) |
|
||||
|
||||
### Anti-Corruption Layer
|
||||
|
||||
The ESP32 WASM management HTTP API (`/wasm/*` on port 8032) returns raw JSON
|
||||
with firmware-specific field names. The ACL normalizes these:
|
||||
|
||||
```rust
|
||||
/// ACL: Translate ESP32 WASM list response to domain WasmModule entities.
|
||||
fn translate_wasm_list(raw: &[serde_json::Value]) -> Vec<WasmModule> {
|
||||
raw.iter().filter_map(|entry| {
|
||||
Some(WasmModule {
|
||||
id: ModuleId(entry["id"].as_str()?.to_string()),
|
||||
name: entry["name"].as_str().unwrap_or("unknown").to_string(),
|
||||
size_bytes: entry["size"].as_u64().unwrap_or(0),
|
||||
status: match entry["state"].as_str() {
|
||||
Some("running") => ModuleStatus::Running,
|
||||
Some("stopped") => ModuleStatus::Stopped,
|
||||
Some("loaded") => ModuleStatus::Loaded,
|
||||
other => ModuleStatus::Error(
|
||||
format!("Unknown state: {:?}", other)
|
||||
),
|
||||
},
|
||||
// ...
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Visualization Context
|
||||
|
||||
**Purpose**: Render real-time and historical sensing data — CSI heatmaps, pose
|
||||
skeletons, vital sign charts, mesh topology graphs.
|
||||
|
||||
**Downstream of**: Sensing Pipeline (receives data events), Device Discovery (needs
|
||||
node metadata for labeling)
|
||||
|
||||
This context is **purely presentational** and contains no domain logic. It
|
||||
transforms domain events from other contexts into visual representations.
|
||||
|
||||
### Aggregates
|
||||
|
||||
None — this context is a **Query Model** (CQRS read side). It subscribes to
|
||||
domain events and projects them into view models.
|
||||
|
||||
### View Models
|
||||
|
||||
#### `DashboardView`
|
||||
|
||||
| Field | Source Context | Description |
|
||||
|-------|---------------|-------------|
|
||||
| `nodes` | Device Discovery | Node cards with health, version, signal quality |
|
||||
| `server` | Sensing Pipeline | Server status, uptime, port info |
|
||||
| `recent_activity` | All contexts | Timeline of recent events |
|
||||
|
||||
#### `SignalView`
|
||||
|
||||
| Field | Source Context | Description |
|
||||
|-------|---------------|-------------|
|
||||
| `csi_heatmap` | Sensing Pipeline | Subcarrier amplitude x time matrix |
|
||||
| `signal_field` | Sensing Pipeline | 2D signal strength grid |
|
||||
| `activity_label` | Sensing Pipeline | Current classification |
|
||||
| `confidence` | Sensing Pipeline | Classification confidence |
|
||||
|
||||
#### `PoseView`
|
||||
|
||||
| Field | Source Context | Description |
|
||||
|-------|---------------|-------------|
|
||||
| `persons` | Sensing Pipeline | Array of detected person skeletons |
|
||||
| `zones` | Sensing Pipeline | Active zones in the sensing area |
|
||||
|
||||
#### `VitalsView`
|
||||
|
||||
| Field | Source Context | Description |
|
||||
|-------|---------------|-------------|
|
||||
| `breathing_rate_bpm` | Sensing Pipeline | Per-node breathing rate time series |
|
||||
| `heart_rate_bpm` | Sensing Pipeline | Per-node heart rate time series |
|
||||
|
||||
#### `MeshView`
|
||||
|
||||
| Field | Source Context | Description |
|
||||
|-------|---------------|-------------|
|
||||
| `nodes` | Device Discovery | Positioned nodes for graph layout |
|
||||
| `edges` | Device Discovery | Inter-node visibility/connectivity |
|
||||
| `tdm_timeline` | Device Discovery | TDM slot schedule visualization |
|
||||
| `sync_status` | Sensing Pipeline | Per-node sync status with server |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Context Event Flow
|
||||
|
||||
```
|
||||
NodeDiscovered
|
||||
Device Discovery ─────────────────────────────────> Firmware Management
|
||||
│ │
|
||||
│ NodeDiscovered │ FlashCompleted
|
||||
│ NodeHealthChanged │
|
||||
├──────────────────> Visualization v
|
||||
│ Configuration
|
||||
│ NodeDiscovered │
|
||||
├──────────────────> Sensing Pipeline │ NodeProvisioned
|
||||
│ │
|
||||
│ v
|
||||
│ Device Discovery
|
||||
│ (re-scan triggered)
|
||||
│
|
||||
│ NodeDiscovered
|
||||
└──────────────────> Edge Module (WASM)
|
||||
│
|
||||
│ ModuleUploaded, ModuleStarted
|
||||
│
|
||||
v
|
||||
Sensing Pipeline
|
||||
│
|
||||
│ CsiFrameReceived, PoseUpdated, VitalSignUpdate
|
||||
│
|
||||
v
|
||||
Visualization
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Event Bus**: Domain events are dispatched via Tauri's event system
|
||||
(`app_handle.emit("event-name", payload)`). The frontend subscribes using
|
||||
`listen("event-name", callback)`. This provides natural cross-context
|
||||
communication without coupling contexts directly.
|
||||
|
||||
2. **State Isolation**: Each bounded context maintains its own `State<'_, T>`
|
||||
managed by Tauri. Contexts do not share mutable state directly — they
|
||||
communicate exclusively through events.
|
||||
|
||||
3. **Module Organization**: Each bounded context maps to a Rust module under
|
||||
`src/commands/` and `src/domain/`:
|
||||
|
||||
```
|
||||
src/
|
||||
commands/ # Tauri command handlers (application layer)
|
||||
discovery.rs # Device Discovery context commands
|
||||
flash.rs # Firmware Management context commands
|
||||
ota.rs # Firmware Management context commands
|
||||
provision.rs # Configuration context commands
|
||||
server.rs # Sensing Pipeline context commands
|
||||
wasm.rs # Edge Module context commands
|
||||
domain/ # Domain models (pure Rust, no Tauri dependency)
|
||||
discovery/
|
||||
mod.rs
|
||||
node.rs # Node entity, MacAddress VO
|
||||
registry.rs # NodeRegistry aggregate
|
||||
events.rs # Discovery domain events
|
||||
firmware/
|
||||
mod.rs
|
||||
binary.rs # FirmwareBinary entity
|
||||
flash.rs # FlashSession aggregate
|
||||
ota.rs # OtaSession aggregate
|
||||
events.rs
|
||||
config/
|
||||
mod.rs
|
||||
nvs.rs # NodeConfig entity
|
||||
mesh.rs # MeshConfig entity
|
||||
provision.rs # ProvisioningSession aggregate
|
||||
events.rs
|
||||
sensing/
|
||||
mod.rs
|
||||
server.rs # SensingServer aggregate
|
||||
session.rs # SensingSession entity
|
||||
events.rs
|
||||
wasm/
|
||||
mod.rs
|
||||
module.rs # WasmModule entity
|
||||
registry.rs # ModuleRegistry aggregate
|
||||
events.rs
|
||||
acl/ # Anti-corruption layers
|
||||
ota_status.rs # ESP32 OTA status response translator
|
||||
wasm_api.rs # ESP32 WASM API response translator
|
||||
espflash.rs # espflash crate adapter
|
||||
```
|
||||
|
||||
4. **Testing Strategy**: Domain modules under `src/domain/` have no Tauri
|
||||
dependency and can be tested with standard `cargo test`. Command handlers
|
||||
under `src/commands/` require Tauri test utilities for integration testing.
|
||||
|
||||
5. **Shared Kernel**: The `MacAddress`, `SemVer`, and `SecureString` value objects
|
||||
are shared across contexts. They live in a `src/domain/shared.rs` module.
|
||||
This is acceptable because they are immutable value objects with no behavior
|
||||
beyond validation and formatting.
|
||||
@@ -0,0 +1,810 @@
|
||||
# ADR-052: Tauri Desktop Frontend — RuView Hardware Management & Visualization
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Proposed |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-012 (ESP32 CSI Mesh), ADR-039 (Edge Intelligence), ADR-040 (WASM Programmable Sensing), ADR-044 (Provisioning Enhancements), ADR-050 (Security Hardening), ADR-051 (Server Decomposition) |
|
||||
| Issue | [#177](https://github.com/ruvnet/RuView/issues/177) |
|
||||
|
||||
## Context
|
||||
|
||||
RuView currently requires users to interact with multiple disconnected tools to manage a WiFi DensePose deployment:
|
||||
|
||||
| Task | Current Tool | Pain Point |
|
||||
|------|-------------|------------|
|
||||
| Flash firmware | `esptool.py` CLI | Requires Python, pip, correct chip/baud flags |
|
||||
| Provision NVS | `provision.py` CLI | 13+ flags, no GUI, no read-back |
|
||||
| OTA update | `curl POST :8032/ota` | Manual HTTP, PSK header construction |
|
||||
| WASM modules | `curl` to `:8032/wasm/*` | No visibility into module state |
|
||||
| Start sensing server | `cargo run` or binary | Manual port configuration, no log viewer |
|
||||
| View sensing data | Browser at `localhost:8080` | Separate window, no hardware context |
|
||||
| Mesh topology | Mental model | No visualization of TDM slots, sync, health |
|
||||
| Node discovery | Manual IP tracking | No mDNS/UDP broadcast discovery |
|
||||
|
||||
There is no single tool that provides a unified view of the entire deployment — from ESP32 hardware through the sensing pipeline to pose visualization. Field operators deploying multi-node meshes must context-switch between terminals, browsers, and serial monitors.
|
||||
|
||||
### Why a Desktop App
|
||||
|
||||
A browser-based UI cannot access serial ports (for flashing), raw UDP sockets (for node discovery), or the local filesystem (for firmware binaries). A desktop application is required for hardware management. Tauri v2 is the natural choice because:
|
||||
|
||||
1. **Rust backend** — integrates directly with the existing Rust workspace (`wifi-densepose-rs`). Crates like `wifi-densepose-hardware` (serial port parsing), `wifi-densepose-config`, and `wifi-densepose-sensing-server` can be linked as library dependencies.
|
||||
2. **Small binary** — Tauri bundles the system webview rather than shipping Chromium (~150 MB savings vs Electron).
|
||||
3. **Cross-platform** — Windows, macOS, Linux from the same codebase.
|
||||
4. **Security model** — Tauri's capability-based permissions system restricts frontend access to explicitly allowed Rust commands.
|
||||
|
||||
### Why Not Electron / Flutter / Native
|
||||
|
||||
| Option | Rejected Because |
|
||||
|--------|-----------------|
|
||||
| Electron | 150+ MB bundle, no Rust integration, duplicates webview |
|
||||
| Flutter | No serial port plugins, Dart FFI to Rust is awkward |
|
||||
| Native (GTK/Qt) | Platform-specific UI code, no web component reuse |
|
||||
| Web-only (PWA) | Cannot access serial ports or raw UDP |
|
||||
|
||||
## Decision
|
||||
|
||||
Build a Tauri v2 desktop application as a new crate in the Rust workspace. The frontend uses TypeScript with React and Vite. The Rust backend exposes Tauri commands that bridge the frontend to serial ports, UDP sockets, HTTP management endpoints, and the sensing server process.
|
||||
|
||||
### 1. Workspace Integration
|
||||
|
||||
Add a new crate to the workspace:
|
||||
|
||||
```
|
||||
rust-port/wifi-densepose-rs/
|
||||
Cargo.toml # Add "crates/wifi-densepose-desktop" to members
|
||||
crates/
|
||||
wifi-densepose-desktop/ # NEW — Tauri app crate
|
||||
Cargo.toml
|
||||
tauri.conf.json
|
||||
capabilities/
|
||||
default.json # Tauri v2 capability permissions
|
||||
icons/ # App icons (all platforms)
|
||||
src/
|
||||
main.rs # Tauri entry point
|
||||
lib.rs # Command module re-exports
|
||||
commands/
|
||||
mod.rs
|
||||
discovery.rs # Node discovery commands
|
||||
flash.rs # Firmware flashing commands
|
||||
ota.rs # OTA update commands
|
||||
wasm.rs # WASM module management commands
|
||||
server.rs # Sensing server lifecycle commands
|
||||
provision.rs # NVS provisioning commands
|
||||
serial.rs # Serial port enumeration
|
||||
state.rs # Tauri managed state
|
||||
discovery/
|
||||
mod.rs
|
||||
mdns.rs # mDNS service discovery
|
||||
udp_broadcast.rs # UDP broadcast probe
|
||||
flash/
|
||||
mod.rs
|
||||
espflash.rs # Rust-native ESP32 flashing (via espflash crate)
|
||||
esptool.rs # Fallback: bundled esptool.py wrapper
|
||||
frontend/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vite.config.ts
|
||||
index.html
|
||||
src/
|
||||
main.tsx
|
||||
App.tsx
|
||||
routes.tsx
|
||||
hooks/
|
||||
useNodes.ts # Node discovery and status polling
|
||||
useServer.ts # Sensing server state
|
||||
useWebSocket.ts # WS connection to sensing server
|
||||
stores/
|
||||
nodeStore.ts # Zustand store for discovered nodes
|
||||
serverStore.ts # Sensing server process state
|
||||
settingsStore.ts # User preferences (dark mode, ports)
|
||||
pages/
|
||||
Dashboard.tsx # Hardware management overview
|
||||
NodeDetail.tsx # Single node detail + config
|
||||
FlashFirmware.tsx # Firmware flashing wizard
|
||||
WasmModules.tsx # WASM module manager
|
||||
SensingView.tsx # Live sensing data visualization
|
||||
MeshTopology.tsx # Multi-node mesh topology view
|
||||
Settings.tsx # App settings and preferences
|
||||
components/
|
||||
NodeCard.tsx # Node status card (health, version, signal)
|
||||
NodeList.tsx # Discovered node list
|
||||
FirmwareProgress.tsx # Flash/OTA progress indicator
|
||||
LogViewer.tsx # Scrolling log output
|
||||
SignalChart.tsx # Real-time CSI signal chart
|
||||
PoseOverlay.tsx # Pose skeleton overlay
|
||||
MeshGraph.tsx # D3/force-graph mesh topology
|
||||
SerialPortSelect.tsx # Serial port dropdown
|
||||
ProvisionForm.tsx # NVS provisioning form
|
||||
lib/
|
||||
tauri.ts # Typed Tauri invoke wrappers
|
||||
types.ts # Shared TypeScript types
|
||||
```
|
||||
|
||||
### 2. Rust Backend — Tauri Commands
|
||||
|
||||
#### 2.1 Node Discovery
|
||||
|
||||
```rust
|
||||
// commands/discovery.rs
|
||||
|
||||
/// Discover ESP32 CSI nodes on the local network.
|
||||
/// Strategy 1: mDNS — nodes announce _ruview._tcp service
|
||||
/// Strategy 2: UDP broadcast probe on port 5005 (CSI aggregator port)
|
||||
/// Strategy 3: HTTP health check sweep on port 8032 (OTA server)
|
||||
#[tauri::command]
|
||||
async fn discover_nodes(timeout_ms: u64) -> Result<Vec<DiscoveredNode>, String>;
|
||||
|
||||
/// Get detailed status from a specific node via HTTP.
|
||||
/// Calls GET /ota/status on port 8032.
|
||||
#[tauri::command]
|
||||
async fn get_node_status(ip: String) -> Result<NodeStatus, String>;
|
||||
|
||||
/// Subscribe to node health updates (periodic polling).
|
||||
#[tauri::command]
|
||||
async fn watch_nodes(interval_ms: u64, state: State<'_, AppState>) -> Result<(), String>;
|
||||
```
|
||||
|
||||
The `DiscoveredNode` struct:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct DiscoveredNode {
|
||||
pub ip: String,
|
||||
pub mac: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub node_id: u8,
|
||||
pub firmware_version: Option<String>,
|
||||
pub tdm_slot: Option<u8>,
|
||||
pub tdm_total: Option<u8>,
|
||||
pub edge_tier: Option<u8>,
|
||||
pub uptime_secs: Option<u64>,
|
||||
pub discovery_method: DiscoveryMethod, // Mdns | UdpProbe | HttpSweep
|
||||
pub last_seen: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Firmware Flashing
|
||||
|
||||
```rust
|
||||
// commands/flash.rs
|
||||
|
||||
/// List available serial ports with chip detection.
|
||||
#[tauri::command]
|
||||
async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String>;
|
||||
|
||||
/// Flash firmware binary to an ESP32 via serial port.
|
||||
/// Uses the `espflash` crate for Rust-native flashing (no Python dependency).
|
||||
/// Falls back to bundled esptool.py if espflash fails.
|
||||
/// Emits progress events via Tauri event system.
|
||||
#[tauri::command]
|
||||
async fn flash_firmware(
|
||||
port: String,
|
||||
firmware_path: String,
|
||||
chip: Chip, // Esp32, Esp32s3, Esp32c3
|
||||
baud: Option<u32>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<FlashResult, String>;
|
||||
|
||||
/// Read firmware info from a connected ESP32 (chip type, flash size, MAC).
|
||||
#[tauri::command]
|
||||
async fn read_chip_info(port: String) -> Result<ChipInfo, String>;
|
||||
```
|
||||
|
||||
Flash progress is emitted as Tauri events:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct FlashProgress {
|
||||
pub phase: FlashPhase, // Connecting | Erasing | Writing | Verifying
|
||||
pub progress_pct: f32, // 0.0 - 100.0
|
||||
pub bytes_written: u64,
|
||||
pub bytes_total: u64,
|
||||
pub speed_bps: u64,
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 OTA Updates
|
||||
|
||||
```rust
|
||||
// commands/ota.rs
|
||||
|
||||
/// Push firmware to a node via HTTP OTA (port 8032).
|
||||
/// Includes PSK authentication per ADR-050.
|
||||
#[tauri::command]
|
||||
async fn ota_update(
|
||||
node_ip: String,
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<OtaResult, String>;
|
||||
|
||||
/// Get OTA status from a node (current version, partition info).
|
||||
#[tauri::command]
|
||||
async fn ota_status(node_ip: String, psk: Option<String>) -> Result<OtaStatus, String>;
|
||||
|
||||
/// Batch OTA update — push firmware to multiple nodes sequentially.
|
||||
/// Skips nodes already running the target version.
|
||||
#[tauri::command]
|
||||
async fn ota_batch_update(
|
||||
nodes: Vec<String>, // IPs
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<Vec<OtaResult>, String>;
|
||||
```
|
||||
|
||||
#### 2.4 WASM Module Management
|
||||
|
||||
```rust
|
||||
// commands/wasm.rs
|
||||
|
||||
/// List WASM modules loaded on a node.
|
||||
/// Calls GET /wasm/list on port 8032.
|
||||
#[tauri::command]
|
||||
async fn wasm_list(node_ip: String) -> Result<Vec<WasmModule>, String>;
|
||||
|
||||
/// Upload a WASM module to a node.
|
||||
/// Calls POST /wasm/upload on port 8032 with binary payload.
|
||||
#[tauri::command]
|
||||
async fn wasm_upload(
|
||||
node_ip: String,
|
||||
wasm_path: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<WasmUploadResult, String>;
|
||||
|
||||
/// Start/stop a WASM module on a node.
|
||||
#[tauri::command]
|
||||
async fn wasm_control(
|
||||
node_ip: String,
|
||||
module_id: String,
|
||||
action: WasmAction, // Start | Stop | Unload
|
||||
) -> Result<(), String>;
|
||||
```
|
||||
|
||||
#### 2.5 Sensing Server Lifecycle
|
||||
|
||||
```rust
|
||||
// commands/server.rs
|
||||
|
||||
/// Start the sensing server as a managed child process.
|
||||
/// The server binary is either bundled with the Tauri app (sidecar)
|
||||
/// or discovered on PATH.
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
config: ServerConfig,
|
||||
state: State<'_, AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String>;
|
||||
|
||||
/// Stop the managed sensing server process.
|
||||
#[tauri::command]
|
||||
async fn stop_server(state: State<'_, AppState>) -> Result<(), String>;
|
||||
|
||||
/// Get sensing server status (running/stopped, PID, ports, uptime).
|
||||
#[tauri::command]
|
||||
async fn server_status(state: State<'_, AppState>) -> Result<ServerStatus, String>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub http_port: u16, // Default: 8080
|
||||
pub ws_port: u16, // Default: 8765
|
||||
pub udp_port: u16, // Default: 5005
|
||||
pub static_dir: Option<String>, // Path to UI static files
|
||||
pub model_dir: Option<String>, // Path to ML models
|
||||
pub log_level: String, // trace, debug, info, warn, error
|
||||
}
|
||||
```
|
||||
|
||||
The sensing server is bundled as a Tauri sidecar binary. Tauri v2 supports sidecar binaries via `externalBin` in `tauri.conf.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bundle": {
|
||||
"externalBin": ["sensing-server"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 NVS Provisioning
|
||||
|
||||
```rust
|
||||
// commands/provision.rs
|
||||
|
||||
/// Provision NVS configuration to an ESP32 via serial port.
|
||||
/// Replaces the Python provision.py script with a Rust-native implementation.
|
||||
/// Generates NVS partition binary and flashes it to the NVS partition offset.
|
||||
#[tauri::command]
|
||||
async fn provision_node(
|
||||
port: String,
|
||||
config: NvsConfig,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<ProvisionResult, String>;
|
||||
|
||||
/// Read current NVS configuration from a connected ESP32.
|
||||
/// Reads the NVS partition and parses key-value pairs.
|
||||
#[tauri::command]
|
||||
async fn read_nvs(port: String) -> Result<NvsConfig, String>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NvsConfig {
|
||||
pub wifi_ssid: Option<String>,
|
||||
pub wifi_password: Option<String>,
|
||||
pub target_ip: Option<String>,
|
||||
pub target_port: Option<u16>,
|
||||
pub node_id: Option<u8>,
|
||||
pub tdm_slot: Option<u8>,
|
||||
pub tdm_total: Option<u8>,
|
||||
pub edge_tier: Option<u8>,
|
||||
pub presence_thresh: Option<u16>,
|
||||
pub fall_thresh: Option<u16>,
|
||||
pub vital_window: Option<u16>,
|
||||
pub vital_interval_ms: Option<u16>,
|
||||
pub top_k_count: Option<u8>,
|
||||
pub hop_count: Option<u8>,
|
||||
pub channel_list: Option<Vec<u8>>,
|
||||
pub dwell_ms: Option<u32>,
|
||||
pub power_duty: Option<u8>,
|
||||
pub wasm_max_modules: Option<u8>,
|
||||
pub wasm_verify: Option<bool>,
|
||||
pub wasm_pubkey: Option<Vec<u8>>,
|
||||
pub ota_psk: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Frontend Architecture
|
||||
|
||||
#### 3.1 Tech Stack
|
||||
|
||||
| Layer | Choice | Rationale |
|
||||
|-------|--------|-----------|
|
||||
| Framework | React 19 | Component model, ecosystem, team familiarity |
|
||||
| Build | Vite 6 | Fast HMR, Tauri plugin support |
|
||||
| State | Zustand | Lightweight, no boilerplate, works with Tauri events |
|
||||
| Routing | React Router v7 | File-based routes, type-safe |
|
||||
| UI Components | shadcn/ui + Tailwind CSS | Accessible, customizable, no runtime CSS-in-JS |
|
||||
| Charts | Recharts or visx | Real-time signal visualization |
|
||||
| Topology Graph | D3 force-directed | Mesh network visualization |
|
||||
| Serial UI | Custom | Tauri command integration |
|
||||
| Icons | Lucide React | Consistent, tree-shakeable |
|
||||
|
||||
#### 3.2 Page Layout
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| RuView [Settings] [?] |
|
||||
+-------+----------------------------------+
|
||||
| | |
|
||||
| Nav | Dashboard / Active Page |
|
||||
| | |
|
||||
| [D] | +--------+ +--------+ +------+ |
|
||||
| [F] | | Node 1 | | Node 2 | | +Add | |
|
||||
| [W] | +--------+ +--------+ +------+ |
|
||||
| [S] | |
|
||||
| [M] | Server Status: Running |
|
||||
| [T] | +--------------------------+ |
|
||||
| | | Live Signal / Pose View | |
|
||||
| | +--------------------------+ |
|
||||
+-------+----------------------------------+
|
||||
| Status Bar: 3 nodes | Server: :8080 |
|
||||
+------------------------------------------+
|
||||
|
||||
Nav items:
|
||||
[D] Dashboard — overview of all nodes and server
|
||||
[F] Flash — firmware flashing wizard
|
||||
[W] WASM — edge module management
|
||||
[S] Sensing — live sensing data view
|
||||
[M] Mesh — topology visualization
|
||||
[T] Settings — ports, paths, preferences
|
||||
```
|
||||
|
||||
#### 3.3 Dashboard Page
|
||||
|
||||
The dashboard is the primary landing page showing:
|
||||
|
||||
1. **Node Grid** — cards for each discovered ESP32 node showing:
|
||||
- IP address and hostname
|
||||
- Firmware version (with update indicator if newer available)
|
||||
- Node ID and TDM slot assignment
|
||||
- Edge processing tier (raw / stats / vitals)
|
||||
- Signal quality indicator (last CSI frame age)
|
||||
- Health status (online/offline/degraded)
|
||||
- Quick actions: OTA update, configure, view logs
|
||||
|
||||
2. **Sensing Server Panel** — start/stop button, port configuration, log tail
|
||||
|
||||
3. **Discovery Controls** — scan button, auto-discovery toggle, network range filter
|
||||
|
||||
#### 3.4 Flash Firmware Page
|
||||
|
||||
A wizard-style flow:
|
||||
|
||||
1. **Select Port** — dropdown of detected serial ports with chip info
|
||||
2. **Select Firmware** — file picker for `.bin` files, or select from bundled builds
|
||||
3. **Configure** — chip type, baud rate, flash mode
|
||||
4. **Flash** — progress bar with phase indicators (connecting, erasing, writing, verifying)
|
||||
5. **Provision** — optional NVS provisioning form (WiFi, target IP, TDM, edge tier)
|
||||
6. **Verify** — serial monitor showing boot log, success/fail indicator
|
||||
|
||||
#### 3.5 WASM Module Manager Page
|
||||
|
||||
| Column | Content |
|
||||
|--------|---------|
|
||||
| Module ID | Auto-assigned by node |
|
||||
| Name | Filename of uploaded `.wasm` |
|
||||
| Size | Module size in KB |
|
||||
| Status | Running / Stopped / Error |
|
||||
| Node | Which ESP32 node it runs on |
|
||||
| Actions | Start / Stop / Unload / View Logs |
|
||||
|
||||
Upload panel: drag-and-drop `.wasm` file, select target node(s), upload button.
|
||||
|
||||
#### 3.6 Sensing View Page
|
||||
|
||||
Embeds the existing web UI (`ui/`) via an iframe pointing at the sensing server's static file route, or builds native React components that connect to the same WebSocket API. The native approach is preferred because it allows:
|
||||
|
||||
- Tighter integration with the node status sidebar
|
||||
- Shared state between hardware management and visualization
|
||||
- Offline access to recorded data
|
||||
|
||||
Key visualization components:
|
||||
- **CSI Heatmap** — subcarrier amplitude over time
|
||||
- **Signal Field** — 2D signal strength visualization
|
||||
- **Pose Skeleton** — detected body keypoints and connections
|
||||
- **Vital Signs** — real-time breathing rate and heart rate charts
|
||||
- **Activity Classification** — current activity label with confidence
|
||||
|
||||
#### 3.7 Mesh Topology Page
|
||||
|
||||
A force-directed graph showing:
|
||||
- Nodes as circles (color = health status, size = edge tier)
|
||||
- Edges between nodes that can see each other
|
||||
- TDM slot labels on each node
|
||||
- Sync status indicators (in-sync / drifting / lost)
|
||||
- Click a node to navigate to its detail page
|
||||
|
||||
### 4. Platform-Specific Considerations
|
||||
|
||||
#### 4.1 macOS
|
||||
|
||||
- **Serial driver signing**: CP210x and CH340 drivers require user approval in System Preferences > Security
|
||||
- **App signing**: Tauri apps must be signed and notarized for distribution outside the App Store
|
||||
- **USB permissions**: No special permissions needed beyond driver installation
|
||||
- **CoreWLAN**: The sensing server can use CoreWLAN for WiFi scanning (ADR-025); the desktop app inherits this capability
|
||||
|
||||
#### 4.2 Windows
|
||||
|
||||
- **COM port access**: Windows assigns COM port numbers; the app lists them via the Windows Registry or `SetupDi` API
|
||||
- **Driver installation**: USB-to-serial drivers (CP210x, CH340, FTDI) must be installed; the app can detect missing drivers and link to downloads
|
||||
- **Firewall**: The sensing server's UDP listener may trigger Windows Firewall prompts; the app should pre-configure rules or guide the user
|
||||
- **Code signing**: EV certificate required for SmartScreen trust; unsigned apps trigger warnings
|
||||
|
||||
#### 4.3 Linux
|
||||
|
||||
- **udev rules**: ESP32 serial ports (`/dev/ttyUSB*`, `/dev/ttyACM*`) require udev rules for non-root access. The app bundles a `99-ruview-esp32.rules` file and offers to install it:
|
||||
```
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", MODE="0666" # CP210x
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", MODE="0666" # CH340
|
||||
```
|
||||
- **AppImage/deb/rpm**: Tauri supports all three packaging formats
|
||||
- **Wayland vs X11**: Tauri uses webkit2gtk which works on both
|
||||
|
||||
### 5. Cargo.toml for the Desktop Crate
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "wifi-densepose-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Tauri desktop frontend for RuView WiFi DensePose"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2" # Sidecar process management
|
||||
tauri-plugin-dialog = "2" # File picker dialogs
|
||||
tauri-plugin-fs = "2" # Filesystem access
|
||||
tauri-plugin-process = "2" # Process management
|
||||
tauri-plugin-notification = "2" # Desktop notifications
|
||||
|
||||
# Workspace crates
|
||||
wifi-densepose-hardware = { workspace = true }
|
||||
wifi-densepose-config = { workspace = true }
|
||||
wifi-densepose-core = { workspace = true }
|
||||
|
||||
# Serial port access
|
||||
serialport = { workspace = true }
|
||||
|
||||
# ESP32 flashing (Rust-native, replaces esptool.py)
|
||||
espflash = "3"
|
||||
|
||||
# Network discovery
|
||||
mdns-sd = "0.11" # mDNS/DNS-SD service discovery
|
||||
|
||||
# HTTP client for OTA and WASM management
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
```
|
||||
|
||||
### 6. Tauri Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "RuView",
|
||||
"version": "0.3.0",
|
||||
"identifier": "net.ruv.ruview",
|
||||
"build": {
|
||||
"frontendDist": "../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd frontend && npm run dev",
|
||||
"beforeBuildCommand": "cd frontend && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "RuView - WiFi DensePose",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": ["sensing-server"],
|
||||
"linux": {
|
||||
"deb": { "depends": ["libwebkit2gtk-4.1-0"] },
|
||||
"appimage": { "bundleMediaFramework": true }
|
||||
},
|
||||
"windows": {
|
||||
"wix": { "language": "en-US" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Tauri v2 Capabilities (Permissions)
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "RuView default capability set",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"process:allow-exit",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Development Workflow
|
||||
|
||||
```bash
|
||||
# Prerequisites
|
||||
cargo install tauri-cli@^2
|
||||
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/frontend
|
||||
npm install
|
||||
|
||||
# Development (hot-reload frontend + Rust rebuild)
|
||||
cd rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
cargo tauri dev
|
||||
|
||||
# Production build
|
||||
cargo tauri build
|
||||
|
||||
# Build sensing-server sidecar (must be done before tauri build)
|
||||
cargo build --release -p wifi-densepose-sensing-server
|
||||
# Copy to sidecar location:
|
||||
# target/release/sensing-server -> crates/wifi-densepose-desktop/binaries/sensing-server-{arch}
|
||||
```
|
||||
|
||||
### 9. Persistent Node Registry
|
||||
|
||||
Discovery alone is transient — nodes appear when they broadcast, disappear when they don't. A persistent local registry transforms discovery into **reconciliation**.
|
||||
|
||||
```
|
||||
~/.ruview/nodes.db (SQLite via rusqlite)
|
||||
```
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE nodes (
|
||||
mac TEXT PRIMARY KEY, -- e.g. "AA:BB:CC:DD:EE:FF"
|
||||
last_ip TEXT, -- last known IP
|
||||
last_seen INTEGER NOT NULL, -- Unix timestamp
|
||||
firmware TEXT, -- e.g. "0.3.1"
|
||||
chip TEXT DEFAULT 'esp32s3', -- esp32, esp32s3, esp32c3
|
||||
mesh_role TEXT DEFAULT 'node', -- 'coordinator' | 'node' | 'aggregator'
|
||||
tdm_slot INTEGER, -- assigned TDM slot index
|
||||
capabilities TEXT, -- JSON: {"wasm": true, "ota": true, "csi": true}
|
||||
friendly_name TEXT, -- user-assigned label
|
||||
notes TEXT -- free-form notes
|
||||
);
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- On discovery broadcast, upsert into registry (update `last_ip`, `last_seen`, `firmware`)
|
||||
- Dashboard shows **all registered nodes**, dimming those not seen recently
|
||||
- User can manually add nodes by MAC/IP (for networks without mDNS)
|
||||
- Export/import registry as JSON for fleet management across machines
|
||||
- Node health history (uptime, last OTA, error count) tracked over time
|
||||
|
||||
This means the desktop app **remembers the mesh** across restarts, which is critical for field deployments where nodes may be offline temporarily.
|
||||
|
||||
### 10. OTA Safety Gate — Rolling Updates
|
||||
|
||||
Mesh deployments cannot tolerate all nodes rebooting simultaneously. The OTA subsystem includes a **rolling update mode** that preserves sensing continuity:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct BatchOtaConfig {
|
||||
/// Update strategy
|
||||
pub strategy: OtaStrategy,
|
||||
/// Max nodes updating concurrently
|
||||
pub max_concurrent: usize,
|
||||
/// Delay between batches (seconds)
|
||||
pub batch_delay_secs: u64,
|
||||
/// Abort if any node fails
|
||||
pub fail_fast: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum OtaStrategy {
|
||||
/// Update one node at a time, wait for it to rejoin mesh
|
||||
Sequential,
|
||||
/// Update non-adjacent TDM slots to maintain coverage
|
||||
TdmSafe,
|
||||
/// Update all nodes simultaneously (development only)
|
||||
Parallel,
|
||||
}
|
||||
```
|
||||
|
||||
**`TdmSafe` strategy:**
|
||||
|
||||
1. Sort nodes by TDM slot index
|
||||
2. Update even-slot nodes first (slots 0, 2, 4...)
|
||||
3. Wait for each to reboot and rejoin mesh (verified via beacon)
|
||||
4. Then update odd-slot nodes (slots 1, 3, 5...)
|
||||
5. At no point are adjacent nodes offline simultaneously
|
||||
|
||||
**UI flow:**
|
||||
|
||||
- User selects target firmware + target nodes
|
||||
- App shows pre-update diff (current vs new version per node)
|
||||
- Progress bar per node with states: `queued → uploading → rebooting → verifying → done`
|
||||
- Abort button halts remaining updates without rolling back completed ones
|
||||
- Post-update health check confirms all nodes are sensing
|
||||
|
||||
### 11. Plugin Architecture (Future)
|
||||
|
||||
This desktop tool is quietly becoming the **control plane for RuView**. Once it manages discovery, firmware, OTA, WASM, sensing, and mesh topology, plugin extensibility becomes inevitable:
|
||||
|
||||
- **Firmware management** today → **swarm orchestration** tomorrow
|
||||
- **WASM upload** today → **edge module marketplace** tomorrow
|
||||
- **Sensing view** today → **activity classification dashboard** tomorrow
|
||||
|
||||
The Tauri command surface should be designed with this trajectory in mind:
|
||||
|
||||
- Commands are grouped by bounded context (already done)
|
||||
- Each context can be extended by loading additional Tauri plugins
|
||||
- The node registry becomes the source of truth for all plugins
|
||||
- Event bus (Tauri's `emit`/`listen`) provides cross-plugin communication
|
||||
|
||||
This does NOT mean building a plugin system in Phase 1. It means keeping the architecture open to it: no hardcoded views, state flows through the registry, commands are typed and versioned.
|
||||
|
||||
### 12. Security Considerations
|
||||
|
||||
1. **PSK Storage**: OTA PSK tokens are stored in the OS keychain via `tauri-plugin-stronghold` or the platform's native credential store, never in plaintext config files.
|
||||
|
||||
2. **Serial Port Access**: Tauri's capability system restricts which commands the frontend can invoke. Serial port access is only available through the typed `flash_firmware` and `provision_node` commands, not raw serial I/O.
|
||||
|
||||
3. **Network Requests**: OTA and WASM management commands only communicate with nodes on the local network. The app does not make external network requests except for update checks (opt-in).
|
||||
|
||||
4. **Firmware Validation**: Before flashing, the app validates the firmware binary header (ESP32 image magic bytes, partition table offset) to prevent bricking.
|
||||
|
||||
5. **WASM Signature Verification**: The desktop app can sign WASM modules before upload using a locally stored Ed25519 key pair, complementing the node-side verification (ADR-040).
|
||||
|
||||
### 13. Implementation Phases
|
||||
|
||||
| Phase | Scope | Effort | Priority |
|
||||
|-------|-------|--------|----------|
|
||||
| **Phase 1: Skeleton** | Tauri project scaffolding, workspace integration, basic window with React | 1 week | P0 |
|
||||
| **Phase 2: Discovery** | Serial port listing, UDP/mDNS node discovery, dashboard with node cards | 1 week | P0 |
|
||||
| **Phase 3: Flash** | espflash integration, firmware flashing wizard with progress events | 1 week | P0 |
|
||||
| **Phase 4: Server** | Sidecar sensing server start/stop, log viewer, status panel | 1 week | P1 |
|
||||
| **Phase 5: OTA** | HTTP OTA with PSK auth, batch update, version comparison | 1 week | P1 |
|
||||
| **Phase 6: Provisioning** | NVS read/write via serial, provisioning form, mesh config file | 1 week | P1 |
|
||||
| **Phase 7: WASM** | Module upload/list/start/stop, drag-and-drop, per-module logs | 1 week | P2 |
|
||||
| **Phase 8: Sensing** | WebSocket integration, live signal charts, pose overlay | 2 weeks | P2 |
|
||||
| **Phase 9: Mesh View** | Force-directed topology graph, TDM slot visualization, sync status | 1 week | P2 |
|
||||
| **Phase 10: Polish** | App signing, auto-update, udev rules installer, onboarding wizard | 1 week | P3 |
|
||||
|
||||
Total estimated effort: ~11 weeks for a single developer.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Single pane of glass** — all hardware management, sensing, and visualization in one app
|
||||
- **No Python dependency** — Rust-native `espflash` replaces `esptool.py` for firmware flashing
|
||||
- **Replaces 6+ CLI tools** — flash, provision, OTA, WASM management, server control, visualization
|
||||
- **Accessible to non-developers** — GUI replaces CLI flags and curl commands
|
||||
- **Cross-platform** — one codebase for Windows, macOS, Linux
|
||||
- **Workspace integration** — shares types, config, and hardware crates with sensing server
|
||||
- **Small binary** — ~15-20 MB vs ~150 MB for Electron equivalent
|
||||
|
||||
### Negative
|
||||
|
||||
- **New frontend dependency** — introduces Node.js/npm build step into the Rust workspace
|
||||
- **Tauri version churn** — Tauri v2 is recent; API stability is not yet proven at scale
|
||||
- **webkit2gtk on Linux** — depends on system webview version; old distros may have stale webkit
|
||||
- **espflash limitations** — the `espflash` crate may not support all chip variants or flash modes that `esptool.py` handles; fallback to bundled Python is needed
|
||||
- **Maintenance surface** — adds ~5,000 lines of TypeScript and ~2,000 lines of Rust
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| espflash cannot flash all ESP32 variants | Medium | High | Bundle esptool.py as fallback sidecar |
|
||||
| Tauri v2 breaking changes | Low | Medium | Pin to specific Tauri version; update in dedicated PRs |
|
||||
| Serial port access fails on macOS Sequoia+ | Medium | Medium | Test on latest macOS; document driver requirements |
|
||||
| webkit2gtk version mismatch on Linux | Medium | Low | Set minimum version in deb/rpm dependencies |
|
||||
| Sidecar sensing server fails to start | Low | Medium | Detect failure and show manual start instructions |
|
||||
|
||||
## References
|
||||
|
||||
- Tauri v2 documentation: https://v2.tauri.app/
|
||||
- espflash crate: https://crates.io/crates/espflash
|
||||
- mdns-sd crate: https://crates.io/crates/mdns-sd
|
||||
- ADR-012: ESP32 CSI Sensor Mesh
|
||||
- ADR-039: ESP32 Edge Intelligence
|
||||
- ADR-040: WASM Programmable Sensing
|
||||
- ADR-044: Provisioning Tool Enhancements
|
||||
- ADR-050: Quality Engineering — Security Hardening
|
||||
- ADR-051: Sensing Server Decomposition
|
||||
- `firmware/esp32-csi-node/` — ESP32 firmware source
|
||||
- `firmware/esp32-csi-node/provision.py` — Current provisioning script
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/` — Sensing server
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/` — Hardware crate
|
||||
- `ui/` — Existing web UI
|
||||
@@ -0,0 +1,274 @@
|
||||
# ADR-053: UI Design System — Dark Professional + Unity-Inspired Interface
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-03-06 |
|
||||
| Deciders | ruv |
|
||||
| Depends on | ADR-052 (Tauri Desktop Frontend) |
|
||||
|
||||
## Context
|
||||
|
||||
RuView Desktop (ADR-052) needs a UI design system that communicates precision and control — befitting a hardware management control plane for embedded sensing infrastructure. The interface must handle dense data (CSI heatmaps, node registries, log streams, mesh topologies) without feeling overwhelming, while remaining usable by both engineers and field operators.
|
||||
|
||||
Two design inspirations:
|
||||
|
||||
1. **Data-first professional tools** — Dense information displays where data speaks for itself. Clean typography, structured layouts, and deliberate use of color for status. The interface shows what matters and hides what doesn't. Think: network monitoring dashboards, embedded systems IDEs, infrastructure control panels.
|
||||
|
||||
2. **Unity Editor** — Dockable panel system, inspector/hierarchy/scene separation, property grids, dark professional theme, and dense-but-organized data display. Unity's UI is purpose-built for managing complex real-time systems — exactly what RuView needs.
|
||||
|
||||
The combination yields a professional control panel for WiFi sensing infrastructure. Data is organized into scannable panels with clear hierarchy. Status is communicated through consistent color coding. The layout adapts from high-level overview down to individual node details through progressive disclosure.
|
||||
|
||||
## Decision
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Data is the interface** — The system reveals patterns through visualization, not through explanation. Every pixel earns its place.
|
||||
2. **Precision typography** — Typography is clean and authoritative. Technical values are displayed without ambiguity. Labels are concise.
|
||||
3. **Panel-based layout** — Dockable regions inspired by Unity's panel system. The operator can see the entire mesh at a glance, then drill into any node.
|
||||
4. **Status through color** — Deliberate color coding: green (online), amber (degraded), red (offline/failed), blue (scanning/new). No gratuitous color.
|
||||
5. **Progressive disclosure** — Dashboard shows the overview. Clicking a node reveals its details. Summary first, detail on interaction.
|
||||
6. **Dual typography** — Monospace for all technical values (MAC addresses, firmware versions, CSI amplitudes). Sans-serif for labels and descriptions. The contrast signals "data vs. context."
|
||||
7. **Powered by rUv** — Subtle branding: footer tagline, about dialog, splash screen.
|
||||
|
||||
### Color System
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Background layers */
|
||||
--bg-base: #0d1117; /* App background */
|
||||
--bg-surface: #161b22; /* Panel backgrounds */
|
||||
--bg-elevated: #1c2333; /* Cards, modals, dropdowns */
|
||||
--bg-hover: #242d3d; /* Hover state */
|
||||
--bg-active: #2d3748; /* Active/selected state */
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e6edf3; /* Headings, primary content */
|
||||
--text-secondary: #8b949e; /* Labels, descriptions */
|
||||
--text-muted: #484f58; /* Disabled, hints, placeholders */
|
||||
|
||||
/* Status indicators */
|
||||
--status-online: #3fb950; /* Node online, healthy */
|
||||
--status-warning: #d29922; /* Degraded, needs attention */
|
||||
--status-error: #f85149; /* Offline, failed, critical */
|
||||
--status-info: #58a6ff; /* Scanning, discovering, info */
|
||||
|
||||
/* Accent */
|
||||
--accent: #7c3aed; /* rUv purple — primary actions */
|
||||
--accent-hover: #6d28d9;
|
||||
|
||||
/* Borders */
|
||||
--border: #30363d;
|
||||
--border-active: #58a6ff;
|
||||
|
||||
/* Data display */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### Typography Scale
|
||||
|
||||
```css
|
||||
/* Typographic hierarchy */
|
||||
.heading-xl { font: 600 28px/1.2 var(--font-sans); } /* Page titles */
|
||||
.heading-lg { font: 600 20px/1.3 var(--font-sans); } /* Section titles */
|
||||
.heading-md { font: 600 16px/1.4 var(--font-sans); } /* Card titles */
|
||||
.heading-sm { font: 600 13px/1.4 var(--font-sans); } /* Panel labels */
|
||||
.body { font: 400 14px/1.6 var(--font-sans); } /* Body text */
|
||||
.body-sm { font: 400 12px/1.5 var(--font-sans); } /* Captions */
|
||||
.data { font: 400 13px/1.4 var(--font-mono); } /* Technical values */
|
||||
.data-lg { font: 500 18px/1.2 var(--font-mono); } /* Key metrics */
|
||||
```
|
||||
|
||||
### Layout System
|
||||
|
||||
Three-region layout: navigation sidebar, node list, and detail inspector. Unity's docking system provides the mechanical framework.
|
||||
|
||||
```
|
||||
+--[ Sidebar ]--+--[ Main ]-------------------------------------+
|
||||
| | |
|
||||
| [Nav Items] | +--[ Command Bar ]---------------------------+ |
|
||||
| | | Breadcrumb | Actions | Search | |
|
||||
| Dashboard | +-------+-----------------------------------+ |
|
||||
| Nodes | | | | |
|
||||
| Flash | | Node | Detail Inspector | |
|
||||
| OTA | | List | (selected node properties) | |
|
||||
| Edge Modules | | | | |
|
||||
| Sensing | | | [Property Grid] | |
|
||||
| Mesh View | | | [Status Indicators] | |
|
||||
| Settings | | | [Action Buttons] | |
|
||||
| | | | | |
|
||||
+-[ Status Bar ]+--+-------+-----------------------------------+ |
|
||||
| rUv | 3 nodes online | Server: running | Port: 8080 |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Panel behaviors:**
|
||||
- Sidebar collapses to icon-only on narrow windows
|
||||
- Node List / Inspector split is resizable via drag handle
|
||||
- Inspector scrolls independently — drill into any node without losing the list
|
||||
- Status Bar shows global system state at a glance (node count, server status, port)
|
||||
|
||||
### Component Library
|
||||
|
||||
#### 1. NodeCard
|
||||
|
||||
```
|
||||
+-- NodeCard -----------------------------------------------+
|
||||
| [●] ESP32-S3 Node #2 firmware: 0.3.1 |
|
||||
| MAC: AA:BB:CC:DD:EE:FF TDM Slot: 2/4 |
|
||||
| IP: 192.168.1.42 Edge Tier: 1 |
|
||||
| Last seen: 3s ago [Flash] [OTA] [···] |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
Status dot uses `--status-online/warning/error`. Card background shifts on hover.
|
||||
|
||||
#### 2. FlashProgress
|
||||
|
||||
```
|
||||
+-- Flash Progress -----------------------------------------+
|
||||
| Flashing firmware to COM3 (ESP32-S3) |
|
||||
| |
|
||||
| Phase: Writing |
|
||||
| [████████████████████░░░░░░░░░░] 67.3% |
|
||||
| 412 KB / 612 KB • 38.2 KB/s • ~5s remaining |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
Progress bar uses `--accent` fill with subtle pulse animation during active writes.
|
||||
|
||||
#### 3. Mesh Topology View (Three.js)
|
||||
|
||||
Interactive 3D visualization of the sensing network. Each node is a sphere. Edges are lines representing signal paths. The coordinator node is visually distinct (larger, outlined ring). Built with **Three.js**, consistent with the existing visualization stack in `ui/observatory/js/` and `ui/components/`.
|
||||
|
||||
```
|
||||
+-- Mesh Topology ------------------------------------------+
|
||||
| |
|
||||
| [Node 0]----[Node 1] |
|
||||
| | \ / | |
|
||||
| | [Coordinator] | Coordinator = TDM master |
|
||||
| | / \ | |
|
||||
| [Node 2]----[Node 3] |
|
||||
| |
|
||||
| Drift: ±0.3ms | Cycle: 50ms | 4/4 nodes online |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Three.js implementation details:**
|
||||
- Force-directed layout computed on CPU, rendered as `THREE.Group` with `THREE.Mesh` (spheres) and `THREE.Line` (edges)
|
||||
- Node spheres use `THREE.MeshPhongMaterial` with emissive color matching `--status-online/warning/error`
|
||||
- Edge lines use `THREE.LineBasicMaterial` with opacity mapped to signal strength
|
||||
- Coordinator node rendered with `THREE.RingGeometry` outline
|
||||
- Camera: `OrbitControls` for pan/zoom/rotate, reset button returns to default view
|
||||
- Follows existing patterns: `BufferGeometry` + `BufferAttribute` for dynamic updates (see `ui/observatory/js/subcarrier-manifold.js`)
|
||||
- Raycasting for node click → opens detail in Inspector panel
|
||||
- Real-time updates as nodes join, leave, or change status — geometry attributes updated per frame
|
||||
|
||||
#### 4. PropertyGrid (Unity Inspector-style)
|
||||
|
||||
```
|
||||
+-- Node Inspector -----------------------------------------+
|
||||
| General [▼] |
|
||||
| MAC Address AA:BB:CC:DD:EE:FF |
|
||||
| IP Address 192.168.1.42 |
|
||||
| Firmware 0.3.1 |
|
||||
| Chip ESP32-S3 |
|
||||
| TDM Configuration [▼] |
|
||||
| Slot Index 2 |
|
||||
| Total Nodes 4 |
|
||||
| Cycle Period 50 ms |
|
||||
| Sync Drift +0.12 ms |
|
||||
| WASM Modules [▼] |
|
||||
| [0] activity_detect running 12.4 KB 83 us/f |
|
||||
| [1] vital_monitor stopped 8.1 KB — us/f |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
Collapsible sections with alternating row backgrounds for scanability.
|
||||
|
||||
#### 5. StatusBadge
|
||||
|
||||
```
|
||||
[● Online] [◐ Degraded] [○ Offline] [↻ Updating]
|
||||
```
|
||||
|
||||
Small inline badges with status dot, label, and optional tooltip.
|
||||
|
||||
#### 6. LogViewer
|
||||
|
||||
```
|
||||
+-- Server Log (auto-scroll) -----------[ Clear ] [ ⏸ ]---+
|
||||
| 19:42:01.234 INFO sensing-server HTTP on 127.0.0.1:8080|
|
||||
| 19:42:01.235 INFO sensing-server WS on 127.0.0.1:8765 |
|
||||
| 19:42:01.890 INFO udp_receiver CSI frame from .42 |
|
||||
| 19:42:02.003 WARN vital_signs Low signal quality |
|
||||
+-----------------------------------------------------------+
|
||||
```
|
||||
|
||||
Monospace, color-coded by log level (INFO=text, WARN=amber, ERROR=red). Virtual scrolling for performance.
|
||||
|
||||
### Spacing and Grid
|
||||
|
||||
```css
|
||||
/* 4px base grid */
|
||||
--space-1: 4px; /* Tight spacing (within components) */
|
||||
--space-2: 8px; /* Component internal padding */
|
||||
--space-3: 12px; /* Between related elements */
|
||||
--space-4: 16px; /* Card padding, section gaps */
|
||||
--space-5: 24px; /* Between sections */
|
||||
--space-6: 32px; /* Page-level spacing */
|
||||
--space-8: 48px; /* Major section breaks */
|
||||
|
||||
/* Panel dimensions */
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed: 52px;
|
||||
--statusbar-height: 28px;
|
||||
--toolbar-height: 44px;
|
||||
```
|
||||
|
||||
### Animations
|
||||
|
||||
Minimal and purposeful:
|
||||
- Panel collapse/expand: 200ms ease-out
|
||||
- Node card health transition: 300ms (color fade, not flash)
|
||||
- Progress bar fill: smooth 60fps CSS transition
|
||||
- Mesh graph: Three.js render loop at 60fps, force simulation on requestAnimationFrame
|
||||
- No loading spinners — use skeleton placeholders instead
|
||||
|
||||
### Branding
|
||||
|
||||
- **Splash screen**: rUv logo + "RuView Desktop" + version, 1.5s duration
|
||||
- **Status bar**: "Powered by rUv" in `--text-muted`, left-aligned
|
||||
- **About dialog**: rUv logo, version, license, links to GitHub and docs
|
||||
- **App icon**: Stylized WiFi signal + human silhouette in rUv purple (#7c3aed)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Professional, data-dense UI suitable for hardware management
|
||||
- Consistent design language across all 7 pages
|
||||
- Dual typography (mono + sans-serif) ensures readability at all information densities
|
||||
- Unity-inspired panels feel natural to engineers familiar with IDE/editor tools
|
||||
- Dark theme reduces eye strain for extended monitoring sessions
|
||||
|
||||
### Negative
|
||||
|
||||
- Custom design system means no off-the-shelf component library (shadcn/ui partially usable)
|
||||
- Dockable panels add complexity to the layout system
|
||||
- Dark-only theme may not suit all users (could add light mode later)
|
||||
|
||||
### Neutral
|
||||
|
||||
- The design system is CSS-only with React components — no heavy UI framework dependency
|
||||
- Component library can be extracted as a separate package if other rUv projects need it
|
||||
|
||||
## References
|
||||
|
||||
- ADR-052: Tauri Desktop Frontend
|
||||
- Unity Editor UI Guidelines: https://docs.unity3d.com/Manual/UIE-USS.html
|
||||
- Three.js (existing project dependency): `ui/observatory/js/`, `ui/components/`
|
||||
- Inter font: https://rsms.me/inter/
|
||||
- JetBrains Mono: https://www.jetbrains.com/lp/mono/
|
||||
@@ -0,0 +1,648 @@
|
||||
# Deployment Platform Domain Model
|
||||
|
||||
The Deployment Platform domain covers everything from cross-compiling the sensing server for ARM targets to managing TV box appliances running Armbian: provisioning devices, deploying binaries, configuring kiosk displays, and coordinating multi-room installations. It bridges the gap between the Sensing Server domain (which produces the binary) and the physical hardware it runs on.
|
||||
|
||||
This document defines the system using [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html) (DDD): bounded contexts that own their data and rules, aggregate roots that enforce invariants, value objects that carry meaning, and domain events that connect everything.
|
||||
|
||||
**Bounded Contexts:**
|
||||
|
||||
| # | Context | Responsibility | Key ADRs | Code |
|
||||
|---|---------|----------------|----------|------|
|
||||
| 1 | [Appliance Management](#1-appliance-management-context) | Device inventory, provisioning, health monitoring, OTA updates for TV box deployments | [ADR-046](../adr/ADR-046-android-tv-box-armbian-deployment.md) | `scripts/deploy/`, `config/armbian/` |
|
||||
| 2 | [Cross-Compilation](#2-cross-compilation-context) | Build pipeline for aarch64, binary packaging, CI/CD release artifacts | [ADR-046](../adr/ADR-046-android-tv-box-armbian-deployment.md) | `.github/workflows/`, `Cross.toml` |
|
||||
| 3 | [Display Kiosk](#3-display-kiosk-context) | HDMI output management, Chromium kiosk mode, screen rotation, auto-start | [ADR-046](../adr/ADR-046-android-tv-box-armbian-deployment.md) | `config/armbian/kiosk/` |
|
||||
| 4 | [WiFi CSI Bridge](#4-wifi-csi-bridge-context) | Custom WiFi driver CSI extraction, protocol translation to ESP32 binary format | [ADR-046](../adr/ADR-046-android-tv-box-armbian-deployment.md) | `tools/csi-bridge/` |
|
||||
| 5 | [Network Topology](#5-network-topology-context) | ESP32 mesh ↔ TV box connectivity, dedicated AP mode, multi-room routing | [ADR-046](../adr/ADR-046-android-tv-box-armbian-deployment.md), [ADR-012](../adr/ADR-012-esp32-csi-sensor-mesh.md) | `config/armbian/network/` |
|
||||
|
||||
---
|
||||
|
||||
## Domain-Driven Design Specification
|
||||
|
||||
### Ubiquitous Language
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Appliance** | A TV box running Armbian with the sensing server deployed, treated as a managed device in the fleet |
|
||||
| **Fleet** | The set of all appliances across a multi-room or multi-site installation |
|
||||
| **Deployment Package** | A self-contained archive containing the sensing-server binary, systemd unit, configuration, and setup script for a target architecture |
|
||||
| **Kiosk Mode** | Chromium running in full-screen, no-UI mode pointing at `localhost:3000`, auto-started by systemd on HDMI-connected appliances |
|
||||
| **CSI Bridge** | A userspace daemon that reads CSI data from a patched WiFi driver and re-encodes it as ESP32-compatible UDP frames for the sensing server |
|
||||
| **Dedicated AP** | An optional `hostapd`-managed WiFi access point on the TV box that creates an isolated network for ESP32 nodes |
|
||||
| **OTA Update** | Over-the-air binary replacement: download new sensing-server binary, validate checksum, swap via atomic rename, restart service |
|
||||
| **Reference Device** | A TV box model that has been tested and validated for Armbian + sensing-server deployment (e.g., T95 Max+ / S905X3) |
|
||||
| **Provisioning** | First-time setup of an appliance: flash Armbian to SD, deploy package, configure WiFi, start services |
|
||||
| **Health Beacon** | Periodic JSON payload sent by each appliance to a central coordinator (if multi-room) containing uptime, CPU temp, memory usage, inference latency, connected ESP32 count |
|
||||
|
||||
---
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
### 1. Appliance Management Context
|
||||
|
||||
**Responsibility:** Track deployed TV box appliances, provision new devices, monitor health, and coordinate OTA updates across the fleet.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| Appliance Management Context |
|
||||
+------------------------------------------------------------+
|
||||
| |
|
||||
| +----------------+ +----------------+ |
|
||||
| | Device | | Provisioning | |
|
||||
| | Registry | | Service | |
|
||||
| | (fleet state) | | (first-time | |
|
||||
| | | | setup) | |
|
||||
| +-------+--------+ +-------+--------+ |
|
||||
| | | |
|
||||
| +----------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Health Monitor | |
|
||||
| | (beacon receiver,| |
|
||||
| | thermal alerts, | |
|
||||
| | connectivity) | |
|
||||
| +--------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | OTA Updater | |
|
||||
| | (binary swap, | |
|
||||
| | rollback, | |
|
||||
| | checksum verify)| |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Aggregates:**
|
||||
|
||||
```rust
|
||||
/// Aggregate Root: A managed TV box appliance in the fleet.
|
||||
/// Identified by MAC address of the primary Ethernet interface.
|
||||
pub struct Appliance {
|
||||
/// Unique device identifier (Ethernet MAC address).
|
||||
pub device_id: DeviceId,
|
||||
/// Human-readable name (e.g., "living-room", "bedroom-1").
|
||||
pub name: String,
|
||||
/// Hardware model (e.g., "T95 Max+ S905X3").
|
||||
pub hardware_model: HardwareModel,
|
||||
/// Current deployment state.
|
||||
pub state: ApplianceState,
|
||||
/// Installed sensing-server version.
|
||||
pub server_version: SemanticVersion,
|
||||
/// Network configuration.
|
||||
pub network: NetworkConfig,
|
||||
/// Last received health beacon.
|
||||
pub last_health: Option<HealthBeacon>,
|
||||
/// Provisioning timestamp.
|
||||
pub provisioned_at: DateTime<Utc>,
|
||||
/// Connected ESP32 node IDs (from last beacon).
|
||||
pub connected_nodes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Lifecycle states for an appliance.
|
||||
pub enum ApplianceState {
|
||||
/// SD card prepared, not yet booted.
|
||||
Provisioned,
|
||||
/// Booted and running, health beacons received.
|
||||
Online,
|
||||
/// No health beacon for >5 minutes.
|
||||
Unreachable,
|
||||
/// OTA update in progress.
|
||||
Updating,
|
||||
/// Manual maintenance / stopped.
|
||||
Offline,
|
||||
/// Thermal throttling or hardware issue detected.
|
||||
Degraded,
|
||||
}
|
||||
```
|
||||
|
||||
**Value Objects:**
|
||||
|
||||
```rust
|
||||
/// Hardware model specification for a TV box.
|
||||
pub struct HardwareModel {
|
||||
/// Marketing name (e.g., "T95 Max+").
|
||||
pub name: String,
|
||||
/// SoC identifier (e.g., "Amlogic S905X3").
|
||||
pub soc: String,
|
||||
/// WiFi chipset (e.g., "RTL8822CS").
|
||||
pub wifi_chipset: String,
|
||||
/// Total RAM in MB.
|
||||
pub ram_mb: u32,
|
||||
/// eMMC storage in GB.
|
||||
pub emmc_gb: u32,
|
||||
/// Whether CSI bridge is supported for this WiFi chipset.
|
||||
pub csi_bridge_supported: bool,
|
||||
/// Armbian device tree name (e.g., "meson-sm1-sei610").
|
||||
pub armbian_dtb: String,
|
||||
}
|
||||
|
||||
/// Periodic health report from an appliance.
|
||||
pub struct HealthBeacon {
|
||||
pub device_id: DeviceId,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub uptime_secs: u64,
|
||||
pub cpu_temp_celsius: f32,
|
||||
pub cpu_usage_percent: f32,
|
||||
pub memory_used_mb: u32,
|
||||
pub memory_total_mb: u32,
|
||||
pub disk_used_percent: f32,
|
||||
pub inference_latency_ms: f32,
|
||||
pub connected_esp32_nodes: Vec<u8>,
|
||||
pub server_version: SemanticVersion,
|
||||
pub csi_frames_per_sec: f32,
|
||||
pub websocket_clients: u32,
|
||||
}
|
||||
|
||||
/// Network configuration for an appliance.
|
||||
pub struct NetworkConfig {
|
||||
/// Primary IP address (Ethernet or WiFi client).
|
||||
pub ip_address: IpAddr,
|
||||
/// Whether the appliance runs a dedicated AP for ESP32 nodes.
|
||||
pub dedicated_ap: Option<DedicatedApConfig>,
|
||||
/// UDP port for ESP32 CSI reception.
|
||||
pub csi_udp_port: u16, // default: 5005
|
||||
/// HTTP port for sensing server.
|
||||
pub http_port: u16, // default: 3000
|
||||
}
|
||||
|
||||
/// Configuration for a dedicated WiFi AP hosted by the appliance.
|
||||
pub struct DedicatedApConfig {
|
||||
/// SSID for the ESP32 mesh network.
|
||||
pub ssid: String,
|
||||
/// WPA2 passphrase.
|
||||
pub passphrase: String,
|
||||
/// Channel (1-11 for 2.4 GHz).
|
||||
pub channel: u8,
|
||||
/// DHCP range for connected ESP32 nodes.
|
||||
pub dhcp_range: (IpAddr, IpAddr),
|
||||
}
|
||||
|
||||
/// Unique device identifier (Ethernet MAC).
|
||||
pub struct DeviceId(pub [u8; 6]);
|
||||
|
||||
/// Semantic version for tracking installed software.
|
||||
pub struct SemanticVersion {
|
||||
pub major: u16,
|
||||
pub minor: u16,
|
||||
pub patch: u16,
|
||||
pub pre: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
**Domain Services:**
|
||||
- `ProvisioningService` — Generates Armbian SD card image with pre-configured deployment package, WiFi credentials, and systemd units
|
||||
- `HealthMonitorService` — Listens for UDP health beacons from fleet appliances, triggers alerts on thermal throttling (>80°C), unreachable (>5 min), or high memory usage (>90%)
|
||||
- `OtaUpdateService` — Downloads new binary from release URL, verifies SHA-256 checksum, performs atomic swap (`rename(new, current)`), restarts systemd service, rolls back if health beacon fails within 60s
|
||||
|
||||
**Invariants:**
|
||||
- Device ID (MAC address) is immutable after provisioning
|
||||
- OTA update refuses to proceed if current CPU temperature >75°C (thermal headroom)
|
||||
- Rollback is automatic if no healthy beacon is received within 60 seconds of restart
|
||||
- Dedicated AP SSID must not match the upstream WiFi SSID
|
||||
|
||||
---
|
||||
|
||||
### 2. Cross-Compilation Context
|
||||
|
||||
**Responsibility:** Build the sensing-server binary for ARM64 targets, package deployment archives, and manage CI/CD release artifacts.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| Cross-Compilation Context |
|
||||
+------------------------------------------------------------+
|
||||
| |
|
||||
| +----------------+ +----------------+ |
|
||||
| | Cross.toml | | GitHub Actions| |
|
||||
| | (target cfg) | | CI Matrix | |
|
||||
| +-------+--------+ +-------+--------+ |
|
||||
| | | |
|
||||
| +----------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Build Pipeline | |
|
||||
| | (cross build | |
|
||||
| | --target | |
|
||||
| | aarch64-unknown-| |
|
||||
| | linux-gnu) | |
|
||||
| +--------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Binary Packager | |
|
||||
| | (strip, compress,|---> .tar.gz artifact |
|
||||
| | bundle assets, | |
|
||||
| | systemd units) | |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Value Objects:**
|
||||
|
||||
```rust
|
||||
/// A packaged deployment archive for a target platform.
|
||||
pub struct DeploymentPackage {
|
||||
/// Target triple (e.g., "aarch64-unknown-linux-gnu").
|
||||
pub target: String,
|
||||
/// Sensing server binary (stripped).
|
||||
pub binary: PathBuf,
|
||||
/// Binary size in bytes.
|
||||
pub binary_size: u64,
|
||||
/// SHA-256 checksum of the binary.
|
||||
pub checksum: String,
|
||||
/// Systemd service unit file.
|
||||
pub service_unit: String,
|
||||
/// Static web UI assets directory.
|
||||
pub ui_assets: PathBuf,
|
||||
/// Armbian configuration files (kiosk, network, etc.).
|
||||
pub config_files: Vec<PathBuf>,
|
||||
/// Setup script (runs on first boot).
|
||||
pub setup_script: PathBuf,
|
||||
/// Version being packaged.
|
||||
pub version: SemanticVersion,
|
||||
}
|
||||
|
||||
/// Build target specification.
|
||||
pub struct BuildTarget {
|
||||
/// Rust target triple.
|
||||
pub triple: String,
|
||||
/// CPU architecture description.
|
||||
pub arch: String,
|
||||
/// Whether NEON SIMD is available.
|
||||
pub has_neon: bool,
|
||||
/// Cross-compilation Docker image.
|
||||
pub cross_image: String,
|
||||
/// Binary size limit in bytes.
|
||||
pub size_limit: u64,
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Targets:**
|
||||
|
||||
| Target Triple | Architecture | Use Case | Size Limit |
|
||||
|---------------|-------------|----------|------------|
|
||||
| `x86_64-unknown-linux-gnu` | x86-64 | PC/laptop (existing) | 30 MB |
|
||||
| `aarch64-unknown-linux-gnu` | ARM64 | TV box (Armbian) | 15 MB |
|
||||
| `armv7-unknown-linux-gnueabihf` | ARMv7 | Older TV boxes (32-bit) | 12 MB |
|
||||
| `x86_64-pc-windows-msvc` | x86-64 | Windows (existing) | 30 MB |
|
||||
|
||||
**Invariants:**
|
||||
- Stripped binary must be under size limit for target
|
||||
- SHA-256 checksum is computed and included in every deployment package
|
||||
- UI assets are embedded in binary via `include_dir!` or bundled alongside
|
||||
- No native GPU dependencies — CPU-only inference (candle or ONNX Runtime)
|
||||
|
||||
---
|
||||
|
||||
### 3. Display Kiosk Context
|
||||
|
||||
**Responsibility:** Manage HDMI output on TV box appliances, running Chromium in kiosk mode to display the sensing dashboard full-screen on boot.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| Display Kiosk Context |
|
||||
+------------------------------------------------------------+
|
||||
| |
|
||||
| +----------------+ +----------------+ |
|
||||
| | systemd | | Chromium | |
|
||||
| | autologin + | | Kiosk Launch | |
|
||||
| | X11/Wayland | | (full-screen, | |
|
||||
| | session | | no-UI bars) | |
|
||||
| +-------+--------+ +-------+--------+ |
|
||||
| | | |
|
||||
| +----------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Display Manager | |
|
||||
| | (resolution, | |
|
||||
| | rotation, | |
|
||||
| | overscan, | |
|
||||
| | sleep/wake) | |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Value Objects:**
|
||||
|
||||
```rust
|
||||
/// Display configuration for kiosk mode.
|
||||
pub struct KioskConfig {
|
||||
/// URL to display (default: "http://localhost:3000").
|
||||
pub url: String,
|
||||
/// Screen rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: u16,
|
||||
/// Whether to hide the mouse cursor.
|
||||
pub hide_cursor: bool,
|
||||
/// Auto-refresh interval in seconds (0 = disabled).
|
||||
pub auto_refresh_secs: u32,
|
||||
/// Display sleep schedule (e.g., off 23:00-06:00).
|
||||
pub sleep_schedule: Option<SleepSchedule>,
|
||||
/// Overscan compensation percentage (0-10).
|
||||
pub overscan_percent: u8,
|
||||
}
|
||||
|
||||
/// Sleep schedule for display power management.
|
||||
pub struct SleepSchedule {
|
||||
/// Time to turn display off (HH:MM local time).
|
||||
pub sleep_time: String,
|
||||
/// Time to turn display on (HH:MM local time).
|
||||
pub wake_time: String,
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants:**
|
||||
- Chromium kiosk starts only after sensing-server systemd unit is `active`
|
||||
- If Chromium crashes, systemd restarts it within 5 seconds (`Restart=always`)
|
||||
- Display sleep/wake uses CEC commands (HDMI-CEC) to control TV power when available
|
||||
- No browser UI elements are visible (address bar, scrollbars, etc.)
|
||||
|
||||
---
|
||||
|
||||
### 4. WiFi CSI Bridge Context
|
||||
|
||||
**Responsibility:** Extract CSI data from patched WiFi drivers on the TV box and translate it into ESP32-compatible binary frames for the sensing server. This is the Phase 2 custom firmware path.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| WiFi CSI Bridge Context |
|
||||
+------------------------------------------------------------+
|
||||
| |
|
||||
| +----------------+ +----------------+ |
|
||||
| | Patched WiFi | | CSI Reader | |
|
||||
| | Driver | | (Netlink / | |
|
||||
| | (kernel space)| | procfs / | |
|
||||
| | CSI hooks | | UDP socket) | |
|
||||
| +-------+--------+ +-------+--------+ |
|
||||
| | | |
|
||||
| +----------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Protocol | |
|
||||
| | Translator | |
|
||||
| | (chipset CSI → | |
|
||||
| | ESP32 binary | |
|
||||
| | 0xC5100001) | |
|
||||
| +--------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | UDP Sender | |
|
||||
| | (localhost:5005) |---> sensing-server |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Value Objects:**
|
||||
|
||||
```rust
|
||||
/// Raw CSI extraction from a WiFi chipset.
|
||||
pub struct ChipsetCsiFrame {
|
||||
/// Source chipset type.
|
||||
pub chipset: WifiChipset,
|
||||
/// Timestamp of extraction (kernel monotonic clock).
|
||||
pub timestamp_us: u64,
|
||||
/// Number of subcarriers (varies by chipset and bandwidth).
|
||||
pub n_subcarriers: u16,
|
||||
/// Number of spatial streams / antennas.
|
||||
pub n_streams: u8,
|
||||
/// Channel frequency in MHz.
|
||||
pub freq_mhz: u16,
|
||||
/// Bandwidth (20/40/80/160 MHz).
|
||||
pub bandwidth_mhz: u16,
|
||||
/// RSSI in dBm.
|
||||
pub rssi_dbm: i8,
|
||||
/// Noise floor estimate in dBm.
|
||||
pub noise_floor_dbm: i8,
|
||||
/// Complex CSI values (I/Q pairs) per subcarrier per stream.
|
||||
pub csi_matrix: Vec<Complex<f32>>,
|
||||
/// Source MAC address (BSSID of the AP being measured).
|
||||
pub source_mac: [u8; 6],
|
||||
}
|
||||
|
||||
/// Supported WiFi chipsets for CSI extraction.
|
||||
pub enum WifiChipset {
|
||||
/// Broadcom BCM43455 via Nexmon CSI patches.
|
||||
BroadcomBcm43455,
|
||||
/// Realtek RTL8822CS via modified rtw88 driver.
|
||||
RealtekRtl8822cs,
|
||||
/// MediaTek MT7661 via mt76 driver modification.
|
||||
MediatekMt7661,
|
||||
}
|
||||
|
||||
/// Translated frame in ESP32 binary protocol (ADR-018).
|
||||
pub struct Esp32CompatFrame {
|
||||
/// Magic: 0xC5100001
|
||||
pub magic: u32,
|
||||
/// Virtual node ID assigned to this WiFi interface.
|
||||
pub node_id: u8,
|
||||
/// Number of antennas / spatial streams.
|
||||
pub n_antennas: u8,
|
||||
/// Number of subcarriers (resampled to match ESP32 format).
|
||||
pub n_subcarriers: u8,
|
||||
/// Frequency in MHz.
|
||||
pub freq_mhz: u16,
|
||||
/// Sequence number (monotonic counter).
|
||||
pub sequence: u32,
|
||||
/// RSSI in dBm.
|
||||
pub rssi: i8,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor: i8,
|
||||
/// Amplitude values (extracted from complex CSI).
|
||||
pub amplitudes: Vec<f32>,
|
||||
/// Phase values (extracted from complex CSI).
|
||||
pub phases: Vec<f32>,
|
||||
}
|
||||
```
|
||||
|
||||
**Domain Services:**
|
||||
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
|
||||
- `SubcarrierResamplerService` — Resamples chipset-specific subcarrier counts to match ESP32 format (e.g., 256 → 128 via decimation or interpolation)
|
||||
- `ProtocolTranslatorService` — Converts `ChipsetCsiFrame` to `Esp32CompatFrame` with ADR-018 binary encoding
|
||||
- `CalibrationService` — Compensates for chipset-specific phase offsets, antenna spacing, and gain differences relative to ESP32 CSI
|
||||
|
||||
**Invariants:**
|
||||
- Bridge assigns virtual `node_id` in range 200-254 (reserved for non-ESP32 sources) to avoid collision with physical ESP32 node IDs (1-199)
|
||||
- Subcarrier resampling preserves frequency ordering (lowest to highest)
|
||||
- Phase values are unwrapped before encoding (continuous, not wrapped to ±π)
|
||||
- Bridge daemon starts only if a compatible patched driver is detected at boot
|
||||
|
||||
---
|
||||
|
||||
### 5. Network Topology Context
|
||||
|
||||
**Responsibility:** Manage network connectivity between ESP32 sensor nodes and TV box appliances, including optional dedicated AP mode and multi-room routing.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| Network Topology Context |
|
||||
+------------------------------------------------------------+
|
||||
| |
|
||||
| +----------------+ +----------------+ |
|
||||
| | hostapd | | DHCP Server | |
|
||||
| | (dedicated AP | | (dnsmasq for | |
|
||||
| | for ESP32 | | ESP32 nodes) | |
|
||||
| | mesh) | | | |
|
||||
| +-------+--------+ +-------+--------+ |
|
||||
| | | |
|
||||
| +----------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Topology Manager | |
|
||||
| | (node discovery, | |
|
||||
| | IP assignment, | |
|
||||
| | route config) | |
|
||||
| +--------+----------+ |
|
||||
| v |
|
||||
| +-------------------+ |
|
||||
| | Firewall Rules | |
|
||||
| | (iptables/nft: | |
|
||||
| | allow UDP 5005, | |
|
||||
| | block external | |
|
||||
| | access to ESP32 | |
|
||||
| | subnet) | |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Value Objects:**
|
||||
|
||||
```rust
|
||||
/// Network topology for a single-room deployment.
|
||||
pub struct RoomTopology {
|
||||
/// Appliance acting as the aggregator.
|
||||
pub appliance: DeviceId,
|
||||
/// Whether the appliance runs a dedicated AP.
|
||||
pub dedicated_ap: bool,
|
||||
/// Connected ESP32 nodes with their assigned IPs.
|
||||
pub nodes: Vec<EspNodeConnection>,
|
||||
/// Upstream network interface (Ethernet or WiFi client).
|
||||
pub uplink_interface: String,
|
||||
/// Sensing network interface (dedicated AP or same as uplink).
|
||||
pub sensing_interface: String,
|
||||
}
|
||||
|
||||
/// An ESP32 node's network connection to the appliance.
|
||||
pub struct EspNodeConnection {
|
||||
/// ESP32 node ID (from firmware NVS).
|
||||
pub node_id: u8,
|
||||
/// MAC address of the ESP32.
|
||||
pub mac: [u8; 6],
|
||||
/// Assigned IP address (via DHCP or static).
|
||||
pub ip: IpAddr,
|
||||
/// Last CSI frame received timestamp.
|
||||
pub last_seen: DateTime<Utc>,
|
||||
/// Average CSI frames per second from this node.
|
||||
pub fps: f32,
|
||||
}
|
||||
```
|
||||
|
||||
**Domain Services:**
|
||||
- `DedicatedApService` — Configures `hostapd` to create a WPA2 AP on the TV box's WiFi interface, assigns DHCP range via `dnsmasq`, sets up IP forwarding
|
||||
- `NodeDiscoveryService` — Monitors UDP port 5005 for new ESP32 node IDs, registers them in the topology, alerts on node departure (no frames for >30s)
|
||||
- `FirewallService` — Configures `nftables`/`iptables` to isolate the ESP32 subnet from the upstream LAN, allowing only UDP 5005 inbound and HTTP 3000 outbound
|
||||
|
||||
**Invariants:**
|
||||
- Dedicated AP uses a separate WiFi interface or virtual interface (not the uplink)
|
||||
- ESP32 subnet is isolated from upstream LAN by default (firewall rules)
|
||||
- If dedicated AP is disabled, ESP32 nodes must be on the same LAN subnet as the appliance
|
||||
- Node discovery does not require mDNS or any discovery protocol — ESP32 nodes are configured with the appliance's IP via NVS provisioning (ADR-044)
|
||||
|
||||
---
|
||||
|
||||
## Domain Events
|
||||
|
||||
| Event | Published By | Consumed By | Payload |
|
||||
|-------|-------------|-------------|---------|
|
||||
| `ApplianceProvisioned` | Appliance Mgmt | Fleet Dashboard | `{ device_id, name, hardware_model, ip }` |
|
||||
| `ApplianceOnline` | Appliance Mgmt | Fleet Dashboard | `{ device_id, server_version, uptime }` |
|
||||
| `ApplianceUnreachable` | Appliance Mgmt | Fleet Dashboard, Alerting | `{ device_id, last_seen, reason }` |
|
||||
| `ApplianceDegraded` | Appliance Mgmt | Fleet Dashboard, Alerting | `{ device_id, cpu_temp, reason }` |
|
||||
| `OtaUpdateStarted` | Appliance Mgmt | Fleet Dashboard | `{ device_id, from_version, to_version }` |
|
||||
| `OtaUpdateCompleted` | Appliance Mgmt | Fleet Dashboard | `{ device_id, new_version, duration_secs }` |
|
||||
| `OtaUpdateRolledBack` | Appliance Mgmt | Fleet Dashboard, Alerting | `{ device_id, attempted_version, rollback_version, reason }` |
|
||||
| `BinaryBuilt` | Cross-Compilation | Release Pipeline | `{ target, version, binary_size, checksum }` |
|
||||
| `DeploymentPackageCreated` | Cross-Compilation | Appliance Mgmt | `{ target, version, package_url }` |
|
||||
| `KioskStarted` | Display Kiosk | Appliance Mgmt | `{ device_id, url, resolution }` |
|
||||
| `KioskCrashed` | Display Kiosk | Appliance Mgmt | `{ device_id, exit_code, restart_count }` |
|
||||
| `CsiBridgeStarted` | WiFi CSI Bridge | Appliance Mgmt, Sensing Server | `{ device_id, chipset, virtual_node_id }` |
|
||||
| `CsiBridgeFailed` | WiFi CSI Bridge | Appliance Mgmt | `{ device_id, chipset, error }` |
|
||||
| `EspNodeDiscovered` | Network Topology | Appliance Mgmt | `{ appliance_id, node_id, mac, ip }` |
|
||||
| `EspNodeLost` | Network Topology | Appliance Mgmt, Alerting | `{ appliance_id, node_id, last_seen }` |
|
||||
| `DedicatedApStarted` | Network Topology | Appliance Mgmt | `{ appliance_id, ssid, channel }` |
|
||||
|
||||
---
|
||||
|
||||
## Context Map
|
||||
|
||||
```
|
||||
+-------------------+ +---------------------+
|
||||
| Appliance |--------->| Fleet Dashboard |
|
||||
| Management | events | (external UI for |
|
||||
| (fleet state) | -------> | multi-room mgmt) |
|
||||
+--------+----------+ +---------------------+
|
||||
|
|
||||
| provisions, monitors
|
||||
v
|
||||
+-------------------+ +---------------------+
|
||||
| Cross-Compilation |--------->| GitHub Releases |
|
||||
| (build pipeline) | uploads | (binary artifacts) |
|
||||
+-------------------+ +---------------------+
|
||||
|
|
||||
| provides binary
|
||||
v
|
||||
+-------------------+ +---------------------+
|
||||
| Display Kiosk |--------->| Sensing Server |
|
||||
| (Chromium on | loads | (upstream domain, |
|
||||
| HDMI output) | UI from | produces web UI) |
|
||||
+-------------------+ +----------+----------+
|
||||
^
|
||||
+-------------------+ |
|
||||
| WiFi CSI Bridge |-----UDP 5005------>|
|
||||
| (patched driver) | ESP32 compat |
|
||||
+-------------------+ frames |
|
||||
|
|
||||
+-------------------+ |
|
||||
| Network Topology |-----UDP 5005------>|
|
||||
| (ESP32 mesh | ESP32 frames |
|
||||
| connectivity) | |
|
||||
+-------------------+ |
|
||||
```
|
||||
|
||||
**Relationships:**
|
||||
|
||||
| Upstream | Downstream | Relationship | Mechanism |
|
||||
|----------|-----------|--------------|-----------|
|
||||
| Cross-Compilation | Appliance Mgmt | Supplier-Consumer | Build produces binary; Appliance Mgmt deploys it |
|
||||
| Appliance Mgmt | Display Kiosk | Customer-Supplier | Appliance Mgmt starts kiosk after server is healthy |
|
||||
| WiFi CSI Bridge | Sensing Server (external) | Conformist | Bridge adapts its output to match ESP32 binary protocol (ADR-018) |
|
||||
| Network Topology | Sensing Server (external) | Shared Kernel | Both depend on UDP port 5005 and ESP32 node ID scheme |
|
||||
| Appliance Mgmt | Network Topology | Customer-Supplier | Appliance config determines whether dedicated AP is enabled |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Corruption Layers
|
||||
|
||||
### ESP32 Protocol ACL (CSI Bridge)
|
||||
|
||||
The WiFi CSI Bridge translates chipset-specific CSI formats (Nexmon, rtw88, mt76) into the ESP32 binary protocol (ADR-018). The sensing server never knows whether frames came from a real ESP32 or a TV box WiFi chipset. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
|
||||
|
||||
### Armbian Platform ACL
|
||||
|
||||
Appliance Management abstracts over Armbian specifics (device tree names, boot configuration, dtb overlays) through the `HardwareModel` value object. Higher-level contexts (Cross-Compilation, Display Kiosk) depend only on the target triple (`aarch64-unknown-linux-gnu`) and systemd service interface, not on Amlogic/Allwinner/Rockchip kernel specifics.
|
||||
|
||||
### Fleet Coordination ACL
|
||||
|
||||
For multi-room deployments, each appliance is self-contained (runs its own sensing server, display, and network). The fleet dashboard reads health beacons but never controls individual appliances directly. OTA updates are pulled by each appliance (not pushed), maintaining the appliance as the authority over its own state.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [ADR-046: Android TV Box / Armbian Deployment](../adr/ADR-046-android-tv-box-armbian-deployment.md) — Primary architectural decision
|
||||
- [ADR-012: ESP32 CSI Sensor Mesh](../adr/ADR-012-esp32-csi-sensor-mesh.md) — ESP32 mesh network design
|
||||
- [ADR-018: Dev Implementation](../adr/ADR-018-dev-implementation.md) — ESP32 binary CSI protocol
|
||||
- [ADR-039: Edge Intelligence](../adr/ADR-039-esp32-edge-intelligence.md) — On-device processing tiers
|
||||
- [ADR-044: Provisioning Tool](../adr/ADR-044-provisioning-tool-enhancements.md) — NVS provisioning for ESP32 nodes
|
||||
- [Hardware Platform Domain Model](hardware-platform-domain-model.md) — Upstream domain (ESP32 hardware)
|
||||
- [Sensing Server Domain Model](sensing-server-domain-model.md) — Upstream domain (server software)
|
||||
+214
-25
@@ -26,15 +26,20 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
7. [Web UI](#web-ui)
|
||||
8. [Vital Sign Detection](#vital-sign-detection)
|
||||
9. [CLI Reference](#cli-reference)
|
||||
10. [Training a Model](#training-a-model)
|
||||
10. [Observatory Visualization](#observatory-visualization)
|
||||
11. [Adaptive Classifier](#adaptive-classifier)
|
||||
- [Recording Training Data](#recording-training-data)
|
||||
- [Training the Model](#training-the-model)
|
||||
- [Using the Trained Model](#using-the-trained-model)
|
||||
12. [Training a Model](#training-a-model)
|
||||
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
|
||||
11. [RVF Model Containers](#rvf-model-containers)
|
||||
12. [Hardware Setup](#hardware-setup)
|
||||
13. [RVF Model Containers](#rvf-model-containers)
|
||||
14. [Hardware Setup](#hardware-setup)
|
||||
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
||||
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
||||
13. [Docker Compose (Multi-Service)](#docker-compose-multi-service)
|
||||
14. [Troubleshooting](#troubleshooting)
|
||||
15. [FAQ](#faq)
|
||||
15. [Docker Compose (Multi-Service)](#docker-compose-multi-service)
|
||||
16. [Troubleshooting](#troubleshooting)
|
||||
17. [FAQ](#faq)
|
||||
|
||||
---
|
||||
|
||||
@@ -42,12 +47,12 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| **OS** | Windows 10, macOS 10.15, Ubuntu 18.04 | Latest stable |
|
||||
| **OS** | Windows 10/11, macOS 10.15, Ubuntu 18.04 | Latest stable |
|
||||
| **RAM** | 4 GB | 8 GB+ |
|
||||
| **Disk** | 2 GB free | 5 GB free |
|
||||
| **Docker** (for Docker path) | Docker 20+ | Docker 24+ |
|
||||
| **Rust** (for source build) | 1.70+ | 1.85+ |
|
||||
| **Python** (for legacy v1) | 3.8+ | 3.11+ |
|
||||
| **Python** (for legacy v1) | 3.10+ | 3.13+ |
|
||||
|
||||
**Hardware for live sensing (optional):**
|
||||
|
||||
@@ -82,15 +87,15 @@ cd RuView/rust-port/wifi-densepose-rs
|
||||
# Build
|
||||
cargo build --release
|
||||
|
||||
# Verify (runs 1,100+ tests)
|
||||
cargo test --workspace
|
||||
# Verify (runs 1,400+ tests)
|
||||
cargo test --workspace --no-default-features
|
||||
```
|
||||
|
||||
The compiled binary is at `target/release/sensing-server`.
|
||||
|
||||
### From crates.io (Individual Crates)
|
||||
|
||||
All 15 crates are published to crates.io at v0.3.0. Add individual crates to your own Rust project:
|
||||
All 16 crates are published to crates.io at v0.3.0. Add individual crates to your own Rust project:
|
||||
|
||||
```bash
|
||||
# Core types and traits
|
||||
@@ -113,6 +118,9 @@ cargo add wifi-densepose-ruvector --features crv
|
||||
|
||||
# WebAssembly bindings
|
||||
cargo add wifi-densepose-wasm
|
||||
|
||||
# WASM edge runtime (lightweight, for embedded/IoT)
|
||||
cargo add wifi-densepose-wasm-edge
|
||||
```
|
||||
|
||||
See the full crate list and dependency order in [CLAUDE.md](../CLAUDE.md#crate-publishing-order).
|
||||
@@ -206,25 +214,27 @@ Default in Docker. Generates synthetic CSI data exercising the full pipeline.
|
||||
```bash
|
||||
# Docker
|
||||
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
# (--source simulated is the default)
|
||||
# (--source auto is the default; falls back to simulate when no hardware detected)
|
||||
|
||||
# From source
|
||||
./target/release/sensing-server --source simulated --http-port 3000 --ws-port 3001
|
||||
./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001
|
||||
```
|
||||
|
||||
### Windows WiFi (RSSI Only)
|
||||
|
||||
Uses `netsh wlan` to capture RSSI from nearby access points. No special hardware needed, but capabilities are limited to coarse presence and motion detection (no pose estimation or vital signs).
|
||||
Uses `netsh wlan` to capture RSSI from nearby access points. No special hardware needed. Supports presence detection, motion classification, and coarse breathing rate estimation. No pose estimation (requires CSI).
|
||||
|
||||
```bash
|
||||
# From source (Windows only)
|
||||
./target/release/sensing-server --source windows --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
|
||||
# Docker (requires --network host on Windows)
|
||||
docker run --network host ruvnet/wifi-densepose:latest --source windows --tick-ms 500
|
||||
docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 500
|
||||
```
|
||||
|
||||
See [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) for a walkthrough.
|
||||
> **Community verified:** Tested on Windows 10 (10.0.26200) with Intel Wi-Fi 6 AX201 160MHz, Python 3.14, StormFiber 5 GHz network. All 7 tutorial steps passed with stable RSSI readings at -48 dBm. See [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) for the full walkthrough and test results.
|
||||
|
||||
**Vital signs from RSSI:** The sensing server now supports breathing rate estimation from RSSI variance patterns (requires stationary subject near AP) and motion classification with confidence scoring. RSSI-based vital sign detection has lower fidelity than ESP32 CSI — it is best for presence detection and coarse motion classification.
|
||||
|
||||
### macOS WiFi (RSSI Only)
|
||||
|
||||
@@ -315,6 +325,9 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
|
||||
| `GET` | `/api/v1/train/status` | Training run status | `{"phase":"idle"}` |
|
||||
| `POST` | `/api/v1/train/start` | Start a training run | `{"status":"started"}` |
|
||||
| `POST` | `/api/v1/train/stop` | Stop the active training run | `{"status":"stopped"}` |
|
||||
| `POST` | `/api/v1/adaptive/train` | Train adaptive classifier from recordings | `{"success":true,"accuracy":0.85}` |
|
||||
| `GET` | `/api/v1/adaptive/status` | Adaptive model status and accuracy | `{"loaded":true,"accuracy":0.85}` |
|
||||
| `POST` | `/api/v1/adaptive/unload` | Unload adaptive model | `{"success":true}` |
|
||||
|
||||
### Example: Get Vital Signs
|
||||
|
||||
@@ -410,9 +423,16 @@ wscat -c ws://localhost:3001/ws/sensing
|
||||
|
||||
## Web UI
|
||||
|
||||
The built-in Three.js UI is served at `http://localhost:3000/` (Docker) or the configured HTTP port.
|
||||
The built-in Three.js UI is served at `http://localhost:3000/ui/` (Docker) or the configured HTTP port.
|
||||
|
||||
**What you see:**
|
||||
**Two visualization modes:**
|
||||
|
||||
| Page | URL | Purpose |
|
||||
|------|-----|---------|
|
||||
| **Dashboard** | `/ui/index.html` | Tabbed monitoring dashboard with body model, signal heatmap, phase plot, vital signs |
|
||||
| **Observatory** | `/ui/observatory.html` | Immersive 3D room visualization with cinematic lighting and wireframe figures |
|
||||
|
||||
**Dashboard panels:**
|
||||
|
||||
| Panel | Description |
|
||||
|-------|-------------|
|
||||
@@ -423,7 +443,7 @@ The built-in Three.js UI is served at `http://localhost:3000/` (Docker) or the c
|
||||
| Vital Signs | Live breathing rate (BPM) and heart rate (BPM) |
|
||||
| Dashboard | System stats, throughput, connected WebSocket clients |
|
||||
|
||||
The UI updates in real-time via the WebSocket connection.
|
||||
Both UIs update in real-time via WebSocket and auto-detect the sensing server on the same origin.
|
||||
|
||||
---
|
||||
|
||||
@@ -441,6 +461,8 @@ The system extracts breathing rate and heart rate from CSI signal fluctuations u
|
||||
- Subject within ~3-5 meters of an access point (up to ~8 m with multistatic mesh)
|
||||
- Relatively stationary subject (large movements mask vital sign oscillations)
|
||||
|
||||
**Signal smoothing:** Vital sign estimates pass through a three-stage smoothing pipeline (ADR-048): outlier rejection (±8 BPM HR, ±2 BPM BR per frame), 21-frame trimmed mean, and EMA with α=0.02. This produces stable readings that hold steady for 5-10+ seconds instead of jumping every frame. See [Adaptive Classifier](#adaptive-classifier) for details.
|
||||
|
||||
**Simulated mode** produces synthetic vital sign data for testing.
|
||||
|
||||
---
|
||||
@@ -451,7 +473,7 @@ The Rust sensing server binary accepts the following flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--source` | `auto` | Data source: `auto`, `simulated`, `windows`, `esp32` |
|
||||
| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32` |
|
||||
| `--http-port` | `8080` | HTTP port for REST API and UI |
|
||||
| `--ws-port` | `8765` | WebSocket port |
|
||||
| `--udp-port` | `5005` | UDP port for ESP32 CSI frames |
|
||||
@@ -472,13 +494,13 @@ The Rust sensing server binary accepts the following flags:
|
||||
|
||||
```bash
|
||||
# Simulated mode with UI (development)
|
||||
./target/release/sensing-server --source simulated --http-port 3000 --ws-port 3001 --ui-path ../../ui
|
||||
./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001 --ui-path ../../ui
|
||||
|
||||
# ESP32 hardware mode
|
||||
./target/release/sensing-server --source esp32 --udp-port 5005
|
||||
|
||||
# Windows WiFi RSSI
|
||||
./target/release/sensing-server --source windows --tick-ms 500
|
||||
./target/release/sensing-server --source wifi --tick-ms 500
|
||||
|
||||
# Run benchmark
|
||||
./target/release/sensing-server --benchmark
|
||||
@@ -492,6 +514,149 @@ The Rust sensing server binary accepts the following flags:
|
||||
|
||||
---
|
||||
|
||||
## Observatory Visualization
|
||||
|
||||
The Observatory is an immersive Three.js visualization that renders WiFi sensing data as a cinematic 3D experience. It features room-scale props, wireframe human figures, WiFi signal animations, and a live data HUD.
|
||||
|
||||
**URL:** `http://localhost:3000/ui/observatory.html`
|
||||
|
||||
**Features:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Room scene | Furniture, walls, floor with emissive materials and 6-point lighting |
|
||||
| Wireframe figures | Up to 4 human skeletons with joint pulsation synced to breathing |
|
||||
| Signal field | Volumetric WiFi wave visualization |
|
||||
| Live HUD | Heart rate, breathing rate, confidence, RSSI, motion level |
|
||||
| Auto-detect | Automatically connects to live ESP32 data when sensing server is running |
|
||||
| Scenario cycling | 6 preset scenarios with smooth transitions (demo mode) |
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `1-6` | Switch scenario |
|
||||
| `A` | Toggle auto-cycle |
|
||||
| `P` | Pause/resume |
|
||||
| `S` | Open settings |
|
||||
| `R` | Reset camera |
|
||||
|
||||
**Live data auto-detect:** When served by the sensing server, the Observatory probes `/health` on the same origin and automatically connects via WebSocket. The HUD badge switches from `DEMO` to `LIVE`. No configuration needed.
|
||||
|
||||
---
|
||||
|
||||
## Adaptive Classifier
|
||||
|
||||
The adaptive classifier (ADR-048) learns your environment's specific WiFi signal patterns from labeled recordings. It replaces static threshold-based classification with a trained logistic regression model that uses 15 features (7 server-computed + 8 subcarrier-derived statistics).
|
||||
|
||||
### Signal Smoothing Pipeline
|
||||
|
||||
All CSI-derived metrics pass through a three-stage pipeline before reaching the UI:
|
||||
|
||||
| Stage | What It Does | Key Parameters |
|
||||
|-------|-------------|----------------|
|
||||
| **Adaptive baseline** | Learns quiet-room noise floor, subtracts drift | α=0.003, 50-frame warm-up |
|
||||
| **EMA + median filter** | Smooths motion score and vital signs | Motion α=0.15; Vitals: 21-frame trimmed mean, α=0.02 |
|
||||
| **Hysteresis debounce** | Prevents rapid state flickering | 4 frames (~0.4s) required for state transition |
|
||||
|
||||
Vital signs use additional stabilization:
|
||||
|
||||
| Parameter | Value | Effect |
|
||||
|-----------|-------|--------|
|
||||
| HR dead-band | ±2 BPM | Prevents micro-drift |
|
||||
| BR dead-band | ±0.5 BPM | Prevents micro-drift |
|
||||
| HR max jump | 8 BPM/frame | Rejects noise spikes |
|
||||
| BR max jump | 2 BPM/frame | Rejects noise spikes |
|
||||
|
||||
### Recording Training Data
|
||||
|
||||
Record labeled CSI sessions while performing distinct activities. Each recording captures full sensing frames (features + raw subcarrier amplitudes) at ~10-25 FPS.
|
||||
|
||||
```bash
|
||||
# 1. Record empty room (leave the room for 30 seconds)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H "Content-Type: application/json" -d '{"id":"train_empty_room"}'
|
||||
# ... wait 30 seconds ...
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
|
||||
# 2. Record sitting still (sit near ESP32 for 30 seconds)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H "Content-Type: application/json" -d '{"id":"train_sitting_still"}'
|
||||
# ... wait 30 seconds ...
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
|
||||
# 3. Record walking (walk around the room for 30 seconds)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H "Content-Type: application/json" -d '{"id":"train_walking"}'
|
||||
# ... wait 30 seconds ...
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
|
||||
# 4. Record active movement (jumping jacks, arm waving for 30 seconds)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H "Content-Type: application/json" -d '{"id":"train_active"}'
|
||||
# ... wait 30 seconds ...
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
```
|
||||
|
||||
Recordings are saved as JSONL files in `data/recordings/`. Filenames must start with `train_` and contain a class keyword:
|
||||
|
||||
| Filename pattern | Class |
|
||||
|-----------------|-------|
|
||||
| `*empty*` or `*absent*` | absent |
|
||||
| `*still*` or `*sitting*` | present_still |
|
||||
| `*walking*` or `*moving*` | present_moving |
|
||||
| `*active*` or `*exercise*` | active |
|
||||
|
||||
### Training the Model
|
||||
|
||||
Train the adaptive classifier from your labeled recordings:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/adaptive/train
|
||||
```
|
||||
|
||||
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `data/adaptive_model.json` and automatically loaded on server restart.
|
||||
|
||||
**Check model status:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/v1/adaptive/status
|
||||
```
|
||||
|
||||
**Unload the model (revert to threshold-based classification):**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/v1/adaptive/unload
|
||||
```
|
||||
|
||||
### Using the Trained Model
|
||||
|
||||
Once trained, the adaptive model runs automatically:
|
||||
|
||||
1. Each CSI frame is classified using the learned weights instead of static thresholds
|
||||
2. Model confidence is blended with smoothed threshold confidence (70/30 split)
|
||||
3. The model persists across server restarts (loaded from `data/adaptive_model.json`)
|
||||
|
||||
**Tips for better accuracy:**
|
||||
|
||||
- Record with clearly distinct activities (actually leave the room for "empty")
|
||||
- Record 30-60 seconds per activity (more data = better model)
|
||||
- Re-record and retrain if you move the ESP32 or rearrange the room
|
||||
- The model is environment-specific — retrain when the physical setup changes
|
||||
|
||||
### Adaptive Classifier API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/v1/adaptive/train` | Train from `train_*` recordings |
|
||||
| `GET` | `/api/v1/adaptive/status` | Model status, accuracy, class stats |
|
||||
| `POST` | `/api/v1/adaptive/unload` | Unload model, revert to thresholds |
|
||||
| `POST` | `/api/v1/recording/start` | Start recording CSI frames |
|
||||
| `POST` | `/api/v1/recording/stop` | Stop recording |
|
||||
| `GET` | `/api/v1/recording/list` | List recordings |
|
||||
|
||||
---
|
||||
|
||||
## Training a Model
|
||||
|
||||
The training pipeline is implemented in pure Rust (7,832 lines, zero external ML dependencies).
|
||||
@@ -805,13 +970,28 @@ rustc --version
|
||||
|
||||
### Windows: RSSI mode shows no data
|
||||
|
||||
Run the terminal as Administrator (required for `netsh wlan` access).
|
||||
Run the terminal as Administrator (required for `netsh wlan` access). Verified working on Windows 10 and 11 with Intel AX201 and Intel BE201 adapters.
|
||||
|
||||
### Vital signs show 0 BPM
|
||||
|
||||
- Vital sign detection requires CSI-capable hardware (ESP32 or research NIC)
|
||||
- RSSI-only mode (Windows WiFi) does not have sufficient resolution for vital signs
|
||||
- In simulated mode, synthetic vital signs are generated after a few seconds of warm-up
|
||||
- With real ESP32 data, vital signs take ~5 seconds to stabilize (smoothing pipeline warm-up)
|
||||
|
||||
### Vital signs jumping around
|
||||
|
||||
The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still unstable:
|
||||
- Ensure the subject is relatively still (large movements mask vital sign oscillations)
|
||||
- Train the adaptive classifier for your specific environment: `curl -X POST http://localhost:3000/api/v1/adaptive/train`
|
||||
- Check signal quality: `curl http://localhost:3000/api/v1/sensing/latest` — look for `signal_quality > 0.4`
|
||||
|
||||
### Observatory shows DEMO instead of LIVE
|
||||
|
||||
- Verify the sensing server is running: `curl http://localhost:3000/health`
|
||||
- Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL)
|
||||
- Hard refresh with Ctrl+Shift+R to clear cached settings
|
||||
- The auto-detect probes `/health` on the same origin — cross-origin won't work
|
||||
|
||||
---
|
||||
|
||||
@@ -838,11 +1018,20 @@ The system uses WiFi radio signals, not cameras. No images or video are captured
|
||||
**Q: What's the Python vs Rust difference?**
|
||||
The Rust implementation (v2) is 810x faster than Python (v1) for the full CSI pipeline. The Docker image is 132 MB vs 569 MB. Rust is the primary and recommended runtime. Python v1 remains available for legacy workflows.
|
||||
|
||||
**Q: Can I use an ESP8266 instead of ESP32-S3?**
|
||||
No. The ESP8266 does not expose WiFi Channel State Information (CSI) through its SDK, has insufficient RAM (~80 KB vs 512 KB), and runs a single-core 80 MHz CPU that cannot handle the signal processing pipeline. The ESP32-S3 is the minimum supported CSI capture device. See [Issue #138](https://github.com/ruvnet/RuView/issues/138) for alternatives including using cheap Android TV boxes as aggregation hubs.
|
||||
|
||||
**Q: Does the Windows WiFi tutorial work on Windows 10?**
|
||||
Yes. Community-tested on Windows 10 (build 26200) with an Intel Wi-Fi 6 AX201 160MHz adapter on a 5 GHz network. All 7 tutorial steps passed with Python 3.14. See [Issue #36](https://github.com/ruvnet/RuView/issues/36) for full test results.
|
||||
|
||||
**Q: Can I run the sensing server on an ARM device (Raspberry Pi, TV box)?**
|
||||
ARM64 deployment is planned ([ADR-046](adr/ADR-046-android-tv-box-armbian-deployment.md)) but not yet available as a pre-built binary. You can cross-compile from source using `cross build --release --target aarch64-unknown-linux-gnu -p wifi-densepose-sensing-server` if you have the Rust cross-compilation toolchain set up.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Decision Records](../docs/adr/) - 43 ADRs covering all design decisions
|
||||
- [Architecture Decision Records](../docs/adr/) - 48 ADRs covering all design decisions
|
||||
- [WiFi-Mat Disaster Response Guide](wifi-mat-user-guide.md) - Search & rescue module
|
||||
- [Build Guide](build-guide.md) - Detailed build instructions
|
||||
- [RuVector](https://github.com/ruvnet/ruvector) - Signal intelligence crate ecosystem
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
INCLUDE_DIRS "."
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
if(CONFIG_DISPLAY_ENABLE)
|
||||
list(APPEND SRCS "display_hal.c" "display_ui.c" "display_task.c")
|
||||
set(REQUIRES esp_lcd esp_lcd_touch lvgl)
|
||||
endif()
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SRCS}
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES ${REQUIRES}
|
||||
)
|
||||
|
||||
@@ -85,6 +85,87 @@ menu "Edge Intelligence (ADR-039)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "AMOLED Display (ADR-045)"
|
||||
|
||||
config DISPLAY_ENABLE
|
||||
bool "Enable AMOLED display support"
|
||||
default y
|
||||
help
|
||||
Enable RM67162 QSPI AMOLED display and LVGL UI.
|
||||
Auto-detects at boot; gracefully skips if no display hardware.
|
||||
Requires SPIRAM for frame buffers.
|
||||
|
||||
config DISPLAY_FPS_LIMIT
|
||||
int "Display refresh rate limit (FPS)"
|
||||
default 30
|
||||
range 10 60
|
||||
depends on DISPLAY_ENABLE
|
||||
help
|
||||
Maximum display refresh rate. Lower values save CPU.
|
||||
|
||||
config DISPLAY_BRIGHTNESS
|
||||
int "Default backlight brightness (%)"
|
||||
default 80
|
||||
range 0 100
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_CS
|
||||
int "QSPI CS GPIO"
|
||||
default 6
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_CLK
|
||||
int "QSPI CLK GPIO"
|
||||
default 47
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_D0
|
||||
int "QSPI D0 GPIO"
|
||||
default 18
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_D1
|
||||
int "QSPI D1 GPIO"
|
||||
default 7
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_D2
|
||||
int "QSPI D2 GPIO"
|
||||
default 48
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_QSPI_D3
|
||||
int "QSPI D3 GPIO"
|
||||
default 5
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_TOUCH_SDA
|
||||
int "Touch I2C SDA GPIO"
|
||||
default 3
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_TOUCH_SCL
|
||||
int "Touch I2C SCL GPIO"
|
||||
default 2
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_TOUCH_INT
|
||||
int "Touch INT GPIO"
|
||||
default 21
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_TOUCH_RST
|
||||
int "Touch RST GPIO"
|
||||
default 17
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
config DISPLAY_BL_PIN
|
||||
int "Backlight PWM GPIO"
|
||||
default 38
|
||||
depends on DISPLAY_ENABLE
|
||||
|
||||
endmenu
|
||||
|
||||
menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
config WASM_ENABLE
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* @file display_hal.c
|
||||
* @brief ADR-045: SH8601 QSPI AMOLED HAL for Waveshare ESP32-S3-Touch-AMOLED-1.8.
|
||||
*
|
||||
* Uses ESP-IDF esp_lcd_panel_io_spi in QSPI mode (quad_mode=true, lcd_cmd_bits=32).
|
||||
* The panel_io layer handles the 0x02/0x32 QSPI command encoding.
|
||||
*
|
||||
* Hardware: SH8601 368x448, FT3168 touch, TCA9554 I/O expander for power/reset.
|
||||
*
|
||||
* Pin assignments (Waveshare ESP32-S3-Touch-AMOLED-1.8):
|
||||
* QSPI: CS=12, CLK=11, D0=4, D1=5, D2=6, D3=7
|
||||
* I2C: SDA=15, SCL=14 (shared: touch FT3168 + TCA9554 expander)
|
||||
* Touch INT=21
|
||||
*/
|
||||
|
||||
#include "display_hal.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if CONFIG_DISPLAY_ENABLE
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_lcd_panel_io.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/i2c.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
static const char *TAG = "disp_hal";
|
||||
|
||||
/* ---- QSPI Pin Definitions (Waveshare board) ---- */
|
||||
#define DISP_QSPI_CS 12
|
||||
#define DISP_QSPI_CLK 11
|
||||
#define DISP_QSPI_D0 4
|
||||
#define DISP_QSPI_D1 5
|
||||
#define DISP_QSPI_D2 6
|
||||
#define DISP_QSPI_D3 7
|
||||
|
||||
/* ---- I2C (shared: touch + TCA9554 expander) ---- */
|
||||
#define I2C_SDA 15
|
||||
#define I2C_SCL 14
|
||||
#define TOUCH_INT_PIN 21
|
||||
#define I2C_MASTER_NUM I2C_NUM_0
|
||||
#define I2C_MASTER_FREQ_HZ 400000
|
||||
|
||||
/* ---- TCA9554 I/O expander ---- */
|
||||
#define TCA9554_ADDR 0x20
|
||||
#define TCA9554_REG_OUTPUT 0x01
|
||||
#define TCA9554_REG_CONFIG 0x03
|
||||
|
||||
/* ---- FT3168 touch controller ---- */
|
||||
#define FT3168_ADDR 0x38
|
||||
|
||||
/* ---- Display dimensions ---- */
|
||||
#define DISP_H_RES 368
|
||||
#define DISP_V_RES 448
|
||||
|
||||
/* ---- QSPI opcodes (packed into lcd_cmd bits [31:24]) ---- */
|
||||
#define LCD_OPCODE_WRITE_CMD 0x02
|
||||
#define LCD_OPCODE_WRITE_COLOR 0x32
|
||||
|
||||
/* ---- State ---- */
|
||||
static esp_lcd_panel_io_handle_t s_io_handle = NULL;
|
||||
static bool s_i2c_initialized = false;
|
||||
static bool s_touch_initialized = false;
|
||||
|
||||
/* ---- I2C helpers ---- */
|
||||
|
||||
static esp_err_t i2c_write_reg(uint8_t dev_addr, uint8_t reg, const uint8_t *data, size_t len)
|
||||
{
|
||||
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
|
||||
i2c_master_start(cmd);
|
||||
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
|
||||
i2c_master_write_byte(cmd, reg, true);
|
||||
if (data && len > 0) {
|
||||
i2c_master_write(cmd, data, len, true);
|
||||
}
|
||||
i2c_master_stop(cmd);
|
||||
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(100));
|
||||
i2c_cmd_link_delete(cmd);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static esp_err_t i2c_read_reg(uint8_t dev_addr, uint8_t reg, uint8_t *data, size_t len)
|
||||
{
|
||||
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
|
||||
i2c_master_start(cmd);
|
||||
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_WRITE, true);
|
||||
i2c_master_write_byte(cmd, reg, true);
|
||||
i2c_master_start(cmd);
|
||||
i2c_master_write_byte(cmd, (dev_addr << 1) | I2C_MASTER_READ, true);
|
||||
i2c_master_read(cmd, data, len, I2C_MASTER_LAST_NACK);
|
||||
i2c_master_stop(cmd);
|
||||
esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(100));
|
||||
i2c_cmd_link_delete(cmd);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static esp_err_t init_i2c_bus(void)
|
||||
{
|
||||
if (s_i2c_initialized) return ESP_OK;
|
||||
|
||||
i2c_config_t i2c_cfg = {
|
||||
.mode = I2C_MODE_MASTER,
|
||||
.sda_io_num = I2C_SDA,
|
||||
.scl_io_num = I2C_SCL,
|
||||
.sda_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.scl_pullup_en = GPIO_PULLUP_ENABLE,
|
||||
.master.clk_speed = I2C_MASTER_FREQ_HZ,
|
||||
};
|
||||
|
||||
esp_err_t ret = i2c_param_config(I2C_MASTER_NUM, &i2c_cfg);
|
||||
if (ret != ESP_OK) return ret;
|
||||
|
||||
ret = i2c_driver_install(I2C_MASTER_NUM, I2C_MODE_MASTER, 0, 0, 0);
|
||||
if (ret != ESP_OK) return ret;
|
||||
|
||||
s_i2c_initialized = true;
|
||||
ESP_LOGI(TAG, "I2C bus init OK (SDA=%d, SCL=%d)", I2C_SDA, I2C_SCL);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- TCA9554 I/O expander: toggle pins for display power/reset ---- */
|
||||
|
||||
static esp_err_t tca9554_init_display_power(void)
|
||||
{
|
||||
/* Set pins 0, 1, 2 as outputs */
|
||||
uint8_t cfg = 0xF8;
|
||||
esp_err_t ret = i2c_write_reg(TCA9554_ADDR, TCA9554_REG_CONFIG, &cfg, 1);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TCA9554 not found at 0x%02X: %s", TCA9554_ADDR, esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Set pins 0,1,2 LOW (reset state) */
|
||||
uint8_t out = 0x00;
|
||||
i2c_write_reg(TCA9554_ADDR, TCA9554_REG_OUTPUT, &out, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
|
||||
/* Set pins 0,1,2 HIGH (power on + release reset) */
|
||||
out = 0x07;
|
||||
i2c_write_reg(TCA9554_ADDR, TCA9554_REG_OUTPUT, &out, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(200));
|
||||
|
||||
ESP_LOGI(TAG, "TCA9554 display power/reset toggled");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- Panel IO helpers: send commands via esp_lcd QSPI panel IO ---- */
|
||||
|
||||
static esp_err_t panel_write_cmd(uint8_t dcs_cmd, const void *data, size_t data_len)
|
||||
{
|
||||
/* Pack as 32-bit lcd_cmd: [31:24]=opcode, [23:8]=dcs_cmd, [7:0]=0 */
|
||||
uint32_t lcd_cmd = ((uint32_t)LCD_OPCODE_WRITE_CMD << 24) | ((uint32_t)dcs_cmd << 8);
|
||||
return esp_lcd_panel_io_tx_param(s_io_handle, (int)lcd_cmd, data, data_len);
|
||||
}
|
||||
|
||||
static esp_err_t panel_write_color(const void *color_data, size_t data_len)
|
||||
{
|
||||
/* RAMWR (0x2C) packed as 32-bit lcd_cmd with quad opcode */
|
||||
uint32_t lcd_cmd = ((uint32_t)LCD_OPCODE_WRITE_COLOR << 24) | (0x2C << 8);
|
||||
return esp_lcd_panel_io_tx_color(s_io_handle, (int)lcd_cmd, color_data, data_len);
|
||||
}
|
||||
|
||||
/* ---- SH8601 init sequence (from Waveshare reference) ---- */
|
||||
|
||||
typedef struct {
|
||||
uint8_t cmd;
|
||||
uint8_t data[4];
|
||||
uint8_t data_len;
|
||||
uint16_t delay_ms;
|
||||
} sh8601_init_cmd_t;
|
||||
|
||||
static const sh8601_init_cmd_t sh8601_init_cmds[] = {
|
||||
{0x11, {0x00}, 0, 120}, /* Sleep Out + 120ms */
|
||||
{0x44, {0x01, 0xD1}, 2, 0}, /* Partial area */
|
||||
{0x35, {0x00}, 1, 0}, /* Tearing Effect ON */
|
||||
{0x53, {0x20}, 1, 10}, /* Write CTRL Display */
|
||||
{0x2A, {0x00, 0x00, 0x01, 0x6F}, 4, 0}, /* CASET: 0-367 */
|
||||
{0x2B, {0x00, 0x00, 0x01, 0xBF}, 4, 0}, /* RASET: 0-447 */
|
||||
{0x51, {0x00}, 1, 10}, /* Brightness: 0 */
|
||||
{0x29, {0x00}, 0, 10}, /* Display ON */
|
||||
{0x51, {0xFF}, 1, 0}, /* Brightness: max */
|
||||
{0x00, {0x00}, 0xFF, 0}, /* End sentinel */
|
||||
};
|
||||
|
||||
static esp_err_t send_init_sequence(void)
|
||||
{
|
||||
for (int i = 0; sh8601_init_cmds[i].data_len != 0xFF; i++) {
|
||||
const sh8601_init_cmd_t *cmd = &sh8601_init_cmds[i];
|
||||
esp_err_t ret = panel_write_cmd(
|
||||
cmd->cmd,
|
||||
cmd->data_len > 0 ? cmd->data : NULL,
|
||||
cmd->data_len);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "CMD 0x%02X failed: %s", cmd->cmd, esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
if (cmd->delay_ms > 0) {
|
||||
vTaskDelay(pdMS_TO_TICKS(cmd->delay_ms));
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
esp_err_t display_hal_init_panel(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Initializing Waveshare AMOLED 1.8\" (SH8601 368x448)...");
|
||||
|
||||
/* Step 1: Init I2C bus */
|
||||
esp_err_t ret = init_i2c_bus();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "I2C bus init failed");
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Step 2: TCA9554 display power/reset (optional — only present on Waveshare board) */
|
||||
ret = tca9554_init_display_power();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "TCA9554 not found — assuming display power is always-on (direct wiring)");
|
||||
/* Continue without TCA9554 — the display may be powered directly */
|
||||
}
|
||||
|
||||
/* Step 3: Initialize SPI bus */
|
||||
spi_bus_config_t bus_cfg = {
|
||||
.sclk_io_num = DISP_QSPI_CLK,
|
||||
.data0_io_num = DISP_QSPI_D0,
|
||||
.data1_io_num = DISP_QSPI_D1,
|
||||
.data2_io_num = DISP_QSPI_D2,
|
||||
.data3_io_num = DISP_QSPI_D3,
|
||||
.max_transfer_sz = DISP_H_RES * DISP_V_RES * 2,
|
||||
};
|
||||
|
||||
ret = spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "SPI bus init failed: %s", esp_err_to_name(ret));
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Step 4: Create panel IO with QSPI mode */
|
||||
esp_lcd_panel_io_spi_config_t io_config = {
|
||||
.dc_gpio_num = -1, /* No DC pin in QSPI mode */
|
||||
.cs_gpio_num = DISP_QSPI_CS,
|
||||
.pclk_hz = 40 * 1000 * 1000,
|
||||
.lcd_cmd_bits = 32, /* 32-bit command: [opcode|dcs_cmd|0x00] */
|
||||
.lcd_param_bits = 8,
|
||||
.spi_mode = 0,
|
||||
.trans_queue_depth = 10,
|
||||
.flags = {
|
||||
.quad_mode = true,
|
||||
},
|
||||
};
|
||||
|
||||
ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)SPI2_HOST, &io_config, &s_io_handle);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Panel IO init failed: %s", esp_err_to_name(ret));
|
||||
spi_bus_free(SPI2_HOST);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
ESP_LOGI(TAG, "QSPI panel IO created (40MHz, quad mode)");
|
||||
|
||||
/* Step 5: Send SH8601 init sequence */
|
||||
ret = send_init_sequence();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "SH8601 init sequence failed");
|
||||
esp_lcd_panel_io_del(s_io_handle);
|
||||
spi_bus_free(SPI2_HOST);
|
||||
s_io_handle = NULL;
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Step 6: Draw test pattern — cyan bar at top */
|
||||
ESP_LOGI(TAG, "Drawing test pattern...");
|
||||
uint16_t *line_buf = heap_caps_malloc(DISP_H_RES * 2, MALLOC_CAP_DMA);
|
||||
if (line_buf) {
|
||||
uint8_t caset[4] = {0, 0, (DISP_H_RES - 1) >> 8, (DISP_H_RES - 1) & 0xFF};
|
||||
uint8_t raset[4] = {0, 0, (DISP_V_RES - 1) >> 8, (DISP_V_RES - 1) & 0xFF};
|
||||
panel_write_cmd(0x2A, caset, 4);
|
||||
panel_write_cmd(0x2B, raset, 4);
|
||||
|
||||
for (int y = 0; y < DISP_V_RES; y++) {
|
||||
uint16_t color = (y < 30) ? 0x07FF : 0x0841;
|
||||
for (int x = 0; x < DISP_H_RES; x++) {
|
||||
line_buf[x] = color;
|
||||
}
|
||||
panel_write_color(line_buf, DISP_H_RES * 2);
|
||||
}
|
||||
free(line_buf);
|
||||
ESP_LOGI(TAG, "Test pattern drawn");
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "SH8601 panel init OK (%dx%d)", DISP_H_RES, DISP_V_RES);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void display_hal_draw(int x_start, int y_start, int x_end, int y_end,
|
||||
const void *color_data)
|
||||
{
|
||||
if (!s_io_handle) return;
|
||||
|
||||
/* SH8601 requires coordinates divisible by 2 */
|
||||
x_start &= ~1;
|
||||
y_start &= ~1;
|
||||
if (x_end & 1) x_end++;
|
||||
if (y_end & 1) y_end++;
|
||||
if (x_end > DISP_H_RES) x_end = DISP_H_RES;
|
||||
if (y_end > DISP_V_RES) y_end = DISP_V_RES;
|
||||
|
||||
uint8_t caset[4] = {
|
||||
(x_start >> 8) & 0xFF, x_start & 0xFF,
|
||||
((x_end - 1) >> 8) & 0xFF, (x_end - 1) & 0xFF,
|
||||
};
|
||||
panel_write_cmd(0x2A, caset, 4);
|
||||
|
||||
uint8_t raset[4] = {
|
||||
(y_start >> 8) & 0xFF, y_start & 0xFF,
|
||||
((y_end - 1) >> 8) & 0xFF, (y_end - 1) & 0xFF,
|
||||
};
|
||||
panel_write_cmd(0x2B, raset, 4);
|
||||
|
||||
size_t len = (x_end - x_start) * (y_end - y_start) * 2;
|
||||
panel_write_color(color_data, len);
|
||||
}
|
||||
|
||||
esp_err_t display_hal_init_touch(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Probing FT3168 touch controller...");
|
||||
|
||||
if (!s_i2c_initialized) {
|
||||
esp_err_t ret = init_i2c_bus();
|
||||
if (ret != ESP_OK) return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
gpio_config_t int_cfg = {
|
||||
.pin_bit_mask = (1ULL << TOUCH_INT_PIN),
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
gpio_config(&int_cfg);
|
||||
|
||||
uint8_t chip_id = 0;
|
||||
esp_err_t ret = i2c_read_reg(FT3168_ADDR, 0xA8, &chip_id, 1);
|
||||
if (ret != ESP_OK || chip_id == 0x00 || chip_id == 0xFF) {
|
||||
ESP_LOGW(TAG, "FT3168 not found (ret=%s, id=0x%02X)", esp_err_to_name(ret), chip_id);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
s_touch_initialized = true;
|
||||
ESP_LOGI(TAG, "FT3168 touch init OK (chip_id=0x%02X)", chip_id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool display_hal_touch_read(uint16_t *x, uint16_t *y)
|
||||
{
|
||||
if (!s_touch_initialized) return false;
|
||||
|
||||
uint8_t buf[7] = {0};
|
||||
esp_err_t ret = i2c_read_reg(FT3168_ADDR, 0x01, buf, 7);
|
||||
if (ret != ESP_OK) return false;
|
||||
|
||||
uint8_t num_points = buf[1];
|
||||
if (num_points == 0 || num_points > 2) return false;
|
||||
|
||||
*x = ((buf[2] & 0x0F) << 8) | buf[3];
|
||||
*y = ((buf[4] & 0x0F) << 8) | buf[5];
|
||||
return true;
|
||||
}
|
||||
|
||||
void display_hal_set_brightness(uint8_t percent)
|
||||
{
|
||||
if (!s_io_handle) return;
|
||||
if (percent > 100) percent = 100;
|
||||
uint8_t val = (uint8_t)((uint32_t)percent * 255 / 100);
|
||||
panel_write_cmd(0x51, &val, 1);
|
||||
}
|
||||
|
||||
#endif /* CONFIG_DISPLAY_ENABLE */
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file display_hal.h
|
||||
* @brief ADR-045: RM67162 QSPI AMOLED + CST816S touch HAL.
|
||||
*
|
||||
* Hardware abstraction for the LilyGO T-Display-S3 AMOLED panel.
|
||||
* Probes hardware at boot; returns ESP_ERR_NOT_FOUND if absent.
|
||||
*/
|
||||
|
||||
#ifndef DISPLAY_HAL_H
|
||||
#define DISPLAY_HAL_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Probe and initialize the RM67162 QSPI AMOLED panel.
|
||||
*
|
||||
* Configures QSPI bus, sends panel init sequence, and fills
|
||||
* the screen with dark background to confirm it works.
|
||||
* Returns ESP_ERR_NOT_FOUND if the panel does not respond.
|
||||
*
|
||||
* @return ESP_OK on success, ESP_ERR_NOT_FOUND if no display detected.
|
||||
*/
|
||||
esp_err_t display_hal_init_panel(void);
|
||||
|
||||
/**
|
||||
* Draw a rectangle of pixels to the AMOLED.
|
||||
* Sends CASET + RASET + RAMWR directly via QSPI.
|
||||
*
|
||||
* @param x_start Left column (inclusive).
|
||||
* @param y_start Top row (inclusive).
|
||||
* @param x_end Right column (exclusive).
|
||||
* @param y_end Bottom row (exclusive).
|
||||
* @param color_data RGB565 pixel data, (x_end-x_start)*(y_end-y_start) pixels.
|
||||
*/
|
||||
void display_hal_draw(int x_start, int y_start, int x_end, int y_end,
|
||||
const void *color_data);
|
||||
|
||||
/**
|
||||
* Probe and initialize the CST816S capacitive touch controller.
|
||||
*
|
||||
* @return ESP_OK on success, ESP_ERR_NOT_FOUND if no touch IC detected.
|
||||
*/
|
||||
esp_err_t display_hal_init_touch(void);
|
||||
|
||||
/**
|
||||
* Read touch point (non-blocking).
|
||||
*
|
||||
* @param[out] x Touch X coordinate (0..535).
|
||||
* @param[out] y Touch Y coordinate (0..239).
|
||||
* @return true if touch is active, false if released.
|
||||
*/
|
||||
bool display_hal_touch_read(uint16_t *x, uint16_t *y);
|
||||
|
||||
/**
|
||||
* Set AMOLED brightness via MIPI DCS command.
|
||||
*
|
||||
* @param percent Brightness 0-100.
|
||||
*/
|
||||
void display_hal_set_brightness(uint8_t percent);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DISPLAY_HAL_H */
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file display_task.c
|
||||
* @brief ADR-045: FreeRTOS display task — LVGL pump on Core 0, priority 1.
|
||||
*
|
||||
* Gracefully skips if RM67162 panel or SPIRAM is absent.
|
||||
* Reads from edge_get_vitals() / edge_get_multi_person() (thread-safe).
|
||||
*/
|
||||
|
||||
#include "display_task.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if CONFIG_DISPLAY_ENABLE
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
#include "display_hal.h"
|
||||
#include "display_ui.h"
|
||||
|
||||
#define DISP_H_RES 368
|
||||
#define DISP_V_RES 448
|
||||
|
||||
static const char *TAG = "disp_task";
|
||||
|
||||
/* ---- Config ---- */
|
||||
#ifdef CONFIG_DISPLAY_FPS_LIMIT
|
||||
#define DISP_FPS_LIMIT CONFIG_DISPLAY_FPS_LIMIT
|
||||
#else
|
||||
#define DISP_FPS_LIMIT 30
|
||||
#endif
|
||||
|
||||
#define DISP_TASK_STACK (8 * 1024)
|
||||
#define DISP_TASK_PRIORITY 1
|
||||
#define DISP_TASK_CORE 0
|
||||
|
||||
#define DISP_BUF_LINES 40
|
||||
|
||||
/* ---- LVGL flush callback — calls display_hal_draw directly ---- */
|
||||
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p)
|
||||
{
|
||||
display_hal_draw(area->x1, area->y1, area->x2 + 1, area->y2 + 1, color_p);
|
||||
lv_disp_flush_ready(drv);
|
||||
}
|
||||
|
||||
/* ---- LVGL touch input callback ---- */
|
||||
static void lvgl_touch_cb(lv_indev_drv_t *drv, lv_indev_data_t *data)
|
||||
{
|
||||
uint16_t x, y;
|
||||
if (display_hal_touch_read(&x, &y)) {
|
||||
data->point.x = x;
|
||||
data->point.y = y;
|
||||
data->state = LV_INDEV_STATE_PRESSED;
|
||||
} else {
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Display task ---- */
|
||||
static void display_task(void *arg)
|
||||
{
|
||||
const TickType_t frame_period = pdMS_TO_TICKS(1000 / DISP_FPS_LIMIT);
|
||||
|
||||
ESP_LOGI(TAG, "Display task running on Core %d, %d fps limit",
|
||||
xPortGetCoreID(), DISP_FPS_LIMIT);
|
||||
|
||||
display_ui_create(lv_scr_act());
|
||||
|
||||
TickType_t last_wake = xTaskGetTickCount();
|
||||
while (1) {
|
||||
display_ui_update();
|
||||
lv_timer_handler();
|
||||
vTaskDelayUntil(&last_wake, frame_period);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
esp_err_t display_task_start(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "Initializing display subsystem...");
|
||||
|
||||
bool use_psram = false;
|
||||
#if CONFIG_SPIRAM
|
||||
size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
|
||||
if (psram_free >= 64 * 1024) {
|
||||
use_psram = true;
|
||||
ESP_LOGI(TAG, "PSRAM available: %u KB — using PSRAM buffers", (unsigned)(psram_free / 1024));
|
||||
} else {
|
||||
ESP_LOGW(TAG, "PSRAM too small (%u bytes) — falling back to internal DMA memory", (unsigned)psram_free);
|
||||
}
|
||||
#else
|
||||
ESP_LOGW(TAG, "SPIRAM not enabled — using internal DMA memory (smaller buffers)");
|
||||
#endif
|
||||
|
||||
/* Probe display hardware */
|
||||
esp_err_t ret = display_hal_init_panel();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Display not available — running headless");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Init touch (optional) */
|
||||
esp_err_t touch_ret = display_hal_init_touch();
|
||||
|
||||
/* Initialize LVGL */
|
||||
lv_init();
|
||||
|
||||
/* Double-buffered draw buffers — prefer PSRAM, fall back to internal DMA */
|
||||
size_t buf_lines = use_psram ? DISP_BUF_LINES : 10; /* Smaller buffers without PSRAM */
|
||||
size_t buf_size = DISP_H_RES * buf_lines * sizeof(lv_color_t);
|
||||
uint32_t alloc_caps = use_psram ? MALLOC_CAP_SPIRAM : (MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
|
||||
lv_color_t *buf1 = heap_caps_malloc(buf_size, alloc_caps);
|
||||
lv_color_t *buf2 = heap_caps_malloc(buf_size, alloc_caps);
|
||||
if (!buf1 || !buf2) {
|
||||
ESP_LOGE(TAG, "Failed to allocate LVGL buffers (%u bytes, caps=0x%lx)",
|
||||
(unsigned)buf_size, (unsigned long)alloc_caps);
|
||||
if (buf1) free(buf1);
|
||||
if (buf2) free(buf2);
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_LOGI(TAG, "LVGL buffers: 2x %u bytes (%u lines, %s)",
|
||||
(unsigned)buf_size, (unsigned)buf_lines, use_psram ? "PSRAM" : "internal DMA");
|
||||
|
||||
static lv_disp_draw_buf_t draw_buf;
|
||||
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, DISP_H_RES * buf_lines);
|
||||
|
||||
static lv_disp_drv_t disp_drv;
|
||||
lv_disp_drv_init(&disp_drv);
|
||||
disp_drv.hor_res = DISP_H_RES;
|
||||
disp_drv.ver_res = DISP_V_RES;
|
||||
disp_drv.flush_cb = lvgl_flush_cb;
|
||||
disp_drv.draw_buf = &draw_buf;
|
||||
lv_disp_drv_register(&disp_drv);
|
||||
|
||||
if (touch_ret == ESP_OK) {
|
||||
static lv_indev_drv_t indev_drv;
|
||||
lv_indev_drv_init(&indev_drv);
|
||||
indev_drv.type = LV_INDEV_TYPE_POINTER;
|
||||
indev_drv.read_cb = lvgl_touch_cb;
|
||||
lv_indev_drv_register(&indev_drv);
|
||||
ESP_LOGI(TAG, "Touch input registered");
|
||||
}
|
||||
|
||||
BaseType_t xret = xTaskCreatePinnedToCore(
|
||||
display_task, "display", DISP_TASK_STACK,
|
||||
NULL, DISP_TASK_PRIORITY, NULL, DISP_TASK_CORE);
|
||||
|
||||
if (xret != pdPASS) {
|
||||
ESP_LOGE(TAG, "Failed to create display task");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Display task started (Core %d, priority %d, %d fps)",
|
||||
DISP_TASK_CORE, DISP_TASK_PRIORITY, DISP_FPS_LIMIT);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#else /* !CONFIG_DISPLAY_ENABLE */
|
||||
|
||||
esp_err_t display_task_start(void)
|
||||
{
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_DISPLAY_ENABLE */
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file display_task.h
|
||||
* @brief ADR-045: FreeRTOS display task — LVGL pump on Core 0.
|
||||
*/
|
||||
|
||||
#ifndef DISPLAY_TASK_H
|
||||
#define DISPLAY_TASK_H
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* Start the display task on Core 0, priority 1.
|
||||
*
|
||||
* Probes for RM67162 panel and SPIRAM. If either is absent,
|
||||
* logs a warning and returns ESP_OK (graceful skip).
|
||||
*
|
||||
* @return ESP_OK always (display is optional).
|
||||
*/
|
||||
esp_err_t display_task_start(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DISPLAY_TASK_H */
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @file display_ui.c
|
||||
* @brief ADR-045: LVGL 4-view swipeable UI — Dashboard | Vitals | Presence | System.
|
||||
*
|
||||
* Dark theme (#0a0a0f background) with cyan (#00d4ff) accent.
|
||||
* Glowing line effects via layered semi-transparent chart series.
|
||||
*/
|
||||
|
||||
#include "display_ui.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if CONFIG_DISPLAY_ENABLE
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "edge_processing.h"
|
||||
|
||||
static const char *TAG = "disp_ui";
|
||||
|
||||
/* ---- Theme colors ---- */
|
||||
#define COLOR_BG lv_color_make(0x0A, 0x0A, 0x0F)
|
||||
#define COLOR_CYAN lv_color_make(0x00, 0xD4, 0xFF)
|
||||
#define COLOR_AMBER lv_color_make(0xFF, 0xB0, 0x00)
|
||||
#define COLOR_GREEN lv_color_make(0x00, 0xFF, 0x80)
|
||||
#define COLOR_RED lv_color_make(0xFF, 0x40, 0x40)
|
||||
#define COLOR_DIM lv_color_make(0x30, 0x30, 0x40)
|
||||
#define COLOR_TEXT lv_color_make(0xCC, 0xCC, 0xDD)
|
||||
#define COLOR_TEXT_DIM lv_color_make(0x66, 0x66, 0x77)
|
||||
|
||||
/* ---- Chart data points ---- */
|
||||
#define CHART_POINTS 60
|
||||
|
||||
/* ---- View handles ---- */
|
||||
static lv_obj_t *s_tileview = NULL;
|
||||
|
||||
/* Dashboard */
|
||||
static lv_obj_t *s_dash_chart = NULL;
|
||||
static lv_chart_series_t *s_csi_series = NULL;
|
||||
static lv_obj_t *s_dash_persons = NULL;
|
||||
static lv_obj_t *s_dash_rssi = NULL;
|
||||
static lv_obj_t *s_dash_motion = NULL;
|
||||
|
||||
/* Vitals */
|
||||
static lv_obj_t *s_vital_chart = NULL;
|
||||
static lv_chart_series_t *s_breath_series = NULL;
|
||||
static lv_chart_series_t *s_hr_series = NULL;
|
||||
static lv_obj_t *s_vital_bpm_br = NULL;
|
||||
static lv_obj_t *s_vital_bpm_hr = NULL;
|
||||
|
||||
/* Presence */
|
||||
#define GRID_COLS 4
|
||||
#define GRID_ROWS 4
|
||||
static lv_obj_t *s_grid_cells[GRID_COLS * GRID_ROWS];
|
||||
static lv_obj_t *s_presence_label = NULL;
|
||||
|
||||
/* System */
|
||||
static lv_obj_t *s_sys_cpu = NULL;
|
||||
static lv_obj_t *s_sys_heap = NULL;
|
||||
static lv_obj_t *s_sys_psram = NULL;
|
||||
static lv_obj_t *s_sys_rssi = NULL;
|
||||
static lv_obj_t *s_sys_uptime = NULL;
|
||||
static lv_obj_t *s_sys_fps = NULL;
|
||||
static lv_obj_t *s_sys_node = NULL;
|
||||
|
||||
/* ---- Style helpers ---- */
|
||||
static lv_style_t s_style_bg;
|
||||
static lv_style_t s_style_label;
|
||||
static lv_style_t s_style_label_big;
|
||||
static bool s_styles_inited = false;
|
||||
|
||||
static void init_styles(void)
|
||||
{
|
||||
if (s_styles_inited) return;
|
||||
s_styles_inited = true;
|
||||
|
||||
lv_style_init(&s_style_bg);
|
||||
lv_style_set_bg_color(&s_style_bg, COLOR_BG);
|
||||
lv_style_set_bg_opa(&s_style_bg, LV_OPA_COVER);
|
||||
lv_style_set_border_width(&s_style_bg, 0);
|
||||
lv_style_set_pad_all(&s_style_bg, 4);
|
||||
|
||||
lv_style_init(&s_style_label);
|
||||
lv_style_set_text_color(&s_style_label, COLOR_TEXT);
|
||||
lv_style_set_text_font(&s_style_label, &lv_font_montserrat_14);
|
||||
|
||||
lv_style_init(&s_style_label_big);
|
||||
lv_style_set_text_color(&s_style_label_big, COLOR_CYAN);
|
||||
lv_style_set_text_font(&s_style_label_big, &lv_font_montserrat_14);
|
||||
}
|
||||
|
||||
static lv_obj_t *make_label(lv_obj_t *parent, const char *text, const lv_style_t *style)
|
||||
{
|
||||
lv_obj_t *lbl = lv_label_create(parent);
|
||||
lv_label_set_text(lbl, text);
|
||||
if (style) lv_obj_add_style(lbl, (lv_style_t *)style, 0);
|
||||
return lbl;
|
||||
}
|
||||
|
||||
static lv_obj_t *make_tile(lv_obj_t *tv, uint8_t col, uint8_t row)
|
||||
{
|
||||
lv_obj_t *tile = lv_tileview_add_tile(tv, col, row, LV_DIR_HOR);
|
||||
lv_obj_add_style(tile, &s_style_bg, 0);
|
||||
return tile;
|
||||
}
|
||||
|
||||
/* ---- View 0: Dashboard ---- */
|
||||
static void create_dashboard(lv_obj_t *tile)
|
||||
{
|
||||
make_label(tile, "CSI Dashboard", &s_style_label);
|
||||
|
||||
/* CSI amplitude chart */
|
||||
s_dash_chart = lv_chart_create(tile);
|
||||
lv_obj_set_size(s_dash_chart, 400, 130);
|
||||
lv_obj_align(s_dash_chart, LV_ALIGN_TOP_LEFT, 0, 24);
|
||||
lv_chart_set_type(s_dash_chart, LV_CHART_TYPE_LINE);
|
||||
lv_chart_set_point_count(s_dash_chart, CHART_POINTS);
|
||||
lv_chart_set_range(s_dash_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
|
||||
lv_obj_set_style_bg_color(s_dash_chart, COLOR_BG, 0);
|
||||
lv_obj_set_style_border_color(s_dash_chart, COLOR_DIM, 0);
|
||||
lv_obj_set_style_line_width(s_dash_chart, 0, LV_PART_TICKS);
|
||||
|
||||
s_csi_series = lv_chart_add_series(s_dash_chart, COLOR_CYAN, LV_CHART_AXIS_PRIMARY_Y);
|
||||
|
||||
/* Stats panel on the right */
|
||||
lv_obj_t *panel = lv_obj_create(tile);
|
||||
lv_obj_set_size(panel, 120, 130);
|
||||
lv_obj_align(panel, LV_ALIGN_TOP_RIGHT, 0, 24);
|
||||
lv_obj_set_style_bg_color(panel, lv_color_make(0x12, 0x12, 0x1A), 0);
|
||||
lv_obj_set_style_border_width(panel, 1, 0);
|
||||
lv_obj_set_style_border_color(panel, COLOR_DIM, 0);
|
||||
lv_obj_set_style_pad_all(panel, 8, 0);
|
||||
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
|
||||
make_label(panel, "Persons", &s_style_label);
|
||||
s_dash_persons = make_label(panel, "0", &s_style_label_big);
|
||||
|
||||
s_dash_rssi = make_label(panel, "RSSI: --", &s_style_label);
|
||||
s_dash_motion = make_label(panel, "Motion: 0.0", &s_style_label);
|
||||
}
|
||||
|
||||
/* ---- View 1: Vitals ---- */
|
||||
static void create_vitals(lv_obj_t *tile)
|
||||
{
|
||||
make_label(tile, "Vital Signs", &s_style_label);
|
||||
|
||||
s_vital_chart = lv_chart_create(tile);
|
||||
lv_obj_set_size(s_vital_chart, 480, 150);
|
||||
lv_obj_align(s_vital_chart, LV_ALIGN_TOP_LEFT, 0, 24);
|
||||
lv_chart_set_type(s_vital_chart, LV_CHART_TYPE_LINE);
|
||||
lv_chart_set_point_count(s_vital_chart, CHART_POINTS);
|
||||
lv_chart_set_range(s_vital_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 120);
|
||||
lv_obj_set_style_bg_color(s_vital_chart, COLOR_BG, 0);
|
||||
lv_obj_set_style_border_color(s_vital_chart, COLOR_DIM, 0);
|
||||
lv_obj_set_style_line_width(s_vital_chart, 0, LV_PART_TICKS);
|
||||
|
||||
/* Breathing series (cyan) */
|
||||
s_breath_series = lv_chart_add_series(s_vital_chart, COLOR_CYAN, LV_CHART_AXIS_PRIMARY_Y);
|
||||
/* Heart rate series (amber) */
|
||||
s_hr_series = lv_chart_add_series(s_vital_chart, COLOR_AMBER, LV_CHART_AXIS_PRIMARY_Y);
|
||||
|
||||
/* BPM readouts */
|
||||
s_vital_bpm_br = make_label(tile, "Breathing: -- BPM", &s_style_label);
|
||||
lv_obj_align(s_vital_bpm_br, LV_ALIGN_BOTTOM_LEFT, 4, -8);
|
||||
lv_obj_set_style_text_color(s_vital_bpm_br, COLOR_CYAN, 0);
|
||||
|
||||
s_vital_bpm_hr = make_label(tile, "Heart Rate: -- BPM", &s_style_label);
|
||||
lv_obj_align(s_vital_bpm_hr, LV_ALIGN_BOTTOM_RIGHT, -4, -8);
|
||||
lv_obj_set_style_text_color(s_vital_bpm_hr, COLOR_AMBER, 0);
|
||||
}
|
||||
|
||||
/* ---- View 2: Presence Grid ---- */
|
||||
static void create_presence(lv_obj_t *tile)
|
||||
{
|
||||
make_label(tile, "Occupancy Map", &s_style_label);
|
||||
|
||||
int cell_w = 50;
|
||||
int cell_h = 45;
|
||||
int x_off = (368 - GRID_COLS * (cell_w + 4)) / 2;
|
||||
int y_off = 30;
|
||||
|
||||
for (int r = 0; r < GRID_ROWS; r++) {
|
||||
for (int c = 0; c < GRID_COLS; c++) {
|
||||
lv_obj_t *cell = lv_obj_create(tile);
|
||||
lv_obj_set_size(cell, cell_w, cell_h);
|
||||
lv_obj_set_pos(cell, x_off + c * (cell_w + 4), y_off + r * (cell_h + 4));
|
||||
lv_obj_set_style_bg_color(cell, COLOR_DIM, 0);
|
||||
lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_color(cell, COLOR_DIM, 0);
|
||||
lv_obj_set_style_border_width(cell, 1, 0);
|
||||
lv_obj_set_style_radius(cell, 4, 0);
|
||||
s_grid_cells[r * GRID_COLS + c] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
s_presence_label = make_label(tile, "Persons: 0", &s_style_label);
|
||||
lv_obj_align(s_presence_label, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
}
|
||||
|
||||
/* ---- View 3: System ---- */
|
||||
static void create_system(lv_obj_t *tile)
|
||||
{
|
||||
make_label(tile, "System Info", &s_style_label);
|
||||
|
||||
lv_obj_t *panel = lv_obj_create(tile);
|
||||
lv_obj_set_size(panel, 500, 180);
|
||||
lv_obj_align(panel, LV_ALIGN_TOP_LEFT, 0, 24);
|
||||
lv_obj_set_style_bg_color(panel, lv_color_make(0x12, 0x12, 0x1A), 0);
|
||||
lv_obj_set_style_border_width(panel, 1, 0);
|
||||
lv_obj_set_style_border_color(panel, COLOR_DIM, 0);
|
||||
lv_obj_set_style_pad_all(panel, 10, 0);
|
||||
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
|
||||
s_sys_node = make_label(panel, "Node: --", &s_style_label);
|
||||
s_sys_cpu = make_label(panel, "CPU: --%", &s_style_label);
|
||||
s_sys_heap = make_label(panel, "Heap: -- KB free", &s_style_label);
|
||||
s_sys_psram = make_label(panel, "PSRAM: -- KB free",&s_style_label);
|
||||
s_sys_rssi = make_label(panel, "WiFi RSSI: --", &s_style_label);
|
||||
s_sys_uptime = make_label(panel, "Uptime: --", &s_style_label);
|
||||
s_sys_fps = make_label(panel, "FPS: --", &s_style_label);
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
void display_ui_create(lv_obj_t *parent)
|
||||
{
|
||||
init_styles();
|
||||
|
||||
s_tileview = lv_tileview_create(parent);
|
||||
lv_obj_add_style(s_tileview, &s_style_bg, 0);
|
||||
lv_obj_set_style_bg_color(s_tileview, COLOR_BG, 0);
|
||||
|
||||
lv_obj_t *t0 = make_tile(s_tileview, 0, 0);
|
||||
lv_obj_t *t1 = make_tile(s_tileview, 1, 0);
|
||||
lv_obj_t *t2 = make_tile(s_tileview, 2, 0);
|
||||
lv_obj_t *t3 = make_tile(s_tileview, 3, 0);
|
||||
|
||||
create_dashboard(t0);
|
||||
create_vitals(t1);
|
||||
create_presence(t2);
|
||||
create_system(t3);
|
||||
|
||||
ESP_LOGI(TAG, "UI created: 4 views (Dashboard|Vitals|Presence|System)");
|
||||
}
|
||||
|
||||
/* ---- FPS tracking ---- */
|
||||
static uint32_t s_frame_count = 0;
|
||||
static uint32_t s_last_fps_time = 0;
|
||||
static uint32_t s_current_fps = 0;
|
||||
|
||||
void display_ui_update(void)
|
||||
{
|
||||
/* FPS counter */
|
||||
s_frame_count++;
|
||||
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
if (now_ms - s_last_fps_time >= 1000) {
|
||||
s_current_fps = s_frame_count;
|
||||
s_frame_count = 0;
|
||||
s_last_fps_time = now_ms;
|
||||
}
|
||||
|
||||
/* Read edge data (thread-safe) */
|
||||
edge_vitals_pkt_t vitals;
|
||||
bool has_vitals = edge_get_vitals(&vitals);
|
||||
|
||||
edge_person_vitals_t persons[EDGE_MAX_PERSONS];
|
||||
uint8_t n_active = 0;
|
||||
edge_get_multi_person(persons, &n_active);
|
||||
|
||||
/* ---- Dashboard update ---- */
|
||||
if (s_dash_chart && has_vitals) {
|
||||
/* Push motion energy as amplitude proxy (scaled 0-100) */
|
||||
int val = (int)(vitals.motion_energy * 10.0f);
|
||||
if (val > 100) val = 100;
|
||||
if (val < 0) val = 0;
|
||||
lv_chart_set_next_value(s_dash_chart, s_csi_series, val);
|
||||
}
|
||||
|
||||
if (s_dash_persons) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", has_vitals ? vitals.n_persons : 0);
|
||||
lv_label_set_text(s_dash_persons, buf);
|
||||
}
|
||||
|
||||
if (s_dash_rssi && has_vitals) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "RSSI: %d", vitals.rssi);
|
||||
lv_label_set_text(s_dash_rssi, buf);
|
||||
}
|
||||
|
||||
if (s_dash_motion && has_vitals) {
|
||||
char buf[24];
|
||||
snprintf(buf, sizeof(buf), "Motion: %.1f", (double)vitals.motion_energy);
|
||||
lv_label_set_text(s_dash_motion, buf);
|
||||
}
|
||||
|
||||
/* ---- Vitals update ---- */
|
||||
if (s_vital_chart && has_vitals) {
|
||||
int br = (int)(vitals.breathing_rate / 100); /* Fixed-point to int BPM */
|
||||
int hr = (int)(vitals.heartrate / 10000);
|
||||
if (br > 120) br = 120;
|
||||
if (hr > 120) hr = 120;
|
||||
lv_chart_set_next_value(s_vital_chart, s_breath_series, br);
|
||||
lv_chart_set_next_value(s_vital_chart, s_hr_series, hr);
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "Breathing: %d BPM", br);
|
||||
lv_label_set_text(s_vital_bpm_br, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "Heart Rate: %d BPM", hr);
|
||||
lv_label_set_text(s_vital_bpm_hr, buf);
|
||||
}
|
||||
|
||||
/* ---- Presence grid update ---- */
|
||||
if (has_vitals) {
|
||||
/* Simple visualization: color cells based on motion energy distribution */
|
||||
float energy = vitals.motion_energy;
|
||||
uint8_t active_cells = (uint8_t)(energy * 2); /* Scale for visibility */
|
||||
if (active_cells > GRID_COLS * GRID_ROWS) active_cells = GRID_COLS * GRID_ROWS;
|
||||
|
||||
for (int i = 0; i < GRID_COLS * GRID_ROWS; i++) {
|
||||
if (i < active_cells) {
|
||||
/* Color gradient: green → amber → red based on intensity */
|
||||
if (energy > 5.0f) {
|
||||
lv_obj_set_style_bg_color(s_grid_cells[i], COLOR_RED, 0);
|
||||
} else if (energy > 2.0f) {
|
||||
lv_obj_set_style_bg_color(s_grid_cells[i], COLOR_AMBER, 0);
|
||||
} else {
|
||||
lv_obj_set_style_bg_color(s_grid_cells[i], COLOR_GREEN, 0);
|
||||
}
|
||||
} else {
|
||||
lv_obj_set_style_bg_color(s_grid_cells[i], COLOR_DIM, 0);
|
||||
}
|
||||
}
|
||||
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "Persons: %u", vitals.n_persons);
|
||||
lv_label_set_text(s_presence_label, buf);
|
||||
}
|
||||
|
||||
/* ---- System info update ---- */
|
||||
{
|
||||
char buf[48];
|
||||
|
||||
#ifdef CONFIG_CSI_NODE_ID
|
||||
snprintf(buf, sizeof(buf), "Node: %d", CONFIG_CSI_NODE_ID);
|
||||
#else
|
||||
snprintf(buf, sizeof(buf), "Node: --");
|
||||
#endif
|
||||
lv_label_set_text(s_sys_node, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "Heap: %lu KB free",
|
||||
(unsigned long)(esp_get_free_heap_size() / 1024));
|
||||
lv_label_set_text(s_sys_heap, buf);
|
||||
|
||||
#if CONFIG_SPIRAM
|
||||
snprintf(buf, sizeof(buf), "PSRAM: %lu KB free",
|
||||
(unsigned long)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||
#else
|
||||
snprintf(buf, sizeof(buf), "PSRAM: N/A");
|
||||
#endif
|
||||
lv_label_set_text(s_sys_psram, buf);
|
||||
|
||||
if (has_vitals) {
|
||||
snprintf(buf, sizeof(buf), "WiFi RSSI: %d dBm", vitals.rssi);
|
||||
lv_label_set_text(s_sys_rssi, buf);
|
||||
}
|
||||
|
||||
uint32_t uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
uint32_t h = uptime_s / 3600;
|
||||
uint32_t m = (uptime_s % 3600) / 60;
|
||||
uint32_t s = uptime_s % 60;
|
||||
snprintf(buf, sizeof(buf), "Uptime: %luh %02lum %02lus",
|
||||
(unsigned long)h, (unsigned long)m, (unsigned long)s);
|
||||
lv_label_set_text(s_sys_uptime, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "FPS: %lu", (unsigned long)s_current_fps);
|
||||
lv_label_set_text(s_sys_fps, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#endif /* CONFIG_DISPLAY_ENABLE */
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @file display_ui.h
|
||||
* @brief ADR-045: LVGL 4-view swipeable UI for CSI node stats.
|
||||
*
|
||||
* Views: Dashboard | Vitals | Presence | System
|
||||
* Dark theme with cyan (#00d4ff) accent.
|
||||
*/
|
||||
|
||||
#ifndef DISPLAY_UI_H
|
||||
#define DISPLAY_UI_H
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Create all LVGL views on the given tileview parent. */
|
||||
void display_ui_create(lv_obj_t *parent);
|
||||
|
||||
/**
|
||||
* Update all views with latest data. Called every display refresh cycle.
|
||||
* Reads from edge_get_vitals() and edge_get_multi_person() internally.
|
||||
*/
|
||||
void display_ui_update(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* DISPLAY_UI_H */
|
||||
@@ -0,0 +1,10 @@
|
||||
## ESP-IDF Managed Component Dependencies (ADR-045)
|
||||
dependencies:
|
||||
## LVGL graphics library
|
||||
lvgl/lvgl: "~8.3"
|
||||
|
||||
## CST816S capacitive touch driver
|
||||
espressif/esp_lcd_touch_cst816s: "^1.0"
|
||||
|
||||
## LCD touch abstraction
|
||||
espressif/esp_lcd_touch: "^1.0"
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file lv_conf.h
|
||||
* @brief LVGL compile-time configuration for ESP32-S3 AMOLED display (ADR-045).
|
||||
*
|
||||
* Tuned for RM67162 536x240 QSPI AMOLED with 8MB PSRAM.
|
||||
* Color depth: RGB565 (16-bit) for QSPI bandwidth.
|
||||
* Double-buffered in SPIRAM, 30fps target.
|
||||
*/
|
||||
|
||||
#ifndef LV_CONF_H
|
||||
#define LV_CONF_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* ---- Core ---- */
|
||||
#define LV_COLOR_DEPTH 16
|
||||
#define LV_COLOR_16_SWAP 1 /* Byte-swap for SPI/QSPI displays */
|
||||
#define LV_MEM_CUSTOM 1 /* Use ESP-IDF heap instead of LVGL's internal allocator */
|
||||
#define LV_MEM_CUSTOM_INCLUDE <stdlib.h>
|
||||
#define LV_MEM_CUSTOM_ALLOC malloc
|
||||
#define LV_MEM_CUSTOM_FREE free
|
||||
#define LV_MEM_CUSTOM_REALLOC realloc
|
||||
|
||||
/* ---- Display ---- */
|
||||
#define LV_HOR_RES_MAX 368
|
||||
#define LV_VER_RES_MAX 448
|
||||
#define LV_DPI_DEF 200
|
||||
|
||||
/* ---- Tick (provided by esp_timer in display_task.c) ---- */
|
||||
#define LV_TICK_CUSTOM 1
|
||||
#define LV_TICK_CUSTOM_INCLUDE "esp_timer.h"
|
||||
#define LV_TICK_CUSTOM_SYS_TIME_EXPR ((uint32_t)(esp_timer_get_time() / 1000))
|
||||
|
||||
/* ---- Drawing ---- */
|
||||
#define LV_DRAW_COMPLEX 1
|
||||
#define LV_SHADOW_CACHE_SIZE 0
|
||||
#define LV_CIRCLE_CACHE_SIZE 4
|
||||
#define LV_IMG_CACHE_DEF_SIZE 0
|
||||
|
||||
/* ---- Fonts ---- */
|
||||
#define LV_FONT_MONTSERRAT_14 1
|
||||
#define LV_FONT_MONTSERRAT_20 1
|
||||
#define LV_FONT_DEFAULT &lv_font_montserrat_14
|
||||
|
||||
/* ---- Widgets ---- */
|
||||
#define LV_USE_ARC 1
|
||||
#define LV_USE_BAR 1
|
||||
#define LV_USE_BTN 0
|
||||
#define LV_USE_BTNMATRIX 0
|
||||
#define LV_USE_CANVAS 0
|
||||
#define LV_USE_CHECKBOX 0
|
||||
#define LV_USE_DROPDOWN 0
|
||||
#define LV_USE_IMG 0
|
||||
#define LV_USE_LABEL 1
|
||||
#define LV_USE_LINE 1
|
||||
#define LV_USE_ROLLER 0
|
||||
#define LV_USE_SLIDER 0
|
||||
#define LV_USE_SWITCH 0
|
||||
#define LV_USE_TEXTAREA 0
|
||||
#define LV_USE_TABLE 0
|
||||
|
||||
/* ---- Extra widgets ---- */
|
||||
#define LV_USE_CHART 1
|
||||
#define LV_CHART_AXIS_TICK_LABEL_MAX_LEN 32
|
||||
#define LV_USE_METER 0
|
||||
#define LV_USE_SPINBOX 0
|
||||
#define LV_USE_SPAN 0
|
||||
#define LV_USE_TILEVIEW 1 /* Used for swipeable page navigation */
|
||||
#define LV_USE_TABVIEW 0
|
||||
#define LV_USE_WIN 0
|
||||
|
||||
/* ---- Themes ---- */
|
||||
#define LV_USE_THEME_DEFAULT 1
|
||||
#define LV_THEME_DEFAULT_DARK 1
|
||||
|
||||
/* ---- Logging ---- */
|
||||
#define LV_USE_LOG 0
|
||||
#define LV_USE_ASSERT_NULL 1
|
||||
#define LV_USE_ASSERT_MALLOC 1
|
||||
|
||||
/* ---- GPU / render ---- */
|
||||
#define LV_USE_GPU_ESP32_S3 0 /* No parallel LCD interface — we use QSPI */
|
||||
|
||||
/* ---- Animation ---- */
|
||||
#define LV_USE_ANIM 1
|
||||
#define LV_ANIM_DEF_TIME 200
|
||||
|
||||
/* ---- Misc ---- */
|
||||
#define LV_USE_GROUP 1 /* For touch/input device routing */
|
||||
#define LV_USE_PERF_MONITOR 0
|
||||
#define LV_USE_MEM_MONITOR 0
|
||||
#define LV_SPRINTF_CUSTOM 0
|
||||
|
||||
#endif /* LV_CONF_H */
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "power_mgmt.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
#include "display_task.h"
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
@@ -203,6 +204,12 @@ void app_main(void)
|
||||
/* Initialize power management. */
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
/* ADR-045: Start AMOLED display task (gracefully skips if no display). */
|
||||
esp_err_t disp_ret = display_task_start();
|
||||
if (disp_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Display init returned: %s", esp_err_to_name(disp_ret));
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
|
||||
static const char *TAG = "ota_update";
|
||||
|
||||
@@ -24,6 +26,52 @@ static const char *TAG = "ota_update";
|
||||
/** Maximum firmware size (900 KB — matches CI binary size gate). */
|
||||
#define OTA_MAX_SIZE (900 * 1024)
|
||||
|
||||
/** NVS namespace and key for the OTA pre-shared key. */
|
||||
#define OTA_NVS_NAMESPACE "security"
|
||||
#define OTA_NVS_KEY "ota_psk"
|
||||
|
||||
/** Maximum PSK length (hex-encoded SHA-256). */
|
||||
#define OTA_PSK_MAX_LEN 65
|
||||
|
||||
/** Cached PSK loaded from NVS at init time. Empty = auth disabled. */
|
||||
static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
|
||||
|
||||
/**
|
||||
* ADR-050: Verify the Authorization header contains the correct PSK.
|
||||
* Returns true if auth is disabled (no PSK provisioned) or if the
|
||||
* Bearer token matches the stored PSK.
|
||||
*/
|
||||
static bool ota_check_auth(httpd_req_t *req)
|
||||
{
|
||||
if (s_ota_psk[0] == '\0') {
|
||||
/* No PSK provisioned — auth disabled (permissive for dev). */
|
||||
return true;
|
||||
}
|
||||
|
||||
char auth_header[128] = {0};
|
||||
if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header,
|
||||
sizeof(auth_header)) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Expect "Bearer <psk>" */
|
||||
const char *prefix = "Bearer ";
|
||||
if (strncmp(auth_header, prefix, strlen(prefix)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *token = auth_header + strlen(prefix);
|
||||
/* Constant-time comparison to prevent timing attacks. */
|
||||
size_t psk_len = strlen(s_ota_psk);
|
||||
size_t tok_len = strlen(token);
|
||||
if (psk_len != tok_len) return false;
|
||||
volatile uint8_t result = 0;
|
||||
for (size_t i = 0; i < psk_len; i++) {
|
||||
result |= (uint8_t)(s_ota_psk[i] ^ token[i]);
|
||||
}
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /ota/status — return firmware version and partition info.
|
||||
*/
|
||||
@@ -53,6 +101,14 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
*/
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
/* ADR-050: Authenticate before accepting firmware upload. */
|
||||
if (!ota_check_auth(req)) {
|
||||
ESP_LOGW(TAG, "OTA upload rejected: authentication failed");
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Authentication required. Use: Authorization: Bearer <psk>");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
|
||||
|
||||
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
|
||||
@@ -187,6 +243,20 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
|
||||
esp_err_t ota_update_init(void)
|
||||
{
|
||||
/* ADR-050: Load OTA PSK from NVS if provisioned. */
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(s_ota_psk);
|
||||
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
|
||||
}
|
||||
nvs_close(nvs);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
|
||||
}
|
||||
|
||||
return ota_start_server(NULL);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Verify signature if wasm_verify is enabled. */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
/* ADR-050: Verify signature (default-on; skip only if
|
||||
* CONFIG_WASM_SKIP_SIGNATURE is explicitly set for dev/lab). */
|
||||
#ifndef CONFIG_WASM_SKIP_SIGNATURE
|
||||
{
|
||||
/* Load pubkey from NVS config (set via provision.py --wasm-pubkey). */
|
||||
extern nvs_config_t g_nvs_config;
|
||||
@@ -173,11 +174,11 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
|
||||
} else if (rvf_is_raw_wasm(buf, (uint32_t)total)) {
|
||||
/* ── Raw WASM path (dev/lab only) ── */
|
||||
#ifdef CONFIG_WASM_VERIFY_SIGNATURE
|
||||
#ifndef CONFIG_WASM_SKIP_SIGNATURE
|
||||
free(buf);
|
||||
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
|
||||
"Raw WASM upload rejected (wasm_verify enabled). "
|
||||
"Use RVF container with signature.");
|
||||
"Raw WASM upload rejected (signature verification enabled). "
|
||||
"Use RVF container with signature, or set CONFIG_WASM_SKIP_SIGNATURE for dev.");
|
||||
return ESP_FAIL;
|
||||
#else
|
||||
format = "raw";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# ESP32-S3 CSI Node — 8MB flash partition table (ADR-045)
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
otadata, data, ota, 0xf000, 0x2000,
|
||||
phy_init, data, phy, 0x11000, 0x1000,
|
||||
ota_0, app, ota_0, 0x20000, 0x200000,
|
||||
ota_1, app, ota_1, 0x220000, 0x200000,
|
||||
spiffs, data, spiffs, 0x420000, 0x1E0000,
|
||||
|
Generated
+3111
-48
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
"crates/wifi-densepose-vitals",
|
||||
"crates/wifi-densepose-ruvector",
|
||||
"crates/wifi-densepose-desktop",
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
@@ -101,7 +102,7 @@ csv = "1.3"
|
||||
indicatif = "0.17"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
clap = { version = "4.4", features = ["derive", "env"] }
|
||||
|
||||
# Testing
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "wifi-densepose-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Tauri v2 desktop frontend for RuView WiFi DensePose"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -0,0 +1,174 @@
|
||||
# RuView Desktop
|
||||
|
||||
> **Work in Progress** — This crate is under active development. APIs and UI are subject to change.
|
||||
|
||||
Cross-platform desktop application for managing ESP32 WiFi sensing networks. Built with **Tauri v2** (Rust backend) and **React + TypeScript** (frontend), following the [ADR-053 design system](../../docs/adr/ADR-053-ui-design-system.md).
|
||||
|
||||
## Overview
|
||||
|
||||
RuView Desktop provides a unified interface for node discovery, firmware management, over-the-air updates, WASM edge module deployment, real-time sensing data visualization, and mesh network topology monitoring — all from a single native application.
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| **Dashboard** | System overview with live stat cards, server panel, quick actions, and node grid | Done |
|
||||
| **Nodes** | Sortable table of discovered ESP32 nodes with expandable detail rows | Done |
|
||||
| **Flash** | 3-step serial firmware flash wizard (select port, pick firmware, flash + verify) | Done |
|
||||
| **OTA Update** | Single-node and batch over-the-air firmware updates with strategy selection | Done |
|
||||
| **Edge Modules** | WASM module upload, lifecycle management (start/stop/unload) per node | Done |
|
||||
| **Sensing** | Server start/stop, live log viewer (pause/clear), activity feed with confidence bars | Done |
|
||||
| **Mesh View** | Force-directed canvas graph showing mesh topology with click-to-inspect nodes | Done |
|
||||
| **Settings** | Server configuration (ports, bind address, discovery interval, theme) | Done |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
wifi-densepose-desktop/
|
||||
├── src/
|
||||
│ ├── main.rs # Tauri app entry point
|
||||
│ ├── lib.rs # Command registration
|
||||
│ ├── commands/ # Tauri IPC command handlers
|
||||
│ │ ├── discovery.rs # Node discovery (mDNS/UDP probe)
|
||||
│ │ ├── flash.rs # Serial firmware flashing
|
||||
│ │ ├── ota.rs # OTA update (single + batch)
|
||||
│ │ ├── wasm.rs # WASM module management
|
||||
│ │ └── server.rs # Sensing server lifecycle
|
||||
│ └── domain/ # DDD domain models
|
||||
│ ├── node.rs # DiscoveredNode, NodeRegistry, HealthStatus
|
||||
│ └── config.rs # ProvisioningConfig with validation
|
||||
├── ui/ # React + TypeScript frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Shell with sidebar nav, live status bar
|
||||
│ │ ├── design-system.css # ADR-053 design tokens and components
|
||||
│ │ ├── types.ts # TypeScript types mirroring Rust domain
|
||||
│ │ ├── components/ # Shared UI components (StatusBadge, NodeCard)
|
||||
│ │ ├── hooks/ # React hooks (useServer, useNodes)
|
||||
│ │ └── pages/ # 8 page components
|
||||
│ └── index.html
|
||||
└── tauri.conf.json # Tauri v2 configuration
|
||||
```
|
||||
|
||||
## Tauri Commands
|
||||
|
||||
| Group | Command | Description |
|
||||
|-------|---------|-------------|
|
||||
| **Discovery** | `discover_nodes` | Scan network for ESP32 nodes via mDNS/UDP |
|
||||
| **Flash** | `list_serial_ports` | List available serial ports |
|
||||
| | `detect_chip` | Detect connected chip type |
|
||||
| | `start_flash` | Flash firmware via serial |
|
||||
| **OTA** | `ota_update` | Push firmware to a single node |
|
||||
| | `batch_ota_update` | Push firmware to multiple nodes |
|
||||
| **WASM** | `wasm_list` | List loaded WASM modules on a node |
|
||||
| | `wasm_upload` | Upload a .wasm module to a node |
|
||||
| | `wasm_control` | Start/stop/unload a WASM module |
|
||||
| **Server** | `start_server` | Start the sensing HTTP/WS server |
|
||||
| | `stop_server` | Stop the sensing server |
|
||||
| | `server_status` | Get current server status |
|
||||
| **Provision** | `get_provision_config` | Read provisioning configuration |
|
||||
| | `save_provision_config` | Save provisioning configuration |
|
||||
|
||||
## Design System (ADR-053)
|
||||
|
||||
The UI follows a dark professional theme with the following design tokens:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--bg-base` | `#0d1117` | Main background |
|
||||
| `--bg-surface` | `#161b22` | Cards, sidebar, panels |
|
||||
| `--bg-elevated` | `#1c2333` | Elevated elements |
|
||||
| `--accent` | `#7c3aed` | Primary accent (purple) |
|
||||
| `--status-online` | `#3fb950` | Online/success indicators |
|
||||
| `--status-error` | `#f85149` | Error/offline indicators |
|
||||
| `--font-mono` | JetBrains Mono | Technical data, code |
|
||||
| `--font-sans` | Inter | UI text, labels |
|
||||
|
||||
### UI Features
|
||||
|
||||
- **Glassmorphism cards** with `backdrop-filter: blur(12px)`
|
||||
- **Count-up animations** on dashboard stat numbers
|
||||
- **Page transitions** with fade-in + scale on navigation
|
||||
- **Gradient accents** on logo, nav indicator, primary buttons
|
||||
- **Status dot glows** with ambient `box-shadow` per health state
|
||||
- **Staggered fade-ins** for card grids
|
||||
- **Force-directed graph** for mesh topology (pure Canvas 2D)
|
||||
|
||||
## Download
|
||||
|
||||
Pre-built binaries are available on the [Releases](https://github.com/ruvnet/RuView/releases) page.
|
||||
|
||||
| Platform | Download | Status |
|
||||
|----------|----------|--------|
|
||||
| Windows x64 | [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-desktop-alpha) | Debug build |
|
||||
| macOS | — | Planned |
|
||||
| Linux | — | Planned |
|
||||
|
||||
### Running the pre-built exe (Windows)
|
||||
|
||||
The current release is a **debug build** that loads the frontend from a local Vite dev server. Follow these steps:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo (or download just the ui/ folder)
|
||||
git clone https://github.com/ruvnet/RuView.git
|
||||
cd RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
|
||||
# 2. Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start the Vite dev server
|
||||
npx vite --host
|
||||
|
||||
# 4. Download and run the exe from the release page
|
||||
# (or run from the repo if you built it locally)
|
||||
# The app window will open and connect to localhost:5173
|
||||
```
|
||||
|
||||
> **Requirements:** Windows 10 (1803+) or Windows 11. WebView2 runtime is required (pre-installed on Windows 10 1803+ and all Windows 11).
|
||||
|
||||
> **Note:** Production builds will bundle the frontend assets directly into the exe, removing the need for a dev server.
|
||||
|
||||
## Build from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Rust 1.85+](https://rustup.rs/)
|
||||
- [Node.js 20+](https://nodejs.org/)
|
||||
- [Tauri v2 CLI](https://v2.tauri.app/start/prerequisites/)
|
||||
- **Windows:** MSVC build tools + MinGW-w64 (for `dlltool`)
|
||||
- **macOS:** Xcode Command Line Tools
|
||||
- **Linux:** `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`
|
||||
|
||||
### Development mode
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
cd ui && npm install
|
||||
|
||||
# Start in dev mode (hot-reload on both Rust and React)
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
# Build optimized release with bundled frontend
|
||||
cargo tauri build
|
||||
```
|
||||
|
||||
The installer/bundle will be in `target/release/bundle/` (`.msi` on Windows, `.dmg` on macOS, `.deb`/`.AppImage` on Linux).
|
||||
|
||||
## Domain Types
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `Node` | ip, mac, hostname, node_id, firmware_version, chip, mesh_role, health, ... | Full node record |
|
||||
| `HealthStatus` | online, offline, degraded, unknown | Node health state |
|
||||
| `FlashSession` | port, firmware, chip, baud, progress | Active flash operation |
|
||||
| `OtaResult` | node_ip, success, previous_version, new_version, duration_ms | OTA outcome |
|
||||
| `WasmModule` | module_id, name, size_bytes, state, node_ip | Edge module record |
|
||||
| `ServerStatus` | running, pid, http_port, ws_port | Sensing server state |
|
||||
| `SensingUpdate` | timestamp, node_id, subcarrier_count, rssi, activity, confidence | Real-time data |
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE) for details.
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "RuView default capability set",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"RuView default capability set","local":true,"windows":["main"],"permissions":["core:default","shell:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save"]}}
|
||||
+2630
File diff suppressed because it is too large
Load Diff
+2630
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
Binary file not shown.
|
After Width: | Height: | Size: 121 B |
@@ -0,0 +1,34 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::domain::node::DiscoveredNode;
|
||||
|
||||
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
|
||||
#[tauri::command]
|
||||
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
|
||||
let _timeout = timeout_ms.unwrap_or(3000);
|
||||
// Stub: return placeholder data
|
||||
Ok(vec![DiscoveredNode {
|
||||
ip: "192.168.1.100".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
hostname: Some("ruview-node-1".into()),
|
||||
node_id: 1,
|
||||
firmware_version: Some("0.3.0".into()),
|
||||
health: crate::domain::node::HealthStatus::Online,
|
||||
last_seen: chrono::Utc::now().to_rfc3339(),
|
||||
}])
|
||||
}
|
||||
|
||||
/// List available serial ports on this machine.
|
||||
#[tauri::command]
|
||||
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
||||
// Stub: return empty list
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SerialPortInfo {
|
||||
pub name: String,
|
||||
pub vid: Option<u16>,
|
||||
pub pid: Option<u16>,
|
||||
pub manufacturer: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Flash firmware binary to an ESP32 via serial port.
|
||||
#[tauri::command]
|
||||
pub async fn flash_firmware(
|
||||
port: String,
|
||||
firmware_path: String,
|
||||
chip: Option<String>,
|
||||
baud: Option<u32>,
|
||||
) -> Result<FlashResult, String> {
|
||||
let _ = (port, firmware_path, chip, baud);
|
||||
// Stub: return placeholder result
|
||||
Ok(FlashResult {
|
||||
success: true,
|
||||
message: "Stub: flash not yet implemented".into(),
|
||||
duration_secs: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get current flash progress (stub for polling-based approach).
|
||||
#[tauri::command]
|
||||
pub async fn flash_progress() -> Result<FlashProgress, String> {
|
||||
Ok(FlashProgress {
|
||||
phase: "idle".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub duration_secs: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashProgress {
|
||||
pub phase: String,
|
||||
pub progress_pct: f32,
|
||||
pub bytes_written: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod discovery;
|
||||
pub mod flash;
|
||||
pub mod ota;
|
||||
pub mod provision;
|
||||
pub mod server;
|
||||
pub mod wasm;
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Push firmware to a single node via HTTP OTA (port 8032).
|
||||
#[tauri::command]
|
||||
pub async fn ota_update(
|
||||
node_ip: String,
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
) -> Result<OtaResult, String> {
|
||||
let _ = (node_ip, firmware_path, psk);
|
||||
Ok(OtaResult {
|
||||
success: true,
|
||||
node_ip: "stub".into(),
|
||||
message: "Stub: OTA not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Push firmware to multiple nodes with rolling update strategy.
|
||||
#[tauri::command]
|
||||
pub async fn batch_ota_update(
|
||||
node_ips: Vec<String>,
|
||||
firmware_path: String,
|
||||
psk: Option<String>,
|
||||
) -> Result<Vec<OtaResult>, String> {
|
||||
let _ = (firmware_path, psk);
|
||||
Ok(node_ips
|
||||
.into_iter()
|
||||
.map(|ip| OtaResult {
|
||||
success: true,
|
||||
node_ip: ip,
|
||||
message: "Stub: batch OTA not yet implemented".into(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OtaResult {
|
||||
pub success: bool,
|
||||
pub node_ip: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::domain::config::ProvisioningConfig;
|
||||
|
||||
/// Provision NVS configuration to an ESP32 via serial port.
|
||||
#[tauri::command]
|
||||
pub async fn provision_node(
|
||||
port: String,
|
||||
config: ProvisioningConfig,
|
||||
) -> Result<ProvisionResult, String> {
|
||||
let _ = (port, config);
|
||||
Ok(ProvisionResult {
|
||||
success: true,
|
||||
message: "Stub: provisioning not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read current NVS configuration from a connected ESP32.
|
||||
#[tauri::command]
|
||||
pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
|
||||
let _ = port;
|
||||
Ok(ProvisioningConfig::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProvisionResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Start the sensing server as a managed child process.
|
||||
#[tauri::command]
|
||||
pub async fn start_server(
|
||||
config: ServerConfig,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let _ = config;
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
srv.running = true;
|
||||
srv.pid = Some(0); // Stub PID
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the managed sensing server process.
|
||||
#[tauri::command]
|
||||
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
srv.running = false;
|
||||
srv.pid = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get sensing server status.
|
||||
#[tauri::command]
|
||||
pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusResponse, String> {
|
||||
let srv = state.server.lock().map_err(|e| e.to_string())?;
|
||||
Ok(ServerStatusResponse {
|
||||
running: srv.running,
|
||||
pid: srv.pid,
|
||||
http_port: None,
|
||||
ws_port: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub http_port: Option<u16>,
|
||||
pub ws_port: Option<u16>,
|
||||
pub udp_port: Option<u16>,
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServerStatusResponse {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
pub http_port: Option<u16>,
|
||||
pub ws_port: Option<u16>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// List WASM modules loaded on a specific node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_list(node_ip: String) -> Result<Vec<WasmModuleInfo>, String> {
|
||||
let _ = node_ip;
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Upload a WASM module to a node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_upload(
|
||||
node_ip: String,
|
||||
wasm_path: String,
|
||||
) -> Result<WasmUploadResult, String> {
|
||||
let _ = (node_ip, wasm_path);
|
||||
Ok(WasmUploadResult {
|
||||
success: true,
|
||||
module_id: "stub-module-0".into(),
|
||||
message: "Stub: WASM upload not yet implemented".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start, stop, or unload a WASM module on a node.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_control(
|
||||
node_ip: String,
|
||||
module_id: String,
|
||||
action: String,
|
||||
) -> Result<(), String> {
|
||||
let _ = (node_ip, module_id, action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WasmModuleInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub size_bytes: u64,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WasmUploadResult {
|
||||
pub success: bool,
|
||||
pub module_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// NVS provisioning configuration for a single ESP32 node.
|
||||
/// Maps to the firmware's nvs_config_t struct.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProvisioningConfig {
|
||||
pub wifi_ssid: Option<String>,
|
||||
pub wifi_password: Option<String>,
|
||||
pub target_ip: Option<String>,
|
||||
pub target_port: Option<u16>,
|
||||
pub node_id: Option<u8>,
|
||||
pub tdm_slot: Option<u8>,
|
||||
pub tdm_total: Option<u8>,
|
||||
pub edge_tier: Option<u8>,
|
||||
pub presence_thresh: Option<u16>,
|
||||
pub fall_thresh: Option<u16>,
|
||||
pub vital_window: Option<u16>,
|
||||
pub vital_interval_ms: Option<u16>,
|
||||
pub top_k_count: Option<u8>,
|
||||
pub hop_count: Option<u8>,
|
||||
pub channel_list: Option<Vec<u8>>,
|
||||
pub dwell_ms: Option<u32>,
|
||||
pub power_duty: Option<u8>,
|
||||
pub wasm_max_modules: Option<u8>,
|
||||
pub wasm_verify: Option<bool>,
|
||||
pub ota_psk: Option<String>,
|
||||
}
|
||||
|
||||
impl ProvisioningConfig {
|
||||
/// Validate invariants:
|
||||
/// - tdm_slot < tdm_total when both set
|
||||
/// - channel_list.len() == hop_count when both set
|
||||
/// - 10 <= power_duty <= 100
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let (Some(slot), Some(total)) = (self.tdm_slot, self.tdm_total) {
|
||||
if slot >= total {
|
||||
return Err(format!(
|
||||
"tdm_slot ({}) must be less than tdm_total ({})",
|
||||
slot, total
|
||||
));
|
||||
}
|
||||
}
|
||||
if let (Some(ref channels), Some(hops)) = (&self.channel_list, self.hop_count) {
|
||||
if channels.len() != hops as usize {
|
||||
return Err(format!(
|
||||
"channel_list length ({}) must equal hop_count ({})",
|
||||
channels.len(),
|
||||
hops
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(duty) = self.power_duty {
|
||||
if !(10..=100).contains(&duty) {
|
||||
return Err(format!(
|
||||
"power_duty ({}) must be between 10 and 100",
|
||||
duty
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh-level configuration that generates per-node ProvisioningConfig instances.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
pub common: ProvisioningConfig,
|
||||
pub nodes: Vec<MeshNodeEntry>,
|
||||
}
|
||||
|
||||
/// Per-node override within a mesh configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshNodeEntry {
|
||||
pub port: String,
|
||||
pub node_id: u8,
|
||||
pub tdm_slot: u8,
|
||||
}
|
||||
|
||||
impl MeshConfig {
|
||||
/// Generate a ProvisioningConfig for a specific mesh node,
|
||||
/// merging common settings with per-node overrides.
|
||||
pub fn config_for_node(&self, entry: &MeshNodeEntry) -> ProvisioningConfig {
|
||||
let mut cfg = self.common.clone();
|
||||
cfg.node_id = Some(entry.node_id);
|
||||
cfg.tdm_slot = Some(entry.tdm_slot);
|
||||
cfg.tdm_total = Some(self.nodes.len() as u8);
|
||||
cfg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A firmware binary to be flashed or OTA-pushed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FirmwareBinary {
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub version: Option<String>,
|
||||
pub chip_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Lifecycle of a serial flash operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashPhase {
|
||||
Connecting,
|
||||
Erasing,
|
||||
Writing,
|
||||
Verifying,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A serial flash session aggregate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlashSession {
|
||||
pub id: String,
|
||||
pub port: String,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub phase: FlashPhase,
|
||||
pub bytes_written: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
||||
/// Lifecycle of an OTA update.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OtaPhase {
|
||||
Uploading,
|
||||
Rebooting,
|
||||
Verifying,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// An OTA update session aggregate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OtaSession {
|
||||
pub id: String,
|
||||
pub target_ip: String,
|
||||
pub target_mac: Option<String>,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub phase: OtaPhase,
|
||||
pub bytes_uploaded: u64,
|
||||
pub bytes_total: u64,
|
||||
}
|
||||
|
||||
/// Strategy for batch OTA updates across a mesh.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OtaStrategy {
|
||||
Sequential,
|
||||
TdmSafe,
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// A batch OTA session coordinating updates across multiple nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchOtaSession {
|
||||
pub id: String,
|
||||
pub firmware: FirmwareBinary,
|
||||
pub strategy: OtaStrategy,
|
||||
pub max_concurrent: usize,
|
||||
pub node_count: usize,
|
||||
pub completed: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod firmware;
|
||||
pub mod node;
|
||||
@@ -0,0 +1,81 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// MAC address value object (e.g., "AA:BB:CC:DD:EE:FF").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MacAddress(pub String);
|
||||
|
||||
impl MacAddress {
|
||||
pub fn new(addr: impl Into<String>) -> Self {
|
||||
Self(addr.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MacAddress {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Node health status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HealthStatus {
|
||||
Online,
|
||||
Offline,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
impl Default for HealthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Offline
|
||||
}
|
||||
}
|
||||
|
||||
/// A discovered ESP32 CSI node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredNode {
|
||||
pub ip: String,
|
||||
pub mac: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub node_id: u8,
|
||||
pub firmware_version: Option<String>,
|
||||
pub health: HealthStatus,
|
||||
pub last_seen: String,
|
||||
}
|
||||
|
||||
/// Aggregate root: maintains the set of all known nodes, keyed by MAC.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NodeRegistry {
|
||||
nodes: std::collections::HashMap<MacAddress, DiscoveredNode>,
|
||||
}
|
||||
|
||||
impl NodeRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Insert or update a node. Deduplicates by MAC address.
|
||||
pub fn upsert(&mut self, mac: MacAddress, node: DiscoveredNode) {
|
||||
self.nodes.insert(mac, node);
|
||||
}
|
||||
|
||||
/// Get a node by MAC address.
|
||||
pub fn get(&self, mac: &MacAddress) -> Option<&DiscoveredNode> {
|
||||
self.nodes.get(mac)
|
||||
}
|
||||
|
||||
/// List all known nodes.
|
||||
pub fn all(&self) -> Vec<&DiscoveredNode> {
|
||||
self.nodes.values().collect()
|
||||
}
|
||||
|
||||
/// Number of registered nodes.
|
||||
pub fn len(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod state;
|
||||
|
||||
use commands::{discovery, flash, ota, provision, server, wasm};
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(state::AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Discovery
|
||||
discovery::discover_nodes,
|
||||
discovery::list_serial_ports,
|
||||
// Flash
|
||||
flash::flash_firmware,
|
||||
flash::flash_progress,
|
||||
// OTA
|
||||
ota::ota_update,
|
||||
ota::batch_ota_update,
|
||||
// WASM
|
||||
wasm::wasm_list,
|
||||
wasm::wasm_upload,
|
||||
wasm::wasm_control,
|
||||
// Server
|
||||
server::start_server,
|
||||
server::stop_server,
|
||||
server::server_status,
|
||||
// Provision
|
||||
provision::provision_node,
|
||||
provision::read_nvs,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#![cfg_attr(
|
||||
all(not(debug_assertions), target_os = "windows"),
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
wifi_densepose_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::domain::node::DiscoveredNode;
|
||||
|
||||
/// Sub-state for discovered nodes.
|
||||
#[derive(Default)]
|
||||
pub struct DiscoveryState {
|
||||
pub nodes: Vec<DiscoveredNode>,
|
||||
}
|
||||
|
||||
/// Sub-state for the managed sensing server process.
|
||||
#[derive(Default)]
|
||||
pub struct ServerState {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Top-level application state managed by Tauri.
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
pub discovery: Mutex<DiscoveryState>,
|
||||
pub server: Mutex<ServerState>,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "RuView Desktop",
|
||||
"version": "0.3.0",
|
||||
"identifier": "net.ruv.ruview",
|
||||
"build": {
|
||||
"frontendDist": "../ui/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../ui && npm run dev",
|
||||
"beforeBuildCommand": "cd ../ui && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "RuView Desktop",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RuView Desktop</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1877
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ruview-desktop-ui",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import { Nodes } from "./pages/Nodes";
|
||||
import { FlashFirmware } from "./pages/FlashFirmware";
|
||||
import { OtaUpdate } from "./pages/OtaUpdate";
|
||||
import { EdgeModules } from "./pages/EdgeModules";
|
||||
import { Sensing } from "./pages/Sensing";
|
||||
import { MeshView } from "./pages/MeshView";
|
||||
import { Settings } from "./pages/Settings";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "nodes"
|
||||
| "flash"
|
||||
| "ota"
|
||||
| "wasm"
|
||||
| "sensing"
|
||||
| "mesh"
|
||||
| "settings";
|
||||
|
||||
interface NavItem {
|
||||
id: Page;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: "\u25A6" },
|
||||
{ id: "nodes", label: "Nodes", icon: "\u25C9" },
|
||||
{ id: "flash", label: "Flash", icon: "\u26A1" },
|
||||
{ id: "ota", label: "OTA", icon: "\u2B06" },
|
||||
{ id: "wasm", label: "Edge Modules", icon: "\u2B21" },
|
||||
{ id: "sensing", label: "Sensing", icon: "\u2248" },
|
||||
{ id: "mesh", label: "Mesh View", icon: "\u2B2F" },
|
||||
{ id: "settings", label: "Settings", icon: "\u2699" },
|
||||
];
|
||||
|
||||
interface LiveStatus {
|
||||
nodeCount: number;
|
||||
onlineCount: number;
|
||||
serverRunning: boolean;
|
||||
serverPort: number | null;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
||||
const [hoveredNav, setHoveredNav] = useState<Page | null>(null);
|
||||
const [pageKey, setPageKey] = useState(0);
|
||||
const [liveStatus, setLiveStatus] = useState<LiveStatus>({
|
||||
nodeCount: 0,
|
||||
onlineCount: 0,
|
||||
serverRunning: false,
|
||||
serverPort: null,
|
||||
});
|
||||
|
||||
const navigateTo = useCallback((page: Page) => {
|
||||
setActivePage(page);
|
||||
setPageKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Poll live status every 5 seconds
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const [nodes, server] = await Promise.all([
|
||||
invoke<{ health: string }[]>("discover_nodes", { timeoutMs: 2000 }).catch(() => []),
|
||||
invoke<{ running: boolean; http_port: number | null }>("server_status").catch(() => ({
|
||||
running: false,
|
||||
http_port: null,
|
||||
})),
|
||||
]);
|
||||
setLiveStatus({
|
||||
nodeCount: nodes.length,
|
||||
onlineCount: nodes.filter((n) => n.health === "online").length,
|
||||
serverRunning: server.running,
|
||||
serverPort: server.http_port,
|
||||
});
|
||||
} catch {
|
||||
// Tauri not available (browser preview) — leave defaults
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 8000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case "dashboard": return <Dashboard />;
|
||||
case "nodes": return <Nodes />;
|
||||
case "flash": return <FlashFirmware />;
|
||||
case "ota": return <OtaUpdate />;
|
||||
case "wasm": return <EdgeModules />;
|
||||
case "sensing": return <Sensing />;
|
||||
case "mesh": return <MeshView />;
|
||||
case "settings": return <Settings />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", overflow: "hidden" }}>
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
style={{
|
||||
width: 220,
|
||||
minWidth: 220,
|
||||
background: "var(--bg-surface)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 16px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg, var(--accent), #a855f7, #ec4899)",
|
||||
backgroundSize: "200% 200%",
|
||||
animation: "gradient-shift 4s ease infinite",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: "#fff",
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 2px 12px rgba(124, 58, 237, 0.4)",
|
||||
}}
|
||||
>
|
||||
R
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
margin: 0,
|
||||
letterSpacing: "-0.01em",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
RuView
|
||||
</h1>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
v0.3.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div style={{ flex: 1, paddingTop: 6, paddingBottom: 6, overflowY: "auto" }}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activePage === item.id;
|
||||
const isHovered = hoveredNav === item.id && !isActive;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigateTo(item.id)}
|
||||
onMouseEnter={() => setHoveredNav(item.id)}
|
||||
onMouseLeave={() => setHoveredNav(null)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "8px 16px",
|
||||
background: isActive
|
||||
? "linear-gradient(90deg, rgba(124, 58, 237, 0.15), transparent)"
|
||||
: isHovered
|
||||
? "var(--bg-hover)"
|
||||
: "transparent",
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: "left",
|
||||
borderLeft: isActive
|
||||
? "3px solid transparent"
|
||||
: "3px solid transparent",
|
||||
fontFamily: "var(--font-sans)",
|
||||
borderRadius: 0,
|
||||
transition: "all 0.15s ease",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Active gradient indicator */}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
width: 3,
|
||||
borderRadius: "0 3px 3px 0",
|
||||
background: "linear-gradient(180deg, var(--accent), #a855f7)",
|
||||
boxShadow: "0 0 8px rgba(124, 58, 237, 0.5)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: isActive
|
||||
? "linear-gradient(135deg, var(--accent), #a855f7)"
|
||||
: isHovered
|
||||
? "var(--bg-active)"
|
||||
: "var(--bg-elevated)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
color: isActive ? "#fff" : "var(--text-muted)",
|
||||
transition: "all 0.15s ease",
|
||||
flexShrink: 0,
|
||||
boxShadow: isActive ? "0 2px 8px rgba(124, 58, 237, 0.3)" : "none",
|
||||
transform: isHovered ? "scale(1.1)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Live connection footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<span className="status-dot status-dot--online" style={{ width: 6, height: 6 }} />
|
||||
<span>Connected</span>
|
||||
{liveStatus.nodeCount > 0 && (
|
||||
<span style={{ marginLeft: "auto", color: "var(--text-muted)" }}>
|
||||
{liveStatus.onlineCount}/{liveStatus.nodeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
background: "var(--bg-base)",
|
||||
}}
|
||||
>
|
||||
<div key={pageKey} className="page-transition">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<footer
|
||||
style={{
|
||||
height: "var(--statusbar-height)",
|
||||
minHeight: "var(--statusbar-height)",
|
||||
background: "var(--bg-surface)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 16px",
|
||||
gap: 16,
|
||||
fontSize: 11,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--text-muted)",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-muted)", fontWeight: 500 }}>
|
||||
Powered by rUv
|
||||
</span>
|
||||
|
||||
<span style={{ color: "var(--border)" }}>{"\u2502"}</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span
|
||||
className={`status-dot ${liveStatus.onlineCount > 0 ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 6, height: 6 }}
|
||||
/>
|
||||
{liveStatus.onlineCount > 0
|
||||
? `${liveStatus.onlineCount} node${liveStatus.onlineCount !== 1 ? "s" : ""} online`
|
||||
: "No nodes"}
|
||||
</span>
|
||||
|
||||
<span style={{ color: "var(--border)" }}>{"\u2502"}</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span
|
||||
className={`status-dot ${liveStatus.serverRunning ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 6, height: 6 }}
|
||||
/>
|
||||
Server: {liveStatus.serverRunning ? "running" : "stopped"}
|
||||
</span>
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
{liveStatus.serverPort && (
|
||||
<span style={{ fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>
|
||||
:{liveStatus.serverPort}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import type { Node } from "../types";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface NodeCardProps {
|
||||
node: Node;
|
||||
onClick?: (node: Node) => void;
|
||||
}
|
||||
|
||||
function formatUptime(secs: number | null): string {
|
||||
if (secs == null) return "--";
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
||||
return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
function formatLastSeen(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
if (diffMs < 60_000) return "just now";
|
||||
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||
const isOnline = node.health === "online";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node)}
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
opacity: isOnline ? 1 : 0.6,
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--accent)";
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.background = "var(--bg-elevated)";
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={node.health} />
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "var(--space-2) var(--space-4)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
|
||||
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} mono />
|
||||
<DetailRow label="Chip" value={node.chip?.toUpperCase() ?? "--"} />
|
||||
<DetailRow label="Role" value={node.mesh_role} />
|
||||
<DetailRow
|
||||
label="TDM"
|
||||
value={
|
||||
node.tdm_slot != null && node.tdm_total != null
|
||||
? `${node.tdm_slot}/${node.tdm_total}`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
/>
|
||||
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} mono />
|
||||
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
fontSize: 12,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
items: NavItem[];
|
||||
activeId: string;
|
||||
onNavigate: (id: string) => void;
|
||||
}
|
||||
|
||||
// Minimal SVG icons to avoid external dependency
|
||||
const ICONS: Record<string, ReactNode> = {
|
||||
dashboard: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
nodes: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="3" />
|
||||
<circle cx="5" cy="19" r="3" />
|
||||
<circle cx="19" cy="19" r="3" />
|
||||
<line x1="12" y1="8" x2="5" y2="16" />
|
||||
<line x1="12" y1="8" x2="19" y2="16" />
|
||||
</svg>
|
||||
),
|
||||
flash: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
server: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<line x1="6" y1="6" x2="6.01" y2="6" />
|
||||
<line x1="6" y1="18" x2="6.01" y2="18" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export const DEFAULT_NAV_ITEMS: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: ICONS.dashboard },
|
||||
{ id: "nodes", label: "Nodes", icon: ICONS.nodes },
|
||||
{ id: "flash", label: "Flash", icon: ICONS.flash },
|
||||
{ id: "server", label: "Server", icon: ICONS.server },
|
||||
{ id: "settings", label: "Settings", icon: ICONS.settings },
|
||||
];
|
||||
|
||||
export function Sidebar({ items, activeId, onNavigate }: SidebarProps) {
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
width: "200px",
|
||||
minWidth: "200px",
|
||||
height: "100%",
|
||||
background: "var(--sidebar-bg, #12121a)",
|
||||
borderRight: "1px solid var(--border, #2e2e3e)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "16px 0",
|
||||
}}
|
||||
>
|
||||
{/* App title */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0 20px 20px",
|
||||
fontSize: "18px",
|
||||
fontWeight: 800,
|
||||
color: "var(--text-primary, #e2e8f0)",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
RuView
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px", flex: 1 }}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "10px 20px",
|
||||
border: "none",
|
||||
background: isActive
|
||||
? "var(--accent-muted, rgba(99, 102, 241, 0.12))"
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? "var(--accent, #6366f1)"
|
||||
: "var(--text-secondary, #94a3b8)",
|
||||
cursor: "pointer",
|
||||
fontSize: "13px",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: "left",
|
||||
borderLeft: isActive
|
||||
? "3px solid var(--accent, #6366f1)"
|
||||
: "3px solid transparent",
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background =
|
||||
"var(--hover-bg, rgba(255,255,255,0.04))";
|
||||
e.currentTarget.style.color = "var(--text-primary, #e2e8f0)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
e.currentTarget.style.color = "var(--text-secondary, #94a3b8)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
fontSize: "10px",
|
||||
color: "var(--text-muted, #64748b)",
|
||||
}}
|
||||
>
|
||||
v0.3.0
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: HealthStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<HealthStatus, { color: string; label: string }> = {
|
||||
online: { color: "var(--status-online)", label: "Online" },
|
||||
offline: { color: "var(--status-error)", label: "Offline" },
|
||||
degraded: { color: "var(--status-warning)", label: "Degraded" },
|
||||
unknown: { color: "var(--text-muted)", label: "Unknown" },
|
||||
};
|
||||
|
||||
const SIZE_STYLES: Record<string, { fontSize: number; padding: string; dot: number }> = {
|
||||
sm: { fontSize: 11, padding: "2px 8px", dot: 6 },
|
||||
md: { fontSize: 13, padding: "4px 12px", dot: 8 },
|
||||
lg: { fontSize: 15, padding: "6px 16px", dot: 10 },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
||||
const { color, label } = STATUS_STYLES[status];
|
||||
const s = SIZE_STYLES[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color,
|
||||
fontSize: s.fontSize,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
padding: s.padding,
|
||||
borderRadius: 9999,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: s.dot,
|
||||
height: s.dot,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
boxShadow: status === "online"
|
||||
? `0 0 4px ${color}, 0 0 8px ${color}`
|
||||
: status === "degraded"
|
||||
? `0 0 4px ${color}`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* RuView Design System (ADR-053)
|
||||
* Dark professional + Unity-inspired interface
|
||||
*/
|
||||
|
||||
/* ===== Design Tokens ===== */
|
||||
:root {
|
||||
/* Background layers */
|
||||
--bg-base: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-elevated: #1c2333;
|
||||
--bg-hover: #242d3d;
|
||||
--bg-active: #2d3748;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #484f58;
|
||||
|
||||
/* Status indicators */
|
||||
--status-online: #3fb950;
|
||||
--status-warning: #d29922;
|
||||
--status-error: #f85149;
|
||||
--status-info: #58a6ff;
|
||||
|
||||
/* Accent */
|
||||
--accent: #7c3aed;
|
||||
--accent-hover: #6d28d9;
|
||||
--accent-glow: rgba(124, 58, 237, 0.15);
|
||||
|
||||
/* Borders */
|
||||
--border: #30363d;
|
||||
--border-active: #58a6ff;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-accent: 0 0 0 3px var(--accent-glow);
|
||||
|
||||
/* Fonts */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
/* Spacing (4px base grid) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
--space-8: 48px;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Panel dimensions */
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-collapsed: 52px;
|
||||
--statusbar-height: 32px;
|
||||
--toolbar-height: 44px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.1s ease;
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.25s ease;
|
||||
}
|
||||
|
||||
/* ===== Reset ===== */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== Typography Scale ===== */
|
||||
.heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.heading-lg { font: 600 20px/1.3 var(--font-sans); color: var(--text-primary); letter-spacing: -0.01em; }
|
||||
.heading-md { font: 600 16px/1.4 var(--font-sans); color: var(--text-primary); }
|
||||
.heading-sm { font: 600 13px/1.4 var(--font-sans); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.body { font: 400 14px/1.6 var(--font-sans); color: var(--text-primary); }
|
||||
.body-sm { font: 400 12px/1.5 var(--font-sans); color: var(--text-secondary); }
|
||||
.data { font: 400 13px/1.4 var(--font-mono); color: var(--text-secondary); }
|
||||
.data-lg { font: 500 24px/1.2 var(--font-mono); color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-active);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== Form Controls ===== */
|
||||
input, select, textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
input:hover, select:hover, textarea:hover {
|
||||
border-color: var(--bg-active);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
input:disabled, select:disabled, textarea:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input[type="number"] {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e' viewBox='0 0 16 16'%3E%3Cpath d='M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-fast);
|
||||
}
|
||||
button:focus-visible {
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
button:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Button variants */
|
||||
.btn-primary {
|
||||
padding: var(--space-2) 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--bg-active);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
color: var(--status-error);
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(248, 81, 73, 0.2);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--bg-active);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.card-elevated {
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Glassmorphism card variant */
|
||||
.card-glass {
|
||||
background: rgba(22, 27, 34, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(48, 54, 61, 0.6);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
.card-glass:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
/* Accent-glow card for stat highlights */
|
||||
.card-glow {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
}
|
||||
.card-glow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), #a855f7, var(--accent));
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
.card-glow:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.card-glow:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 0 20px rgba(124, 58, 237, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
thead th {
|
||||
padding: 10px var(--space-4);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-surface);
|
||||
z-index: 1;
|
||||
}
|
||||
tbody td {
|
||||
padding: 10px var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ===== Badge ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Divider ===== */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* ===== Animations ===== */
|
||||
@keyframes pulse-accent {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
from { opacity: 0; transform: scale(0.97) translateY(4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.06; }
|
||||
50% { opacity: 0.12; }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px currentColor; }
|
||||
50% { box-shadow: 0 0 10px currentColor, 0 0 20px currentColor; }
|
||||
}
|
||||
|
||||
@keyframes count-up-pop {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
60% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Page transition wrapper */
|
||||
.page-transition {
|
||||
animation: fade-in-scale 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger children animation */
|
||||
.stagger-children > * {
|
||||
animation: fade-in 0.3s ease-out backwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== Focus ring ===== */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-accent);
|
||||
}
|
||||
|
||||
/* ===== Selection ===== */
|
||||
::selection {
|
||||
background: rgba(124, 58, 237, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===== Tooltip-style truncation ===== */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Mono data ===== */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ===== Status dot with glow ===== */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot--online {
|
||||
background: var(--status-online);
|
||||
box-shadow: 0 0 6px rgba(63, 185, 80, 0.5), 0 0 12px rgba(63, 185, 80, 0.2);
|
||||
}
|
||||
.status-dot--error {
|
||||
background: var(--status-error);
|
||||
box-shadow: 0 0 6px rgba(248, 81, 73, 0.5);
|
||||
}
|
||||
.status-dot--warning {
|
||||
background: var(--status-warning);
|
||||
box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
|
||||
}
|
||||
|
||||
/* ===== Gradient button ===== */
|
||||
.btn-gradient {
|
||||
padding: var(--space-2) 20px;
|
||||
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||||
background-size: 200% 200%;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
|
||||
transition: box-shadow var(--transition-normal), background-position 0.4s ease, transform var(--transition-fast);
|
||||
}
|
||||
.btn-gradient:hover:not(:disabled) {
|
||||
background-position: 100% 0;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
/* ===== Sidebar nav active indicator ===== */
|
||||
.nav-indicator {
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: linear-gradient(180deg, var(--accent), #a855f7);
|
||||
box-shadow: 0 0 8px rgba(124, 58, 237, 0.4);
|
||||
transition: height var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ===== Empty state ===== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.empty-state-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Node } from "../types";
|
||||
|
||||
interface UseNodesOptions {
|
||||
/** Auto-poll interval in milliseconds. Set to 0 to disable. Default: 10000 */
|
||||
pollInterval?: number;
|
||||
/** Whether to start scanning on mount. Default: false */
|
||||
autoScan?: boolean;
|
||||
}
|
||||
|
||||
interface UseNodesReturn {
|
||||
nodes: Node[];
|
||||
isScanning: boolean;
|
||||
error: string | null;
|
||||
scan: () => Promise<void>;
|
||||
/** Total nodes discovered */
|
||||
total: number;
|
||||
/** Nodes currently online */
|
||||
onlineCount: number;
|
||||
/** Nodes currently offline */
|
||||
offlineCount: number;
|
||||
}
|
||||
|
||||
export function useNodes(options: UseNodesOptions = {}): UseNodesReturn {
|
||||
const { pollInterval = 10_000, autoScan = false } = options;
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const discovered = await invoke<Node[]>("discover_nodes", {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
setNodes(discovered);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
}, [isScanning]);
|
||||
|
||||
// Auto-scan on mount if requested
|
||||
useEffect(() => {
|
||||
if (autoScan) {
|
||||
scan();
|
||||
}
|
||||
}, [autoScan]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Polling interval
|
||||
useEffect(() => {
|
||||
if (pollInterval <= 0) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
scan();
|
||||
}, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollInterval]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onlineCount = nodes.filter(
|
||||
(n) => n.health === "online"
|
||||
).length;
|
||||
const offlineCount = nodes.filter(
|
||||
(n) => n.health === "offline"
|
||||
).length;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
isScanning,
|
||||
error,
|
||||
scan,
|
||||
total: nodes.length,
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { ServerConfig, ServerStatus } from "../types";
|
||||
|
||||
const DEFAULT_CONFIG: ServerConfig = {
|
||||
http_port: 8080,
|
||||
ws_port: 8765,
|
||||
udp_port: 5005,
|
||||
static_dir: null,
|
||||
model_dir: null,
|
||||
log_level: "info",
|
||||
};
|
||||
|
||||
interface UseServerOptions {
|
||||
/** Poll interval for status checks in ms. Default: 5000 */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
interface UseServerReturn {
|
||||
status: ServerStatus | null;
|
||||
isRunning: boolean;
|
||||
error: string | null;
|
||||
start: (config?: Partial<ServerConfig>) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useServer(options: UseServerOptions = {}): UseServerReturn {
|
||||
const { pollInterval = 5000 } = options;
|
||||
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const s = await invoke<ServerStatus>("server_status");
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (overrides: Partial<ServerConfig> = {}) => {
|
||||
setError(null);
|
||||
const config: ServerConfig = { ...DEFAULT_CONFIG, ...overrides };
|
||||
try {
|
||||
await invoke("start_server", { config });
|
||||
// Allow the server a moment to start, then refresh status
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("stop_server");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// Initial status check
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
if (pollInterval <= 0) return;
|
||||
|
||||
intervalRef.current = setInterval(refresh, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollInterval, refresh]);
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
|
||||
return {
|
||||
status,
|
||||
isRunning,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./design-system.css";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,326 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: HealthStatus;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
http_port: number | null;
|
||||
ws_port: number | null;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 3000 });
|
||||
setNodes(found);
|
||||
} catch (err) {
|
||||
console.error("Discovery failed:", err);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServerStatus = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const status = await invoke<ServerStatus>("server_status");
|
||||
setServerStatus(status);
|
||||
} catch (err) {
|
||||
console.error("Server status check failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleScan();
|
||||
fetchServerStatus();
|
||||
}, []);
|
||||
|
||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1100 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="heading-lg" style={{ margin: 0 }}>Dashboard</h2>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
System overview and quick actions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="btn-gradient"
|
||||
style={{ opacity: scanning ? 0.6 : 1 }}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Scan Network"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="stagger-children"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: "var(--space-4)",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<StatCard label="Total Nodes" value={nodes.length} />
|
||||
<StatCard label="Online" value={onlineCount} color="var(--status-online)" />
|
||||
<StatCard label="Offline" value={nodes.length - onlineCount} color={nodes.length - onlineCount > 0 ? "var(--status-error)" : "var(--text-muted)"} />
|
||||
<StatCard
|
||||
label="Server"
|
||||
value={serverStatus?.running ? "Running" : "Stopped"}
|
||||
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
|
||||
isText
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-5)" }}>
|
||||
{/* Server panel */}
|
||||
<div className="card">
|
||||
<h3 className="heading-sm" style={{ marginBottom: "var(--space-3)" }}>Sensing Server</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span
|
||||
className={`status-dot ${serverStatus?.running ? "status-dot--online" : "status-dot--error"}`}
|
||||
style={{ width: 10, height: 10 }}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: "var(--text-primary)", fontWeight: 500 }}>
|
||||
{serverStatus?.running ? "Running" : "Stopped"}
|
||||
</span>
|
||||
{serverStatus?.running && serverStatus.pid && (
|
||||
<span className="data" style={{ marginLeft: "auto" }}>
|
||||
PID {serverStatus.pid}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{serverStatus?.running && serverStatus.http_port && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", gap: "var(--space-4)" }}>
|
||||
<PortTag label="HTTP" port={serverStatus.http_port} />
|
||||
{serverStatus.ws_port && <PortTag label="WS" port={serverStatus.ws_port} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions panel */}
|
||||
<div className="card">
|
||||
<h3 className="heading-sm" style={{ marginBottom: "var(--space-3)" }}>Quick Actions</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
||||
<QuickAction label="Flash Firmware" desc="Flash via serial port" />
|
||||
<QuickAction label="Push OTA Update" desc="Over-the-air to nodes" />
|
||||
<QuickAction label="Upload WASM" desc="Deploy edge modules" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node list */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||
<h3 className="heading-sm">Discovered Nodes ({nodes.length})</h3>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<div className="empty-state-icon">{"\u25C9"}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
|
||||
No nodes discovered
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", maxWidth: 280, textAlign: "center", lineHeight: 1.5 }}>
|
||||
Click "Scan Network" to discover ESP32 devices on your local network.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||
gap: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
{nodes.map((node, i) => (
|
||||
<NodeDashCard key={node.mac || i} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useCountUp(target: number, duration = 600): number {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const prevTarget = useRef(0);
|
||||
useEffect(() => {
|
||||
const start = prevTarget.current;
|
||||
prevTarget.current = target;
|
||||
if (target === start) return;
|
||||
const startTime = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
||||
setCurrent(Math.round(start + (target - start) * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [target, duration]);
|
||||
return current;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
isText = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
color?: string;
|
||||
isText?: boolean;
|
||||
}) {
|
||||
const animatedValue = useCountUp(typeof value === "number" ? value : 0);
|
||||
const displayValue = isText || typeof value === "string" ? value : animatedValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card-glow"
|
||||
style={{ padding: "var(--space-4)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: "var(--space-2)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: isText ? 16 : 28,
|
||||
fontWeight: 600,
|
||||
color: color || "var(--text-primary)",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortTag({ label, port }: { label: string; port: number }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-muted)", fontWeight: 600 }}>{label}</span>
|
||||
<span className="mono" style={{ color: "var(--text-secondary)" }}>:{port}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({ label, desc }: { label: string; desc: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "var(--bg-base)")}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--text-primary)" }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>{desc}</div>
|
||||
</div>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 14 }}>{"\u203A"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDashCard({ node }: { node: DiscoveredNode }) {
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "var(--space-4)",
|
||||
cursor: "pointer",
|
||||
opacity: node.health === "online" ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start", marginBottom: "var(--space-3)" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 1 }}>
|
||||
{node.hostname || `Node ${node.node_id}`}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{node.ip}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={node.health} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 16px", fontSize: 12 }}>
|
||||
<KV label="MAC" value={node.mac || "--"} mono />
|
||||
<KV label="Firmware" value={node.firmware_version || "--"} mono />
|
||||
<KV label="Node ID" value={String(node.node_id)} mono />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>{label}</span>
|
||||
<span className={mono ? "mono" : ""} style={{ color: "var(--text-secondary)", fontSize: 12 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Node, WasmModule, WasmModuleState } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATE_STYLES: Record<WasmModuleState, { color: string; label: string }> = {
|
||||
running: { color: "var(--status-online)", label: "Running" },
|
||||
stopped: { color: "var(--status-warning)", label: "Stopped" },
|
||||
error: { color: "var(--status-error)", label: "Error" },
|
||||
loading: { color: "var(--status-info)", label: "Loading" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EdgeModules page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function EdgeModules() {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [selectedIp, setSelectedIp] = useState<string>("");
|
||||
const [modules, setModules] = useState<WasmModule[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// ---- Discover nodes on mount ----
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const discovered = await invoke<Node[]>("discover_nodes", {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
setNodes(discovered);
|
||||
if (discovered.length > 0) {
|
||||
setSelectedIp(discovered[0].ip);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ---- Fetch modules when selected node changes ----
|
||||
const fetchModules = useCallback(async (ip: string) => {
|
||||
if (!ip) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await invoke<WasmModule[]>("wasm_list", { nodeIp: ip });
|
||||
setModules(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setModules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIp) {
|
||||
fetchModules(selectedIp);
|
||||
}
|
||||
}, [selectedIp, fetchModules]);
|
||||
|
||||
// ---- Upload .wasm file ----
|
||||
const handleUpload = async () => {
|
||||
if (!selectedIp) return;
|
||||
const filePath = await open({
|
||||
title: "Select WASM Module",
|
||||
filters: [{ name: "WASM Modules", extensions: ["wasm"] }],
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (!filePath) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const result = await invoke<{ success: boolean; module_id: string; message: string }>(
|
||||
"wasm_upload",
|
||||
{ nodeIp: selectedIp, wasmPath: filePath },
|
||||
);
|
||||
if (result.success) {
|
||||
setSuccess(`Module uploaded: ${result.module_id}`);
|
||||
await fetchModules(selectedIp);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Module actions ----
|
||||
const handleAction = async (moduleId: string, action: "start" | "stop" | "unload") => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await invoke("wasm_control", {
|
||||
nodeIp: selectedIp,
|
||||
moduleId,
|
||||
action,
|
||||
});
|
||||
setSuccess(`Module ${moduleId} ${action === "unload" ? "unloaded" : action === "start" ? "started" : "stopped"}`);
|
||||
await fetchModules(selectedIp);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>Edge Modules (WASM)</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
||||
Manage WASM modules deployed to ESP32 nodes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedIp || isUploading}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: !selectedIp || isUploading ? "var(--bg-active)" : "var(--accent)",
|
||||
color: !selectedIp || isUploading ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: !selectedIp || isUploading ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
}}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Upload Module"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Node selector */}
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
display: "block",
|
||||
marginBottom: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
Target Node
|
||||
</label>
|
||||
<select
|
||||
value={selectedIp}
|
||||
onChange={(e) => setSelectedIp(e.target.value)}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-elevated)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-mono)",
|
||||
minWidth: 260,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{nodes.length === 0 && <option value="">No nodes discovered</option>}
|
||||
{nodes.map((node) => (
|
||||
<option key={node.ip} value={node.ip}>
|
||||
{node.ip}{node.hostname ? ` (${node.hostname})` : ""}{node.friendly_name ? ` - ${node.friendly_name}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Success banner */}
|
||||
{success && (
|
||||
<Banner
|
||||
type="success"
|
||||
message={success}
|
||||
onDismiss={() => setSuccess(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<Banner
|
||||
type="error"
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Module table */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading modules...
|
||||
</div>
|
||||
) : modules.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{selectedIp
|
||||
? "No WASM modules loaded on this node. Use \"Upload Module\" to deploy one."
|
||||
: "Select a node to view its WASM modules."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
|
||||
<Th>Name</Th>
|
||||
<Th>Size</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Loaded At</Th>
|
||||
<Th>Actions</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((mod) => (
|
||||
<ModuleRow
|
||||
key={mod.module_id}
|
||||
module={mod}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleStateBadge({ state }: { state: WasmModuleState }) {
|
||||
const { color, label } = STATE_STYLES[state];
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 9999,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
variant = "default",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
}) {
|
||||
const isDanger = variant === "danger";
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--font-sans)",
|
||||
border: `1px solid ${isDanger ? "var(--status-error)" : "var(--border)"}`,
|
||||
background: "transparent",
|
||||
color: isDanger ? "var(--status-error)" : "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDanger
|
||||
? "rgba(248, 81, 73, 0.1)"
|
||||
: "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleRow({
|
||||
module: mod,
|
||||
onAction,
|
||||
}: {
|
||||
module: WasmModule;
|
||||
onAction: (moduleId: string, action: "start" | "stop" | "unload") => void;
|
||||
}) {
|
||||
return (
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<Td mono>{mod.name}</Td>
|
||||
<Td mono>{formatBytes(mod.size_bytes)}</Td>
|
||||
<Td><ModuleStateBadge state={mod.state} /></Td>
|
||||
<Td>{formatLoadedAt(mod.loaded_at)}</Td>
|
||||
<td style={{ padding: "10px var(--space-4)", whiteSpace: "nowrap" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
{mod.state === "stopped" && (
|
||||
<ActionButton label="Start" onClick={() => onAction(mod.module_id, "start")} />
|
||||
)}
|
||||
{mod.state === "running" && (
|
||||
<ActionButton label="Stop" onClick={() => onAction(mod.module_id, "stop")} />
|
||||
)}
|
||||
<ActionButton
|
||||
label="Unload"
|
||||
onClick={() => onAction(mod.module_id, "unload")}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({
|
||||
type,
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
type: "error" | "success";
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isError = type === "error";
|
||||
const color = isError ? "var(--status-error)" : "var(--status-online)";
|
||||
const bgAlpha = isError ? "rgba(248, 81, 73, 0.1)" : "rgba(63, 185, 80, 0.1)";
|
||||
const borderAlpha = isError ? "rgba(248, 81, 73, 0.3)" : "rgba(63, 185, 80, 0.3)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgAlpha,
|
||||
border: `1px solid ${borderAlpha}`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color,
|
||||
cursor: "pointer",
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
padding: "0 0 0 var(--space-3)",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatLoadedAt(iso: string | null): string {
|
||||
if (!iso) return "--";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { SerialPort, Chip, FlashProgress, FlashPhase } from "../types";
|
||||
|
||||
type WizardStep = 1 | 2 | 3;
|
||||
|
||||
export function FlashFirmware() {
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [selectedPort, setSelectedPort] = useState("");
|
||||
const [firmwarePath, setFirmwarePath] = useState("");
|
||||
const [chip, setChip] = useState<Chip>("esp32s3");
|
||||
const [baud, setBaud] = useState(460800);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [progress, setProgress] = useState<FlashProgress | null>(null);
|
||||
const [isFlashing, setIsFlashing] = useState(false);
|
||||
const [flashResult, setFlashResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<SerialPort[]>("list_serial_ports");
|
||||
setPorts(result);
|
||||
if (result.length === 1) setSelectedPort(result[0].name);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadPorts(); }, [loadPorts]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<FlashProgress>("flash-progress", (event) => {
|
||||
setProgress(event.payload);
|
||||
}).then((fn) => { unlisten = fn; });
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
const pickFirmware = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Firmware Binary", extensions: ["bin"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") setFirmwarePath(selected);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const startFlash = async () => {
|
||||
if (!selectedPort || !firmwarePath) return;
|
||||
setIsFlashing(true);
|
||||
setFlashResult(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("flash_firmware", { port: selectedPort, firmwarePath, chip, baud });
|
||||
setFlashResult({ success: true, message: "Firmware flashed successfully." });
|
||||
} catch (err) {
|
||||
setFlashResult({ success: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setIsFlashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = (s: WizardStep): boolean => {
|
||||
if (s === 1) return selectedPort !== "";
|
||||
if (s === 2) return firmwarePath !== "";
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 700 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Flash Firmware</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Flash firmware to an ESP32 via serial connection
|
||||
</p>
|
||||
|
||||
<StepIndicator current={step} />
|
||||
|
||||
{error && (
|
||||
<div style={bannerStyle("var(--status-error)")}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Select Serial Port */}
|
||||
{step === 1 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 1: Select Serial Port</h2>
|
||||
<p style={stepDescStyle}>Connect your ESP32 via USB and select the serial port.</p>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Serial Port</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<select
|
||||
value={selectedPort}
|
||||
onChange={(e) => setSelectedPort(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
disabled={isLoadingPorts}
|
||||
>
|
||||
<option value="">
|
||||
{isLoadingPorts ? "Loading..." : ports.length === 0 ? "No ports detected" : "Select a port..."}
|
||||
</option>
|
||||
{ports.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}{p.description ? ` - ${p.description}` : ""}{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={loadPorts} style={secondaryBtn} disabled={isLoadingPorts}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setStep(2)} disabled={!canProceed(1)} style={canProceed(1) ? primaryBtn : disabledBtn}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select Firmware */}
|
||||
{step === 2 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 2: Select Firmware</h2>
|
||||
<p style={stepDescStyle}>Choose the firmware binary file and chip configuration.</p>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-4)" }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Chip</label>
|
||||
<select value={chip} onChange={(e) => setChip(e.target.value as Chip)}>
|
||||
<option value="esp32">ESP32</option>
|
||||
<option value="esp32s3">ESP32-S3</option>
|
||||
<option value="esp32c3">ESP32-C3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Baud Rate</label>
|
||||
<select value={baud} onChange={(e) => setBaud(Number(e.target.value))}>
|
||||
<option value={115200}>115200</option>
|
||||
<option value={230400}>230400</option>
|
||||
<option value={460800}>460800</option>
|
||||
<option value={921600}>921600</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button onClick={() => setStep(1)} style={secondaryBtn}>Back</button>
|
||||
<button onClick={() => setStep(3)} disabled={!canProceed(2)} style={canProceed(2) ? primaryBtn : disabledBtn}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Flash */}
|
||||
{step === 3 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={stepTitleStyle}>Step 3: Flash</h2>
|
||||
|
||||
{/* Summary */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "var(--space-2)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<SummaryField label="Port" value={selectedPort} />
|
||||
<SummaryField label="Firmware" value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath} />
|
||||
<SummaryField label="Chip" value={chip.toUpperCase()} />
|
||||
<SummaryField label="Baud" value={String(baud)} />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{(isFlashing || progress) && !flashResult && (
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<ProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{flashResult && (
|
||||
<div style={bannerStyle(flashResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||
{flashResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<button
|
||||
onClick={() => { setStep(2); setFlashResult(null); setProgress(null); }}
|
||||
style={secondaryBtn}
|
||||
disabled={isFlashing}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{flashResult ? (
|
||||
<button
|
||||
onClick={() => { setStep(1); setFlashResult(null); setProgress(null); setFirmwarePath(""); setSelectedPort(""); }}
|
||||
style={primaryBtn}
|
||||
>
|
||||
Flash Another
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={startFlash} disabled={isFlashing} style={isFlashing ? disabledBtn : primaryBtn}>
|
||||
{isFlashing ? "Flashing..." : "Start Flash"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function StepIndicator({ current }: { current: WizardStep }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Select Port" },
|
||||
{ n: 2, label: "Select Firmware" },
|
||||
{ n: 3, label: "Flash" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "var(--space-5)" }}>
|
||||
{steps.map(({ n, label }, i) => {
|
||||
const isActive = n === current;
|
||||
const isDone = n < current;
|
||||
return (
|
||||
<div key={n} style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--font-mono)",
|
||||
background: isActive ? "var(--accent)" : isDone ? "rgba(63, 185, 80, 0.2)" : "var(--border)",
|
||||
color: isActive ? "#fff" : isDone ? "var(--status-online)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{isDone ? "\u2713" : n}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div style={{ width: 40, height: 1, background: "var(--border)", margin: "0 var(--space-3)" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<FlashPhase, string> = {
|
||||
connecting: "Connecting...",
|
||||
erasing: "Erasing flash...",
|
||||
writing: "Writing firmware...",
|
||||
verifying: "Verifying...",
|
||||
done: "Complete",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
function ProgressBar({ progress }: { progress: FlashProgress | null }) {
|
||||
const pct = progress?.progress_pct ?? 0;
|
||||
const phase = progress?.phase ?? "connecting";
|
||||
const speed = progress?.speed_bps ?? 0;
|
||||
const speedKB = speed > 0 ? `${(speed / 1024).toFixed(1)} KB/s` : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: "var(--text-secondary)" }}>{PHASE_LABELS[phase]}</span>
|
||||
<span style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
|
||||
{pct.toFixed(1)}%{speedKB && ` | ${speedKB}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ width: "100%", height: 8, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
height: "100%",
|
||||
background: phase === "error" ? "var(--status-error)" : phase === "done" ? "var(--status-online)" : "var(--accent)",
|
||||
borderRadius: 4,
|
||||
transition: "width 0.3s ease",
|
||||
animation: phase === "writing" ? "pulse-accent 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: 1 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontFamily: "var(--font-mono)", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared styles ---
|
||||
|
||||
function bannerStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
};
|
||||
|
||||
const stepTitleStyle: React.CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 var(--space-1)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const stepDescStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "var(--space-4)",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const disabledBtn: React.CSSProperties = {
|
||||
...primaryBtn,
|
||||
background: "var(--bg-active)",
|
||||
color: "var(--text-muted)",
|
||||
};
|
||||
@@ -0,0 +1,703 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { HealthStatus } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: HealthStatus;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface SimNode {
|
||||
id: number;
|
||||
label: string;
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
firmware: string | null;
|
||||
health: HealthStatus;
|
||||
isCoordinator: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
tdmSlot: number;
|
||||
}
|
||||
|
||||
interface SimEdge {
|
||||
source: number; // index into nodes
|
||||
target: number;
|
||||
strength: number; // 0.3 - 1.0 opacity
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANVAS_HEIGHT = 500;
|
||||
const REPULSION = 8000;
|
||||
const SPRING_K = 0.005;
|
||||
const SPRING_REST = 120;
|
||||
const DAMPING = 0.92;
|
||||
const VELOCITY_THRESHOLD = 0.15;
|
||||
const DT = 1;
|
||||
|
||||
const HEALTH_COLORS: Record<HealthStatus, string> = {
|
||||
online: "#3fb950",
|
||||
offline: "#f85149",
|
||||
degraded: "#d29922",
|
||||
unknown: "#8b949e",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGraph(
|
||||
rawNodes: DiscoveredNode[],
|
||||
canvasWidth: number,
|
||||
): { nodes: SimNode[]; edges: SimEdge[] } {
|
||||
const cx = canvasWidth / 2;
|
||||
const cy = CANVAS_HEIGHT / 2;
|
||||
|
||||
const nodes: SimNode[] = rawNodes.map((n, i) => {
|
||||
const isCoord = n.node_id === 0 || i === 0;
|
||||
const angle = (2 * Math.PI * i) / Math.max(rawNodes.length, 1);
|
||||
const spread = Math.min(canvasWidth, CANVAS_HEIGHT) * 0.3;
|
||||
return {
|
||||
id: n.node_id,
|
||||
label: n.hostname || `Node ${n.node_id}`,
|
||||
ip: n.ip,
|
||||
mac: n.mac,
|
||||
firmware: n.firmware_version,
|
||||
health: n.health,
|
||||
isCoordinator: isCoord,
|
||||
x: cx + Math.cos(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
y: cy + Math.sin(angle) * spread + (Math.random() - 0.5) * 20,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
radius: isCoord ? 30 : 20,
|
||||
tdmSlot: i,
|
||||
};
|
||||
});
|
||||
|
||||
const edges: SimEdge[] = [];
|
||||
const coordIdx = 0;
|
||||
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
// Connect every node to coordinator
|
||||
edges.push({
|
||||
source: coordIdx,
|
||||
target: i,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
// Connect to next neighbor (ring)
|
||||
if (i < nodes.length - 1) {
|
||||
edges.push({
|
||||
source: i,
|
||||
target: i + 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Close the ring if 3+ non-coordinator nodes
|
||||
if (nodes.length > 3) {
|
||||
edges.push({
|
||||
source: nodes.length - 1,
|
||||
target: 1,
|
||||
strength: 0.3 + Math.random() * 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function hitTest(
|
||||
mx: number,
|
||||
my: number,
|
||||
nodes: SimNode[],
|
||||
): SimNode | null {
|
||||
// Iterate in reverse so topmost (last-drawn) wins
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const dx = mx - n.x;
|
||||
const dy = my - n.y;
|
||||
if (dx * dx + dy * dy <= n.radius * n.radius) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MeshView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasWidth, setCanvasWidth] = useState(800);
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<SimNode | null>(null);
|
||||
|
||||
// Track simulation data in a ref so the animation loop can read it without
|
||||
// re-renders triggering a new effect.
|
||||
const simRef = useRef<{ nodes: SimNode[]; edges: SimEdge[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
const animRef = useRef<number>(0);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch nodes from Rust backend
|
||||
// -----------------------------------------------------------------------
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
setSelectedNode(null);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
setNodes(found);
|
||||
} catch (err) {
|
||||
console.error("Discovery failed:", err);
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Measure container width
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const w = el.clientWidth;
|
||||
if (w > 0) setCanvasWidth(w);
|
||||
};
|
||||
measure();
|
||||
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build graph + run force simulation whenever nodes or width change
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) {
|
||||
simRef.current = { nodes: [], edges: [] };
|
||||
// Clear canvas
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes: simNodes, edges } = buildGraph(nodes, canvasWidth);
|
||||
simRef.current = { nodes: simNodes, edges };
|
||||
|
||||
let settled = false;
|
||||
|
||||
const step = () => {
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
// Coulomb repulsion
|
||||
for (let i = 0; i < sn.length; i++) {
|
||||
for (let j = i + 1; j < sn.length; j++) {
|
||||
let dx = sn[j].x - sn[i].x;
|
||||
let dy = sn[j].y - sn[i].y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const force = REPULSION / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
sn[i].vx -= fx;
|
||||
sn[i].vy -= fy;
|
||||
sn[j].vx += fx;
|
||||
sn[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Spring attraction along edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) dist = 1;
|
||||
const displacement = dist - SPRING_REST;
|
||||
const force = SPRING_K * displacement;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// Integrate + damp + clamp to canvas bounds
|
||||
let maxV = 0;
|
||||
for (const n of sn) {
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx * DT;
|
||||
n.y += n.vy * DT;
|
||||
|
||||
// Keep nodes within canvas with padding
|
||||
const pad = n.radius + 10;
|
||||
if (n.x < pad) { n.x = pad; n.vx = 0; }
|
||||
if (n.x > canvasWidth - pad) { n.x = canvasWidth - pad; n.vx = 0; }
|
||||
if (n.y < pad) { n.y = pad; n.vy = 0; }
|
||||
if (n.y > CANVAS_HEIGHT - pad) { n.y = CANVAS_HEIGHT - pad; n.vy = 0; }
|
||||
|
||||
const v = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
|
||||
if (maxV < VELOCITY_THRESHOLD) settled = true;
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
// Edges
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
// Coordinator ring
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Selected highlight
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Node ID text inside circle
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
// Label below
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (!settled) step();
|
||||
draw();
|
||||
if (!settled) {
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
cancelAnimationFrame(animRef.current);
|
||||
animRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
// selectedNode is intentionally excluded from deps so clicking doesn't
|
||||
// restart the simulation. We redraw via the click handler instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, canvasWidth]);
|
||||
|
||||
// Redraw when selectedNode changes (without restarting simulation)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || simRef.current.nodes.length === 0) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const sn = simRef.current.nodes;
|
||||
const se = simRef.current.edges;
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT);
|
||||
|
||||
for (const e of se) {
|
||||
const a = sn[e.source];
|
||||
const b = sn[e.target];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = `rgba(139, 148, 158, ${e.strength * 0.6})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (const n of sn) {
|
||||
const color = HEALTH_COLORS[n.health] || HEALTH_COLORS.unknown;
|
||||
|
||||
if (n.isCoordinator) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 5, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = n.health === "offline" ? 0.45 : 0.85;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (selectedNode && selectedNode.id === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(String(n.id), n.x, n.y);
|
||||
|
||||
ctx.fillStyle = "#8b949e";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(n.label, n.x, n.y + n.radius + 6);
|
||||
}
|
||||
}, [selectedNode, canvasWidth]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Canvas click handler
|
||||
// -----------------------------------------------------------------------
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
const hit = hitTest(mx, my, simRef.current.nodes);
|
||||
setSelectedNode(hit);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Derived stats
|
||||
// -----------------------------------------------------------------------
|
||||
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>
|
||||
Mesh Topology
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
Force-directed view of the ESP32 mesh network
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchNodes}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: scanning ? "var(--bg-active)" : "var(--accent)",
|
||||
color: scanning ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: "none",
|
||||
cursor: scanning ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
{scanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
height: CANVAS_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{scanning
|
||||
? "Scanning for nodes..."
|
||||
: "No nodes found. Click Refresh to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={canvasWidth}
|
||||
height={CANVAS_HEIGHT}
|
||||
onClick={handleCanvasClick}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: CANVAS_HEIGHT,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-5)",
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Nodes </span>
|
||||
<span style={{ color: "var(--status-online)" }}>{onlineCount}</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>/{nodes.length} online</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Drift </span>
|
||||
±0.3ms
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: "var(--text-muted)" }}>Cycle </span>
|
||||
50ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Selected node detail card */}
|
||||
{selectedNode && (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{selectedNode.label}
|
||||
</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 10,
|
||||
background:
|
||||
HEALTH_COLORS[selectedNode.health] + "22",
|
||||
color: HEALTH_COLORS[selectedNode.health],
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{selectedNode.health}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
|
||||
gap: "var(--space-3) var(--space-5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailField label="IP Address" value={selectedNode.ip} mono />
|
||||
<DetailField label="MAC" value={selectedNode.mac ?? "--"} mono />
|
||||
<DetailField
|
||||
label="Firmware"
|
||||
value={selectedNode.firmware ?? "--"}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Role"
|
||||
value={selectedNode.isCoordinator ? "Coordinator" : "Node"}
|
||||
/>
|
||||
<DetailField
|
||||
label="TDM Slot"
|
||||
value={`${selectedNode.tdmSlot} / ${nodes.length}`}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Node ID"
|
||||
value={String(selectedNode.id)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useState } from "react";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import type { Node } from "../types";
|
||||
|
||||
export function Nodes() {
|
||||
const { nodes, isScanning, scan, error } = useNodes({
|
||||
pollInterval: 10_000,
|
||||
autoScan: true,
|
||||
});
|
||||
const [expandedMac, setExpandedMac] = useState<string | null>(null);
|
||||
|
||||
const toggleExpand = (node: Node) => {
|
||||
const key = node.mac ?? node.ip;
|
||||
setExpandedMac((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="heading-lg" style={{ margin: 0 }}>Nodes</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={isScanning}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
background: isScanning ? "var(--bg-active)" : "var(--accent)",
|
||||
color: isScanning ? "var(--text-muted)" : "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{nodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-8)",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
|
||||
<Th>Status</Th>
|
||||
<Th>MAC</Th>
|
||||
<Th>IP</Th>
|
||||
<Th>Firmware</Th>
|
||||
<Th>Chip</Th>
|
||||
<Th>Last Seen</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node) => {
|
||||
const key = node.mac ?? node.ip;
|
||||
return (
|
||||
<NodeRow
|
||||
key={key}
|
||||
node={node}
|
||||
isExpanded={expandedMac === key}
|
||||
onToggle={() => toggleExpand(node)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: "10px var(--space-4)",
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLastSeen(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: Node;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<Td><StatusBadge status={node.health} /></Td>
|
||||
<Td mono>{node.mac ?? "--"}</Td>
|
||||
<Td mono>{node.ip}</Td>
|
||||
<Td mono>{node.firmware_version ?? "--"}</Td>
|
||||
<Td>{node.chip?.toUpperCase() ?? "--"}</Td>
|
||||
<Td>{formatLastSeen(node.last_seen)}</Td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td colSpan={6} style={{ padding: "0 var(--space-4) var(--space-4)" }}>
|
||||
<ExpandedDetails node={node} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDetails({ node }: { node: Node }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-4)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: "var(--space-3) var(--space-5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<DetailField label="Hostname" value={node.hostname ?? "--"} />
|
||||
<DetailField label="Node ID" value={String(node.node_id)} mono />
|
||||
<DetailField label="Mesh Role" value={node.mesh_role} />
|
||||
<DetailField
|
||||
label="TDM Slot"
|
||||
value={
|
||||
node.tdm_slot != null && node.tdm_total != null
|
||||
? `${node.tdm_slot} / ${node.tdm_total}`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Edge Tier"
|
||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label="Uptime"
|
||||
value={
|
||||
node.uptime_secs != null
|
||||
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
|
||||
: "--"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<DetailField label="Discovery" value={node.discovery_method} />
|
||||
<DetailField
|
||||
label="Capabilities"
|
||||
value={
|
||||
node.capabilities
|
||||
? Object.entries(node.capabilities)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(", ") || "none"
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
{node.friendly_name && <DetailField label="Name" value={node.friendly_name} />}
|
||||
{node.notes && <DetailField label="Notes" value={node.notes} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)" }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Node,
|
||||
OtaStrategy,
|
||||
BatchNodeState,
|
||||
OtaResult,
|
||||
} from "../types";
|
||||
|
||||
type Mode = "single" | "batch";
|
||||
|
||||
interface DiscoveredNode {
|
||||
ip: string;
|
||||
mac: string | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
health: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<OtaStrategy, string> = {
|
||||
sequential: "Sequential",
|
||||
tdm_safe: "TDM-Safe",
|
||||
parallel: "Parallel",
|
||||
};
|
||||
|
||||
const STATE_CONFIG: Record<BatchNodeState, { label: string; color: string }> = {
|
||||
queued: { label: "Queued", color: "var(--text-muted)" },
|
||||
uploading: { label: "Uploading", color: "var(--status-info)" },
|
||||
rebooting: { label: "Rebooting", color: "var(--status-warning)" },
|
||||
verifying: { label: "Verifying", color: "var(--status-info)" },
|
||||
done: { label: "Done", color: "var(--status-online)" },
|
||||
failed: { label: "Failed", color: "var(--status-error)" },
|
||||
skipped: { label: "Skipped", color: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
export function OtaUpdate() {
|
||||
const [mode, setMode] = useState<Mode>("single");
|
||||
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [firmwarePath, setFirmwarePath] = useState("");
|
||||
const [psk, setPsk] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Single mode state
|
||||
const [selectedNodeIp, setSelectedNodeIp] = useState("");
|
||||
const [isSingleUpdating, setIsSingleUpdating] = useState(false);
|
||||
const [singleResult, setSingleResult] = useState<OtaResult | null>(null);
|
||||
|
||||
// Batch mode state
|
||||
const [selectedBatchIps, setSelectedBatchIps] = useState<Set<string>>(new Set());
|
||||
const [strategy, setStrategy] = useState<OtaStrategy>("sequential");
|
||||
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
|
||||
const [batchResults, setBatchResults] = useState<OtaResult[]>([]);
|
||||
const [batchNodeStates, setBatchNodeStates] = useState<Map<string, BatchNodeState>>(new Map());
|
||||
|
||||
const discoverNodes = useCallback(async () => {
|
||||
setIsDiscovering(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 5000 });
|
||||
setNodes(result);
|
||||
if (result.length === 0) {
|
||||
setError("No nodes discovered. Ensure ESP32 nodes are online and reachable.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickFirmware = async () => {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Firmware Binary", extensions: ["bin"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (selected && typeof selected === "string") setFirmwarePath(selected);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const startSingleOta = async () => {
|
||||
if (!selectedNodeIp || !firmwarePath) return;
|
||||
setIsSingleUpdating(true);
|
||||
setSingleResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<OtaResult>("ota_update", {
|
||||
nodeIp: selectedNodeIp,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setSingleResult(result);
|
||||
} catch (err) {
|
||||
setSingleResult({
|
||||
node_ip: selectedNodeIp,
|
||||
success: false,
|
||||
previous_version: null,
|
||||
new_version: null,
|
||||
duration_ms: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsSingleUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startBatchOta = async () => {
|
||||
const ips = Array.from(selectedBatchIps);
|
||||
if (ips.length === 0 || !firmwarePath) return;
|
||||
setIsBatchUpdating(true);
|
||||
setBatchResults([]);
|
||||
setError(null);
|
||||
|
||||
// Initialize all nodes as queued
|
||||
const initialStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => initialStates.set(ip, "queued"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
// Mark all as uploading while the batch runs
|
||||
ips.forEach((ip) => initialStates.set(ip, "uploading"));
|
||||
setBatchNodeStates(new Map(initialStates));
|
||||
|
||||
try {
|
||||
const results = await invoke<OtaResult[]>("batch_ota_update", {
|
||||
nodeIps: ips,
|
||||
firmwarePath,
|
||||
psk: psk || null,
|
||||
});
|
||||
setBatchResults(results);
|
||||
|
||||
// Update per-node states from results
|
||||
const finalStates = new Map<string, BatchNodeState>();
|
||||
results.forEach((r) => {
|
||||
finalStates.set(r.node_ip, r.success ? "done" : "failed");
|
||||
});
|
||||
setBatchNodeStates(finalStates);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// Mark all as failed on total failure
|
||||
const failStates = new Map<string, BatchNodeState>();
|
||||
ips.forEach((ip) => failStates.set(ip, "failed"));
|
||||
setBatchNodeStates(failStates);
|
||||
} finally {
|
||||
setIsBatchUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBatchNode = (ip: string) => {
|
||||
setSelectedBatchIps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ip)) next.delete(ip);
|
||||
else next.add(ip);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedBatchIps.size === nodes.length) {
|
||||
setSelectedBatchIps(new Set());
|
||||
} else {
|
||||
setSelectedBatchIps(new Set(nodes.map((n) => n.ip)));
|
||||
}
|
||||
};
|
||||
|
||||
const nodeLabel = (n: DiscoveredNode) => {
|
||||
const parts = [n.ip];
|
||||
if (n.hostname) parts.push(n.hostname);
|
||||
if (n.firmware_version) parts.push(`v${n.firmware_version}`);
|
||||
return parts.join(" - ");
|
||||
};
|
||||
|
||||
const canStartSingle = selectedNodeIp !== "" && firmwarePath !== "" && !isSingleUpdating;
|
||||
const canStartBatch = selectedBatchIps.size > 0 && firmwarePath !== "" && !isBatchUpdating;
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 800 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>OTA Update</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Push firmware updates to ESP32 nodes over the network
|
||||
</p>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div style={{ display: "flex", gap: 0, marginBottom: "var(--space-5)" }}>
|
||||
<TabButton label="Single Node" active={mode === "single"} onClick={() => setMode("single")} side="left" />
|
||||
<TabButton label="Batch OTA" active={mode === "batch"} onClick={() => setMode("batch")} side="right" />
|
||||
</div>
|
||||
|
||||
{error && <div style={bannerStyle("var(--status-error)")}>{error}</div>}
|
||||
|
||||
{/* Node Discovery Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-3)" }}>
|
||||
<h2 style={sectionTitleStyle}>Discovered Nodes</h2>
|
||||
<button onClick={discoverNodes} style={secondaryBtn} disabled={isDiscovering}>
|
||||
{isDiscovering ? "Scanning..." : nodes.length > 0 ? "Re-scan" : "Discover Nodes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && !isDiscovering && (
|
||||
<p style={{ fontSize: 13, color: "var(--text-muted)", margin: 0 }}>
|
||||
No nodes discovered yet. Click Discover Nodes to scan the network.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "single" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Target Node</label>
|
||||
<select
|
||||
value={selectedNodeIp}
|
||||
onChange={(e) => setSelectedNodeIp(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="">Select a node...</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.ip} value={n.ip}>{nodeLabel(n)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodes.length > 0 && mode === "batch" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", marginBottom: "var(--space-2)" }}>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>Select Nodes</label>
|
||||
<button onClick={toggleAll} style={{ ...linkBtn, fontSize: 11 }}>
|
||||
{selectedBatchIps.size === nodes.length ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 6 }}>
|
||||
{nodes.map((n) => (
|
||||
<label
|
||||
key={n.ip}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
background: selectedBatchIps.has(n.ip) ? "var(--bg-hover)" : "transparent",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBatchIps.has(n.ip)}
|
||||
onChange={() => toggleBatchNode(n.ip)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
<span style={{ flex: 1, color: "var(--text-primary)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||
{n.ip}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{n.hostname ?? "unknown"}
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
|
||||
{n.firmware_version ? `v${n.firmware_version}` : ""}
|
||||
</span>
|
||||
<StatusDot health={n.health} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)", marginBottom: 0 }}>
|
||||
{selectedBatchIps.size} of {nodes.length} nodes selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Firmware & Config Section */}
|
||||
<div style={{ ...cardStyle, marginBottom: "var(--space-4)" }}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Firmware & Configuration</h2>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: mode === "batch" ? "1fr 1fr" : "1fr", gap: "var(--space-4)", marginBottom: "var(--space-2)" }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Pre-Shared Key (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={psk}
|
||||
onChange={(e) => setPsk(e.target.value)}
|
||||
placeholder="Leave blank if none"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
{mode === "batch" && (
|
||||
<div>
|
||||
<label style={labelStyle}>Update Strategy</label>
|
||||
<select value={strategy} onChange={(e) => setStrategy(e.target.value as OtaStrategy)} style={{ width: "100%" }}>
|
||||
{(Object.keys(STRATEGY_LABELS) as OtaStrategy[]).map((s) => (
|
||||
<option key={s} value={s}>{STRATEGY_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4, marginBottom: 0 }}>
|
||||
{strategy === "sequential" && "Updates nodes one at a time."}
|
||||
{strategy === "tdm_safe" && "Respects TDM slots to avoid overlapping transmissions."}
|
||||
{strategy === "parallel" && "Updates all nodes simultaneously (fastest, highest network load)."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-5)" }}>
|
||||
{mode === "single" ? (
|
||||
<button onClick={startSingleOta} disabled={!canStartSingle} style={canStartSingle ? primaryBtn : disabledBtn}>
|
||||
{isSingleUpdating ? "Pushing Update..." : "Push Update"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={startBatchOta} disabled={!canStartBatch} style={canStartBatch ? primaryBtn : disabledBtn}>
|
||||
{isBatchUpdating ? "Updating..." : `Start Batch Update (${selectedBatchIps.size} node${selectedBatchIps.size !== 1 ? "s" : ""})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single Result */}
|
||||
{mode === "single" && singleResult && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>Result</h2>
|
||||
<div style={bannerStyle(singleResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{singleResult.success ? "Update Successful" : "Update Failed"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
Node: {singleResult.node_ip}
|
||||
{singleResult.previous_version && ` | Previous: v${singleResult.previous_version}`}
|
||||
{singleResult.new_version && ` | New: v${singleResult.new_version}`}
|
||||
{singleResult.duration_ms > 0 && ` | Duration: ${(singleResult.duration_ms / 1000).toFixed(1)}s`}
|
||||
</div>
|
||||
{singleResult.error && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, fontFamily: "var(--font-mono)" }}>
|
||||
{singleResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Progress & Results */}
|
||||
{mode === "batch" && batchNodeStates.size > 0 && (
|
||||
<div style={cardStyle}>
|
||||
<h2 style={{ ...sectionTitleStyle, marginBottom: "var(--space-3)" }}>
|
||||
{isBatchUpdating ? "Update Progress" : "Results"}
|
||||
</h2>
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden" }}>
|
||||
{/* Table header */}
|
||||
<div style={tableHeaderRow}>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Node IP</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Status</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>Version</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right" }}>Duration</span>
|
||||
</div>
|
||||
{/* Table rows */}
|
||||
{Array.from(batchNodeStates.entries()).map(([ip, state]) => {
|
||||
const result = batchResults.find((r) => r.node_ip === ip);
|
||||
const cfg = STATE_CONFIG[state];
|
||||
return (
|
||||
<div key={ip} style={tableRow}>
|
||||
<span style={{ ...tableCell, flex: 2, fontFamily: "var(--font-mono)" }}>{ip}</span>
|
||||
<span style={{ ...tableCell, flex: 2 }}>
|
||||
<NodeStateBadge state={state} />
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 2, fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{result?.previous_version && result?.new_version
|
||||
? `v${result.previous_version} -> v${result.new_version}`
|
||||
: result?.error
|
||||
? <span style={{ color: "var(--status-error)", fontFamily: "var(--font-mono)", fontSize: 11 }}>{result.error}</span>
|
||||
: "--"}
|
||||
</span>
|
||||
<span style={{ ...tableCell, flex: 1, textAlign: "right", fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{result && result.duration_ms > 0 ? `${(result.duration_ms / 1000).toFixed(1)}s` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{!isBatchUpdating && batchResults.length > 0 && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", gap: "var(--space-4)", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--status-online)" }}>
|
||||
{batchResults.filter((r) => r.success).length} succeeded
|
||||
</span>
|
||||
<span style={{ color: "var(--status-error)" }}>
|
||||
{batchResults.filter((r) => !r.success).length} failed
|
||||
</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
{batchResults.length} total
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabButton({ label, active, onClick, side }: { label: string; active: boolean; onClick: () => void; side: "left" | "right" }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? "var(--text-primary)" : "var(--text-muted)",
|
||||
background: active ? "var(--bg-surface)" : "transparent",
|
||||
border: `1px solid ${active ? "var(--border-active)" : "var(--border)"}`,
|
||||
borderRadius: side === "left" ? "6px 0 0 6px" : "0 6px 6px 0",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ health }: { health: string }) {
|
||||
const color =
|
||||
health === "online" ? "var(--status-online)" :
|
||||
health === "degraded" ? "var(--status-warning)" :
|
||||
health === "offline" ? "var(--status-error)" :
|
||||
"var(--text-muted)";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeStateBadge({ state }: { state: BatchNodeState }) {
|
||||
const cfg = STATE_CONFIG[state];
|
||||
const isAnimating = state === "uploading" || state === "rebooting" || state === "verifying";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: cfg.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: cfg.color,
|
||||
animation: isAnimating ? "pulse-accent 1.5s infinite" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bannerStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
};
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const disabledBtn: React.CSSProperties = {
|
||||
...primaryBtn,
|
||||
background: "var(--bg-active)",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "not-allowed",
|
||||
};
|
||||
|
||||
const linkBtn: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
const tableHeaderRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
const tableRow: React.CSSProperties = {
|
||||
display: "flex",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const tableCell: React.CSSProperties = {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
};
|
||||
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { useServer } from "../hooks/useServer";
|
||||
import type { SensingUpdate } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log entry model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
timestamp: string; // HH:MM:SS.mmm
|
||||
level: LogLevel;
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_LOG_TEMPLATES: { level: LogLevel; source: string; message: string }[] = [
|
||||
{ level: "INFO", source: "sensing-server", message: "HTTP listening on 127.0.0.1:8080" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.42" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Low signal quality on node 2" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: walking (confidence: 0.87)" },
|
||||
{ level: "ERROR", source: "ws_session", message: "Client disconnected unexpectedly" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.15" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sitting (confidence: 0.93)" },
|
||||
{ level: "INFO", source: "sensing-server", message: "WebSocket client connected from 127.0.0.1" },
|
||||
{ level: "WARN", source: "mesh_sync", message: "Node 4 heartbeat delayed by 1200ms" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: standing (confidence: 0.91)" },
|
||||
{ level: "INFO", source: "udp_receiver", message: "CSI frame from 192.168.1.78" },
|
||||
{ level: "ERROR", source: "udp_receiver", message: "Malformed CSI payload (len=0)" },
|
||||
{ level: "INFO", source: "csi_pipeline", message: "Subcarrier FFT complete (52 bins)" },
|
||||
{ level: "WARN", source: "vital_signs", message: "Breathing rate out of range on node 5" },
|
||||
{ level: "INFO", source: "pose_engine", message: "Activity: sleeping (confidence: 0.78)" },
|
||||
];
|
||||
|
||||
const MOCK_ACTIVITIES = [
|
||||
{ activity: "walking", confidence: 0.87 },
|
||||
{ activity: "sitting", confidence: 0.93 },
|
||||
{ activity: "standing", confidence: 0.91 },
|
||||
{ activity: "sleeping", confidence: 0.78 },
|
||||
{ activity: "exercising", confidence: 0.65 },
|
||||
];
|
||||
|
||||
function formatTimestamp(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
let nextLogId = 1;
|
||||
|
||||
function createMockLogEntry(): LogEntry {
|
||||
const template = MOCK_LOG_TEMPLATES[Math.floor(Math.random() * MOCK_LOG_TEMPLATES.length)];
|
||||
return {
|
||||
id: nextLogId++,
|
||||
timestamp: formatTimestamp(new Date()),
|
||||
level: template.level,
|
||||
source: template.source,
|
||||
message: template.message,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSensingUpdate(): SensingUpdate {
|
||||
const act = MOCK_ACTIVITIES[Math.floor(Math.random() * MOCK_ACTIVITIES.length)];
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
node_id: Math.floor(Math.random() * 6) + 1,
|
||||
subcarrier_count: 52,
|
||||
rssi: -(Math.floor(Math.random() * 40) + 30),
|
||||
activity: act.activity,
|
||||
confidence: parseFloat((act.confidence + (Math.random() * 0.1 - 0.05)).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const LOG_INTERVAL_MS = 2000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LogViewer component (ADR-053)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LEVEL_COLOR: Record<LogLevel, string> = {
|
||||
INFO: "var(--text-secondary)",
|
||||
WARN: "var(--status-warning)",
|
||||
ERROR: "var(--status-error)",
|
||||
};
|
||||
|
||||
function LogViewer({
|
||||
entries,
|
||||
onClear,
|
||||
paused,
|
||||
onTogglePause,
|
||||
}: {
|
||||
entries: LogEntry[];
|
||||
onClear: () => void;
|
||||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paused && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [entries, paused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--bg-elevated)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Server Log
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: paused ? "var(--status-warning)" : "var(--bg-hover)",
|
||||
color: paused ? "#000" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: "var(--space-1) var(--space-3)",
|
||||
fontSize: 12,
|
||||
borderRadius: 4,
|
||||
background: "var(--bg-hover)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflowY: "auto",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div style={{ color: "var(--text-muted)", padding: "var(--space-4)", textAlign: "center" }}>
|
||||
No log entries yet.
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<div key={entry.id} style={{ whiteSpace: "nowrap" }}>
|
||||
<span style={{ color: "var(--text-muted)" }}>{entry.timestamp}</span>{" "}
|
||||
<span
|
||||
style={{
|
||||
color: LEVEL_COLOR[entry.level],
|
||||
fontWeight: entry.level === "ERROR" ? 700 : 500,
|
||||
display: "inline-block",
|
||||
minWidth: 40,
|
||||
}}
|
||||
>
|
||||
{entry.level}
|
||||
</span>{" "}
|
||||
<span style={{ color: "var(--accent)" }}>{entry.source}</span>{" "}
|
||||
<span style={{ color: LEVEL_COLOR[entry.level] }}>{entry.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensing page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Sensing: React.FC = () => {
|
||||
const { status, isRunning, error, start, stop } = useServer({ pollInterval: 5000 });
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
// Log viewer state
|
||||
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const pausedRef = useRef(paused);
|
||||
pausedRef.current = paused;
|
||||
|
||||
// Activity feed state
|
||||
const [activities, setActivities] = useState<SensingUpdate[]>([]);
|
||||
|
||||
// Simulated log feed
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (pausedRef.current) return;
|
||||
const entry = createMockLogEntry();
|
||||
setLogEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next;
|
||||
});
|
||||
|
||||
// Also push an activity update every ~3rd tick
|
||||
if (Math.random() < 0.35) {
|
||||
setActivities((prev) => {
|
||||
const update = createMockSensingUpdate();
|
||||
const next = [update, ...prev];
|
||||
return next.slice(0, 5);
|
||||
});
|
||||
}
|
||||
}, LOG_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleClearLog = useCallback(() => setLogEntries([]), []);
|
||||
const handleTogglePause = useCallback(() => setPaused((p) => !p), []);
|
||||
|
||||
const handleStart = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await start();
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await stop();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)" }}>
|
||||
{/* Page header */}
|
||||
<h2 className="heading-lg" style={{ marginBottom: "var(--space-5)" }}>
|
||||
Sensing
|
||||
</h2>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 1: Server Control */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
marginBottom: "var(--space-5)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Left: status info */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: isRunning ? "var(--status-online)" : "var(--status-error)",
|
||||
boxShadow: isRunning ? "0 0 6px var(--status-online)" : "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
|
||||
Sensing Server
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)", marginTop: 2 }}>
|
||||
{isRunning ? "Running" : "Stopped"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Running details */}
|
||||
{isRunning && status && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "var(--space-4)",
|
||||
marginLeft: "var(--space-3)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{status.pid != null && <span>PID {status.pid}</span>}
|
||||
{status.http_port != null && <span>HTTP :{status.http_port}</span>}
|
||||
{status.ws_port != null && <span>WS :{status.ws_port}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: action button */}
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={starting || stopping}
|
||||
style={{
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting || stopping ? "not-allowed" : "pointer",
|
||||
border: "none",
|
||||
background: isRunning ? "var(--status-error)" : "var(--accent)",
|
||||
color: "#fff",
|
||||
opacity: starting || stopping ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "rgba(255,59,48,0.1)",
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
color: "var(--status-error)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 2: Log Viewer (ADR-053) */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div style={{ marginBottom: "var(--space-5)" }}>
|
||||
<LogViewer
|
||||
entries={logEntries}
|
||||
onClear={handleClearLog}
|
||||
paused={paused}
|
||||
onTogglePause={handleTogglePause}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
{/* Section 3: Activity Feed */}
|
||||
{/* ----------------------------------------------------------------- */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
Activity Feed
|
||||
</h3>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", textAlign: "center", padding: "var(--space-4)" }}>
|
||||
Waiting for sensing data...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
{activities.map((update, i) => {
|
||||
const ts = new Date(update.timestamp);
|
||||
const conf = update.confidence ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={`${update.timestamp}-${i}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--space-3)",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
background: "var(--bg-base)",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 72,
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(ts)}
|
||||
</span>
|
||||
|
||||
{/* Node ID */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
minWidth: 48,
|
||||
}}
|
||||
>
|
||||
Node {update.node_id}
|
||||
</span>
|
||||
|
||||
{/* Activity */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 80,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{update.activity ?? "unknown"}
|
||||
</span>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: "var(--bg-hover)",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.round(conf * 100)}%`,
|
||||
height: "100%",
|
||||
background: conf >= 0.8 ? "var(--status-online)" : conf >= 0.6 ? "var(--status-warning)" : "var(--status-error)",
|
||||
borderRadius: 3,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence value */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
color: "var(--text-secondary)",
|
||||
flexShrink: 0,
|
||||
minWidth: 36,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{Math.round(conf * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sensing;
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { AppSettings } from "../types";
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
server_http_port: 8080,
|
||||
server_ws_port: 8765,
|
||||
server_udp_port: 5005,
|
||||
bind_address: "127.0.0.1",
|
||||
ui_path: "",
|
||||
ota_psk: "",
|
||||
auto_discover: true,
|
||||
discover_interval_ms: 10_000,
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [showPsk, setShowPsk] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const persisted = await invoke<AppSettings | null>("get_settings");
|
||||
if (persisted) setSettings(persisted);
|
||||
} catch {
|
||||
// Settings command may not exist yet
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("save_settings", { settings });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "var(--space-5)", maxWidth: 600 }}>
|
||||
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Settings</h1>
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||
Configure server, network, and application preferences
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-error)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(63, 185, 80, 0.1)",
|
||||
border: "1px solid rgba(63, 185, 80, 0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "var(--space-3) var(--space-4)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: 13,
|
||||
color: "var(--status-online)",
|
||||
}}
|
||||
>
|
||||
Settings saved.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensing Server */}
|
||||
<Section title="Sensing Server">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||
<Field label="HTTP Port">
|
||||
<NumberInput value={settings.server_http_port} onChange={(v) => update("server_http_port", v)} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="WebSocket Port">
|
||||
<NumberInput value={settings.server_ws_port} onChange={(v) => update("server_ws_port", v)} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="UDP Port">
|
||||
<NumberInput value={settings.server_udp_port} onChange={(v) => update("server_udp_port", v)} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="Bind Address">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.bind_address}
|
||||
onChange={(e) => update("bind_address", e.target.value)}
|
||||
placeholder="127.0.0.1"
|
||||
style={{ fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ marginTop: "var(--space-4)" }}>
|
||||
<Field label="UI Static Files Path">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ui_path}
|
||||
onChange={(e) => update("ui_path", e.target.value)}
|
||||
placeholder="Leave empty for default"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Security */}
|
||||
<Section title="Security">
|
||||
<Field label="OTA Pre-Shared Key (PSK)">
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input
|
||||
type={showPsk ? "text" : "password"}
|
||||
value={settings.ota_psk}
|
||||
onChange={(e) => update("ota_psk", e.target.value)}
|
||||
placeholder="Enter PSK for OTA authentication"
|
||||
style={{ flex: 1, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<button onClick={() => setShowPsk((prev) => !prev)} style={secondaryBtn}>
|
||||
{showPsk ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)" }}>
|
||||
Used for authenticating OTA firmware updates to nodes.
|
||||
</p>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Discovery */}
|
||||
<Section title="Network Discovery">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||
<Field label="Auto-Discover">
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.auto_discover}
|
||||
onChange={(e) => update("auto_discover", e.target.checked)}
|
||||
style={{ accentColor: "var(--accent)" }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Enable periodic scanning</span>
|
||||
</label>
|
||||
</Field>
|
||||
<Field label="Scan Interval (ms)">
|
||||
<NumberInput
|
||||
value={settings.discover_interval_ms}
|
||||
onChange={(v) => update("discover_interval_ms", v)}
|
||||
min={1000}
|
||||
max={120_000}
|
||||
step={1000}
|
||||
disabled={!settings.auto_discover}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "var(--space-5)" }}>
|
||||
<button onClick={reset} style={secondaryBtn}>Reset to Defaults</button>
|
||||
<button onClick={save} style={primaryBtn}>Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "var(--space-5)",
|
||||
marginBottom: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 var(--space-4)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value, onChange, min, max, step = 1, disabled = false,
|
||||
}: {
|
||||
value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => { const n = parseInt(e.target.value, 10); if (!isNaN(n)) onChange(n); }}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared styles ---
|
||||
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) 20px",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
padding: "var(--space-2) var(--space-4)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
// =============================================================================
|
||||
// types.ts — TypeScript types matching the Rust domain model for RuView
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node Discovery & Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MacAddress = string; // "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
export type HealthStatus = "online" | "offline" | "degraded" | "unknown";
|
||||
|
||||
export type DiscoveryMethod = "mdns" | "udp_probe" | "http_sweep" | "manual";
|
||||
|
||||
export type MeshRole = "coordinator" | "node" | "aggregator";
|
||||
|
||||
export type Chip = "esp32" | "esp32s3" | "esp32c3";
|
||||
|
||||
export interface TdmConfig {
|
||||
slot: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface NodeCapabilities {
|
||||
wasm: boolean;
|
||||
ota: boolean;
|
||||
csi: boolean;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
ip: string;
|
||||
mac: MacAddress | null;
|
||||
hostname: string | null;
|
||||
node_id: number;
|
||||
firmware_version: string | null;
|
||||
tdm_slot: number | null;
|
||||
tdm_total: number | null;
|
||||
edge_tier: number | null;
|
||||
uptime_secs: number | null;
|
||||
discovery_method: DiscoveryMethod;
|
||||
last_seen: string; // ISO 8601 datetime
|
||||
health: HealthStatus;
|
||||
chip: Chip;
|
||||
mesh_role: MeshRole;
|
||||
capabilities: NodeCapabilities | null;
|
||||
friendly_name: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Firmware Flashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FlashPhase =
|
||||
| "connecting"
|
||||
| "erasing"
|
||||
| "writing"
|
||||
| "verifying"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface FlashProgress {
|
||||
phase: FlashPhase;
|
||||
progress_pct: number; // 0.0 - 100.0
|
||||
bytes_written: number;
|
||||
bytes_total: number;
|
||||
speed_bps: number;
|
||||
}
|
||||
|
||||
export interface FirmwareBinary {
|
||||
path: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
chip: Chip | null;
|
||||
}
|
||||
|
||||
export interface FlashSession {
|
||||
port: string;
|
||||
firmware: FirmwareBinary;
|
||||
chip: Chip;
|
||||
baud: number;
|
||||
progress: FlashProgress | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FlashResult {
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
bytes_written: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ChipInfo {
|
||||
chip: Chip;
|
||||
mac: MacAddress;
|
||||
flash_size_bytes: number;
|
||||
crystal_freq_mhz: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTA Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OtaStrategy = "sequential" | "tdm_safe" | "parallel";
|
||||
|
||||
export type BatchNodeState =
|
||||
| "queued"
|
||||
| "uploading"
|
||||
| "rebooting"
|
||||
| "verifying"
|
||||
| "done"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
export interface OtaSession {
|
||||
node_ip: string;
|
||||
firmware_path: string;
|
||||
progress_pct: number;
|
||||
state: BatchNodeState;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BatchOtaSession {
|
||||
strategy: OtaStrategy;
|
||||
max_concurrent: number;
|
||||
batch_delay_secs: number;
|
||||
fail_fast: boolean;
|
||||
nodes: OtaSession[];
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface OtaResult {
|
||||
node_ip: string;
|
||||
success: boolean;
|
||||
previous_version: string | null;
|
||||
new_version: string | null;
|
||||
duration_ms: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface OtaStatus {
|
||||
current_version: string;
|
||||
partition: string;
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM Modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WasmModuleState = "running" | "stopped" | "error" | "loading";
|
||||
|
||||
export interface WasmModule {
|
||||
module_id: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
state: WasmModuleState;
|
||||
node_ip: string;
|
||||
loaded_at: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensing Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServerConfig {
|
||||
http_port: number;
|
||||
ws_port: number;
|
||||
udp_port: number;
|
||||
static_dir: string | null;
|
||||
model_dir: string | null;
|
||||
log_level: string;
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
http_port: number | null;
|
||||
ws_port: number | null;
|
||||
udp_port: number | null;
|
||||
uptime_secs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SensingUpdate {
|
||||
timestamp: string;
|
||||
node_id: number;
|
||||
subcarrier_count: number;
|
||||
rssi: number;
|
||||
activity: string | null;
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serial Port
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SerialPort {
|
||||
name: string; // e.g. "COM3" or "/dev/ttyUSB0"
|
||||
description: string; // e.g. "Silicon Labs CP210x"
|
||||
chip: Chip | null; // detected chip type, if any
|
||||
manufacturer: string | null;
|
||||
vid: number | null; // USB vendor ID
|
||||
pid: number | null; // USB product ID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AppSettings {
|
||||
server_http_port: number;
|
||||
server_ws_port: number;
|
||||
server_udp_port: number;
|
||||
bind_address: string;
|
||||
ui_path: string;
|
||||
ota_psk: string;
|
||||
auto_discover: boolean;
|
||||
discover_interval_ms: number;
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: "esnext",
|
||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
@@ -24,6 +24,9 @@ linux-wifi = []
|
||||
[dependencies]
|
||||
# CLI argument parsing (for bin/aggregator)
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Cryptographic HMAC (ADR-050: replace fake XOR-fold HMAC)
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
# Byte parsing
|
||||
byteorder = "1.5"
|
||||
# Time
|
||||
|
||||
+103
-10
@@ -33,9 +33,13 @@ use super::quic_transport::{
|
||||
QuicTransportHandle, QuicTransportError, SecurityMode,
|
||||
};
|
||||
use super::tdm::{SyncBeacon, TdmCoordinator, TdmSchedule, TdmSlotCompleted};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -245,19 +249,17 @@ impl AuthenticatedBeacon {
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute the expected HMAC tag for this beacon using the given key.
|
||||
/// Compute the HMAC-SHA256 tag for this beacon, truncated to 8 bytes.
|
||||
///
|
||||
/// Uses a simplified HMAC approximation for testing. In production,
|
||||
/// this calls mbedtls HMAC-SHA256 via the ESP-IDF hardware accelerator
|
||||
/// or the `sha2` crate on aggregator nodes.
|
||||
/// Uses the `hmac` + `sha2` crates for cryptographically secure
|
||||
/// message authentication (ADR-050, Sprint 1).
|
||||
pub fn compute_tag(payload_and_nonce: &[u8], key: &[u8; 16]) -> [u8; HMAC_TAG_SIZE] {
|
||||
// Simplified HMAC: XOR key into payload hash. In production, use
|
||||
// real HMAC-SHA256 from sha2 crate. This is sufficient for
|
||||
// testing the protocol structure.
|
||||
let mut mac = HmacSha256::new_from_slice(key)
|
||||
.expect("HMAC-SHA256 accepts any key length");
|
||||
mac.update(payload_and_nonce);
|
||||
let result = mac.finalize().into_bytes();
|
||||
let mut tag = [0u8; HMAC_TAG_SIZE];
|
||||
for (i, byte) in payload_and_nonce.iter().enumerate() {
|
||||
tag[i % HMAC_TAG_SIZE] ^= byte ^ key[i % 16];
|
||||
}
|
||||
tag.copy_from_slice(&result[..HMAC_TAG_SIZE]);
|
||||
tag
|
||||
}
|
||||
|
||||
@@ -975,6 +977,97 @@ mod tests {
|
||||
assert_eq!(SecLevel::Enforcing as u8, 2);
|
||||
}
|
||||
|
||||
// ---- Security tests (ADR-050) ----
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_keys_produce_different_tags() {
|
||||
let msg = b"test payload with nonce";
|
||||
let key1: [u8; 16] = [0x01; 16];
|
||||
let key2: [u8; 16] = [0x02; 16];
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(msg, &key1);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(msg, &key2);
|
||||
assert_ne!(tag1, tag2, "Different keys must produce different HMAC tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_messages_produce_different_tags() {
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(b"message one", &key);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(b"message two", &key);
|
||||
assert_ne!(tag1, tag2, "Different messages must produce different HMAC tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_is_deterministic() {
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let msg = b"determinism test";
|
||||
let tag1 = AuthenticatedBeacon::compute_tag(msg, &key);
|
||||
let tag2 = AuthenticatedBeacon::compute_tag(msg, &key);
|
||||
assert_eq!(tag1, tag2, "Same key + message must produce identical tags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails_verification() {
|
||||
let beacon = SyncBeacon {
|
||||
cycle_id: 42,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
};
|
||||
let correct_key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let wrong_key: [u8; 16] = [0xFF; 16];
|
||||
let nonce = 1u32;
|
||||
|
||||
let mut msg = [0u8; 20];
|
||||
msg[..16].copy_from_slice(&beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
|
||||
let tag = AuthenticatedBeacon::compute_tag(&msg, &correct_key);
|
||||
|
||||
let auth = AuthenticatedBeacon { beacon, nonce, hmac_tag: tag };
|
||||
assert!(auth.verify(&wrong_key).is_err(), "Wrong key must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_bit_flip_in_payload_fails_verification() {
|
||||
let beacon = SyncBeacon {
|
||||
cycle_id: 42,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
};
|
||||
let key: [u8; 16] = DEFAULT_TEST_KEY;
|
||||
let nonce = 1u32;
|
||||
|
||||
let mut msg = [0u8; 20];
|
||||
msg[..16].copy_from_slice(&beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
|
||||
let tag = AuthenticatedBeacon::compute_tag(&msg, &key);
|
||||
|
||||
let auth = AuthenticatedBeacon { beacon, nonce, hmac_tag: tag };
|
||||
let mut wire = auth.to_bytes();
|
||||
// Flip one bit in the beacon payload
|
||||
wire[0] ^= 0x01;
|
||||
let tampered = AuthenticatedBeacon::from_bytes(&wire).unwrap();
|
||||
assert!(tampered.verify(&key).is_err(), "Single bit flip must fail verification");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enforcing_mode_rejects_unauthenticated() {
|
||||
let mut cfg = manual_config();
|
||||
cfg.sec_level = SecLevel::Enforcing;
|
||||
let mut coord = SecureTdmCoordinator::new(test_schedule(), cfg).unwrap();
|
||||
|
||||
// Raw 16-byte beacon without HMAC
|
||||
let raw = SyncBeacon {
|
||||
cycle_id: 1,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
}.to_bytes();
|
||||
|
||||
assert!(coord.verify_beacon(&raw).is_err());
|
||||
}
|
||||
|
||||
// ---- Error display tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
//! Adaptive CSI Activity Classifier
|
||||
//!
|
||||
//! Learns environment-specific classification thresholds from labeled JSONL
|
||||
//! recordings. Uses a lightweight approach:
|
||||
//!
|
||||
//! 1. **Feature statistics**: per-class mean/stddev for each of 7 CSI features
|
||||
//! 2. **Mahalanobis-like distance**: weighted distance to each class centroid
|
||||
//! 3. **Logistic regression weights**: learned via gradient descent on the
|
||||
//! labeled data for fine-grained boundary tuning
|
||||
//!
|
||||
//! The trained model is serialised as JSON and hot-loaded at runtime so that
|
||||
//! the classification thresholds adapt to the specific room and ESP32 placement.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ── Feature vector ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Extended feature vector: 7 server features + 8 subcarrier-derived features = 15.
|
||||
const N_FEATURES: usize = 15;
|
||||
|
||||
/// Activity classes we recognise.
|
||||
pub const CLASSES: &[&str] = &["absent", "present_still", "present_moving", "active"];
|
||||
const N_CLASSES: usize = 4;
|
||||
|
||||
/// Extract extended feature vector from a JSONL frame (features + raw amplitudes).
|
||||
pub fn features_from_frame(frame: &serde_json::Value) -> [f64; N_FEATURES] {
|
||||
let feat = frame.get("features").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let nodes = frame.get("nodes").and_then(|n| n.as_array());
|
||||
let amps: Vec<f64> = nodes
|
||||
.and_then(|ns| ns.first())
|
||||
.and_then(|n| n.get("amplitude"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Server-computed features (0-6).
|
||||
let variance = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let mbp = feat.get("motion_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let bbp = feat.get("breathing_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sp = feat.get("spectral_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let df = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let cp = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rssi = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
// Subcarrier-derived features (7-14).
|
||||
let (amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range) =
|
||||
subcarrier_stats(&s);
|
||||
|
||||
[
|
||||
variance, mbp, bbp, sp, df, cp, rssi,
|
||||
amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range,
|
||||
]
|
||||
}
|
||||
|
||||
/// Also keep a simpler version for runtime (no JSONL, just FeatureInfo + amps).
|
||||
pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_FEATURES] {
|
||||
let variance = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let mbp = feat.get("motion_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let bbp = feat.get("breathing_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sp = feat.get("spectral_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let df = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let cp = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rssi = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let (amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range) =
|
||||
subcarrier_stats(amps);
|
||||
[
|
||||
variance, mbp, bbp, sp, df, cp, rssi,
|
||||
amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range,
|
||||
]
|
||||
}
|
||||
|
||||
/// Compute statistical features from raw subcarrier amplitudes.
|
||||
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
if amps.is_empty() {
|
||||
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
let n = amps.len() as f64;
|
||||
let mean = amps.iter().sum::<f64>() / n;
|
||||
let var = amps.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n;
|
||||
let std = var.sqrt().max(1e-9);
|
||||
|
||||
// Skewness (asymmetry).
|
||||
let skew = amps.iter().map(|a| ((a - mean) / std).powi(3)).sum::<f64>() / n;
|
||||
// Kurtosis (peakedness).
|
||||
let kurt = amps.iter().map(|a| ((a - mean) / std).powi(4)).sum::<f64>() / n - 3.0;
|
||||
|
||||
// IQR (inter-quartile range).
|
||||
let mut sorted = amps.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let q1 = sorted[sorted.len() / 4];
|
||||
let q3 = sorted[3 * sorted.len() / 4];
|
||||
let iqr = q3 - q1;
|
||||
|
||||
// Spectral entropy (normalised).
|
||||
let total_power: f64 = amps.iter().map(|a| a * a).sum::<f64>().max(1e-9);
|
||||
let entropy: f64 = amps.iter()
|
||||
.map(|a| {
|
||||
let p = (a * a) / total_power;
|
||||
if p > 1e-12 { -p * p.ln() } else { 0.0 }
|
||||
})
|
||||
.sum::<f64>() / n.ln().max(1e-9); // normalise to [0,1]
|
||||
|
||||
let max_val = sorted.last().copied().unwrap_or(0.0);
|
||||
let range = max_val - sorted.first().copied().unwrap_or(0.0);
|
||||
|
||||
(mean, std, skew, kurt, iqr, entropy, max_val, range)
|
||||
}
|
||||
|
||||
// ── Per-class statistics ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassStats {
|
||||
pub label: String,
|
||||
pub count: usize,
|
||||
pub mean: [f64; N_FEATURES],
|
||||
pub stddev: [f64; N_FEATURES],
|
||||
}
|
||||
|
||||
// ── Trained model ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdaptiveModel {
|
||||
/// Per-class feature statistics (centroid + spread).
|
||||
pub class_stats: Vec<ClassStats>,
|
||||
/// Logistic regression weights: [N_CLASSES x (N_FEATURES + 1)] (last = bias).
|
||||
pub weights: Vec<[f64; N_FEATURES + 1]>,
|
||||
/// Global feature normalisation: mean and stddev across all training data.
|
||||
pub global_mean: [f64; N_FEATURES],
|
||||
pub global_std: [f64; N_FEATURES],
|
||||
/// Training metadata.
|
||||
pub trained_frames: usize,
|
||||
pub training_accuracy: f64,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
impl Default for AdaptiveModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
class_stats: Vec::new(),
|
||||
weights: vec![[0.0; N_FEATURES + 1]; N_CLASSES],
|
||||
global_mean: [0.0; N_FEATURES],
|
||||
global_std: [1.0; N_FEATURES],
|
||||
trained_frames: 0,
|
||||
training_accuracy: 0.0,
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdaptiveModel {
|
||||
/// Classify a raw feature vector. Returns (class_label, confidence).
|
||||
pub fn classify(&self, raw_features: &[f64; N_FEATURES]) -> (&'static str, f64) {
|
||||
if self.weights.is_empty() || self.class_stats.is_empty() {
|
||||
return ("present_still", 0.5);
|
||||
}
|
||||
|
||||
// Normalise features.
|
||||
let mut x = [0.0f64; N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
x[i] = (raw_features[i] - self.global_mean[i]) / (self.global_std[i] + 1e-9);
|
||||
}
|
||||
|
||||
// Compute logits: w·x + b for each class.
|
||||
let mut logits = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES.min(self.weights.len()) {
|
||||
let w = &self.weights[c];
|
||||
let mut z = w[N_FEATURES]; // bias
|
||||
for i in 0..N_FEATURES {
|
||||
z += w[i] * x[i];
|
||||
}
|
||||
logits[c] = z;
|
||||
}
|
||||
|
||||
// Softmax.
|
||||
let max_logit = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let exp_sum: f64 = logits.iter().map(|z| (z - max_logit).exp()).sum();
|
||||
let mut probs = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES {
|
||||
probs[c] = ((logits[c] - max_logit).exp()) / exp_sum;
|
||||
}
|
||||
|
||||
// Pick argmax.
|
||||
let (best_c, best_p) = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap();
|
||||
let label = if best_c < CLASSES.len() { CLASSES[best_c] } else { "present_still" };
|
||||
(label, *best_p)
|
||||
}
|
||||
|
||||
/// Save model to a JSON file.
|
||||
pub fn save(&self, path: &Path) -> std::io::Result<()> {
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
|
||||
/// Load model from a JSON file.
|
||||
pub fn load(path: &Path) -> std::io::Result<Self> {
|
||||
let json = std::fs::read_to_string(path)?;
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Training ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A labeled training sample.
|
||||
struct Sample {
|
||||
features: [f64; N_FEATURES],
|
||||
class_idx: usize,
|
||||
}
|
||||
|
||||
/// Load JSONL recording frames and assign a class label based on filename.
|
||||
fn load_recording(path: &Path, class_idx: usize) -> Vec<Sample> {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
content.lines().filter_map(|line| {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
// Use extended features (server features + subcarrier stats).
|
||||
Some(Sample {
|
||||
features: features_from_frame(&v),
|
||||
class_idx,
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Map a recording filename to a class index.
|
||||
fn classify_recording_name(name: &str) -> Option<usize> {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.contains("empty") || lower.contains("absent") { Some(0) }
|
||||
else if lower.contains("still") || lower.contains("sitting") || lower.contains("standing") { Some(1) }
|
||||
else if lower.contains("walking") || lower.contains("moving") { Some(2) }
|
||||
else if lower.contains("active") || lower.contains("exercise") || lower.contains("running") { Some(3) }
|
||||
else { None }
|
||||
}
|
||||
|
||||
/// Train a model from labeled JSONL recordings in a directory.
|
||||
///
|
||||
/// Recordings are matched to classes by filename pattern:
|
||||
/// - `*empty*` / `*absent*` → absent (0)
|
||||
/// - `*still*` / `*sitting*` → present_still (1)
|
||||
/// - `*walking*` / `*moving*` → present_moving (2)
|
||||
/// - `*active*` / `*exercise*`→ active (3)
|
||||
pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, String> {
|
||||
// Scan for train_* files.
|
||||
let mut samples: Vec<Sample> = Vec::new();
|
||||
let entries = std::fs::read_dir(recordings_dir)
|
||||
.map_err(|e| format!("Cannot read {}: {}", recordings_dir.display(), e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let fname = entry.file_name().to_string_lossy().to_string();
|
||||
if !fname.starts_with("train_") || !fname.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
if let Some(class_idx) = classify_recording_name(&fname) {
|
||||
let loaded = load_recording(&entry.path(), class_idx);
|
||||
eprintln!(" Loaded {}: {} frames → class '{}'",
|
||||
fname, loaded.len(), CLASSES[class_idx]);
|
||||
samples.extend(loaded);
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
return Err("No training samples found. Record data with train_* prefix.".into());
|
||||
}
|
||||
|
||||
let n = samples.len();
|
||||
eprintln!("Total training samples: {n}");
|
||||
|
||||
// ── Compute global normalisation stats ──
|
||||
let mut global_mean = [0.0f64; N_FEATURES];
|
||||
let mut global_var = [0.0f64; N_FEATURES];
|
||||
for s in &samples {
|
||||
for i in 0..N_FEATURES { global_mean[i] += s.features[i]; }
|
||||
}
|
||||
for i in 0..N_FEATURES { global_mean[i] /= n as f64; }
|
||||
for s in &samples {
|
||||
for i in 0..N_FEATURES {
|
||||
global_var[i] += (s.features[i] - global_mean[i]).powi(2);
|
||||
}
|
||||
}
|
||||
let mut global_std = [0.0f64; N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
global_std[i] = (global_var[i] / n as f64).sqrt().max(1e-9);
|
||||
}
|
||||
|
||||
// ── Compute per-class statistics ──
|
||||
let mut class_sums = vec![[0.0f64; N_FEATURES]; N_CLASSES];
|
||||
let mut class_sq = vec![[0.0f64; N_FEATURES]; N_CLASSES];
|
||||
let mut class_counts = vec![0usize; N_CLASSES];
|
||||
for s in &samples {
|
||||
let c = s.class_idx;
|
||||
class_counts[c] += 1;
|
||||
for i in 0..N_FEATURES {
|
||||
class_sums[c][i] += s.features[i];
|
||||
class_sq[c][i] += s.features[i] * s.features[i];
|
||||
}
|
||||
}
|
||||
|
||||
let mut class_stats = Vec::new();
|
||||
for c in 0..N_CLASSES {
|
||||
let cnt = class_counts[c].max(1) as f64;
|
||||
let mut mean = [0.0; N_FEATURES];
|
||||
let mut stddev = [0.0; N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
mean[i] = class_sums[c][i] / cnt;
|
||||
stddev[i] = ((class_sq[c][i] / cnt) - mean[i] * mean[i]).max(0.0).sqrt();
|
||||
}
|
||||
class_stats.push(ClassStats {
|
||||
label: CLASSES[c].to_string(),
|
||||
count: class_counts[c],
|
||||
mean,
|
||||
stddev,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Normalise all samples ──
|
||||
let mut norm_samples: Vec<([f64; N_FEATURES], usize)> = samples.iter().map(|s| {
|
||||
let mut x = [0.0; N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
x[i] = (s.features[i] - global_mean[i]) / (global_std[i] + 1e-9);
|
||||
}
|
||||
(x, s.class_idx)
|
||||
}).collect();
|
||||
|
||||
// ── Train logistic regression via mini-batch SGD ──
|
||||
let mut weights = vec![[0.0f64; N_FEATURES + 1]; N_CLASSES];
|
||||
let lr = 0.1;
|
||||
let epochs = 200;
|
||||
let batch_size = 32;
|
||||
|
||||
// Shuffle helper (simple LCG for determinism).
|
||||
let mut rng_state: u64 = 42;
|
||||
let mut rng_next = move || -> u64 {
|
||||
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
rng_state >> 33
|
||||
};
|
||||
|
||||
for epoch in 0..epochs {
|
||||
// Shuffle samples.
|
||||
for i in (1..norm_samples.len()).rev() {
|
||||
let j = (rng_next() as usize) % (i + 1);
|
||||
norm_samples.swap(i, j);
|
||||
}
|
||||
|
||||
let mut epoch_loss = 0.0f64;
|
||||
let mut batch_count = 0;
|
||||
|
||||
for batch_start in (0..norm_samples.len()).step_by(batch_size) {
|
||||
let batch_end = (batch_start + batch_size).min(norm_samples.len());
|
||||
let batch = &norm_samples[batch_start..batch_end];
|
||||
|
||||
// Accumulate gradients.
|
||||
let mut grad = vec![[0.0f64; N_FEATURES + 1]; N_CLASSES];
|
||||
|
||||
for (x, target) in batch {
|
||||
// Forward: softmax.
|
||||
let mut logits = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES {
|
||||
logits[c] = weights[c][N_FEATURES]; // bias
|
||||
for i in 0..N_FEATURES {
|
||||
logits[c] += weights[c][i] * x[i];
|
||||
}
|
||||
}
|
||||
let max_l = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let exp_sum: f64 = logits.iter().map(|z| (z - max_l).exp()).sum();
|
||||
let mut probs = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES {
|
||||
probs[c] = ((logits[c] - max_l).exp()) / exp_sum;
|
||||
}
|
||||
|
||||
// Cross-entropy loss.
|
||||
epoch_loss += -(probs[*target].max(1e-15)).ln();
|
||||
|
||||
// Gradient: prob - one_hot(target).
|
||||
for c in 0..N_CLASSES {
|
||||
let delta = probs[c] - if c == *target { 1.0 } else { 0.0 };
|
||||
for i in 0..N_FEATURES {
|
||||
grad[c][i] += delta * x[i];
|
||||
}
|
||||
grad[c][N_FEATURES] += delta; // bias grad
|
||||
}
|
||||
}
|
||||
|
||||
// Update weights.
|
||||
let bs = batch.len() as f64;
|
||||
let current_lr = lr * (1.0 - epoch as f64 / epochs as f64); // linear decay
|
||||
for c in 0..N_CLASSES {
|
||||
for i in 0..=N_FEATURES {
|
||||
weights[c][i] -= current_lr * grad[c][i] / bs;
|
||||
}
|
||||
}
|
||||
batch_count += 1;
|
||||
}
|
||||
|
||||
if epoch % 50 == 0 || epoch == epochs - 1 {
|
||||
let avg_loss = epoch_loss / n as f64;
|
||||
eprintln!(" Epoch {epoch:3}: loss = {avg_loss:.4}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Evaluate accuracy ──
|
||||
let mut correct = 0;
|
||||
for (x, target) in &norm_samples {
|
||||
let mut logits = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES {
|
||||
logits[c] = weights[c][N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
logits[c] += weights[c][i] * x[i];
|
||||
}
|
||||
}
|
||||
let pred = logits.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap().0;
|
||||
if pred == *target { correct += 1; }
|
||||
}
|
||||
let accuracy = correct as f64 / n as f64;
|
||||
eprintln!("Training accuracy: {correct}/{n} = {accuracy:.1}%");
|
||||
|
||||
// ── Per-class accuracy ──
|
||||
let mut class_correct = vec![0usize; N_CLASSES];
|
||||
let mut class_total = vec![0usize; N_CLASSES];
|
||||
for (x, target) in &norm_samples {
|
||||
class_total[*target] += 1;
|
||||
let mut logits = [0.0f64; N_CLASSES];
|
||||
for c in 0..N_CLASSES {
|
||||
logits[c] = weights[c][N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
logits[c] += weights[c][i] * x[i];
|
||||
}
|
||||
}
|
||||
let pred = logits.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap().0;
|
||||
if pred == *target { class_correct[*target] += 1; }
|
||||
}
|
||||
for c in 0..N_CLASSES {
|
||||
let tot = class_total[c].max(1);
|
||||
eprintln!(" {}: {}/{} ({:.0}%)", CLASSES[c], class_correct[c], tot,
|
||||
class_correct[c] as f64 / tot as f64 * 100.0);
|
||||
}
|
||||
|
||||
Ok(AdaptiveModel {
|
||||
class_stats,
|
||||
weights,
|
||||
global_mean,
|
||||
global_std,
|
||||
trained_frames: n,
|
||||
training_accuracy: accuracy,
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default path for the saved adaptive model.
|
||||
pub fn model_path() -> PathBuf {
|
||||
PathBuf::from("data/adaptive_model.json")
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//!
|
||||
//! Replaces both ws_server.py and the Python HTTP server.
|
||||
|
||||
mod adaptive_classifier;
|
||||
mod rvf_container;
|
||||
mod rvf_pipeline;
|
||||
mod vital_signs;
|
||||
@@ -76,6 +77,10 @@ struct Args {
|
||||
#[arg(long, default_value = "100")]
|
||||
tick_ms: u64,
|
||||
|
||||
/// Bind address (default 127.0.0.1; set to 0.0.0.0 for network access)
|
||||
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
|
||||
bind_addr: String,
|
||||
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
@@ -299,6 +304,34 @@ struct AppStateInner {
|
||||
model_loaded: bool,
|
||||
/// Smoothed person count (EMA) for hysteresis — prevents frame-to-frame jumping.
|
||||
smoothed_person_score: f64,
|
||||
// ── Motion smoothing & adaptive baseline (ADR-047 tuning) ────────────
|
||||
/// EMA-smoothed motion score (alpha ~0.15 for ~10 FPS → ~1s time constant).
|
||||
smoothed_motion: f64,
|
||||
/// Current classification state for hysteresis debounce.
|
||||
current_motion_level: String,
|
||||
/// How many consecutive frames the *raw* classification has agreed with a
|
||||
/// *candidate* new level. State only changes after DEBOUNCE_FRAMES.
|
||||
debounce_counter: u32,
|
||||
/// The candidate motion level that the debounce counter is tracking.
|
||||
debounce_candidate: String,
|
||||
/// Adaptive baseline: EMA of motion score when room is "quiet" (low motion).
|
||||
/// Subtracted from raw score so slow environmental drift doesn't inflate readings.
|
||||
baseline_motion: f64,
|
||||
/// Number of frames processed so far (for baseline warm-up).
|
||||
baseline_frames: u64,
|
||||
// ── Vital signs smoothing ────────────────────────────────────────────
|
||||
/// EMA-smoothed heart rate (BPM).
|
||||
smoothed_hr: f64,
|
||||
/// EMA-smoothed breathing rate (BPM).
|
||||
smoothed_br: f64,
|
||||
/// EMA-smoothed HR confidence.
|
||||
smoothed_hr_conf: f64,
|
||||
/// EMA-smoothed BR confidence.
|
||||
smoothed_br_conf: f64,
|
||||
/// Median filter buffer for HR (last N raw values for outlier rejection).
|
||||
hr_buffer: VecDeque<f64>,
|
||||
/// Median filter buffer for BR.
|
||||
br_buffer: VecDeque<f64>,
|
||||
/// ADR-039: Latest edge vitals packet from ESP32.
|
||||
edge_vitals: Option<Esp32VitalsPacket>,
|
||||
/// ADR-040: Latest WASM output packet from ESP32.
|
||||
@@ -324,6 +357,9 @@ struct AppStateInner {
|
||||
training_status: String,
|
||||
/// Training configuration, if any.
|
||||
training_config: Option<serde_json::Value>,
|
||||
// ── Adaptive classifier (environment-tuned) ──────────────────────────
|
||||
/// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime).
|
||||
adaptive_model: Option<adaptive_classifier::AdaptiveModel>,
|
||||
}
|
||||
|
||||
/// Number of frames retained in `frame_history` for temporal analysis.
|
||||
@@ -716,11 +752,12 @@ fn compute_subcarrier_variances(frame_history: &VecDeque<Vec<f64>>, n_sub: usize
|
||||
/// the amplitude time series.
|
||||
/// - **Signal quality**: based on SNR estimate (RSSI – noise floor) and subcarrier
|
||||
/// variance stability.
|
||||
/// Returns (features, raw_classification, breathing_rate_hz, sub_variances, raw_motion_score).
|
||||
fn extract_features_from_frame(
|
||||
frame: &Esp32Frame,
|
||||
frame_history: &VecDeque<Vec<f64>>,
|
||||
sample_rate_hz: f64,
|
||||
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>) {
|
||||
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>, f64) {
|
||||
let n_sub = frame.amplitudes.len().max(1);
|
||||
let n = n_sub as f64;
|
||||
let mean_amp: f64 = frame.amplitudes.iter().sum::<f64>() / n;
|
||||
@@ -799,8 +836,11 @@ fn extract_features_from_frame(
|
||||
};
|
||||
|
||||
// Blend temporal motion with variance-based motion for robustness.
|
||||
// Also factor in motion_band_power and change_points for ESP32 real-world sensitivity.
|
||||
let variance_motion = (temporal_variance / 10.0).clamp(0.0, 1.0);
|
||||
let motion_score = (temporal_motion_score * 0.7 + variance_motion * 0.3).clamp(0.0, 1.0);
|
||||
let mbp_motion = (motion_band_power / 25.0).clamp(0.0, 1.0);
|
||||
let cp_motion = (change_points as f64 / 15.0).clamp(0.0, 1.0);
|
||||
let motion_score = (temporal_motion_score * 0.4 + variance_motion * 0.2 + mbp_motion * 0.25 + cp_motion * 0.15).clamp(0.0, 1.0);
|
||||
|
||||
// ── Signal quality metric ──
|
||||
// Based on estimated SNR (RSSI relative to noise floor) and subcarrier consistency.
|
||||
@@ -823,24 +863,198 @@ fn extract_features_from_frame(
|
||||
spectral_power,
|
||||
};
|
||||
|
||||
// ── Classification ──
|
||||
let (motion_level, presence) = if motion_score > 0.4 {
|
||||
("active".to_string(), true)
|
||||
} else if motion_score > 0.08 {
|
||||
("present_still".to_string(), true)
|
||||
// Return raw motion_score and signal_quality — classification is done by
|
||||
// `smooth_and_classify()` which has access to EMA state and hysteresis.
|
||||
let raw_classification = ClassificationInfo {
|
||||
motion_level: raw_classify(motion_score),
|
||||
presence: motion_score > 0.04,
|
||||
confidence: (0.4 + signal_quality * 0.3 + motion_score * 0.3).clamp(0.0, 1.0),
|
||||
};
|
||||
|
||||
(features, raw_classification, breathing_rate_hz, sub_variances, motion_score)
|
||||
}
|
||||
|
||||
/// Simple threshold classification (no smoothing) — used as the "raw" input.
|
||||
fn raw_classify(score: f64) -> String {
|
||||
if score > 0.25 { "active".into() }
|
||||
else if score > 0.12 { "present_moving".into() }
|
||||
else if score > 0.04 { "present_still".into() }
|
||||
else { "absent".into() }
|
||||
}
|
||||
|
||||
/// Debounce frames required before state transition (at ~10 FPS = ~0.4s).
|
||||
const DEBOUNCE_FRAMES: u32 = 4;
|
||||
/// EMA alpha for motion smoothing (~1s time constant at 10 FPS).
|
||||
const MOTION_EMA_ALPHA: f64 = 0.15;
|
||||
/// EMA alpha for slow-adapting baseline (~30s time constant at 10 FPS).
|
||||
const BASELINE_EMA_ALPHA: f64 = 0.003;
|
||||
/// Number of warm-up frames before baseline subtraction kicks in.
|
||||
const BASELINE_WARMUP: u64 = 50;
|
||||
|
||||
/// Apply EMA smoothing, adaptive baseline subtraction, and hysteresis debounce
|
||||
/// to the raw classification. Mutates the smoothing state in `AppStateInner`.
|
||||
fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, raw_motion: f64) {
|
||||
// 1. Adaptive baseline: slowly track the "quiet room" floor.
|
||||
// Only update baseline when raw score is below the current smoothed level
|
||||
// (i.e. during calm periods) so walking doesn't inflate the baseline.
|
||||
state.baseline_frames += 1;
|
||||
if state.baseline_frames < BASELINE_WARMUP {
|
||||
// During warm-up, aggressively learn the baseline.
|
||||
state.baseline_motion = state.baseline_motion * 0.9 + raw_motion * 0.1;
|
||||
} else if raw_motion < state.smoothed_motion + 0.05 {
|
||||
state.baseline_motion = state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA)
|
||||
+ raw_motion * BASELINE_EMA_ALPHA;
|
||||
}
|
||||
|
||||
// 2. Subtract baseline and clamp.
|
||||
let adjusted = (raw_motion - state.baseline_motion * 0.7).max(0.0);
|
||||
|
||||
// 3. EMA smooth the adjusted score.
|
||||
state.smoothed_motion = state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA)
|
||||
+ adjusted * MOTION_EMA_ALPHA;
|
||||
let sm = state.smoothed_motion;
|
||||
|
||||
// 4. Classify from smoothed score.
|
||||
let candidate = raw_classify(sm);
|
||||
|
||||
// 5. Hysteresis debounce: require N consecutive frames agreeing on a new state.
|
||||
if candidate == state.current_motion_level {
|
||||
// Already in this state — reset debounce.
|
||||
state.debounce_counter = 0;
|
||||
state.debounce_candidate = candidate;
|
||||
} else if candidate == state.debounce_candidate {
|
||||
state.debounce_counter += 1;
|
||||
if state.debounce_counter >= DEBOUNCE_FRAMES {
|
||||
// Transition accepted.
|
||||
state.current_motion_level = candidate;
|
||||
state.debounce_counter = 0;
|
||||
}
|
||||
} else {
|
||||
("absent".to_string(), false)
|
||||
};
|
||||
// New candidate — restart counter.
|
||||
state.debounce_candidate = candidate;
|
||||
state.debounce_counter = 1;
|
||||
}
|
||||
|
||||
let confidence = (0.4 + signal_quality * 0.3 + motion_score * 0.3).clamp(0.0, 1.0);
|
||||
// 6. Write the smoothed result back into the classification.
|
||||
raw.motion_level = state.current_motion_level.clone();
|
||||
raw.presence = sm > 0.03;
|
||||
raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
let classification = ClassificationInfo {
|
||||
motion_level,
|
||||
presence,
|
||||
confidence,
|
||||
};
|
||||
/// If an adaptive model is loaded, override the classification with the
|
||||
/// model's prediction. Uses the full 15-feature vector for higher accuracy.
|
||||
fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo) {
|
||||
if let Some(ref model) = state.adaptive_model {
|
||||
// Get current frame amplitudes from the latest history entry.
|
||||
let amps = state.frame_history.back()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
"motion_band_power": features.motion_band_power,
|
||||
"breathing_band_power": features.breathing_band_power,
|
||||
"spectral_power": features.spectral_power,
|
||||
"dominant_freq_hz": features.dominant_freq_hz,
|
||||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
amps,
|
||||
);
|
||||
let (label, conf) = model.classify(&feat_arr);
|
||||
classification.motion_level = label.to_string();
|
||||
classification.presence = label != "absent";
|
||||
// Blend model confidence with existing smoothed confidence.
|
||||
classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
(features, classification, breathing_rate_hz, sub_variances)
|
||||
/// Size of the median filter window for vital signs outlier rejection.
|
||||
const VITAL_MEDIAN_WINDOW: usize = 21;
|
||||
/// EMA alpha for vital signs (~5s time constant at 10 FPS).
|
||||
const VITAL_EMA_ALPHA: f64 = 0.02;
|
||||
/// Maximum BPM jump per frame before a value is rejected as an outlier.
|
||||
const HR_MAX_JUMP: f64 = 8.0;
|
||||
const BR_MAX_JUMP: f64 = 2.0;
|
||||
/// Minimum change from current smoothed value before EMA updates (dead-band).
|
||||
/// Prevents micro-drift from creeping in.
|
||||
const HR_DEAD_BAND: f64 = 2.0;
|
||||
const BR_DEAD_BAND: f64 = 0.5;
|
||||
|
||||
/// Smooth vital signs using median-filter outlier rejection + EMA.
|
||||
/// Mutates `state.smoothed_hr`, `state.smoothed_br`, etc.
|
||||
/// Returns the smoothed VitalSigns to broadcast.
|
||||
fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns {
|
||||
let raw_hr = raw.heart_rate_bpm.unwrap_or(0.0);
|
||||
let raw_br = raw.breathing_rate_bpm.unwrap_or(0.0);
|
||||
|
||||
// -- Outlier rejection: skip values that jump too far from current EMA --
|
||||
let hr_ok = state.smoothed_hr < 1.0 || (raw_hr - state.smoothed_hr).abs() < HR_MAX_JUMP;
|
||||
let br_ok = state.smoothed_br < 1.0 || (raw_br - state.smoothed_br).abs() < BR_MAX_JUMP;
|
||||
|
||||
// Push into buffer (only non-outlier values)
|
||||
if hr_ok && raw_hr > 0.0 {
|
||||
state.hr_buffer.push_back(raw_hr);
|
||||
if state.hr_buffer.len() > VITAL_MEDIAN_WINDOW { state.hr_buffer.pop_front(); }
|
||||
}
|
||||
if br_ok && raw_br > 0.0 {
|
||||
state.br_buffer.push_back(raw_br);
|
||||
if state.br_buffer.len() > VITAL_MEDIAN_WINDOW { state.br_buffer.pop_front(); }
|
||||
}
|
||||
|
||||
// Compute trimmed mean: drop top/bottom 25% then average the middle 50%.
|
||||
// This is more stable than pure median and less noisy than raw mean.
|
||||
let trimmed_hr = trimmed_mean(&state.hr_buffer);
|
||||
let trimmed_br = trimmed_mean(&state.br_buffer);
|
||||
|
||||
// EMA smooth with dead-band: only update if the trimmed mean differs
|
||||
// from the current smoothed value by more than the dead-band.
|
||||
// This prevents the display from constantly creeping by tiny amounts.
|
||||
if trimmed_hr > 0.0 {
|
||||
if state.smoothed_hr < 1.0 {
|
||||
state.smoothed_hr = trimmed_hr;
|
||||
} else if (trimmed_hr - state.smoothed_hr).abs() > HR_DEAD_BAND {
|
||||
state.smoothed_hr = state.smoothed_hr * (1.0 - VITAL_EMA_ALPHA)
|
||||
+ trimmed_hr * VITAL_EMA_ALPHA;
|
||||
}
|
||||
// else: within dead-band, hold current value
|
||||
}
|
||||
if trimmed_br > 0.0 {
|
||||
if state.smoothed_br < 1.0 {
|
||||
state.smoothed_br = trimmed_br;
|
||||
} else if (trimmed_br - state.smoothed_br).abs() > BR_DEAD_BAND {
|
||||
state.smoothed_br = state.smoothed_br * (1.0 - VITAL_EMA_ALPHA)
|
||||
+ trimmed_br * VITAL_EMA_ALPHA;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth confidence
|
||||
state.smoothed_hr_conf = state.smoothed_hr_conf * 0.92 + raw.heartbeat_confidence * 0.08;
|
||||
state.smoothed_br_conf = state.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08;
|
||||
|
||||
VitalSigns {
|
||||
breathing_rate_bpm: if state.smoothed_br > 1.0 { Some(state.smoothed_br) } else { None },
|
||||
heart_rate_bpm: if state.smoothed_hr > 1.0 { Some(state.smoothed_hr) } else { None },
|
||||
breathing_confidence: state.smoothed_br_conf,
|
||||
heartbeat_confidence: state.smoothed_hr_conf,
|
||||
signal_quality: raw.signal_quality,
|
||||
}
|
||||
}
|
||||
|
||||
/// Trimmed mean: sort, drop top/bottom 25%, average the middle 50%.
|
||||
/// More robust than median (uses more data) and less noisy than raw mean.
|
||||
fn trimmed_mean(buf: &VecDeque<f64>) -> f64 {
|
||||
if buf.is_empty() { return 0.0; }
|
||||
let mut sorted: Vec<f64> = buf.iter().copied().collect();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let n = sorted.len();
|
||||
let trim = n / 4; // drop 25% from each end
|
||||
let middle = &sorted[trim..n - trim.max(0)];
|
||||
if middle.is_empty() {
|
||||
sorted[n / 2] // fallback to median if too few samples
|
||||
} else {
|
||||
middle.iter().sum::<f64>() / middle.len() as f64
|
||||
}
|
||||
}
|
||||
|
||||
// ── Windows WiFi RSSI collector ──────────────────────────────────────────────
|
||||
@@ -982,8 +1196,10 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
s_write_pre.frame_history.pop_front();
|
||||
}
|
||||
let sample_rate_hz = 1000.0 / tick_ms as f64;
|
||||
let (features, classification, breathing_rate_hz, sub_variances) =
|
||||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion);
|
||||
adaptive_override(&s_write_pre, &features, &mut classification);
|
||||
drop(s_write_pre);
|
||||
|
||||
// ── Step 5: Build enhanced fields from pipeline result ───────
|
||||
@@ -1025,7 +1241,8 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
0.05
|
||||
};
|
||||
|
||||
let vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases);
|
||||
let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases);
|
||||
let vitals = smooth_vitals(&mut s, &raw_vitals);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let feat_variance = features.variance;
|
||||
@@ -1132,8 +1349,10 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
s.frame_history.pop_front();
|
||||
}
|
||||
let sample_rate_hz = 2.0_f64; // fallback tick ~ 500 ms => 2 Hz
|
||||
let (features, classification, breathing_rate_hz, sub_variances) =
|
||||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s, &mut classification, raw_motion);
|
||||
adaptive_override(&s, &features, &mut classification);
|
||||
|
||||
s.source = format!("wifi:{ssid}");
|
||||
s.rssi_history.push_back(rssi_dbm);
|
||||
@@ -1152,7 +1371,8 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
0.05
|
||||
};
|
||||
|
||||
let vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases);
|
||||
let raw_vitals = s.vital_detector.process_frame(&frame.amplitudes, &frame.phases);
|
||||
let vitals = smooth_vitals(&mut s, &raw_vitals);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let feat_variance = features.variance;
|
||||
@@ -1896,7 +2116,15 @@ async fn delete_model(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let path = PathBuf::from("data/models").join(format!("{}.rvf", id));
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
if safe_id.is_empty() || safe_id != id {
|
||||
return Json(serde_json::json!({ "error": "invalid model id", "success": false }));
|
||||
}
|
||||
let path = PathBuf::from("data/models").join(format!("{}.rvf", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete model file {:?}: {}", path, e);
|
||||
@@ -2147,7 +2375,15 @@ async fn delete_recording(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", id));
|
||||
// ADR-050: Sanitize path to prevent directory traversal
|
||||
let safe_id = std::path::Path::new(&id)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("");
|
||||
if safe_id.is_empty() || safe_id != id {
|
||||
return Json(serde_json::json!({ "error": "invalid recording id", "success": false }));
|
||||
}
|
||||
let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete recording {:?}: {}", path, e);
|
||||
@@ -2251,6 +2487,77 @@ async fn train_stop(State(state): State<SharedState>) -> Json<serde_json::Value>
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Adaptive classifier endpoints ────────────────────────────────────────────
|
||||
|
||||
/// POST /api/v1/adaptive/train — train the adaptive classifier from recordings.
|
||||
async fn adaptive_train(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let rec_dir = PathBuf::from("data/recordings");
|
||||
eprintln!("=== Adaptive Classifier Training ===");
|
||||
match adaptive_classifier::train_from_recordings(&rec_dir) {
|
||||
Ok(model) => {
|
||||
let accuracy = model.training_accuracy;
|
||||
let frames = model.trained_frames;
|
||||
let stats: Vec<_> = model.class_stats.iter().map(|cs| {
|
||||
serde_json::json!({
|
||||
"class": cs.label,
|
||||
"samples": cs.count,
|
||||
"feature_means": cs.mean,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Save to disk.
|
||||
if let Err(e) = model.save(&adaptive_classifier::model_path()) {
|
||||
warn!("Failed to save adaptive model: {e}");
|
||||
} else {
|
||||
info!("Adaptive model saved to {}", adaptive_classifier::model_path().display());
|
||||
}
|
||||
|
||||
// Load into runtime state.
|
||||
let mut s = state.write().await;
|
||||
s.adaptive_model = Some(model);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"trained_frames": frames,
|
||||
"accuracy": accuracy,
|
||||
"class_stats": stats,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": e,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/adaptive/status — check adaptive model status.
|
||||
async fn adaptive_status(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
match &s.adaptive_model {
|
||||
Some(model) => Json(serde_json::json!({
|
||||
"loaded": true,
|
||||
"trained_frames": model.trained_frames,
|
||||
"accuracy": model.training_accuracy,
|
||||
"version": model.version,
|
||||
"classes": adaptive_classifier::CLASSES,
|
||||
"class_stats": model.class_stats,
|
||||
})),
|
||||
None => Json(serde_json::json!({
|
||||
"loaded": false,
|
||||
"message": "No adaptive model. POST /api/v1/adaptive/train to train one.",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds).
|
||||
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
s.adaptive_model = None;
|
||||
Json(serde_json::json!({ "success": true, "message": "Adaptive model unloaded." }))
|
||||
}
|
||||
|
||||
/// Generate a simple timestamp string (epoch seconds) for recording IDs.
|
||||
fn chrono_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -2492,8 +2799,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
|
||||
let sample_rate_hz = 1000.0 / 500.0_f64; // default tick; ESP32 frames arrive as fast as they come
|
||||
let (features, classification, breathing_rate_hz, sub_variances) =
|
||||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s, &mut classification, raw_motion);
|
||||
adaptive_override(&s, &features, &mut classification);
|
||||
|
||||
// Update RSSI history
|
||||
s.rssi_history.push_back(features.mean_rssi);
|
||||
@@ -2508,10 +2817,11 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
let vitals = s.vital_detector.process_frame(
|
||||
let raw_vitals = s.vital_detector.process_frame(
|
||||
&frame.amplitudes,
|
||||
&frame.phases,
|
||||
);
|
||||
let vitals = smooth_vitals(&mut s, &raw_vitals);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
// Multi-person estimation with temporal smoothing.
|
||||
@@ -2595,8 +2905,10 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
}
|
||||
|
||||
let sample_rate_hz = 1000.0 / tick_ms as f64;
|
||||
let (features, classification, breathing_rate_hz, sub_variances) =
|
||||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s, &mut classification, raw_motion);
|
||||
adaptive_override(&s, &features, &mut classification);
|
||||
|
||||
s.rssi_history.push_back(features.mean_rssi);
|
||||
if s.rssi_history.len() > 60 {
|
||||
@@ -2607,10 +2919,11 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
let vitals = s.vital_detector.process_frame(
|
||||
let raw_vitals = s.vital_detector.process_frame(
|
||||
&frame.amplitudes,
|
||||
&frame.phases,
|
||||
);
|
||||
let vitals = smooth_vitals(&mut s, &raw_vitals);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let frame_amplitudes = frame.amplitudes.clone();
|
||||
@@ -3264,6 +3577,18 @@ async fn main() {
|
||||
active_sona_profile: None,
|
||||
model_loaded,
|
||||
smoothed_person_score: 0.0,
|
||||
smoothed_motion: 0.0,
|
||||
current_motion_level: "absent".to_string(),
|
||||
debounce_counter: 0,
|
||||
debounce_candidate: "absent".to_string(),
|
||||
baseline_motion: 0.0,
|
||||
baseline_frames: 0,
|
||||
smoothed_hr: 0.0,
|
||||
smoothed_br: 0.0,
|
||||
smoothed_hr_conf: 0.0,
|
||||
smoothed_br_conf: 0.0,
|
||||
hr_buffer: VecDeque::with_capacity(8),
|
||||
br_buffer: VecDeque::with_capacity(8),
|
||||
edge_vitals: None,
|
||||
latest_wasm_events: None,
|
||||
// Model management
|
||||
@@ -3278,6 +3603,11 @@ async fn main() {
|
||||
// Training
|
||||
training_status: "idle".to_string(),
|
||||
training_config: None,
|
||||
adaptive_model: adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path()).ok().map(|m| {
|
||||
info!("Loaded adaptive classifier: {} frames, {:.1}% accuracy",
|
||||
m.trained_frames, m.training_accuracy * 100.0);
|
||||
m
|
||||
}),
|
||||
}));
|
||||
|
||||
// Start background tasks based on source
|
||||
@@ -3294,6 +3624,10 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-050: Parse bind address once, use for all listeners
|
||||
let bind_ip: std::net::IpAddr = args.bind_addr.parse()
|
||||
.expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)");
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
@@ -3301,7 +3635,7 @@ async fn main() {
|
||||
.route("/health", get(health))
|
||||
.with_state(ws_state);
|
||||
|
||||
let ws_addr = SocketAddr::from(([0, 0, 0, 0], args.ws_port));
|
||||
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
|
||||
let ws_listener = tokio::net::TcpListener::bind(ws_addr).await
|
||||
.expect("Failed to bind WebSocket port");
|
||||
info!("WebSocket server listening on {ws_addr}");
|
||||
@@ -3364,6 +3698,10 @@ async fn main() {
|
||||
.route("/api/v1/train/status", get(train_status))
|
||||
.route("/api/v1/train/start", post(train_start))
|
||||
.route("/api/v1/train/stop", post(train_stop))
|
||||
// Adaptive classifier endpoints
|
||||
.route("/api/v1/adaptive/train", post(adaptive_train))
|
||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
||||
// Static UI files
|
||||
.nest_service("/ui", ServeDir::new(&ui_path))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
@@ -3372,7 +3710,7 @@ async fn main() {
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from(([0, 0, 0, 0], args.http_port));
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
let http_listener = tokio::net::TcpListener::bind(http_addr).await
|
||||
.expect("Failed to bind HTTP port");
|
||||
info!("HTTP server listening on {http_addr}");
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"class_stats": [
|
||||
{
|
||||
"label": "absent",
|
||||
"count": 862,
|
||||
"mean": [
|
||||
66.68196972264862,
|
||||
67.23973219951662,
|
||||
65.0340640002779,
|
||||
205.65861248066514,
|
||||
1.2587006960556917,
|
||||
8.192575406032482,
|
||||
0.0,
|
||||
9.823395623712905,
|
||||
6.970045450727901,
|
||||
-0.04488812678641681,
|
||||
-0.9594767860850162,
|
||||
10.78889030301701,
|
||||
0.8330000846014487,
|
||||
22.47189099978742,
|
||||
22.47189099978742
|
||||
],
|
||||
"stddev": [
|
||||
64.0493846652119,
|
||||
90.27545165651007,
|
||||
40.157907144682206,
|
||||
161.60550836256004,
|
||||
1.3807130815029451,
|
||||
3.2814660018571113,
|
||||
0.0,
|
||||
2.219723108446689,
|
||||
1.6521309619598676,
|
||||
0.342852106459665,
|
||||
0.30620004291079783,
|
||||
3.529722483499124,
|
||||
0.17574148506941875,
|
||||
5.519861526721805,
|
||||
5.519861526721805
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "present_still",
|
||||
"count": 852,
|
||||
"mean": [
|
||||
66.39259262094396,
|
||||
64.42298266818027,
|
||||
68.34546366405283,
|
||||
203.34049479166666,
|
||||
1.1900821596244182,
|
||||
8.200704225352112,
|
||||
0.0,
|
||||
10.032339700775715,
|
||||
7.234479413048846,
|
||||
0.027056637948278107,
|
||||
-0.9161490234231624,
|
||||
10.991429347401095,
|
||||
0.8298622589530178,
|
||||
23.588978503428145,
|
||||
23.588978503428145
|
||||
],
|
||||
"stddev": [
|
||||
59.144593976065984,
|
||||
82.61098004853669,
|
||||
40.08306971525127,
|
||||
152.89405234329087,
|
||||
1.2031203046363153,
|
||||
3.0571012493320526,
|
||||
0.0,
|
||||
2.22294769203091,
|
||||
1.6508044238677446,
|
||||
0.3315329147240876,
|
||||
0.29437997092330526,
|
||||
3.3214071045026303,
|
||||
0.17096813624285292,
|
||||
5.622953396738593,
|
||||
5.622953396738593
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "present_moving",
|
||||
"count": 808,
|
||||
"mean": [
|
||||
65.17005228763453,
|
||||
66.55424930761484,
|
||||
63.785855267654334,
|
||||
208.73719832920793,
|
||||
1.3400990099009942,
|
||||
7.570544554455446,
|
||||
0.0,
|
||||
10.069915394050431,
|
||||
6.923405617584522,
|
||||
-0.1440461642917184,
|
||||
-1.0022460352626226,
|
||||
10.664608744841848,
|
||||
0.8384559212414682,
|
||||
21.798331033369895,
|
||||
21.798331033369895
|
||||
],
|
||||
"stddev": [
|
||||
66.1800697503931,
|
||||
93.22042148141067,
|
||||
42.07226450730718,
|
||||
164.93282045618218,
|
||||
1.3706144246607475,
|
||||
3.1453995481213224,
|
||||
0.0,
|
||||
2.431170975696439,
|
||||
1.672707406405861,
|
||||
0.35643090355922863,
|
||||
0.30897080072710387,
|
||||
3.325911716352165,
|
||||
0.1806597020966414,
|
||||
5.418714527442832,
|
||||
5.418714527442832
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "active",
|
||||
"count": 794,
|
||||
"mean": [
|
||||
61.85289600233076,
|
||||
61.12723986655727,
|
||||
62.468831971775344,
|
||||
193.2018524349286,
|
||||
1.2329974811083138,
|
||||
8.083123425692696,
|
||||
0.0,
|
||||
9.747035051350043,
|
||||
7.009904234422278,
|
||||
0.007176072447431498,
|
||||
-0.9950501087764124,
|
||||
11.015545839210892,
|
||||
0.8278984910895401,
|
||||
22.445656559614797,
|
||||
22.445656559614797
|
||||
],
|
||||
"stddev": [
|
||||
50.44687370766278,
|
||||
74.07914900524236,
|
||||
31.558067649516538,
|
||||
121.0762294406304,
|
||||
1.2507304998955402,
|
||||
3.4503520526220344,
|
||||
0.0,
|
||||
2.2730029390882156,
|
||||
1.6768264387667406,
|
||||
0.3214256392367928,
|
||||
0.31003127617615406,
|
||||
3.1187829194728285,
|
||||
0.1772099351197549,
|
||||
5.595050695741912,
|
||||
5.595050695741912
|
||||
]
|
||||
}
|
||||
],
|
||||
"weights": [
|
||||
[
|
||||
0.9923736589617821,
|
||||
-0.4600422332552322,
|
||||
-0.3922101552522972,
|
||||
-0.1686954616947851,
|
||||
-0.08471937018349271,
|
||||
0.033940973559074515,
|
||||
0.0,
|
||||
-1.116294981490482,
|
||||
-0.213861080404439,
|
||||
-0.41727297566573723,
|
||||
0.08025552056009382,
|
||||
0.20864577739519874,
|
||||
0.36814779033318357,
|
||||
0.46242679535538855,
|
||||
0.46242679535538855,
|
||||
0.09475205040199337
|
||||
],
|
||||
[
|
||||
0.04661470129518883,
|
||||
0.7974124099989739,
|
||||
0.3953040913806362,
|
||||
-1.2708868935843511,
|
||||
0.10073070355913086,
|
||||
0.0735810797517633,
|
||||
0.0,
|
||||
-0.3957608057630568,
|
||||
0.22091779039114648,
|
||||
-0.43105406953304665,
|
||||
0.24907697332262252,
|
||||
-0.17604200203759515,
|
||||
-0.5059663705836186,
|
||||
0.5740861193153091,
|
||||
0.5740861193153091,
|
||||
0.020569218347928304
|
||||
],
|
||||
[
|
||||
-0.5295363836864718,
|
||||
0.14729609046092632,
|
||||
0.16131671233151712,
|
||||
0.15039859740752318,
|
||||
0.08189110214725194,
|
||||
-0.1429062024394049,
|
||||
0.0,
|
||||
2.459247211223509,
|
||||
-0.162133339181718,
|
||||
0.6345474095048843,
|
||||
0.16626892477248892,
|
||||
0.2710091094981082,
|
||||
-0.08197569509399917,
|
||||
-1.2007197895193034,
|
||||
-1.2007197895193034,
|
||||
-0.10027402587742726
|
||||
],
|
||||
[
|
||||
-0.5094519765704947,
|
||||
-0.48466626720467487,
|
||||
-0.1644106484598614,
|
||||
1.2891837578716183,
|
||||
-0.0979024355228887,
|
||||
0.0353841491285671,
|
||||
0.0,
|
||||
-0.9471914239699604,
|
||||
0.15507662919500606,
|
||||
0.2137796356938993,
|
||||
-0.49560141865520463,
|
||||
-0.30361288485571664,
|
||||
0.21979427534444013,
|
||||
0.16420687484859928,
|
||||
0.16420687484859928,
|
||||
-0.015047242872495047
|
||||
]
|
||||
],
|
||||
"global_mean": [
|
||||
65.08291570815048,
|
||||
64.88537161757283,
|
||||
64.96650236787292,
|
||||
202.8304440905207,
|
||||
1.25474969843183,
|
||||
8.016887816646562,
|
||||
0.0,
|
||||
9.918865477040464,
|
||||
7.036167472733628,
|
||||
-0.038097952045357715,
|
||||
-0.9672836370393502,
|
||||
10.86491812646321,
|
||||
0.8323017200972911,
|
||||
22.58850497890069,
|
||||
22.58850497890069
|
||||
],
|
||||
"global_std": [
|
||||
60.376895354908775,
|
||||
85.49291935872783,
|
||||
38.814475392686795,
|
||||
151.54766198012683,
|
||||
1.3049002582695195,
|
||||
3.2446975526483737,
|
||||
1e-9,
|
||||
2.2904371592847603,
|
||||
1.667114434239705,
|
||||
0.34470363318292857,
|
||||
0.3067332188136679,
|
||||
3.334427501751985,
|
||||
0.17614366955910027,
|
||||
5.577838072123601,
|
||||
5.577838072123601
|
||||
],
|
||||
"trained_frames": 3316,
|
||||
"training_accuracy": 0.4149577804583836,
|
||||
"version": 1
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
<button class="nav-tab" data-tab="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training">Training</button>
|
||||
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView Observatory — WiFi DensePose</title>
|
||||
<link rel="stylesheet" href="observatory/css/observatory.css">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="observatory-canvas"></canvas>
|
||||
|
||||
<!-- ======= HUD Overlay ======= -->
|
||||
<div id="hud">
|
||||
|
||||
<!-- Top-left: Branding -->
|
||||
<div id="brand">
|
||||
<div id="brand-logo"><span class="pi">π</span> RuView</div>
|
||||
<div id="brand-tagline">WiFi DensePose Sensing Observatory</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-right: Connection + status + gear -->
|
||||
<div id="status-bar">
|
||||
<div id="data-source-badge">
|
||||
<span class="dot dot--demo"></span>
|
||||
<span id="data-source-label">DEMO</span>
|
||||
</div>
|
||||
<div id="scenario-area">
|
||||
<span id="autoplay-icon" title="Auto-cycling">▶</span>
|
||||
<select id="scenario-quick-select" title="Change scenario">
|
||||
<option value="auto">Auto-Cycle</option>
|
||||
<option value="empty_room">Empty Room</option>
|
||||
<option value="single_breathing">Vital Signs</option>
|
||||
<option value="two_walking">Multi-Person</option>
|
||||
<option value="fall_event">Fall Detect</option>
|
||||
<option value="sleep_monitoring">Sleep Monitor</option>
|
||||
<option value="intrusion_detect">Intrusion</option>
|
||||
<option value="gesture_control">Gesture Ctrl</option>
|
||||
<option value="crowd_occupancy">Crowd (4 ppl)</option>
|
||||
<option value="search_rescue">Search Rescue</option>
|
||||
<option value="elderly_care">Elderly Care</option>
|
||||
<option value="fitness_tracking">Fitness</option>
|
||||
<option value="security_patrol">Security Patrol</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="scenario-description"></div>
|
||||
<div id="fps-counter" style="display:none">60 FPS</div>
|
||||
<button id="settings-btn" title="Settings">⚙</button>
|
||||
</div>
|
||||
|
||||
<!-- Left panel: Vital Signs -->
|
||||
<div id="panel-vitals" class="data-panel">
|
||||
<div class="panel-header">Vital Signs</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">♡</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Heart Rate</div>
|
||||
<div class="vital-value"><span id="hr-value">--</span> <span class="vital-unit">BPM</span></div>
|
||||
<div class="vital-bar"><div id="hr-bar" class="vital-bar-fill vital-bar--hr"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">☼</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Respiration</div>
|
||||
<div class="vital-value"><span id="br-value">--</span> <span class="vital-unit">RPM</span></div>
|
||||
<div class="vital-bar"><div id="br-bar" class="vital-bar-fill vital-bar--br"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vital-row">
|
||||
<div class="vital-icon">⚖</div>
|
||||
<div class="vital-data">
|
||||
<div class="vital-label">Confidence</div>
|
||||
<div class="vital-value"><span id="conf-value">--</span><span class="vital-unit">%</span></div>
|
||||
<div class="vital-bar"><div id="conf-bar" class="vital-bar-fill vital-bar--conf"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: Signal & Presence -->
|
||||
<div id="panel-signal" class="data-panel">
|
||||
<div class="panel-header">WiFi Signal</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">RSSI</span>
|
||||
<span class="signal-value" id="rssi-value">-- dBm</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Variance</span>
|
||||
<span class="signal-value" id="var-value">--</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Motion</span>
|
||||
<span class="signal-value" id="motion-value">--</span>
|
||||
</div>
|
||||
<div class="signal-row">
|
||||
<span class="signal-label">Persons</span>
|
||||
<span class="signal-value" id="persons-value">0</span>
|
||||
<span id="persons-dots" class="persons-dots"></span>
|
||||
</div>
|
||||
<canvas id="rssi-sparkline" width="200" height="48"></canvas>
|
||||
|
||||
<div class="panel-header" style="margin-top:12px">Presence</div>
|
||||
<div id="presence-indicator" class="presence-state presence--absent">
|
||||
<span id="presence-label">ABSENT</span>
|
||||
</div>
|
||||
<div id="fall-alert" class="fall-alert" style="display:none">FALL DETECTED</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge module badges (populated dynamically by HudController) -->
|
||||
<div id="edge-modules-bar"></div>
|
||||
|
||||
<!-- Bottom bar: capabilities -->
|
||||
<div id="capabilities-bar">
|
||||
<div class="cap-item"><span class="cap-icon">⚪</span><span>Human Pose Estimation</span></div>
|
||||
<div class="cap-divider"></div>
|
||||
<div class="cap-item"><span class="cap-icon">♥</span><span>Vital Sign Monitoring</span></div>
|
||||
<div class="cap-divider"></div>
|
||||
<div class="cap-item"><span class="cap-icon">☸</span><span>Presence Detection</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom-right: keyboard hints -->
|
||||
<div id="key-hints">
|
||||
<span class="key-hint">[A] Orbit</span>
|
||||
<span class="key-hint">[D] Scenario</span>
|
||||
<span class="key-hint">[F] FPS</span>
|
||||
<span class="key-hint">[S] Settings</span>
|
||||
<span class="key-hint">[Space] Pause</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= Settings Dialog ======= -->
|
||||
<div id="settings-overlay" class="settings-overlay" style="display:none">
|
||||
<div class="settings-dialog">
|
||||
<div class="settings-header">
|
||||
<span>Settings</span>
|
||||
<button id="settings-close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-tabs">
|
||||
<button class="stab active" data-stab="rendering">Rendering</button>
|
||||
<button class="stab" data-stab="wireframe">Wireframe</button>
|
||||
<button class="stab" data-stab="scene">Scene</button>
|
||||
<button class="stab" data-stab="data">Data</button>
|
||||
</div>
|
||||
|
||||
<!-- Rendering tab -->
|
||||
<div class="stab-content active" id="stab-rendering">
|
||||
<label class="setting-row">
|
||||
<span>Bloom Strength</span>
|
||||
<input type="range" id="opt-bloom" min="0" max="3" step="0.1" value="1.0">
|
||||
<span class="range-val" id="opt-bloom-val">1.0</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Bloom Radius</span>
|
||||
<input type="range" id="opt-bloom-radius" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-bloom-radius-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Bloom Threshold</span>
|
||||
<input type="range" id="opt-bloom-thresh" min="0" max="1" step="0.05" value="0.25">
|
||||
<span class="range-val" id="opt-bloom-thresh-val">0.25</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Exposure</span>
|
||||
<input type="range" id="opt-exposure" min="0.3" max="2" step="0.05" value="0.9">
|
||||
<span class="range-val" id="opt-exposure-val">0.9</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Vignette</span>
|
||||
<input type="range" id="opt-vignette" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-vignette-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Film Grain</span>
|
||||
<input type="range" id="opt-grain" min="0" max="0.15" step="0.005" value="0.03">
|
||||
<span class="range-val" id="opt-grain-val">0.03</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Chromatic Aberration</span>
|
||||
<input type="range" id="opt-chromatic" min="0" max="0.008" step="0.0005" value="0.0015">
|
||||
<span class="range-val" id="opt-chromatic-val">0.0015</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Wireframe tab -->
|
||||
<div class="stab-content" id="stab-wireframe">
|
||||
<label class="setting-row">
|
||||
<span>Bone Thickness</span>
|
||||
<input type="range" id="opt-bone-thick" min="0.005" max="0.06" step="0.002" value="0.02">
|
||||
<span class="range-val" id="opt-bone-thick-val">0.02</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Joint Size</span>
|
||||
<input type="range" id="opt-joint-size" min="0.02" max="0.12" step="0.005" value="0.05">
|
||||
<span class="range-val" id="opt-joint-size-val">0.05</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Glow Intensity</span>
|
||||
<input type="range" id="opt-glow" min="0" max="2" step="0.1" value="0.8">
|
||||
<span class="range-val" id="opt-glow-val">0.8</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Particle Trail</span>
|
||||
<input type="range" id="opt-trail" min="0" max="1" step="0.05" value="0.6">
|
||||
<span class="range-val" id="opt-trail-val">0.6</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Wireframe Color</span>
|
||||
<input type="color" id="opt-wire-color" value="#00d878">
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Joint Color</span>
|
||||
<input type="color" id="opt-joint-color" value="#ff4060">
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Aura Opacity</span>
|
||||
<input type="range" id="opt-aura" min="0" max="0.2" step="0.01" value="0.06">
|
||||
<span class="range-val" id="opt-aura-val">0.06</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Scene tab -->
|
||||
<div class="stab-content" id="stab-scene">
|
||||
<label class="setting-row">
|
||||
<span>Signal Field</span>
|
||||
<input type="range" id="opt-field" min="0" max="1" step="0.05" value="0.5">
|
||||
<span class="range-val" id="opt-field-val">0.5</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>WiFi Waves</span>
|
||||
<input type="range" id="opt-waves" min="0" max="1" step="0.05" value="0.6">
|
||||
<span class="range-val" id="opt-waves-val">0.6</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Room Brightness</span>
|
||||
<input type="range" id="opt-ambient" min="0" max="1" step="0.05" value="0.4">
|
||||
<span class="range-val" id="opt-ambient-val">0.4</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Floor Reflection</span>
|
||||
<input type="range" id="opt-reflect" min="0" max="1" step="0.05" value="0.3">
|
||||
<span class="range-val" id="opt-reflect-val">0.3</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>FOV</span>
|
||||
<input type="range" id="opt-fov" min="30" max="90" step="1" value="50">
|
||||
<span class="range-val" id="opt-fov-val">50</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Orbit Speed</span>
|
||||
<input type="range" id="opt-orbit-speed" min="0.02" max="0.5" step="0.02" value="0.15">
|
||||
<span class="range-val" id="opt-orbit-speed-val">0.15</span>
|
||||
</label>
|
||||
<label class="setting-row check-row">
|
||||
<span>Show Grid</span>
|
||||
<input type="checkbox" id="opt-grid" checked>
|
||||
</label>
|
||||
<label class="setting-row check-row">
|
||||
<span>Show Room Boundary</span>
|
||||
<input type="checkbox" id="opt-room" checked>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Data tab -->
|
||||
<div class="stab-content" id="stab-data">
|
||||
<label class="setting-row">
|
||||
<span>Scenario</span>
|
||||
<select id="opt-scenario">
|
||||
<option value="auto">Auto-Cycle (30s)</option>
|
||||
<optgroup label="Core Sensing">
|
||||
<option value="empty_room">Empty Room</option>
|
||||
<option value="single_breathing">Vital Signs (Breathing)</option>
|
||||
<option value="two_walking">Multi-Person Tracking</option>
|
||||
<option value="fall_event">Fall Detection</option>
|
||||
</optgroup>
|
||||
<optgroup label="Medical / Health">
|
||||
<option value="sleep_monitoring">Sleep Monitoring (Apnea)</option>
|
||||
<option value="elderly_care">Elderly Care (Gait)</option>
|
||||
<option value="fitness_tracking">Fitness Tracking</option>
|
||||
</optgroup>
|
||||
<optgroup label="Security">
|
||||
<option value="intrusion_detect">Intrusion Detection</option>
|
||||
<option value="security_patrol">Security Patrol</option>
|
||||
</optgroup>
|
||||
<optgroup label="Building / Retail">
|
||||
<option value="crowd_occupancy">Crowd Occupancy (4 ppl)</option>
|
||||
<option value="gesture_control">Gesture Control (DTW)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Disaster / Tactical">
|
||||
<option value="search_rescue">Search & Rescue (WiFi-Mat)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Cycle Speed (s)</span>
|
||||
<input type="range" id="opt-cycle" min="10" max="120" step="5" value="30">
|
||||
<span class="range-val" id="opt-cycle-val">30</span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Style Preset</span>
|
||||
<select id="opt-preset">
|
||||
<option value="custom">Custom</option>
|
||||
<option value="foundation">Foundation (Default)</option>
|
||||
<option value="cinematic">Cinematic</option>
|
||||
<option value="minimal">Minimal / Clean</option>
|
||||
<option value="neon">Neon Glow</option>
|
||||
<option value="tactical">Tactical / Military</option>
|
||||
<option value="medical">Medical Monitor</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<span>Data Source</span>
|
||||
<select id="opt-data-source">
|
||||
<option value="demo" selected>Demo Generator</option>
|
||||
<option value="ws">Live WebSocket</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-row" id="ws-url-row" style="display:none">
|
||||
<span>WS URL</span>
|
||||
<input type="text" id="opt-ws-url" value="" placeholder="ws://localhost:3000/ws/sensing">
|
||||
</label>
|
||||
<button id="btn-reset-camera" class="settings-btn">Reset Camera</button>
|
||||
<button id="btn-reset-settings" class="settings-btn">Reset to Defaults</button>
|
||||
<button id="btn-export-settings" class="settings-btn">Export Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Three.js r160 + addons from CDN -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module" src="observatory/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,698 @@
|
||||
/* ============================================================
|
||||
RuView Observatory — Foundation Color Scheme
|
||||
Warm dark background, electric green wireframe, amber data
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-deep: #080c14;
|
||||
--bg-panel: rgba(8, 16, 28, 0.85);
|
||||
--bg-panel-border: rgba(0, 210, 120, 0.2);
|
||||
--green-glow: #00d878;
|
||||
--green-bright:#3eff8a;
|
||||
--green-dim: #0a6b3a;
|
||||
--amber: #ffb020;
|
||||
--amber-dim: #a06800;
|
||||
--blue-signal: #2090ff;
|
||||
--blue-dim: #0a3060;
|
||||
--red-alert: #ff3040;
|
||||
--red-heart: #ff4060;
|
||||
--text-primary: #e8ece0;
|
||||
--text-secondary: rgba(232,236,224, 0.55);
|
||||
--text-label: rgba(232,236,224, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#observatory-canvas {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- HUD Overlay ---- */
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ---- Brand ---- */
|
||||
#brand {
|
||||
position: absolute;
|
||||
top: 24px; left: 28px;
|
||||
}
|
||||
|
||||
#brand-logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 0 30px rgba(0, 216, 120, 0.3);
|
||||
}
|
||||
|
||||
.pi {
|
||||
color: var(--green-glow);
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
#brand-tagline {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Status bar (top right) ---- */
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
top: 24px; right: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#data-source-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
border: 1px solid rgba(0, 216, 120, 0.25);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot--demo { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||
.dot--live { background: var(--green-glow); box-shadow: 0 0 6px var(--green-glow); animation: pulse-dot 2s infinite; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
#scenario-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border: 1px solid rgba(255, 176, 32, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
#autoplay-icon {
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
#autoplay-icon.hidden { display: none; }
|
||||
#scenario-quick-select {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#scenario-quick-select:hover,
|
||||
#scenario-quick-select:focus { color: var(--green-glow); }
|
||||
#scenario-quick-select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#fps-counter {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Data Panels ---- */
|
||||
.data-panel {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-label);
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* ---- Vitals Panel (left) ---- */
|
||||
#panel-vitals {
|
||||
left: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.vital-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.vital-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.vital-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-row:nth-child(2) .vital-icon { color: var(--red-heart); }
|
||||
.vital-row:nth-child(3) .vital-icon { color: var(--green-glow); }
|
||||
.vital-row:nth-child(4) .vital-icon { color: var(--amber); }
|
||||
|
||||
.vital-data { flex: 1; }
|
||||
|
||||
.vital-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vital-bar {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.vital-bar--hr { background: var(--red-heart); width: 0%; }
|
||||
.vital-bar--br { background: var(--green-glow); width: 0%; }
|
||||
.vital-bar--conf { background: var(--amber); width: 0%; }
|
||||
|
||||
/* ---- Signal Panel (right) ---- */
|
||||
#panel-signal {
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.signal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signal-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.signal-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--blue-signal);
|
||||
}
|
||||
|
||||
#rssi-sparkline {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Presence */
|
||||
.presence-state {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.presence--absent {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--text-label);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.presence--present {
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
color: var(--green-glow);
|
||||
border: 1px solid rgba(0, 216, 120, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 216, 120, 0.1);
|
||||
}
|
||||
|
||||
.presence--active {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
color: var(--amber);
|
||||
border: 1px solid rgba(255, 176, 32, 0.3);
|
||||
box-shadow: 0 0 20px rgba(255, 176, 32, 0.1);
|
||||
}
|
||||
|
||||
.fall-alert {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: rgba(255, 48, 64, 0.15);
|
||||
color: var(--red-alert);
|
||||
border: 1px solid rgba(255, 48, 64, 0.4);
|
||||
animation: pulse-alert 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-alert {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ---- Capabilities Bar (bottom center) ---- */
|
||||
#capabilities-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 30px;
|
||||
padding: 8px 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.cap-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cap-icon {
|
||||
font-size: 16px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.cap-item:nth-child(3) .cap-icon { color: var(--red-heart); }
|
||||
.cap-item:nth-child(5) .cap-icon { color: var(--blue-signal); }
|
||||
|
||||
.cap-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* ---- Key hints ---- */
|
||||
#key-hints {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 28px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* ---- Settings button ---- */
|
||||
#settings-btn {
|
||||
pointer-events: auto;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
#settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Settings Dialog ---- */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
background: rgba(10, 16, 28, 0.96);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
border-radius: 16px;
|
||||
width: 440px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px rgba(0,216,120,0.05);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.settings-header button:hover { color: var(--red-alert); }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.stab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-label);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stab:hover { color: var(--text-secondary); }
|
||||
.stab.active {
|
||||
color: var(--green-glow);
|
||||
border-bottom-color: var(--green-glow);
|
||||
}
|
||||
|
||||
.stab-content {
|
||||
display: none;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.stab-content.active { display: block; }
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-row span:first-child {
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--green-glow);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(0,216,120,0.4);
|
||||
}
|
||||
|
||||
.setting-row input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
width: 36px; height: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.setting-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.setting-row input[type="color"]::-webkit-color-swatch { border-radius: 2px; border: none; }
|
||||
|
||||
.setting-row select,
|
||||
.setting-row input[type="text"] {
|
||||
flex: 1;
|
||||
background: #0c1420;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row select:focus,
|
||||
.setting-row input[type="text"]:focus {
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
.setting-row select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.setting-row select optgroup {
|
||||
background: #0a1018;
|
||||
color: var(--green-glow);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 18px; height: 18px;
|
||||
accent-color: var(--green-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.range-val {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
background: rgba(0, 216, 120, 0.08);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
color: var(--green-glow);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Scenario Description ---- */
|
||||
#scenario-description {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 28px;
|
||||
max-width: 340px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* ---- Edge Module Badges ---- */
|
||||
#edge-modules-bar {
|
||||
position: absolute;
|
||||
bottom: 58px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--badge-color, var(--text-secondary));
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--badge-color, rgba(255,255,255,0.1));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--badge-color, transparent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Person Count Dots ---- */
|
||||
.persons-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.person-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
.person-dot--active {
|
||||
background: var(--green-glow);
|
||||
border-color: var(--green-glow);
|
||||
box-shadow: 0 0 4px rgba(0, 216, 120, 0.4);
|
||||
}
|
||||
|
||||
/* ---- Vital Value Color Transitions ---- */
|
||||
.vital-value span:first-child {
|
||||
transition: color 0.6s ease;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
transition: width 0.5s ease, background 0.6s ease;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1200px) {
|
||||
.data-panel { width: 190px; padding: 12px; }
|
||||
.vital-value { font-size: 22px; }
|
||||
#capabilities-bar { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.data-panel { display: none; }
|
||||
#key-hints { display: none; }
|
||||
.settings-dialog { width: 95vw; }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Module E — "Statistical Convergence Engine"
|
||||
* RSSI waveform, person orbs, classification, fall alert, metric bars
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const WAVEFORM_POINTS = 120;
|
||||
|
||||
export class ConvergenceEngine {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// --- RSSI Waveform (scrolling line) ---
|
||||
this._rssiHistory = new Float32Array(WAVEFORM_POINTS);
|
||||
const waveGeo = new THREE.BufferGeometry();
|
||||
this._wavePositions = new Float32Array(WAVEFORM_POINTS * 3);
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
this._wavePositions[i * 3] = (i / WAVEFORM_POINTS) * 6 - 3; // x: -3 to 3
|
||||
this._wavePositions[i * 3 + 1] = 0;
|
||||
this._wavePositions[i * 3 + 2] = 0;
|
||||
}
|
||||
waveGeo.setAttribute('position', new THREE.BufferAttribute(this._wavePositions, 3));
|
||||
const waveMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveform = new THREE.Line(waveGeo, waveMat);
|
||||
this._waveform.position.y = 1.5;
|
||||
this.group.add(this._waveform);
|
||||
|
||||
// Waveform glow (thicker, dimmer duplicate)
|
||||
const glowMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveGlow = new THREE.Line(waveGeo.clone(), glowMat);
|
||||
this._waveGlow.position.y = 1.5;
|
||||
this._waveGlow.scale.set(1, 1.3, 1);
|
||||
this.group.add(this._waveGlow);
|
||||
|
||||
// --- Person orbs (up to 4) ---
|
||||
this._personOrbs = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const orbGeo = new THREE.SphereGeometry(0.2, 16, 16);
|
||||
const orbMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff8800,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const orb = new THREE.Mesh(orbGeo, orbMat);
|
||||
orb.position.set(-2 + i * 1.2, -0.5, 0);
|
||||
this.group.add(orb);
|
||||
|
||||
const light = new THREE.PointLight(0xff8800, 0, 3);
|
||||
orb.add(light);
|
||||
|
||||
this._personOrbs.push({ mesh: orb, light, mat: orbMat });
|
||||
}
|
||||
|
||||
// --- Classification text sprite ---
|
||||
this._classCanvas = document.createElement('canvas');
|
||||
this._classCanvas.width = 256;
|
||||
this._classCanvas.height = 48;
|
||||
this._classCtx = this._classCanvas.getContext('2d');
|
||||
this._classTex = new THREE.CanvasTexture(this._classCanvas);
|
||||
const classMat = new THREE.SpriteMaterial({
|
||||
map: this._classTex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._classSprite = new THREE.Sprite(classMat);
|
||||
this._classSprite.scale.set(3, 0.6, 1);
|
||||
this._classSprite.position.y = 0.3;
|
||||
this.group.add(this._classSprite);
|
||||
|
||||
// --- Fall alert ring ---
|
||||
const alertGeo = new THREE.TorusGeometry(2.5, 0.05, 8, 48);
|
||||
this._alertMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2244,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._alertRing = new THREE.Mesh(alertGeo, this._alertMat);
|
||||
this._alertRing.rotation.x = Math.PI / 2;
|
||||
this._alertRing.position.y = -1;
|
||||
this.group.add(this._alertRing);
|
||||
|
||||
// --- Metric bars (3: frame rate, confidence, variance) ---
|
||||
this._metricBars = [];
|
||||
const barLabels = ['CONF', 'VAR', 'SPEC'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const barGeo = new THREE.PlaneGeometry(0.15, 1.5);
|
||||
const barMat = new THREE.MeshBasicMaterial({
|
||||
color: [0x00d4ff, 0x8844ff, 0xff8800][i],
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const bar = new THREE.Mesh(barGeo, barMat);
|
||||
bar.position.set(2 + i * 0.4, -1.2, 0);
|
||||
this.group.add(bar);
|
||||
this._metricBars.push({ mesh: bar, mat: barMat });
|
||||
}
|
||||
|
||||
this._rssiHead = 0;
|
||||
this._lastClassification = '';
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const features = data?.features || {};
|
||||
const classification = data?.classification || {};
|
||||
const persons = data?.persons || [];
|
||||
const estPersons = data?.estimated_persons || 0;
|
||||
|
||||
// --- Update RSSI waveform ---
|
||||
const rssi = features.mean_rssi || -50;
|
||||
this._rssiHistory[this._rssiHead] = rssi;
|
||||
this._rssiHead = (this._rssiHead + 1) % WAVEFORM_POINTS;
|
||||
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
const histIdx = (this._rssiHead + i) % WAVEFORM_POINTS;
|
||||
const val = this._rssiHistory[histIdx];
|
||||
// Normalize RSSI (-80 to -20 range) to -1.5 to 1.5
|
||||
this._wavePositions[i * 3 + 1] = ((val + 50) / 30) * 1.5;
|
||||
}
|
||||
this._waveform.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Copy to glow
|
||||
const glowPos = this._waveGlow.geometry.attributes.position;
|
||||
glowPos.array.set(this._wavePositions);
|
||||
glowPos.needsUpdate = true;
|
||||
|
||||
// --- Person orbs ---
|
||||
for (let i = 0; i < this._personOrbs.length; i++) {
|
||||
const { mesh, light, mat } = this._personOrbs[i];
|
||||
if (i < estPersons) {
|
||||
mat.opacity = 0.7;
|
||||
light.intensity = 1.0 + Math.sin(elapsed * 3 + i * 1.5) * 0.5;
|
||||
const pulse = 1.0 + Math.sin(elapsed * 2 + i) * 0.15;
|
||||
mesh.scale.set(pulse, pulse, pulse);
|
||||
} else {
|
||||
mat.opacity = 0.05;
|
||||
light.intensity = 0;
|
||||
mesh.scale.set(0.5, 0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Classification text ---
|
||||
const motionLevel = classification.motion_level || 'absent';
|
||||
const label = motionLevel.toUpperCase().replace('_', ' ');
|
||||
if (label !== this._lastClassification) {
|
||||
this._lastClassification = label;
|
||||
const ctx = this._classCtx;
|
||||
ctx.clearRect(0, 0, 256, 48);
|
||||
ctx.font = '600 24px "Courier New", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
if (motionLevel === 'active') ctx.fillStyle = '#ff8800';
|
||||
else if (motionLevel.includes('present')) ctx.fillStyle = '#00d4ff';
|
||||
else ctx.fillStyle = '#445566';
|
||||
|
||||
ctx.fillText(label, 128, 32);
|
||||
this._classTex.needsUpdate = true;
|
||||
}
|
||||
|
||||
// --- Fall alert ---
|
||||
const fallDetected = classification.fall_detected || false;
|
||||
if (fallDetected) {
|
||||
this._alertMat.opacity = 0.3 + Math.abs(Math.sin(elapsed * 6)) * 0.5;
|
||||
const scale = 1.0 + Math.sin(elapsed * 4) * 0.1;
|
||||
this._alertRing.scale.set(scale, scale, 1);
|
||||
} else {
|
||||
this._alertMat.opacity = 0;
|
||||
}
|
||||
|
||||
// --- Metric bars ---
|
||||
const confidence = classification.confidence || 0;
|
||||
const variance = Math.min(1, (features.variance || 0) / 5);
|
||||
const spectral = Math.min(1, (features.spectral_power || 0) / 0.5);
|
||||
const values = [confidence, variance, spectral];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bar = this._metricBars[i];
|
||||
const v = values[i];
|
||||
bar.mesh.scale.y = Math.max(0.05, v);
|
||||
bar.mesh.position.y = -1.2 + v * 0.75;
|
||||
bar.mat.opacity = 0.3 + v * 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._waveform.geometry.dispose();
|
||||
this._waveform.material.dispose();
|
||||
this._waveGlow.geometry.dispose();
|
||||
this._waveGlow.material.dispose();
|
||||
this._alertRing.geometry.dispose();
|
||||
this._alertMat.dispose();
|
||||
this._classTex.dispose();
|
||||
for (const { mesh, mat } of this._personOrbs) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
for (const { mesh, mat } of this._metricBars) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* FigurePool — Manages a pool of wireframe human figures for multi-person rendering.
|
||||
*
|
||||
* Extracted from main.js Observatory class. Owns the lifecycle of up to MAX_FIGURES
|
||||
* Three.js figure groups, each containing joints, bones, body segments, and aura.
|
||||
*
|
||||
* Improvements over the original inline implementation:
|
||||
* - Smooth joint interpolation (lerp toward target instead of snapping)
|
||||
* - Joint pulsation synced with breathing
|
||||
* - Natural bone thickness taper (thicker at shoulder/hip, thinner at extremities)
|
||||
* - Secondary motion with slight delay/overshoot for organic feel
|
||||
* - Pose-adaptive aura shape (wider for exercise, narrower for crouching)
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
// 17-keypoint COCO skeleton connectivity
|
||||
export const SKELETON_PAIRS = [
|
||||
[0, 1], [0, 2], [1, 3], [2, 4],
|
||||
[5, 6], [5, 7], [7, 9], [6, 8], [8, 10],
|
||||
[5, 11], [6, 12], [11, 12],
|
||||
[11, 13], [13, 15], [12, 14], [14, 16],
|
||||
];
|
||||
|
||||
// Body segment cylinders that give volume to the wireframe
|
||||
export const BODY_SEGMENT_DEFS = [
|
||||
{ joints: [5, 11], radius: 0.12 }, // left torso
|
||||
{ joints: [6, 12], radius: 0.12 }, // right torso
|
||||
{ joints: [5, 6], radius: 0.1 }, // shoulder bar
|
||||
{ joints: [11, 12], radius: 0.1 }, // hip bar
|
||||
{ joints: [5, 7], radius: 0.05 }, // left upper arm
|
||||
{ joints: [6, 8], radius: 0.05 }, // right upper arm
|
||||
{ joints: [7, 9], radius: 0.04 }, // left forearm
|
||||
{ joints: [8, 10], radius: 0.04 }, // right forearm
|
||||
{ joints: [11, 13], radius: 0.07 }, // left thigh
|
||||
{ joints: [12, 14], radius: 0.07 }, // right thigh
|
||||
{ joints: [13, 15], radius: 0.05 }, // left shin
|
||||
{ joints: [14, 16], radius: 0.05 }, // right shin
|
||||
{ joints: [0, 0], radius: 0.1, isHead: true },
|
||||
];
|
||||
|
||||
// Bone thickness multipliers — thicker at torso, thinner at extremities
|
||||
const BONE_TAPER = (() => {
|
||||
const tapers = new Map();
|
||||
// Torso and shoulder/hip connections are thickest
|
||||
tapers.set('5-6', 1.4); // shoulder bar
|
||||
tapers.set('11-12', 1.3); // hip bar
|
||||
tapers.set('5-11', 1.3); // left torso
|
||||
tapers.set('6-12', 1.3); // right torso
|
||||
// Upper limbs
|
||||
tapers.set('5-7', 1.0); // left upper arm
|
||||
tapers.set('6-8', 1.0); // right upper arm
|
||||
tapers.set('11-13', 1.1); // left thigh
|
||||
tapers.set('12-14', 1.1); // right thigh
|
||||
// Lower limbs / extremities — thinnest
|
||||
tapers.set('7-9', 0.7); // left forearm
|
||||
tapers.set('8-10', 0.7); // right forearm
|
||||
tapers.set('13-15', 0.8); // left shin
|
||||
tapers.set('14-16', 0.8); // right shin
|
||||
// Head connections
|
||||
tapers.set('0-1', 0.5);
|
||||
tapers.set('0-2', 0.5);
|
||||
tapers.set('1-3', 0.4);
|
||||
tapers.set('2-4', 0.4);
|
||||
return tapers;
|
||||
})();
|
||||
|
||||
// Secondary motion delay factors per joint — extremities lag more
|
||||
const SECONDARY_DELAY = [
|
||||
0.12, // 0 nose
|
||||
0.10, // 1 left eye
|
||||
0.10, // 2 right eye
|
||||
0.08, // 3 left ear
|
||||
0.08, // 4 right ear
|
||||
0.18, // 5 left shoulder
|
||||
0.18, // 6 right shoulder
|
||||
0.14, // 7 left elbow
|
||||
0.14, // 8 right elbow
|
||||
0.10, // 9 left wrist (most lag)
|
||||
0.10, // 10 right wrist
|
||||
0.20, // 11 left hip (anchored, fast follow)
|
||||
0.20, // 12 right hip
|
||||
0.15, // 13 left knee
|
||||
0.15, // 14 right knee
|
||||
0.10, // 15 left ankle
|
||||
0.10, // 16 right ankle
|
||||
];
|
||||
|
||||
// Overshoot factors — extremities overshoot more for organic feel
|
||||
const OVERSHOOT = [
|
||||
0.02, // 0 nose
|
||||
0.01, // 1 left eye
|
||||
0.01, // 2 right eye
|
||||
0.01, // 3 left ear
|
||||
0.01, // 4 right ear
|
||||
0.03, // 5 left shoulder
|
||||
0.03, // 6 right shoulder
|
||||
0.05, // 7 left elbow
|
||||
0.05, // 8 right elbow
|
||||
0.08, // 9 left wrist
|
||||
0.08, // 10 right wrist
|
||||
0.02, // 11 left hip
|
||||
0.02, // 12 right hip
|
||||
0.04, // 13 left knee
|
||||
0.04, // 14 right knee
|
||||
0.06, // 15 left ankle
|
||||
0.06, // 16 right ankle
|
||||
];
|
||||
|
||||
const MAX_FIGURES = 4;
|
||||
|
||||
// Reusable vectors to avoid per-frame allocation
|
||||
const _vecFrom = new THREE.Vector3();
|
||||
const _vecTo = new THREE.Vector3();
|
||||
const _vecTarget = new THREE.Vector3();
|
||||
|
||||
export class FigurePool {
|
||||
/**
|
||||
* @param {THREE.Scene} scene - The Three.js scene to add figures to
|
||||
* @param {object} settings - Shared settings object (boneThick, jointSize, glow, etc.)
|
||||
* @param {object} poseSystem - PoseSystem instance with generateKeypoints(person, elapsed, breathPulse)
|
||||
*/
|
||||
constructor(scene, settings, poseSystem) {
|
||||
this._scene = scene;
|
||||
this._settings = settings;
|
||||
this._poseSystem = poseSystem;
|
||||
this._figures = [];
|
||||
this._maxFigures = MAX_FIGURES;
|
||||
this._build();
|
||||
}
|
||||
|
||||
/** @returns {Array} The array of figure objects */
|
||||
get figures() { return this._figures; }
|
||||
|
||||
// ---- Construction ----
|
||||
|
||||
_build() {
|
||||
for (let f = 0; f < this._maxFigures; f++) {
|
||||
this._figures.push(this._createFigure());
|
||||
}
|
||||
}
|
||||
|
||||
_createFigure() {
|
||||
const group = new THREE.Group();
|
||||
this._scene.add(group);
|
||||
const wireColor = new THREE.Color(this._settings.wireColor);
|
||||
const jointColor = new THREE.Color(this._settings.jointColor);
|
||||
|
||||
// Joints (17 COCO keypoints)
|
||||
const joints = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const isNose = i === 0;
|
||||
const size = isNose ? this._settings.jointSize * 0.7 : this._settings.jointSize;
|
||||
const geo = new THREE.SphereGeometry(size, 12, 12);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: isNose ? wireColor : jointColor,
|
||||
emissive: isNose ? wireColor : jointColor,
|
||||
emissiveIntensity: 0.35,
|
||||
transparent: true, opacity: 0,
|
||||
roughness: 0.3, metalness: 0.2,
|
||||
});
|
||||
const sphere = new THREE.Mesh(geo, mat);
|
||||
sphere.castShadow = true;
|
||||
group.add(sphere);
|
||||
joints.push(sphere);
|
||||
|
||||
// Halo glow on key joints
|
||||
if ([5, 6, 9, 10, 11, 12, 15, 16].includes(i)) {
|
||||
const haloGeo = new THREE.SphereGeometry(size * 1.3, 8, 8);
|
||||
const haloMat = new THREE.MeshBasicMaterial({
|
||||
color: jointColor,
|
||||
transparent: true, opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
||||
sphere.add(halo);
|
||||
sphere._halo = halo;
|
||||
sphere._haloMat = haloMat;
|
||||
|
||||
const glow = new THREE.PointLight(jointColor, 0, 0.8);
|
||||
sphere.add(glow);
|
||||
sphere._glow = glow;
|
||||
}
|
||||
}
|
||||
|
||||
// Bones — tapered thickness
|
||||
const bones = [];
|
||||
for (const [a, b] of SKELETON_PAIRS) {
|
||||
const taperKey = `${Math.min(a, b)}-${Math.max(a, b)}`;
|
||||
const taper = BONE_TAPER.get(taperKey) || 1.0;
|
||||
const thick = this._settings.boneThick * taper;
|
||||
// Top radius thicker than bottom for natural taper along bone length
|
||||
const topRadius = thick;
|
||||
const botRadius = thick * 0.65;
|
||||
const geo = new THREE.CylinderGeometry(topRadius, botRadius, 1, 8, 1);
|
||||
geo.translate(0, 0.5, 0);
|
||||
geo.rotateX(Math.PI / 2);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: wireColor, emissive: wireColor, emissiveIntensity: 0.3,
|
||||
transparent: true, opacity: 0, roughness: 0.4, metalness: 0.1,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = true;
|
||||
group.add(mesh);
|
||||
bones.push({ mesh, a, b, taper });
|
||||
}
|
||||
|
||||
// Body segments (volume cylinders and head sphere)
|
||||
const bodySegments = [];
|
||||
for (const seg of BODY_SEGMENT_DEFS) {
|
||||
const geo = seg.isHead
|
||||
? new THREE.SphereGeometry(seg.radius, 12, 12)
|
||||
: new THREE.CylinderGeometry(seg.radius, seg.radius * 0.85, 1, 8, 1);
|
||||
if (!seg.isHead) {
|
||||
geo.translate(0, 0.5, 0);
|
||||
geo.rotateX(Math.PI / 2);
|
||||
}
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: wireColor, emissive: wireColor, emissiveIntensity: 0.12,
|
||||
transparent: true, opacity: 0, roughness: 0.5, metalness: 0.1,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
group.add(mesh);
|
||||
bodySegments.push({ mesh, mat, a: seg.joints[0], b: seg.joints[1], isHead: seg.isHead });
|
||||
}
|
||||
|
||||
// Aura cylinder
|
||||
const auraGeo = new THREE.CylinderGeometry(0.4, 0.3, 1.7, 16, 1, true);
|
||||
const auraMat = new THREE.MeshBasicMaterial({
|
||||
color: wireColor, transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
const aura = new THREE.Mesh(auraGeo, auraMat);
|
||||
aura.position.y = 1;
|
||||
group.add(aura);
|
||||
|
||||
// Per-figure point light
|
||||
const personLight = new THREE.PointLight(wireColor, 0, 6);
|
||||
personLight.position.y = 1;
|
||||
group.add(personLight);
|
||||
|
||||
// Interpolation state: previous positions for smooth lerp and secondary motion
|
||||
const prevPositions = [];
|
||||
const velocities = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
prevPositions.push(new THREE.Vector3(0, 0, 0));
|
||||
velocities.push(new THREE.Vector3(0, 0, 0));
|
||||
}
|
||||
|
||||
return {
|
||||
group, joints, bones, bodySegments, aura, auraMat, personLight,
|
||||
visible: false,
|
||||
prevPositions,
|
||||
velocities,
|
||||
_initialized: false,
|
||||
_lastPose: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Per-frame update ----
|
||||
|
||||
/**
|
||||
* Update all figures based on current data frame.
|
||||
* @param {object} data - Current sensing data with persons[], vital_signs, classification
|
||||
* @param {number} elapsed - Elapsed time in seconds
|
||||
*/
|
||||
update(data, elapsed) {
|
||||
const persons = data?.persons || [];
|
||||
const vs = data?.vital_signs || {};
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const breathBpm = vs.breathing_rate_bpm || 0;
|
||||
const breathPulse = breathBpm > 0
|
||||
? Math.sin(elapsed * Math.PI * 2 * (breathBpm / 60)) * 0.012
|
||||
: 0;
|
||||
|
||||
for (let f = 0; f < this._figures.length; f++) {
|
||||
const fig = this._figures[f];
|
||||
if (f < persons.length && isPresent) {
|
||||
const p = persons[f];
|
||||
const kps = this._poseSystem.generateKeypoints(p, elapsed, breathPulse);
|
||||
this.applyKeypoints(fig, kps, breathPulse, p.position || [0, 0, 0], elapsed, p.pose);
|
||||
fig.visible = true;
|
||||
} else {
|
||||
if (fig.visible) {
|
||||
this.hide(fig);
|
||||
fig.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply keypoints to a figure with smooth interpolation, pulsation, and secondary motion.
|
||||
* @param {object} fig - Figure object from the pool
|
||||
* @param {Array} kps - 17-element array of [x,y,z] keypoint positions
|
||||
* @param {number} breathPulse - Current breathing pulse value
|
||||
* @param {Array} pos - Person world position [x,y,z]
|
||||
* @param {number} elapsed - Elapsed time for pulsation effects
|
||||
* @param {string} pose - Current pose name for aura adaptation
|
||||
*/
|
||||
applyKeypoints(fig, kps, breathPulse, pos, elapsed = 0, pose = 'standing') {
|
||||
const lerpFactor = fig._initialized ? 0.18 : 1.0;
|
||||
|
||||
// Joints with smooth interpolation and secondary motion
|
||||
for (let i = 0; i < 17 && i < kps.length; i++) {
|
||||
const j = fig.joints[i];
|
||||
_vecTarget.set(kps[i][0], kps[i][1], kps[i][2]);
|
||||
|
||||
if (fig._initialized) {
|
||||
// Compute velocity for overshoot
|
||||
const prev = fig.prevPositions[i];
|
||||
const vel = fig.velocities[i];
|
||||
|
||||
// Smooth lerp with per-joint delay
|
||||
const delay = SECONDARY_DELAY[i];
|
||||
const jointLerp = lerpFactor + delay;
|
||||
j.position.lerp(_vecTarget, Math.min(jointLerp, 0.95));
|
||||
|
||||
// Apply subtle overshoot based on velocity change
|
||||
const overshoot = OVERSHOOT[i];
|
||||
vel.subVectors(j.position, prev).multiplyScalar(overshoot);
|
||||
j.position.add(vel);
|
||||
|
||||
prev.copy(j.position);
|
||||
} else {
|
||||
// First frame: snap to position
|
||||
j.position.copy(_vecTarget);
|
||||
fig.prevPositions[i].copy(_vecTarget);
|
||||
fig.velocities[i].set(0, 0, 0);
|
||||
}
|
||||
|
||||
j.material.opacity = 0.95;
|
||||
|
||||
// Joint pulsation synced with breathing
|
||||
const pulseFactor = 1.0 + Math.abs(breathPulse) * 8.0;
|
||||
j.material.emissiveIntensity = 0.35 * pulseFactor;
|
||||
|
||||
const baseScale = this._settings.jointSize / 0.04;
|
||||
// Subtle size pulsation on breathing
|
||||
const pulseScale = baseScale * (1.0 + Math.abs(breathPulse) * 3.0);
|
||||
j.scale.setScalar(pulseScale);
|
||||
|
||||
if (j._haloMat) {
|
||||
j._haloMat.opacity = 0.04 * this._settings.glow * pulseFactor;
|
||||
}
|
||||
if (j._glow) {
|
||||
j._glow.intensity = this._settings.glow * 0.12 * pulseFactor;
|
||||
}
|
||||
}
|
||||
|
||||
fig._initialized = true;
|
||||
|
||||
// Bones with tapered thickness
|
||||
for (const bone of fig.bones) {
|
||||
const pA = kps[bone.a], pB = kps[bone.b];
|
||||
if (pA && pB) {
|
||||
_vecFrom.set(pA[0], pA[1], pA[2]);
|
||||
_vecTo.set(pB[0], pB[1], pB[2]);
|
||||
const len = _vecFrom.distanceTo(_vecTo);
|
||||
|
||||
// Use interpolated joint positions for smooth bone movement
|
||||
if (fig._initialized) {
|
||||
const jA = fig.joints[bone.a];
|
||||
const jB = fig.joints[bone.b];
|
||||
bone.mesh.position.copy(jA.position);
|
||||
bone.mesh.scale.set(1, 1, jA.position.distanceTo(jB.position));
|
||||
bone.mesh.lookAt(jB.position);
|
||||
} else {
|
||||
bone.mesh.position.copy(_vecFrom);
|
||||
bone.mesh.scale.set(1, 1, len);
|
||||
bone.mesh.lookAt(_vecTo);
|
||||
}
|
||||
|
||||
bone.mesh.material.opacity = 0.85;
|
||||
bone.mesh.material.emissiveIntensity = 0.3 + Math.abs(breathPulse) * 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Body segments
|
||||
for (const seg of fig.bodySegments) {
|
||||
if (seg.isHead) {
|
||||
const headJoint = fig.joints[seg.a];
|
||||
seg.mesh.position.set(headJoint.position.x, headJoint.position.y + 0.05, headJoint.position.z);
|
||||
seg.mat.opacity = 0.15;
|
||||
} else {
|
||||
const jA = fig.joints[seg.a];
|
||||
const jB = fig.joints[seg.b];
|
||||
if (jA && jB) {
|
||||
const len = jA.position.distanceTo(jB.position);
|
||||
seg.mesh.position.copy(jA.position);
|
||||
seg.mesh.scale.set(1, 1, len);
|
||||
seg.mesh.lookAt(jB.position);
|
||||
seg.mat.opacity = 0.12;
|
||||
}
|
||||
}
|
||||
seg.mat.emissiveIntensity = 0.1 + Math.abs(breathPulse) * 0.4;
|
||||
}
|
||||
|
||||
// Aura — adapt shape to pose
|
||||
const hipY = (fig.joints[11].position.y + fig.joints[12].position.y) / 2;
|
||||
const cx = (fig.joints[11].position.x + fig.joints[12].position.x) / 2;
|
||||
const cz = (fig.joints[11].position.z + fig.joints[12].position.z) / 2;
|
||||
fig.aura.position.set(cx, hipY, cz);
|
||||
fig.auraMat.opacity = this._settings.aura + Math.abs(breathPulse) * 0.8;
|
||||
|
||||
// Pose-adaptive aura: compute from actual keypoint spread
|
||||
const auraShape = this._computeAuraShape(fig, pose, breathPulse);
|
||||
fig.aura.scale.set(auraShape.scaleX, auraShape.scaleY, auraShape.scaleZ);
|
||||
|
||||
// Person light
|
||||
fig.personLight.position.set(pos[0], 1.2, pos[2]);
|
||||
fig.personLight.intensity = this._settings.glow * 0.4;
|
||||
|
||||
fig._lastPose = pose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pose-adaptive aura shape based on actual keypoint spread.
|
||||
* Wider for exercise/spread poses, narrower for crouching/compact poses.
|
||||
*/
|
||||
_computeAuraShape(fig, pose, breathPulse) {
|
||||
// Measure horizontal spread from shoulders and hips
|
||||
const lShoulder = fig.joints[5].position;
|
||||
const rShoulder = fig.joints[6].position;
|
||||
const lHip = fig.joints[11].position;
|
||||
const rHip = fig.joints[12].position;
|
||||
const nose = fig.joints[0].position;
|
||||
const lAnkle = fig.joints[15].position;
|
||||
const rAnkle = fig.joints[16].position;
|
||||
|
||||
// Horizontal spread (X-Z plane)
|
||||
const shoulderWidth = Math.sqrt(
|
||||
(rShoulder.x - lShoulder.x) ** 2 +
|
||||
(rShoulder.z - lShoulder.z) ** 2
|
||||
);
|
||||
const ankleWidth = Math.sqrt(
|
||||
(rAnkle.x - lAnkle.x) ** 2 +
|
||||
(rAnkle.z - lAnkle.z) ** 2
|
||||
);
|
||||
const maxWidth = Math.max(shoulderWidth, ankleWidth);
|
||||
|
||||
// Vertical extent
|
||||
const headY = nose.y;
|
||||
const footY = Math.min(lAnkle.y, rAnkle.y);
|
||||
const height = headY - footY;
|
||||
|
||||
// Normalize to base aura dimensions
|
||||
const baseWidth = 0.44; // default shoulder width
|
||||
const baseHeight = 1.7; // default standing height
|
||||
|
||||
const widthRatio = Math.max(0.6, Math.min(2.0, maxWidth / baseWidth));
|
||||
const heightRatio = Math.max(0.4, Math.min(1.3, height / baseHeight));
|
||||
|
||||
// Breathing modulation
|
||||
const breathMod = 1 + breathPulse * 2;
|
||||
|
||||
return {
|
||||
scaleX: widthRatio * breathMod,
|
||||
scaleY: heightRatio * breathMod,
|
||||
scaleZ: widthRatio * breathMod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a figure by fading all materials to invisible.
|
||||
* @param {object} fig - Figure object to hide
|
||||
*/
|
||||
hide(fig) {
|
||||
for (const j of fig.joints) {
|
||||
j.material.opacity = 0;
|
||||
if (j._haloMat) j._haloMat.opacity = 0;
|
||||
if (j._glow) j._glow.intensity = 0;
|
||||
}
|
||||
for (const b of fig.bones) b.mesh.material.opacity = 0;
|
||||
for (const seg of fig.bodySegments) seg.mat.opacity = 0;
|
||||
fig.auraMat.opacity = 0;
|
||||
fig.personLight.intensity = 0;
|
||||
fig._initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply wire and joint colors to all figures in the pool.
|
||||
* @param {THREE.Color} wireColor
|
||||
* @param {THREE.Color} jointColor
|
||||
*/
|
||||
applyColors(wireColor, jointColor) {
|
||||
for (const fig of this._figures) {
|
||||
for (let i = 0; i < fig.joints.length; i++) {
|
||||
const j = fig.joints[i];
|
||||
if (i === 0) {
|
||||
j.material.color.copy(wireColor);
|
||||
j.material.emissive.copy(wireColor);
|
||||
} else {
|
||||
j.material.color.copy(jointColor);
|
||||
j.material.emissive.copy(jointColor);
|
||||
}
|
||||
if (j._haloMat) j._haloMat.color.copy(jointColor);
|
||||
if (j._glow) j._glow.color.copy(jointColor);
|
||||
}
|
||||
for (const b of fig.bones) {
|
||||
b.mesh.material.color.copy(wireColor);
|
||||
b.mesh.material.emissive.copy(wireColor);
|
||||
}
|
||||
for (const seg of fig.bodySegments) {
|
||||
seg.mat.color.copy(wireColor);
|
||||
seg.mat.emissive.copy(wireColor);
|
||||
}
|
||||
fig.auraMat.color.copy(wireColor);
|
||||
fig.personLight.color.copy(wireColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Holographic Panel — Reusable frame with border shader, scan line, title
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BORDER_VERTEX = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BORDER_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Thin border
|
||||
float bx = step(vUv.x, 0.015) + step(1.0 - 0.015, vUv.x);
|
||||
float by = step(vUv.y, 0.02) + step(1.0 - 0.02, vUv.y);
|
||||
float border = clamp(bx + by, 0.0, 1.0);
|
||||
|
||||
// Scan line moving upward
|
||||
float scan = smoothstep(0.0, 0.02, abs(vUv.y - fract(uTime * 0.15))) ;
|
||||
scan = 1.0 - (1.0 - scan) * 0.4;
|
||||
|
||||
// Corner accents
|
||||
float corner = 0.0;
|
||||
float cx = min(vUv.x, 1.0 - vUv.x);
|
||||
float cy = min(vUv.y, 1.0 - vUv.y);
|
||||
if (cx < 0.06 && cy < 0.08) corner = 0.6;
|
||||
|
||||
// Subtle fill
|
||||
float fill = 0.03 + corner * 0.05;
|
||||
|
||||
float alpha = max(border * 0.7, fill) * scan;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export class HolographicPanel {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {number[]} opts.position - [x, y, z]
|
||||
* @param {number} opts.width
|
||||
* @param {number} opts.height
|
||||
* @param {string} opts.title
|
||||
* @param {number} [opts.color=0x00d4ff]
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.set(...opts.position);
|
||||
|
||||
const color = new THREE.Color(opts.color || 0x00d4ff);
|
||||
|
||||
// Border plane
|
||||
this._uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: color },
|
||||
};
|
||||
|
||||
const borderGeo = new THREE.PlaneGeometry(opts.width, opts.height);
|
||||
const borderMat = new THREE.ShaderMaterial({
|
||||
vertexShader: BORDER_VERTEX,
|
||||
fragmentShader: BORDER_FRAGMENT,
|
||||
uniforms: this._uniforms,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._border = new THREE.Mesh(borderGeo, borderMat);
|
||||
this.group.add(this._border);
|
||||
|
||||
// Title sprite
|
||||
if (opts.title) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, 512, 64);
|
||||
ctx.font = '600 28px "Courier New", monospace';
|
||||
ctx.fillStyle = `#${color.getHexString()}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(opts.title.toUpperCase(), 256, 42);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(opts.width * 0.8, opts.width * 0.1, 1);
|
||||
sprite.position.y = opts.height / 2 + 0.3;
|
||||
this.group.add(sprite);
|
||||
this._titleSprite = sprite;
|
||||
this._titleTex = tex;
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this._uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
/** Make panel face camera */
|
||||
lookAt(cameraPos) {
|
||||
this.group.lookAt(cameraPos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._border.geometry.dispose();
|
||||
this._border.material.dispose();
|
||||
if (this._titleTex) this._titleTex.dispose();
|
||||
if (this._titleSprite) this._titleSprite.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* HudController — Extracted HUD update, settings dialog, and scenario UI
|
||||
*
|
||||
* Manages all DOM-based HUD elements:
|
||||
* - Vital sign display with smooth lerp transitions and color coding
|
||||
* - Signal metrics, sparkline, and presence indicator
|
||||
* - Scenario description and edge module badges
|
||||
* - Mini person-count dot visualization
|
||||
* - Settings dialog (tabs, ranges, presets, data source)
|
||||
* - Quick-select scenario dropdown
|
||||
*/
|
||||
|
||||
// ---- Constants ----
|
||||
|
||||
export const SCENARIO_NAMES = [
|
||||
'EMPTY ROOM','VITAL SIGNS','MULTI-PERSON','FALL DETECT',
|
||||
'SLEEP MONITOR','INTRUSION','GESTURE CTRL','CROWD OCCUPANCY',
|
||||
'SEARCH RESCUE','ELDERLY CARE','FITNESS','SECURITY PATROL',
|
||||
];
|
||||
|
||||
export const DEFAULTS = {
|
||||
bloom: 0.08, bloomRadius: 0.2, bloomThresh: 0.6,
|
||||
exposure: 1.3, vignette: 0.25, grain: 0.01, chromatic: 0.0005,
|
||||
boneThick: 0.018, jointSize: 0.035, glow: 0.3, trail: 0.35,
|
||||
wireColor: '#00d878', jointColor: '#ff4060', aura: 0.02,
|
||||
field: 0.45, waves: 0.4, ambient: 0.7, reflect: 0.2,
|
||||
fov: 50, orbitSpeed: 0.15, grid: true, room: true,
|
||||
scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '',
|
||||
};
|
||||
|
||||
export const SETTINGS_VERSION = '6';
|
||||
|
||||
export const PRESETS = {
|
||||
foundation: {},
|
||||
cinematic: {
|
||||
bloom: 1.2, bloomRadius: 0.5, bloomThresh: 0.2,
|
||||
exposure: 0.8, vignette: 0.7, grain: 0.04, chromatic: 0.002,
|
||||
glow: 0.6, trail: 0.8, aura: 0.06, field: 0.4,
|
||||
waves: 0.7, ambient: 0.25, reflect: 0.5, fov: 40, orbitSpeed: 0.08,
|
||||
},
|
||||
minimal: {
|
||||
bloom: 0.3, bloomRadius: 0.2, bloomThresh: 0.5,
|
||||
exposure: 1.1, vignette: 0.2, grain: 0, chromatic: 0,
|
||||
glow: 0.3, trail: 0.2, aura: 0.02, field: 0.7,
|
||||
waves: 0.3, ambient: 0.6, reflect: 0.1, wireColor: '#40ff90', jointColor: '#4080ff',
|
||||
},
|
||||
neon: {
|
||||
bloom: 2.5, bloomRadius: 0.8, bloomThresh: 0.1,
|
||||
exposure: 0.6, vignette: 0.6, grain: 0.02, chromatic: 0.004,
|
||||
glow: 2.0, trail: 1.0, aura: 0.15, field: 0.6,
|
||||
waves: 1.0, ambient: 0.15, reflect: 0.7, wireColor: '#00ffaa', jointColor: '#ff00ff',
|
||||
},
|
||||
tactical: {
|
||||
bloom: 0.5, bloomRadius: 0.3, bloomThresh: 0.4,
|
||||
exposure: 0.85, vignette: 0.4, grain: 0.04, chromatic: 0.001,
|
||||
glow: 0.5, trail: 0.4, aura: 0.03, field: 0.8,
|
||||
waves: 0.4, ambient: 0.3, reflect: 0.15, wireColor: '#30ff60', jointColor: '#ff8800',
|
||||
},
|
||||
medical: {
|
||||
bloom: 0.6, bloomRadius: 0.4, bloomThresh: 0.35,
|
||||
exposure: 1.0, vignette: 0.3, grain: 0.01, chromatic: 0.0005,
|
||||
glow: 0.6, trail: 0.3, aura: 0.04, field: 0.5,
|
||||
waves: 0.3, ambient: 0.5, reflect: 0.2, wireColor: '#00ccff', jointColor: '#ff3355',
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario descriptions shown below the dropdown
|
||||
const SCENARIO_DESCRIPTIONS = {
|
||||
auto: 'Auto-cycling through all sensing scenarios.',
|
||||
empty_room: 'Baseline calibration with no human presence in the monitored zone.',
|
||||
single_breathing: 'Detecting vital signs through WiFi signal micro-variations.',
|
||||
two_walking: 'Tracking multiple people simultaneously via CSI multiplex separation.',
|
||||
fall_event: 'Sudden posture-change detection using acceleration feature analysis.',
|
||||
sleep_monitoring: 'Monitoring breathing patterns and apnea events during sleep.',
|
||||
intrusion_detect: 'Passive perimeter monitoring -- no cameras, pure RF sensing.',
|
||||
gesture_control: 'DTW-based gesture recognition from hand/arm motion signatures.',
|
||||
crowd_occupancy: 'Estimating room occupancy count from aggregate CSI variance.',
|
||||
search_rescue: 'Through-wall survivor detection using WiFi-MAT multistatic mode.',
|
||||
elderly_care: 'Continuous gait analysis for early mobility-decline detection.',
|
||||
fitness_tracking: 'Rep counting and exercise classification from body kinematics.',
|
||||
security_patrol: 'Multi-zone presence patrol with camera-free motion heatmaps.',
|
||||
};
|
||||
|
||||
// Edge modules active per scenario
|
||||
const SCENARIO_EDGE_MODULES = {
|
||||
auto: [],
|
||||
empty_room: [],
|
||||
single_breathing: ['VITALS'],
|
||||
two_walking: ['GAIT', 'TRACKING'],
|
||||
fall_event: ['FALL', 'VITALS'],
|
||||
sleep_monitoring: ['VITALS', 'APNEA'],
|
||||
intrusion_detect: ['PRESENCE', 'ALERT'],
|
||||
gesture_control: ['GESTURE', 'DTW'],
|
||||
crowd_occupancy: ['OCCUPANCY'],
|
||||
search_rescue: ['MAT', 'VITALS', 'PRESENCE'],
|
||||
elderly_care: ['GAIT', 'VITALS', 'FALL'],
|
||||
fitness_tracking: ['GESTURE', 'GAIT'],
|
||||
security_patrol: ['PRESENCE', 'ALERT', 'TRACKING'],
|
||||
};
|
||||
|
||||
// Edge-module badge colors
|
||||
const MODULE_COLORS = {
|
||||
VITALS: 'var(--red-heart)',
|
||||
GAIT: 'var(--green-glow)',
|
||||
FALL: 'var(--red-alert)',
|
||||
GESTURE: 'var(--amber)',
|
||||
PRESENCE: 'var(--blue-signal)',
|
||||
TRACKING: 'var(--green-bright)',
|
||||
OCCUPANCY: 'var(--amber)',
|
||||
ALERT: 'var(--red-alert)',
|
||||
DTW: 'var(--amber)',
|
||||
APNEA: 'var(--red-heart)',
|
||||
MAT: 'var(--blue-signal)',
|
||||
};
|
||||
|
||||
// Vital-sign color-coding thresholds
|
||||
function vitalColor(type, value) {
|
||||
if (value <= 0) return 'var(--text-secondary)';
|
||||
if (type === 'hr') {
|
||||
if (value < 50 || value > 130) return 'var(--red-alert)';
|
||||
if (value < 60 || value > 100) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
if (type === 'br') {
|
||||
if (value < 8 || value > 28) return 'var(--red-alert)';
|
||||
if (value < 12 || value > 20) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
if (type === 'conf') {
|
||||
if (value < 40) return 'var(--red-alert)';
|
||||
if (value < 70) return 'var(--amber)';
|
||||
return 'var(--green-glow)';
|
||||
}
|
||||
return 'var(--text-primary)';
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// ---- HudController class ----
|
||||
|
||||
export class HudController {
|
||||
constructor(observatory) {
|
||||
this._obs = observatory;
|
||||
this._settingsOpen = false;
|
||||
this._rssiHistory = [];
|
||||
this._sparklineCtx = document.getElementById('rssi-sparkline')?.getContext('2d');
|
||||
|
||||
// Lerp state for smooth vital-sign transitions
|
||||
this._lerpHr = 0;
|
||||
this._lerpBr = 0;
|
||||
this._lerpConf = 0;
|
||||
|
||||
// Track current scenario for description/edge updates
|
||||
this._currentScenarioKey = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings dialog
|
||||
// ============================================================
|
||||
|
||||
initSettings() {
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
const btn = document.getElementById('settings-btn');
|
||||
const closeBtn = document.getElementById('settings-close');
|
||||
btn.addEventListener('click', () => this.toggleSettings());
|
||||
closeBtn.addEventListener('click', () => this.toggleSettings());
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.toggleSettings(); });
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.stab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.stab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.stab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`stab-${tab.dataset.stab}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const obs = this._obs;
|
||||
const s = obs.settings;
|
||||
|
||||
// Bind ranges
|
||||
this._bindRange('opt-bloom', 'bloom', v => { obs._postProcessing._bloomPass.strength = v; });
|
||||
this._bindRange('opt-bloom-radius', 'bloomRadius', v => { obs._postProcessing._bloomPass.radius = v; });
|
||||
this._bindRange('opt-bloom-thresh', 'bloomThresh', v => { obs._postProcessing._bloomPass.threshold = v; });
|
||||
this._bindRange('opt-exposure', 'exposure', v => { obs._renderer.toneMappingExposure = v; });
|
||||
this._bindRange('opt-vignette', 'vignette', v => { obs._postProcessing._vignettePass.uniforms.uVignetteStrength.value = v; });
|
||||
this._bindRange('opt-grain', 'grain', v => { obs._postProcessing._vignettePass.uniforms.uGrainStrength.value = v; });
|
||||
this._bindRange('opt-chromatic', 'chromatic', v => { obs._postProcessing._vignettePass.uniforms.uChromaticStrength.value = v; });
|
||||
this._bindRange('opt-bone-thick', 'boneThick');
|
||||
this._bindRange('opt-joint-size', 'jointSize');
|
||||
this._bindRange('opt-glow', 'glow');
|
||||
this._bindRange('opt-trail', 'trail');
|
||||
this._bindRange('opt-aura', 'aura');
|
||||
this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; });
|
||||
this._bindRange('opt-waves', 'waves');
|
||||
this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 5.0; });
|
||||
this._bindRange('opt-reflect', 'reflect', v => {
|
||||
obs._floorMat.roughness = 1.0 - v * 0.7;
|
||||
obs._floorMat.metalness = v * 0.5;
|
||||
});
|
||||
this._bindRange('opt-fov', 'fov', v => {
|
||||
obs._camera.fov = v;
|
||||
obs._camera.updateProjectionMatrix();
|
||||
});
|
||||
this._bindRange('opt-orbit-speed', 'orbitSpeed');
|
||||
this._bindRange('opt-cycle', 'cycle', v => { obs._demoData.setCycleDuration(v); });
|
||||
|
||||
// Color pickers
|
||||
document.getElementById('opt-wire-color').value = s.wireColor;
|
||||
document.getElementById('opt-wire-color').addEventListener('input', (e) => {
|
||||
s.wireColor = e.target.value; obs._applyColors(); this.saveSettings();
|
||||
});
|
||||
document.getElementById('opt-joint-color').value = s.jointColor;
|
||||
document.getElementById('opt-joint-color').addEventListener('input', (e) => {
|
||||
s.jointColor = e.target.value; obs._applyColors(); this.saveSettings();
|
||||
});
|
||||
|
||||
// Checkboxes
|
||||
document.getElementById('opt-grid').checked = s.grid;
|
||||
document.getElementById('opt-grid').addEventListener('change', (e) => {
|
||||
s.grid = e.target.checked; obs._grid.visible = e.target.checked; this.saveSettings();
|
||||
});
|
||||
document.getElementById('opt-room').checked = s.room;
|
||||
document.getElementById('opt-room').addEventListener('change', (e) => {
|
||||
s.room = e.target.checked; obs._roomWire.visible = e.target.checked; this.saveSettings();
|
||||
});
|
||||
|
||||
// Scenario select
|
||||
const scenarioSel = document.getElementById('opt-scenario');
|
||||
scenarioSel.value = s.scenario;
|
||||
scenarioSel.addEventListener('change', (e) => {
|
||||
s.scenario = e.target.value;
|
||||
obs._demoData.setScenario(e.target.value);
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// Data source
|
||||
const dsSel = document.getElementById('opt-data-source');
|
||||
dsSel.value = s.dataSource;
|
||||
dsSel.addEventListener('change', (e) => {
|
||||
s.dataSource = e.target.value;
|
||||
document.getElementById('ws-url-row').style.display = e.target.value === 'ws' ? 'flex' : 'none';
|
||||
if (e.target.value === 'ws' && s.wsUrl) obs._connectWS(s.wsUrl);
|
||||
else obs._disconnectWS();
|
||||
this.updateSourceBadge(s.dataSource, obs._ws);
|
||||
this.saveSettings();
|
||||
});
|
||||
document.getElementById('ws-url-row').style.display = s.dataSource === 'ws' ? 'flex' : 'none';
|
||||
|
||||
const wsInput = document.getElementById('opt-ws-url');
|
||||
wsInput.value = s.wsUrl;
|
||||
wsInput.addEventListener('change', (e) => {
|
||||
s.wsUrl = e.target.value;
|
||||
if (s.dataSource === 'ws') obs._connectWS(e.target.value);
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// Buttons
|
||||
document.getElementById('btn-reset-camera').addEventListener('click', () => {
|
||||
obs._camera.position.set(6, 5, 8);
|
||||
obs._controls.target.set(0, 1.2, 0);
|
||||
obs._controls.update();
|
||||
});
|
||||
document.getElementById('btn-export-settings').addEventListener('click', () => {
|
||||
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'ruview-observatory-settings.json';
|
||||
a.click();
|
||||
});
|
||||
document.getElementById('btn-reset-settings').addEventListener('click', () => {
|
||||
this.applyPreset(DEFAULTS);
|
||||
});
|
||||
|
||||
const presetSel = document.getElementById('opt-preset');
|
||||
presetSel.addEventListener('change', (e) => {
|
||||
const p = PRESETS[e.target.value];
|
||||
if (p) this.applyPreset({ ...DEFAULTS, ...p });
|
||||
});
|
||||
|
||||
obs._grid.visible = s.grid;
|
||||
obs._roomWire.visible = s.room;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Quick-select (top bar scenario dropdown)
|
||||
// ============================================================
|
||||
|
||||
initQuickSelect() {
|
||||
const sel = document.getElementById('scenario-quick-select');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', (e) => {
|
||||
this._obs._demoData.setScenario(e.target.value);
|
||||
const settingsSel = document.getElementById('opt-scenario');
|
||||
if (settingsSel) settingsSel.value = e.target.value;
|
||||
this._obs.settings.scenario = e.target.value;
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Toggle / save / preset
|
||||
// ============================================================
|
||||
|
||||
toggleSettings() {
|
||||
this._settingsOpen = !this._settingsOpen;
|
||||
document.getElementById('settings-overlay').style.display = this._settingsOpen ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
get settingsOpen() {
|
||||
return this._settingsOpen;
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
try {
|
||||
localStorage.setItem('ruview-observatory-settings', JSON.stringify(this._obs.settings));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
applyPreset(preset) {
|
||||
const obs = this._obs;
|
||||
Object.assign(obs.settings, preset);
|
||||
this.saveSettings();
|
||||
const rangeMap = {
|
||||
'opt-bloom': 'bloom', 'opt-bloom-radius': 'bloomRadius', 'opt-bloom-thresh': 'bloomThresh',
|
||||
'opt-exposure': 'exposure', 'opt-vignette': 'vignette', 'opt-grain': 'grain', 'opt-chromatic': 'chromatic',
|
||||
'opt-bone-thick': 'boneThick', 'opt-joint-size': 'jointSize', 'opt-glow': 'glow', 'opt-trail': 'trail', 'opt-aura': 'aura',
|
||||
'opt-field': 'field', 'opt-waves': 'waves', 'opt-ambient': 'ambient', 'opt-reflect': 'reflect',
|
||||
'opt-fov': 'fov', 'opt-orbit-speed': 'orbitSpeed', 'opt-cycle': 'cycle',
|
||||
};
|
||||
for (const [id, key] of Object.entries(rangeMap)) {
|
||||
const el = document.getElementById(id);
|
||||
const valEl = document.getElementById(`${id}-val`);
|
||||
if (el) el.value = obs.settings[key];
|
||||
if (valEl) valEl.textContent = obs.settings[key];
|
||||
}
|
||||
const gridEl = document.getElementById('opt-grid');
|
||||
if (gridEl) { gridEl.checked = obs.settings.grid; obs._grid.visible = obs.settings.grid; }
|
||||
const roomEl = document.getElementById('opt-room');
|
||||
if (roomEl) { roomEl.checked = obs.settings.room; obs._roomWire.visible = obs.settings.room; }
|
||||
document.getElementById('opt-wire-color').value = obs.settings.wireColor;
|
||||
document.getElementById('opt-joint-color').value = obs.settings.jointColor;
|
||||
obs._applyPostSettings();
|
||||
obs._renderer.toneMappingExposure = obs.settings.exposure;
|
||||
obs._fieldMat.opacity = obs.settings.field;
|
||||
obs._ambient.intensity = obs.settings.ambient * 5.0;
|
||||
obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7;
|
||||
obs._floorMat.metalness = obs.settings.reflect * 0.5;
|
||||
obs._camera.fov = obs.settings.fov;
|
||||
obs._camera.updateProjectionMatrix();
|
||||
obs._demoData.setCycleDuration(obs.settings.cycle);
|
||||
obs._applyColors();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Source badge
|
||||
// ============================================================
|
||||
|
||||
updateSourceBadge(dataSource, ws) {
|
||||
const dot = document.querySelector('#data-source-badge .dot');
|
||||
const label = document.getElementById('data-source-label');
|
||||
if (dataSource === 'ws' && ws?.readyState === WebSocket.OPEN) {
|
||||
dot.className = 'dot dot--live'; label.textContent = 'LIVE';
|
||||
} else {
|
||||
dot.className = 'dot dot--demo'; label.textContent = 'DEMO';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HUD update (called every frame)
|
||||
// ============================================================
|
||||
|
||||
updateHUD(data, demoData) {
|
||||
if (!data) return;
|
||||
const vs = data.vital_signs || {};
|
||||
const feat = data.features || {};
|
||||
const cls = data.classification || {};
|
||||
|
||||
// Sync scenario dropdown
|
||||
const quickSel = document.getElementById('scenario-quick-select');
|
||||
const cur = demoData._autoMode ? 'auto' : demoData.currentScenario;
|
||||
if (quickSel && quickSel.value !== cur) quickSel.value = cur;
|
||||
const autoIcon = document.getElementById('autoplay-icon');
|
||||
if (autoIcon) autoIcon.className = demoData._autoMode ? '' : 'hidden';
|
||||
|
||||
const targetHr = vs.heart_rate_bpm || 0;
|
||||
const targetBr = vs.breathing_rate_bpm || 0;
|
||||
const targetConf = Math.round((cls.confidence || 0) * 100);
|
||||
|
||||
// Smooth lerp transitions (blend 4% per frame toward target — very stable)
|
||||
const lerpFactor = 0.04;
|
||||
this._lerpHr = targetHr > 0 ? lerp(this._lerpHr, targetHr, lerpFactor) : 0;
|
||||
this._lerpBr = targetBr > 0 ? lerp(this._lerpBr, targetBr, lerpFactor) : 0;
|
||||
this._lerpConf = targetConf > 0 ? lerp(this._lerpConf, targetConf, lerpFactor) : 0;
|
||||
|
||||
const dispHr = this._lerpHr > 1 ? Math.round(this._lerpHr) : '--';
|
||||
const dispBr = this._lerpBr > 1 ? Math.round(this._lerpBr) : '--';
|
||||
const dispConf = this._lerpConf > 1 ? Math.round(this._lerpConf) : '--';
|
||||
|
||||
this._setText('hr-value', dispHr);
|
||||
this._setText('br-value', dispBr);
|
||||
this._setText('conf-value', dispConf);
|
||||
this._setWidth('hr-bar', Math.min(100, this._lerpHr / 120 * 100));
|
||||
this._setWidth('br-bar', Math.min(100, this._lerpBr / 30 * 100));
|
||||
this._setWidth('conf-bar', this._lerpConf);
|
||||
|
||||
// Color-code vital values
|
||||
this._setColor('hr-value', vitalColor('hr', this._lerpHr));
|
||||
this._setColor('br-value', vitalColor('br', this._lerpBr));
|
||||
this._setColor('conf-value', vitalColor('conf', this._lerpConf));
|
||||
|
||||
// Color-code bar fills to match
|
||||
this._setBarColor('hr-bar', vitalColor('hr', this._lerpHr));
|
||||
this._setBarColor('br-bar', vitalColor('br', this._lerpBr));
|
||||
this._setBarColor('conf-bar', vitalColor('conf', this._lerpConf));
|
||||
|
||||
this._setText('rssi-value', `${Math.round(feat.mean_rssi || 0)} dBm`);
|
||||
this._setText('var-value', (feat.variance || 0).toFixed(2));
|
||||
this._setText('motion-value', (feat.motion_band_power || 0).toFixed(3));
|
||||
|
||||
// Mini person-count dots
|
||||
const personCount = data.estimated_persons || 0;
|
||||
this._updatePersonDots(personCount);
|
||||
|
||||
const presEl = document.getElementById('presence-indicator');
|
||||
const presLabel = document.getElementById('presence-label');
|
||||
if (presEl) {
|
||||
const ml = cls.motion_level || 'absent';
|
||||
presEl.className = 'presence-state';
|
||||
if (ml === 'active') { presEl.classList.add('presence--active'); presLabel.textContent = 'ACTIVE'; }
|
||||
else if (cls.presence) { presEl.classList.add('presence--present'); presLabel.textContent = 'PRESENT'; }
|
||||
else { presEl.classList.add('presence--absent'); presLabel.textContent = 'ABSENT'; }
|
||||
}
|
||||
|
||||
const fallEl = document.getElementById('fall-alert');
|
||||
if (fallEl) fallEl.style.display = cls.fall_detected ? 'block' : 'none';
|
||||
|
||||
// Scenario description and edge modules
|
||||
const scenarioKey = demoData._autoMode ? (demoData.currentScenario || 'auto') : (demoData.currentScenario || 'auto');
|
||||
if (scenarioKey !== this._currentScenarioKey) {
|
||||
this._currentScenarioKey = scenarioKey;
|
||||
this._updateScenarioDescription(scenarioKey);
|
||||
this._updateEdgeModules(scenarioKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sparkline
|
||||
// ============================================================
|
||||
|
||||
updateSparkline(data) {
|
||||
const rssi = data?.features?.mean_rssi;
|
||||
if (rssi == null || !this._sparklineCtx) return;
|
||||
this._rssiHistory.push(rssi);
|
||||
if (this._rssiHistory.length > 60) this._rssiHistory.shift();
|
||||
|
||||
const ctx = this._sparklineCtx;
|
||||
const w = ctx.canvas.width, h = ctx.canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (this._rssiHistory.length < 2) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#2090ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#2090ff';
|
||||
ctx.shadowBlur = 4;
|
||||
for (let i = 0; i < this._rssiHistory.length; i++) {
|
||||
const x = (i / (this._rssiHistory.length - 1)) * w;
|
||||
const norm = Math.max(0, Math.min(1, (this._rssiHistory[i] + 80) / 60));
|
||||
const y = h - norm * h;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.lineTo(w, h);
|
||||
ctx.lineTo(0, h);
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, 'rgba(32,144,255,0.15)');
|
||||
grad.addColorStop(1, 'rgba(32,144,255,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private helpers
|
||||
// ============================================================
|
||||
|
||||
_setText(id, val) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.textContent = val;
|
||||
}
|
||||
|
||||
_setWidth(id, pct) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.width = `${pct}%`;
|
||||
}
|
||||
|
||||
_setColor(id, color) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.color = color;
|
||||
}
|
||||
|
||||
_setBarColor(id, color) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) e.style.background = color;
|
||||
}
|
||||
|
||||
_bindRange(id, key, applyFn) {
|
||||
const el = document.getElementById(id);
|
||||
const valEl = document.getElementById(`${id}-val`);
|
||||
if (!el) return;
|
||||
el.value = this._obs.settings[key];
|
||||
if (valEl) valEl.textContent = this._obs.settings[key];
|
||||
el.addEventListener('input', (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
this._obs.settings[key] = v;
|
||||
if (valEl) valEl.textContent = v;
|
||||
if (applyFn) applyFn(v);
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
_updatePersonDots(count) {
|
||||
const container = document.getElementById('persons-dots');
|
||||
if (!container) {
|
||||
// Fall back to text-only display
|
||||
this._setText('persons-value', count);
|
||||
return;
|
||||
}
|
||||
// Build dot icons: filled for detected persons, dim for empty slots (max 8)
|
||||
const maxDots = 8;
|
||||
const clamped = Math.min(count, maxDots);
|
||||
let html = '';
|
||||
for (let i = 0; i < maxDots; i++) {
|
||||
const active = i < clamped;
|
||||
html += `<span class="person-dot${active ? ' person-dot--active' : ''}"></span>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
this._setText('persons-value', count);
|
||||
}
|
||||
|
||||
_updateScenarioDescription(scenarioKey) {
|
||||
const el = document.getElementById('scenario-description');
|
||||
if (!el) return;
|
||||
el.textContent = SCENARIO_DESCRIPTIONS[scenarioKey] || '';
|
||||
}
|
||||
|
||||
_updateEdgeModules(scenarioKey) {
|
||||
const bar = document.getElementById('edge-modules-bar');
|
||||
if (!bar) return;
|
||||
const modules = SCENARIO_EDGE_MODULES[scenarioKey] || [];
|
||||
if (modules.length === 0) {
|
||||
bar.innerHTML = '';
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
bar.innerHTML = modules.map(m => {
|
||||
const color = MODULE_COLORS[m] || 'var(--text-secondary)';
|
||||
return `<span class="edge-badge" style="--badge-color:${color}">${m}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* RuView Observatory — Main Scene Orchestrator
|
||||
*
|
||||
* Room-based WiFi sensing visualization with:
|
||||
* - Pool of 4 human wireframe figures (multi-person scenarios)
|
||||
* - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching)
|
||||
* - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk)
|
||||
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
|
||||
* - Reflective floor, settings dialog, and practical data HUD
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DemoDataGenerator } from './demo-data.js';
|
||||
import { NebulaBackground } from './nebula-background.js';
|
||||
import { PostProcessing } from './post-processing.js';
|
||||
import { FigurePool, SKELETON_PAIRS } from './figure-pool.js';
|
||||
import { PoseSystem } from './pose-system.js';
|
||||
import { ScenarioProps } from './scenario-props.js';
|
||||
import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js';
|
||||
|
||||
// ---- Palette ----
|
||||
const C = {
|
||||
greenGlow: 0x00d878,
|
||||
greenBright:0x3eff8a,
|
||||
greenDim: 0x0a6b3a,
|
||||
amber: 0xffb020,
|
||||
blueSignal: 0x2090ff,
|
||||
redAlert: 0xff3040,
|
||||
redHeart: 0xff4060,
|
||||
bgDeep: 0x080c14,
|
||||
};
|
||||
|
||||
// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js
|
||||
|
||||
// ---- Main Class ----
|
||||
|
||||
class Observatory {
|
||||
constructor() {
|
||||
this._canvas = document.getElementById('observatory-canvas');
|
||||
this.settings = { ...DEFAULTS };
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
const ver = localStorage.getItem('ruview-settings-version');
|
||||
if (ver === SETTINGS_VERSION) {
|
||||
const saved = localStorage.getItem('ruview-observatory-settings');
|
||||
if (saved) Object.assign(this.settings, JSON.parse(saved));
|
||||
} else {
|
||||
localStorage.removeItem('ruview-observatory-settings');
|
||||
localStorage.setItem('ruview-settings-version', SETTINGS_VERSION);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Renderer
|
||||
this._renderer = new THREE.WebGLRenderer({
|
||||
canvas: this._canvas,
|
||||
antialias: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this._renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this._renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this._renderer.toneMappingExposure = this.settings.exposure;
|
||||
this._renderer.shadowMap.enabled = true;
|
||||
this._renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
// Scene
|
||||
this._scene = new THREE.Scene();
|
||||
this._scene.background = new THREE.Color(C.bgDeep);
|
||||
this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005);
|
||||
|
||||
// Camera
|
||||
this._camera = new THREE.PerspectiveCamera(
|
||||
this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300
|
||||
);
|
||||
this._camera.position.set(6, 5, 8);
|
||||
this._camera.lookAt(0, 1.2, 0);
|
||||
|
||||
// Controls
|
||||
this._controls = new OrbitControls(this._camera, this._canvas);
|
||||
this._controls.enableDamping = true;
|
||||
this._controls.dampingFactor = 0.08;
|
||||
this._controls.minDistance = 2;
|
||||
this._controls.maxDistance = 25;
|
||||
this._controls.maxPolarAngle = Math.PI * 0.88;
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
|
||||
this._clock = new THREE.Clock();
|
||||
|
||||
// Data
|
||||
this._demoData = new DemoDataGenerator();
|
||||
this._demoData.setCycleDuration(this.settings.cycle || 30);
|
||||
if (this.settings.scenario && this.settings.scenario !== 'auto') {
|
||||
this._demoData.setScenario(this.settings.scenario);
|
||||
}
|
||||
this._currentData = null;
|
||||
this._currentScenario = null;
|
||||
|
||||
// Build scene
|
||||
this._setupLighting();
|
||||
this._nebula = new NebulaBackground(this._scene);
|
||||
this._buildRoom();
|
||||
this._buildRouter();
|
||||
this._poseSystem = new PoseSystem();
|
||||
this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem);
|
||||
this._scenarioProps = new ScenarioProps(this._scene);
|
||||
this._buildDotMatrixMist();
|
||||
this._buildParticleTrail();
|
||||
this._buildWifiWaves();
|
||||
this._buildSignalField();
|
||||
|
||||
// Post-processing
|
||||
this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera);
|
||||
this._applyPostSettings();
|
||||
|
||||
// HUD controller (settings dialog, sparkline, vital displays)
|
||||
this._hud = new HudController(this);
|
||||
|
||||
// State
|
||||
this._autopilot = false;
|
||||
this._autoAngle = 0;
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
this._fpsValue = 60;
|
||||
this._showFps = false;
|
||||
this._qualityLevel = 2;
|
||||
|
||||
// WebSocket for live data — always try auto-detect on startup
|
||||
this._ws = null;
|
||||
this._liveData = null;
|
||||
this._autoDetectLive();
|
||||
|
||||
// Input
|
||||
this._initKeyboard();
|
||||
this._hud.initSettings();
|
||||
this._hud.initQuickSelect();
|
||||
window.addEventListener('resize', () => this._onResize());
|
||||
|
||||
// Start
|
||||
this._animate();
|
||||
}
|
||||
|
||||
// ---- Lighting ----
|
||||
|
||||
_setupLighting() {
|
||||
this._ambient = new THREE.AmbientLight(0xccccdd, this.settings.ambient * 5.0);
|
||||
this._scene.add(this._ambient);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2);
|
||||
this._scene.add(hemi);
|
||||
|
||||
const key = new THREE.DirectionalLight(0xffeedd, 1.2);
|
||||
key.position.set(4, 8, 3);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(1024, 1024);
|
||||
key.shadow.camera.near = 0.5;
|
||||
key.shadow.camera.far = 20;
|
||||
key.shadow.camera.left = -8;
|
||||
key.shadow.camera.right = 8;
|
||||
key.shadow.camera.top = 8;
|
||||
key.shadow.camera.bottom = -8;
|
||||
this._scene.add(key);
|
||||
|
||||
// Fill light from opposite side
|
||||
const fill = new THREE.DirectionalLight(0x8899bb, 0.7);
|
||||
fill.position.set(-4, 5, -2);
|
||||
this._scene.add(fill);
|
||||
|
||||
// Rim light from above/behind for edge definition
|
||||
const rim = new THREE.DirectionalLight(0x6699cc, 0.5);
|
||||
rim.position.set(0, 6, -5);
|
||||
this._scene.add(rim);
|
||||
|
||||
// Overhead room light — general illumination
|
||||
const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0);
|
||||
overhead.position.set(0, 3.8, 0);
|
||||
this._scene.add(overhead);
|
||||
}
|
||||
|
||||
// ---- Room ----
|
||||
|
||||
_buildRoom() {
|
||||
this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818);
|
||||
this._grid.material.opacity = 0.5;
|
||||
this._grid.material.transparent = true;
|
||||
this._scene.add(this._grid);
|
||||
|
||||
const boxGeo = new THREE.BoxGeometry(12, 4, 10);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
|
||||
color: C.greenDim, opacity: 0.3, transparent: true,
|
||||
}));
|
||||
this._roomWire.position.y = 2;
|
||||
this._scene.add(this._roomWire);
|
||||
|
||||
// Reflective floor
|
||||
const floorGeo = new THREE.PlaneGeometry(12, 10);
|
||||
this._floorMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x101810,
|
||||
roughness: 1.0 - this.settings.reflect * 0.7,
|
||||
metalness: this.settings.reflect * 0.5,
|
||||
emissive: 0x020404,
|
||||
emissiveIntensity: 0.08,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, this._floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
this._scene.add(floor);
|
||||
|
||||
// Table under router
|
||||
const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5);
|
||||
const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 });
|
||||
const table = new THREE.Mesh(tableGeo, tableMat);
|
||||
table.position.set(-4, 0.3, -3);
|
||||
table.castShadow = true;
|
||||
this._scene.add(table);
|
||||
}
|
||||
|
||||
// ---- Router ----
|
||||
|
||||
_buildRouter() {
|
||||
this._routerGroup = new THREE.Group();
|
||||
this._routerGroup.position.set(-4, 0.92, -3);
|
||||
|
||||
const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35);
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 });
|
||||
this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat));
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35);
|
||||
const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
const ant = new THREE.Mesh(antGeo, antMat);
|
||||
ant.position.set(i * 0.2, 0.24, 0);
|
||||
ant.rotation.z = i * 0.15;
|
||||
this._routerGroup.add(ant);
|
||||
}
|
||||
|
||||
const ledGeo = new THREE.SphereGeometry(0.025);
|
||||
this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow }));
|
||||
this._routerLed.position.set(0.22, 0.07, 0.18);
|
||||
this._routerGroup.add(this._routerLed);
|
||||
|
||||
this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8);
|
||||
this._routerLight.position.set(0, 0.3, 0);
|
||||
this._routerGroup.add(this._routerLight);
|
||||
|
||||
this._scene.add(this._routerGroup);
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_buildWifiWaves() {
|
||||
this._wifiWaves = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const radius = 0.8 + i * 1.0;
|
||||
const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: C.blueSignal,
|
||||
transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, wireframe: true,
|
||||
});
|
||||
const shell = new THREE.Mesh(geo, mat);
|
||||
shell.position.copy(this._routerGroup.position);
|
||||
shell.position.y += 0.5;
|
||||
this._scene.add(shell);
|
||||
this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DOT MATRIX MIST
|
||||
// ========================================
|
||||
|
||||
_buildDotMatrixMist() {
|
||||
const COUNT = 800;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const alphas = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.random() * 0.5;
|
||||
positions[i * 3] = Math.cos(angle) * r;
|
||||
positions[i * 3 + 1] = Math.random() * 1.8;
|
||||
positions[i * 3 + 2] = Math.sin(angle) * r;
|
||||
alphas[i] = 0;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float alpha;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = 3.0 * (200.0 / -mv.z);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float edge = smoothstep(0.5, 0.2, d);
|
||||
gl_FragColor = vec4(uColor, edge * vAlpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._mistPoints = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._mistPoints);
|
||||
this._mistCount = COUNT;
|
||||
}
|
||||
|
||||
// ---- Particle Trail ----
|
||||
|
||||
_buildParticleTrail() {
|
||||
const COUNT = 200;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const ages = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) ages[i] = 1;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('age', new THREE.BufferAttribute(ages, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float age;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
vAge = age;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z));
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d);
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._trail = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._trail);
|
||||
this._trailHead = 0;
|
||||
this._trailCount = COUNT;
|
||||
this._trailTimer = 0;
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_buildSignalField() {
|
||||
const gridSize = 20;
|
||||
const count = gridSize * gridSize;
|
||||
const positions = new Float32Array(count * 3);
|
||||
this._fieldColors = new Float32Array(count * 3);
|
||||
this._fieldSizes = new Float32Array(count);
|
||||
for (let iz = 0; iz < gridSize; iz++) {
|
||||
for (let ix = 0; ix < gridSize; ix++) {
|
||||
const idx = iz * gridSize + ix;
|
||||
positions[idx * 3] = (ix - gridSize / 2) * 0.6;
|
||||
positions[idx * 3 + 1] = 0.02;
|
||||
positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5;
|
||||
this._fieldSizes[idx] = 8;
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3));
|
||||
geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1));
|
||||
this._fieldMat = new THREE.PointsMaterial({
|
||||
size: 0.35, vertexColors: true, transparent: true,
|
||||
opacity: this.settings.field, blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, sizeAttenuation: true,
|
||||
});
|
||||
this._fieldPoints = new THREE.Points(geo, this._fieldMat);
|
||||
this._scene.add(this._fieldPoints);
|
||||
}
|
||||
|
||||
// ---- Keyboard ----
|
||||
|
||||
_initKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this._hud.settingsOpen) return;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
this._autopilot = !this._autopilot;
|
||||
this._controls.enabled = !this._autopilot;
|
||||
break;
|
||||
case 'd': this._demoData.cycleScenario(); break;
|
||||
case 'f':
|
||||
this._showFps = !this._showFps;
|
||||
document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none';
|
||||
break;
|
||||
case 's': this._hud.toggleSettings(); break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this._demoData.paused = !this._demoData.paused;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Settings / HUD methods delegated to HudController ----
|
||||
|
||||
_applyPostSettings() {
|
||||
const pp = this._postProcessing;
|
||||
pp._bloomPass.strength = this.settings.bloom;
|
||||
pp._bloomPass.radius = this.settings.bloomRadius;
|
||||
pp._bloomPass.threshold = this.settings.bloomThresh;
|
||||
pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette;
|
||||
pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain;
|
||||
pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic;
|
||||
}
|
||||
|
||||
_applyColors() {
|
||||
const wc = new THREE.Color(this.settings.wireColor);
|
||||
const jc = new THREE.Color(this.settings.jointColor);
|
||||
this._figurePool.applyColors(wc, jc);
|
||||
this._mistPoints.material.uniforms.uColor.value.copy(wc);
|
||||
}
|
||||
|
||||
// ---- WebSocket live data ----
|
||||
|
||||
_autoDetectLive() {
|
||||
// Probe sensing server health on same origin, then common ports
|
||||
const host = window.location.hostname || 'localhost';
|
||||
const candidates = [
|
||||
window.location.origin, // same origin (e.g. :3000)
|
||||
`http://${host}:8765`, // default WS port
|
||||
`http://${host}:3000`, // default HTTP port
|
||||
];
|
||||
// Deduplicate
|
||||
const unique = [...new Set(candidates)];
|
||||
|
||||
const tryNext = (i) => {
|
||||
if (i >= unique.length) {
|
||||
console.log('[Observatory] No sensing server detected, using demo mode');
|
||||
return;
|
||||
}
|
||||
const base = unique[i];
|
||||
fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => {
|
||||
if (data && data.status === 'ok') {
|
||||
const wsProto = base.startsWith('https') ? 'wss:' : 'ws:';
|
||||
const urlObj = new URL(base);
|
||||
const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`;
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => tryNext(i + 1));
|
||||
};
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
_connectWS(url) {
|
||||
this._disconnectWS();
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
};
|
||||
this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} };
|
||||
this._ws.onclose = () => {
|
||||
console.log('[Observatory] WebSocket closed, falling back to demo');
|
||||
this._ws = null;
|
||||
this.settings.dataSource = 'demo';
|
||||
this._hud.updateSourceBadge('demo', null);
|
||||
};
|
||||
this._ws.onerror = () => {};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_disconnectWS() {
|
||||
if (this._ws) { this._ws.close(); this._ws = null; }
|
||||
this._liveData = null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ANIMATION LOOP
|
||||
// ========================================
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(() => this._animate());
|
||||
const dt = Math.min(this._clock.getDelta(), 0.1);
|
||||
const elapsed = this._clock.getElapsedTime();
|
||||
|
||||
// Data source
|
||||
if (this.settings.dataSource === 'ws' && this._liveData) {
|
||||
this._currentData = this._liveData;
|
||||
} else {
|
||||
this._currentData = this._demoData.update(dt);
|
||||
}
|
||||
const data = this._currentData;
|
||||
|
||||
// Updates
|
||||
this._nebula.update(dt, elapsed);
|
||||
this._figurePool.update(data, elapsed);
|
||||
this._scenarioProps.update(data, this._demoData.currentScenario);
|
||||
this._updateDotMatrixMist(data, elapsed);
|
||||
this._updateParticleTrail(data, dt, elapsed);
|
||||
this._updateWifiWaves(elapsed);
|
||||
this._updateSignalField(data);
|
||||
this._hud.updateHUD(data, this._demoData);
|
||||
this._hud.updateSparkline(data);
|
||||
|
||||
// Router LED
|
||||
this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8);
|
||||
this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3);
|
||||
|
||||
// Autopilot orbit
|
||||
if (this._autopilot) {
|
||||
this._autoAngle += dt * this.settings.orbitSpeed;
|
||||
const r = 10;
|
||||
this._camera.position.set(
|
||||
Math.sin(this._autoAngle) * r,
|
||||
4.5 + Math.sin(this._autoAngle * 0.5),
|
||||
Math.cos(this._autoAngle) * r
|
||||
);
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
}
|
||||
this._controls.update();
|
||||
this._postProcessing.update(elapsed);
|
||||
this._postProcessing.render();
|
||||
this._updateFPS(dt);
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// MIST & TRAIL
|
||||
// ========================================
|
||||
|
||||
_updateDotMatrixMist(data, elapsed) {
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._mistPoints.geometry.attributes.position;
|
||||
const alpha = this._mistPoints.geometry.attributes.alpha;
|
||||
|
||||
if (!isPresent || persons.length === 0) {
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
alpha.array[i] = Math.max(0, alpha.array[i] - 0.02);
|
||||
}
|
||||
alpha.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Follow primary person
|
||||
const pp = persons[0].position || [0, 0, 0];
|
||||
const px = pp[0] || 0, pz = pp[2] || 0;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const pose = persons[0].pose || 'standing';
|
||||
const isLying = pose === 'lying' || pose === 'fallen';
|
||||
const bodyH = isLying ? 0.4 : 1.7;
|
||||
const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05;
|
||||
const spread = ms > 50 ? 0.6 : 0.4;
|
||||
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003;
|
||||
const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1;
|
||||
const layerT = (i % 20) / 20;
|
||||
const layerY = bodyBaseY + layerT * bodyH;
|
||||
|
||||
let bodyWidth;
|
||||
if (isLying) {
|
||||
bodyWidth = 0.25;
|
||||
} else {
|
||||
bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18);
|
||||
}
|
||||
const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread;
|
||||
|
||||
const tx = px + Math.cos(angle + i * 0.3) * r + drift;
|
||||
const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6;
|
||||
|
||||
pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05;
|
||||
pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05;
|
||||
pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05;
|
||||
|
||||
const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08;
|
||||
alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
alpha.needsUpdate = true;
|
||||
}
|
||||
|
||||
_updateParticleTrail(data, dt, elapsed) {
|
||||
if (this.settings.trail <= 0) return;
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._trail.geometry.attributes.position;
|
||||
const ages = this._trail.geometry.attributes.age;
|
||||
|
||||
for (let i = 0; i < this._trailCount; i++) {
|
||||
ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8);
|
||||
}
|
||||
|
||||
// Emit from all active persons
|
||||
if (isPresent && persons.length > 0) {
|
||||
this._trailTimer += dt;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const emitRate = ms > 50 ? 0.02 : 0.08;
|
||||
|
||||
if (this._trailTimer >= emitRate) {
|
||||
this._trailTimer = 0;
|
||||
for (const p of persons) {
|
||||
const pp = p.position || [0, 0, 0];
|
||||
const idx = this._trailHead;
|
||||
pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1;
|
||||
pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
ages.array[idx] = 0;
|
||||
this._trailHead = (this._trailHead + 1) % this._trailCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
ages.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_updateWifiWaves(elapsed) {
|
||||
for (const w of this._wifiWaves) {
|
||||
const t = (elapsed * 0.8 + w.phase) % 4.5;
|
||||
const life = t / 4.5;
|
||||
w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life));
|
||||
const scale = 1 + life * 0.6;
|
||||
w.mesh.scale.set(scale, scale, scale);
|
||||
w.mesh.rotation.y = elapsed * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_updateSignalField(data) {
|
||||
const field = data?.signal_field?.values;
|
||||
if (!field) return;
|
||||
const count = Math.min(field.length, 400);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = field[i] || 0;
|
||||
let r, g, b;
|
||||
if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; }
|
||||
else if (v < 0.6) {
|
||||
const t = (v - 0.3) / 0.3;
|
||||
r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05;
|
||||
} else {
|
||||
const t = (v - 0.6) / 0.4;
|
||||
r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04;
|
||||
}
|
||||
this._fieldColors[i * 3] = r;
|
||||
this._fieldColors[i * 3 + 1] = g;
|
||||
this._fieldColors[i * 3 + 2] = b;
|
||||
this._fieldSizes[i] = 5 + v * 15;
|
||||
}
|
||||
this._fieldPoints.geometry.attributes.color.needsUpdate = true;
|
||||
this._fieldPoints.geometry.attributes.size.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- FPS ----
|
||||
|
||||
_updateFPS(dt) {
|
||||
this._fpsFrames++;
|
||||
this._fpsTime += dt;
|
||||
if (this._fpsTime >= 1) {
|
||||
this._fpsValue = Math.round(this._fpsFrames / this._fpsTime);
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
if (this._showFps) {
|
||||
document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`;
|
||||
}
|
||||
this._adaptQuality();
|
||||
}
|
||||
}
|
||||
|
||||
_adaptQuality() {
|
||||
let nl = this._qualityLevel;
|
||||
if (this._fpsValue < 25 && nl > 0) nl--;
|
||||
else if (this._fpsValue > 55 && nl < 2) nl++;
|
||||
if (nl !== this._qualityLevel) {
|
||||
this._qualityLevel = nl;
|
||||
this._nebula.setQuality(nl);
|
||||
this._postProcessing.setQuality(nl);
|
||||
}
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
this._camera.aspect = w / h;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._renderer.setSize(w, h);
|
||||
this._postProcessing.resize(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
new Observatory();
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Room Atmosphere Background — Warm dark gradient with subtle particles
|
||||
* Matches RuView Foundation aesthetic: deep blue-black with warm undertones
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BG_VERTEX = `
|
||||
varying vec3 vWorldPos;
|
||||
void main() {
|
||||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BG_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform float uOctaves;
|
||||
varying vec3 vWorldPos;
|
||||
|
||||
vec3 hash33(vec3 p) {
|
||||
p = fract(p * vec3(443.8975, 397.2973, 491.1871));
|
||||
p += dot(p, p.yxz + 19.19);
|
||||
return fract(vec3(p.x * p.y, p.y * p.z, p.z * p.x));
|
||||
}
|
||||
|
||||
float noise3d(vec3 p) {
|
||||
vec3 i = floor(p);
|
||||
vec3 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float n = mix(
|
||||
mix(mix(dot(hash33(i), f), dot(hash33(i + vec3(1,0,0)), f - vec3(1,0,0)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(hash33(i + vec3(1,1,0)), f - vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(dot(hash33(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(hash33(i + vec3(1,0,1)), f - vec3(1,0,1)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(hash33(i + vec3(1,1,1)), f - vec3(1,1,1)), f.x), f.y),
|
||||
f.z);
|
||||
return n * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float fbm(vec3 p, float octaves) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (float i = 0.0; i < 5.0; i++) {
|
||||
if (i >= octaves) break;
|
||||
v += a * noise3d(p);
|
||||
p *= 2.0;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 dir = normalize(vWorldPos);
|
||||
|
||||
// Warm dark atmosphere with subtle color variation
|
||||
float n1 = fbm(dir * 2.5 + uTime * 0.008, uOctaves);
|
||||
float n2 = fbm(dir * 4.0 - uTime * 0.005, max(1.0, uOctaves - 1.0));
|
||||
|
||||
// Foundation palette: deep blue-black with warm undertones
|
||||
vec3 deepBlack = vec3(0.03, 0.04, 0.06);
|
||||
vec3 warmNavy = vec3(0.04, 0.05, 0.10);
|
||||
vec3 greenTint = vec3(0.01, 0.06, 0.04);
|
||||
|
||||
vec3 bg = mix(deepBlack, warmNavy, n1 * 0.5);
|
||||
bg = mix(bg, greenTint, n2 * 0.15);
|
||||
|
||||
// Subtle top-down gradient (lighter ceiling)
|
||||
float upFactor = max(0.0, dir.y) * 0.08;
|
||||
bg += vec3(0.02, 0.03, 0.05) * upFactor;
|
||||
|
||||
// Very subtle dim stars (distant)
|
||||
vec3 c = floor(dir * 200.0);
|
||||
vec3 h = hash33(c);
|
||||
float star = step(0.998, h.x) * h.y * 0.15;
|
||||
star *= 0.7 + 0.3 * sin(uTime * 1.5 + h.z * 80.0);
|
||||
bg += vec3(0.6, 0.7, 0.8) * star;
|
||||
|
||||
gl_FragColor = vec4(bg, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export class NebulaBackground {
|
||||
constructor(scene) {
|
||||
this._octaves = 4;
|
||||
|
||||
this.uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uOctaves: { value: this._octaves },
|
||||
};
|
||||
|
||||
const geo = new THREE.SphereGeometry(150, 32, 32);
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: BG_VERTEX,
|
||||
fragmentShader: BG_FRAGMENT,
|
||||
uniforms: this.uniforms,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(geo, mat);
|
||||
scene.add(this.mesh);
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
setQuality(level) {
|
||||
this._octaves = [2, 3, 4][level] || 4;
|
||||
this.uniforms.uOctaves.value = this._octaves;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.mesh.geometry.dispose();
|
||||
this.mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Module D — "The Phase Constellation"
|
||||
* I/Q star map with constellation lines and rotating temporal view
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const NUM_SUBCARRIERS = 64;
|
||||
|
||||
export class PhaseConstellation {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Star points (current frame)
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
this._positions = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._colors = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._sizes = new Float32Array(NUM_SUBCARRIERS);
|
||||
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(this._positions, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(this._colors, 3));
|
||||
starGeo.setAttribute('size', new THREE.BufferAttribute(this._sizes, 1));
|
||||
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 0.12,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._stars = new THREE.Points(starGeo, starMat);
|
||||
this.group.add(this._stars);
|
||||
|
||||
// Ghost layer (previous frame)
|
||||
const ghostGeo = new THREE.BufferGeometry();
|
||||
this._ghostPos = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
ghostGeo.setAttribute('position', new THREE.BufferAttribute(this._ghostPos, 3));
|
||||
|
||||
const ghostMat = new THREE.PointsMaterial({
|
||||
color: 0x00d4ff,
|
||||
size: 0.06,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._ghosts = new THREE.Points(ghostGeo, ghostMat);
|
||||
this.group.add(this._ghosts);
|
||||
|
||||
// Constellation lines (connecting adjacent subcarriers)
|
||||
const lineGeo = new THREE.BufferGeometry();
|
||||
this._linePos = new Float32Array(NUM_SUBCARRIERS * 2 * 3); // pairs
|
||||
lineGeo.setAttribute('position', new THREE.BufferAttribute(this._linePos, 3));
|
||||
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._lines = new THREE.LineSegments(lineGeo, lineMat);
|
||||
this.group.add(this._lines);
|
||||
|
||||
// Axes
|
||||
this._addAxes();
|
||||
|
||||
this._prevIQ = null;
|
||||
}
|
||||
|
||||
_addAxes() {
|
||||
const axesMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.1,
|
||||
});
|
||||
|
||||
// I axis
|
||||
const iGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-2.5, 0, 0),
|
||||
new THREE.Vector3(2.5, 0, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(iGeo, axesMat));
|
||||
|
||||
// Q axis
|
||||
const qGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, -2.5, 0),
|
||||
new THREE.Vector3(0, 2.5, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(qGeo, axesMat));
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const iq = data?._observatory?.subcarrier_iq;
|
||||
const variance = data?._observatory?.per_subcarrier_variance;
|
||||
const amplitude = data?.nodes?.[0]?.amplitude;
|
||||
|
||||
// Slow Y rotation for temporal evolution
|
||||
this.group.rotation.y = elapsed * 0.05;
|
||||
|
||||
if (!iq || iq.length < NUM_SUBCARRIERS) return;
|
||||
|
||||
// Copy current to ghost
|
||||
this._ghostPos.set(this._positions);
|
||||
this._ghosts.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Update current positions from I/Q
|
||||
for (let s = 0; s < NUM_SUBCARRIERS; s++) {
|
||||
const i3 = s * 3;
|
||||
const iVal = (iq[s]?.i || 0) * 4; // scale for visibility
|
||||
const qVal = (iq[s]?.q || 0) * 4;
|
||||
|
||||
this._positions[i3] = iVal;
|
||||
this._positions[i3 + 1] = qVal;
|
||||
this._positions[i3 + 2] = 0;
|
||||
|
||||
// Size from amplitude
|
||||
const amp = amplitude ? (amplitude[s % amplitude.length] || 0.1) : 0.1;
|
||||
this._sizes[s] = 0.06 + amp * 0.15;
|
||||
|
||||
// Color from variance: blue(low) -> amber(high)
|
||||
const v = variance ? Math.min(1, (variance[s] || 0) * 2) : 0;
|
||||
this._colors[i3] = v * 1.0; // R
|
||||
this._colors[i3 + 1] = 0.5 + v * 0.3; // G
|
||||
this._colors[i3 + 2] = 1.0 - v * 0.7; // B
|
||||
}
|
||||
|
||||
this._stars.geometry.attributes.position.needsUpdate = true;
|
||||
this._stars.geometry.attributes.color.needsUpdate = true;
|
||||
this._stars.geometry.attributes.size.needsUpdate = true;
|
||||
|
||||
// Update constellation lines
|
||||
for (let s = 0; s < NUM_SUBCARRIERS - 1; s++) {
|
||||
const li = s * 6;
|
||||
const i3a = s * 3;
|
||||
const i3b = (s + 1) * 3;
|
||||
|
||||
this._linePos[li] = this._positions[i3a];
|
||||
this._linePos[li + 1] = this._positions[i3a + 1];
|
||||
this._linePos[li + 2] = this._positions[i3a + 2];
|
||||
this._linePos[li + 3] = this._positions[i3b];
|
||||
this._linePos[li + 4] = this._positions[i3b + 1];
|
||||
this._linePos[li + 5] = this._positions[i3b + 2];
|
||||
}
|
||||
// Last pair: wrap around
|
||||
const lastLi = (NUM_SUBCARRIERS - 1) * 6;
|
||||
const lastI3 = (NUM_SUBCARRIERS - 1) * 3;
|
||||
this._linePos[lastLi] = this._positions[lastI3];
|
||||
this._linePos[lastLi + 1] = this._positions[lastI3 + 1];
|
||||
this._linePos[lastLi + 2] = this._positions[lastI3 + 2];
|
||||
this._linePos[lastLi + 3] = this._positions[0];
|
||||
this._linePos[lastLi + 4] = this._positions[1];
|
||||
this._linePos[lastLi + 5] = this._positions[2];
|
||||
|
||||
this._lines.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._stars.geometry.dispose();
|
||||
this._stars.material.dispose();
|
||||
this._ghosts.geometry.dispose();
|
||||
this._ghosts.material.dispose();
|
||||
this._lines.geometry.dispose();
|
||||
this._lines.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* PoseSystem -- Stateless pose keypoint generator for COCO 17-keypoint format.
|
||||
*
|
||||
* Keypoint indices:
|
||||
* 0:nose 1:left_eye 2:right_eye 3:left_ear 4:right_ear
|
||||
* 5:left_shoulder 6:right_shoulder 7:left_elbow 8:right_elbow
|
||||
* 9:left_wrist 10:right_wrist 11:left_hip 12:right_hip
|
||||
* 13:left_knee 14:right_knee 15:left_ankle 16:right_ankle
|
||||
*
|
||||
* Every public method is a pure function: parameters in, keypoint array out.
|
||||
*/
|
||||
|
||||
export class PoseSystem {
|
||||
|
||||
// ---- Entry point -------------------------------------------------------
|
||||
|
||||
generateKeypoints(person, elapsed, breathPulse) {
|
||||
const pose = person.pose || 'standing';
|
||||
const pos = person.position || [0, 0, 0];
|
||||
const facing = person.facing || 0;
|
||||
const px = pos[0], pz = pos[2];
|
||||
const ms = person.motion_score || 0;
|
||||
const bp = breathPulse;
|
||||
|
||||
let kps;
|
||||
switch (pose) {
|
||||
case 'lying': kps = this.poseLying(px, pos[1] || 0, pz, elapsed, bp); break;
|
||||
case 'sitting': kps = this.poseSitting(px, pz, elapsed, bp); break;
|
||||
case 'fallen': kps = this.poseFallen(px, pz, elapsed); break;
|
||||
case 'falling': kps = this.poseFalling(px, pz, elapsed, person.fallProgress || 0); break;
|
||||
case 'exercising': kps = this.poseExercising(px, pz, elapsed, person.exerciseType, person.exerciseTime); break;
|
||||
case 'gesturing': kps = this.poseGesturing(px, pz, elapsed, person.gestureType, person.gestureIntensity || 0); break;
|
||||
case 'crouching': kps = this.poseCrouching(px, pz, elapsed, bp); break;
|
||||
case 'walking': kps = this.poseWalking(px, pz, elapsed, ms, bp); break;
|
||||
case 'standing':
|
||||
default: kps = this.poseStanding(px, pz, elapsed, ms, bp); break;
|
||||
}
|
||||
|
||||
// Apply facing rotation
|
||||
if (Math.abs(facing) > 0.01) {
|
||||
this.rotateKps(kps, px, pz, facing);
|
||||
}
|
||||
return kps;
|
||||
}
|
||||
|
||||
// ---- Rotation utility --------------------------------------------------
|
||||
|
||||
rotateKps(kps, cx, cz, angle) {
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle);
|
||||
for (const kp of kps) {
|
||||
const dx = kp[0] - cx, dz = kp[2] - cz;
|
||||
kp[0] = cx + dx * cos - dz * sin;
|
||||
kp[2] = cz + dx * sin + dz * cos;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standing ----------------------------------------------------------
|
||||
// Weight shift between feet, idle head look-around, breathing
|
||||
|
||||
poseStanding(px, pz, elapsed, ms, bp) {
|
||||
// Slow weight shift side to side
|
||||
const weightShift = Math.sin(elapsed * 0.6) * 0.012;
|
||||
// Idle head look around
|
||||
const headTurn = Math.sin(elapsed * 0.3) * 0.015;
|
||||
const headTilt = Math.cos(elapsed * 0.25) * 0.008;
|
||||
// Slight sway from micro-balance adjustments
|
||||
const sway = Math.sin(elapsed * 0.8) * 0.005 + weightShift;
|
||||
// Knee bend alternation with weight shift
|
||||
const leftKneeBend = Math.max(0, Math.sin(elapsed * 0.6)) * 0.015;
|
||||
const rightKneeBend = Math.max(0, -Math.sin(elapsed * 0.6)) * 0.015;
|
||||
|
||||
return [
|
||||
[px + sway + headTurn, 1.72 + bp + headTilt, pz], // 0 nose
|
||||
[px - 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 2 right eye
|
||||
[px - 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 3 left ear
|
||||
[px + 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 4 right ear
|
||||
[px - 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 5 left shoulder
|
||||
[px + 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 6 right shoulder
|
||||
[px - 0.24 + weightShift * 0.2, 1.18 + bp, pz + 0.02], // 7 left elbow
|
||||
[px + 0.24 + weightShift * 0.2, 1.18 + bp, pz - 0.02], // 8 right elbow
|
||||
[px - 0.22 + weightShift * 0.15, 0.92 + bp, pz + 0.05], // 9 left wrist
|
||||
[px + 0.22 + weightShift * 0.15, 0.92 + bp, pz - 0.05], // 10 right wrist
|
||||
[px - 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 11 left hip
|
||||
[px + 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 12 right hip
|
||||
[px - 0.12 + weightShift * 0.3, 0.52 + leftKneeBend, pz], // 13 left knee
|
||||
[px + 0.12 + weightShift * 0.3, 0.52 + rightKneeBend, pz], // 14 right knee
|
||||
[px - 0.12 + weightShift * 0.4, 0.04, pz], // 15 left ankle
|
||||
[px + 0.12 + weightShift * 0.4, 0.04, pz], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Walking -----------------------------------------------------------
|
||||
// Torso rotation, head bob, natural arm pendulum with elbow bend
|
||||
|
||||
poseWalking(px, pz, elapsed, ms, bp) {
|
||||
const speed = Math.min(ms / 100, 2.5);
|
||||
const wp = elapsed * speed * 1.8;
|
||||
const sFactor = Math.min(speed, 1);
|
||||
|
||||
// Leg stride
|
||||
const legStride = Math.sin(wp) * 0.25 * sFactor;
|
||||
const legBack = Math.sin(wp + Math.PI) * 0.25 * sFactor;
|
||||
const kneeAmt = Math.abs(Math.sin(wp)) * 0.08;
|
||||
|
||||
// Natural arm pendulum -- opposite to legs, with elbow bend
|
||||
const armPhase = Math.sin(wp);
|
||||
const armSwingL = -armPhase * 0.3 * sFactor; // left arm opposite right leg
|
||||
const armSwingR = armPhase * 0.3 * sFactor;
|
||||
const elbowBendL = Math.max(0, -armPhase) * 0.12 * sFactor; // bend on backswing
|
||||
const elbowBendR = Math.max(0, armPhase) * 0.12 * sFactor;
|
||||
|
||||
// Torso twist (shoulders rotate opposite to hips)
|
||||
const torsoTwist = Math.sin(wp) * 0.03 * sFactor;
|
||||
|
||||
// Vertical bob (double frequency -- peak at mid-stance)
|
||||
const bob = Math.abs(Math.sin(wp)) * 0.025;
|
||||
|
||||
// Head bob -- slight lag behind body
|
||||
const headBob = Math.abs(Math.sin(wp - 0.2)) * 0.015;
|
||||
const headLean = Math.sin(wp) * 0.008;
|
||||
|
||||
return [
|
||||
[px + headLean, 1.72 + bp + bob + headBob, pz], // 0 nose
|
||||
[px - 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 2 right eye
|
||||
[px - 0.07, 1.72 + bp + bob + headBob, pz], // 3 left ear
|
||||
[px + 0.07, 1.72 + bp + bob + headBob, pz], // 4 right ear
|
||||
[px - 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 5 left shoulder (twist)
|
||||
[px + 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 6 right shoulder
|
||||
[px - 0.28 + armSwingL * 0.3, 1.18 + bp + bob - elbowBendL, pz + armSwingL * 0.3], // 7 left elbow
|
||||
[px + 0.28 + armSwingR * 0.3, 1.18 + bp + bob - elbowBendR, pz + armSwingR * 0.3], // 8 right elbow
|
||||
[px - 0.26 + armSwingL * 0.6, 0.92 + bp + bob - elbowBendL * 1.5, pz + armSwingL * 0.5], // 9 left wrist
|
||||
[px + 0.26 + armSwingR * 0.6, 0.92 + bp + bob - elbowBendR * 1.5, pz + armSwingR * 0.5], // 10 right wrist
|
||||
[px - 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 11 left hip (counter-twist)
|
||||
[px + 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 12 right hip
|
||||
[px - 0.12 + legStride * 0.3, 0.52 + kneeAmt, pz + legStride], // 13 left knee
|
||||
[px + 0.12 + legBack * 0.3, 0.52 + kneeAmt, pz + legBack], // 14 right knee
|
||||
[px - 0.12 + legStride * 0.6, 0.04, pz + legStride * 1.5], // 15 left ankle
|
||||
[px + 0.12 + legBack * 0.6, 0.04, pz + legBack * 1.5], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Lying -------------------------------------------------------------
|
||||
// Subtle micro-movements, differentiate supine vs side-lying via elapsed hash
|
||||
|
||||
poseLying(px, surfaceY, pz, elapsed, bp) {
|
||||
const y = (surfaceY || 0) + 0.2;
|
||||
const chest = bp * 0.015;
|
||||
|
||||
// Micro-movements -- tiny random-feeling shifts (deterministic from elapsed)
|
||||
const microX = Math.sin(elapsed * 0.17) * 0.004;
|
||||
const microZ = Math.cos(elapsed * 0.13) * 0.003;
|
||||
const fingerTwitch = Math.sin(elapsed * 0.7) * 0.008;
|
||||
|
||||
// Determine supine vs side-lying from a slow oscillation (stays one way for ~20s)
|
||||
const lyingMode = Math.sin(elapsed * 0.05);
|
||||
|
||||
if (lyingMode > 0.3) {
|
||||
// Side-lying (on left side)
|
||||
const curl = Math.sin(elapsed * 0.1) * 0.02; // slight fetal curl
|
||||
return [
|
||||
[px - 0.72 + microX, y + 0.12, pz - 0.08], // 0 nose (turned)
|
||||
[px - 0.70, y + 0.14, pz - 0.10], // 1 left eye
|
||||
[px - 0.70, y + 0.16, pz - 0.06], // 2 right eye (up)
|
||||
[px - 0.76, y + 0.11, pz - 0.12], // 3 left ear (down)
|
||||
[px - 0.76, y + 0.14, pz - 0.04], // 4 right ear
|
||||
[px - 0.45, y + chest + 0.05, pz - 0.12], // 5 left shoulder (down)
|
||||
[px - 0.45, y + chest + 0.2, pz + 0.04], // 6 right shoulder (up)
|
||||
[px - 0.38, y + 0.02, pz - 0.28 + curl], // 7 left elbow
|
||||
[px - 0.35, y + 0.18, pz + 0.15 + fingerTwitch], // 8 right elbow
|
||||
[px - 0.20, y - 0.01, pz - 0.30 + curl], // 9 left wrist
|
||||
[px - 0.18, y + 0.12, pz + 0.25 + fingerTwitch], // 10 right wrist
|
||||
[px + 0.05 + microX, y + chest * 0.4 + 0.03, pz - 0.08], // 11 left hip
|
||||
[px + 0.05 + microX, y + chest * 0.4 + 0.12, pz + 0.06], // 12 right hip
|
||||
[px + 0.40 + curl * 2, y + 0.02, pz - 0.14 + curl], // 13 left knee
|
||||
[px + 0.38 + curl * 2, y + 0.10, pz + 0.10 + curl], // 14 right knee
|
||||
[px + 0.75, y - 0.01, pz - 0.12], // 15 left ankle
|
||||
[px + 0.72, y + 0.04, pz + 0.08], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// Supine (face up) -- default
|
||||
return [
|
||||
[px - 0.75 + microX, y + 0.08, pz + microZ], // 0 nose
|
||||
[px - 0.72, y + 0.1, pz - 0.02 + microZ], // 1 left eye
|
||||
[px - 0.72, y + 0.1, pz + 0.02 + microZ], // 2 right eye
|
||||
[px - 0.78, y + 0.08, pz - 0.05], // 3 left ear
|
||||
[px - 0.78, y + 0.08, pz + 0.05], // 4 right ear
|
||||
[px - 0.45, y + chest, pz - 0.18], // 5 left shoulder
|
||||
[px - 0.45, y + chest, pz + 0.18], // 6 right shoulder
|
||||
[px - 0.42, y, pz - 0.35 + fingerTwitch], // 7 left elbow
|
||||
[px - 0.42, y, pz + 0.35 - fingerTwitch], // 8 right elbow
|
||||
[px - 0.2, y - 0.02, pz - 0.38 + fingerTwitch], // 9 left wrist
|
||||
[px - 0.2, y - 0.02, pz + 0.38 - fingerTwitch], // 10 right wrist
|
||||
[px + 0.05 + microX, y + chest * 0.5, pz - 0.1], // 11 left hip
|
||||
[px + 0.05 + microX, y + chest * 0.5, pz + 0.1], // 12 right hip
|
||||
[px + 0.45, y, pz - 0.11], // 13 left knee
|
||||
[px + 0.45, y, pz + 0.11], // 14 right knee
|
||||
[px + 0.82, y - 0.02, pz - 0.1], // 15 left ankle
|
||||
[px + 0.82, y - 0.02, pz + 0.1], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Sitting -----------------------------------------------------------
|
||||
// Occasional fidget, breathing chest expansion, weight shift
|
||||
|
||||
poseSitting(px, pz, elapsed, bp) {
|
||||
const sway = Math.sin(elapsed * 0.5) * 0.003;
|
||||
|
||||
// Fidget: occasional hand movement (every ~6s a small gesture)
|
||||
const fidgetCycle = elapsed % 6.0;
|
||||
const fidgetActive = fidgetCycle > 5.2 && fidgetCycle < 5.8;
|
||||
const fidgetAmt = fidgetActive ? Math.sin((fidgetCycle - 5.2) * Math.PI / 0.6) * 0.06 : 0;
|
||||
|
||||
// Weight shift side to side (slow)
|
||||
const weightShift = Math.sin(elapsed * 0.25) * 0.008;
|
||||
|
||||
// Chest expansion from breathing
|
||||
const chestExpand = bp * 0.008;
|
||||
|
||||
return [
|
||||
[px + sway + weightShift, 1.15 + bp, pz], // 0 nose
|
||||
[px - 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 1 left eye
|
||||
[px + 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 2 right eye
|
||||
[px - 0.07 + weightShift, 1.15 + bp, pz], // 3 left ear
|
||||
[px + 0.07 + weightShift, 1.15 + bp, pz], // 4 right ear
|
||||
[px - 0.20 - chestExpand + weightShift, 0.95 + bp, pz], // 5 left shoulder
|
||||
[px + 0.20 + chestExpand + weightShift, 0.95 + bp, pz], // 6 right shoulder
|
||||
[px - 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 7 left elbow
|
||||
[px + 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 8 right elbow
|
||||
[px - 0.18 + fidgetAmt, 0.55 + fidgetAmt * 0.3, pz + 0.15], // 9 left wrist (fidgets)
|
||||
[px + 0.18, 0.55, pz + 0.15], // 10 right wrist
|
||||
[px - 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 11 left hip
|
||||
[px + 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 12 right hip
|
||||
[px - 0.12, 0.48, pz + 0.4], // 13 left knee
|
||||
[px + 0.12, 0.48, pz + 0.4], // 14 right knee
|
||||
[px - 0.12, 0.04, pz + 0.4], // 15 left ankle
|
||||
[px + 0.12, 0.04, pz + 0.4], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Fallen ------------------------------------------------------------
|
||||
// Occasional twitch/attempt to move, asymmetric breathing
|
||||
|
||||
poseFallen(px, pz, elapsed) {
|
||||
// Irregular twitch -- sharper, less periodic
|
||||
const twitchArm = Math.sin(elapsed * 0.3) * 0.003 +
|
||||
Math.sin(elapsed * 1.7) * 0.008 * Math.max(0, Math.sin(elapsed * 0.15));
|
||||
const twitchLeg = Math.cos(elapsed * 0.4) * 0.005 *
|
||||
Math.max(0, Math.sin(elapsed * 0.2 + 1.0));
|
||||
|
||||
// Asymmetric breathing (one side of chest rises more)
|
||||
const breathL = Math.sin(elapsed * 0.8) * 0.006;
|
||||
const breathR = Math.sin(elapsed * 0.8 + 0.3) * 0.004;
|
||||
|
||||
// Attempt to move (slow reach every ~10s)
|
||||
const attemptCycle = elapsed % 10.0;
|
||||
const attempting = attemptCycle > 8.0 && attemptCycle < 9.5;
|
||||
const attemptAmt = attempting ? Math.sin((attemptCycle - 8.0) * Math.PI / 1.5) * 0.05 : 0;
|
||||
|
||||
return [
|
||||
[px + 0.35, 0.12, pz + 0.15 + twitchArm], // 0 nose
|
||||
[px + 0.33, 0.14, pz + 0.13], // 1 left eye
|
||||
[px + 0.37, 0.14, pz + 0.17], // 2 right eye
|
||||
[px + 0.38, 0.11, pz + 0.1], // 3 left ear
|
||||
[px + 0.38, 0.11, pz + 0.2], // 4 right ear
|
||||
[px + 0.15, 0.15 + breathL, pz - 0.1], // 5 left shoulder
|
||||
[px + 0.15, 0.2 + breathR, pz + 0.25], // 6 right shoulder
|
||||
[px - 0.05, 0.08, pz - 0.25 + twitchArm], // 7 left elbow
|
||||
[px + 0.3, 0.22 + attemptAmt * 0.5, pz + 0.45 + attemptAmt], // 8 right elbow (reaching)
|
||||
[px - 0.15, 0.05, pz - 0.3 + twitchArm * 1.5], // 9 left wrist
|
||||
[px + 0.4, 0.15 + attemptAmt, pz + 0.5 + attemptAmt * 1.5], // 10 right wrist (reaching)
|
||||
[px - 0.05, 0.12, pz - 0.05], // 11 left hip
|
||||
[px - 0.05, 0.12, pz + 0.15], // 12 right hip
|
||||
[px - 0.2, 0.08 + twitchLeg, pz - 0.3], // 13 left knee
|
||||
[px - 0.15, 0.15, pz + 0.35 + twitchLeg], // 14 right knee
|
||||
[px - 0.35, 0.04, pz - 0.2], // 15 left ankle
|
||||
[px - 0.3, 0.04, pz + 0.5], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Falling -----------------------------------------------------------
|
||||
// Flailing arms, head snap, non-linear easing (cubic ease-in)
|
||||
|
||||
poseFalling(px, pz, elapsed, progress) {
|
||||
const standing = this.poseStanding(px, pz, elapsed, 0, 0);
|
||||
const fallen = this.poseFallen(px, pz, elapsed);
|
||||
|
||||
// Cubic ease-in for realistic acceleration
|
||||
const t = progress * progress * progress;
|
||||
|
||||
// Arm flailing -- sinusoidal perturbation that peaks mid-fall then diminishes
|
||||
const flailIntensity = Math.sin(progress * Math.PI) * 0.15;
|
||||
const flailL = Math.sin(elapsed * 8 + progress * 5) * flailIntensity;
|
||||
const flailR = Math.cos(elapsed * 8 + progress * 5) * flailIntensity;
|
||||
|
||||
// Head snaps back early in the fall
|
||||
const headSnap = progress < 0.4 ? Math.sin(progress * Math.PI / 0.4) * 0.06 : 0;
|
||||
|
||||
const kps = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
kps.push([
|
||||
standing[i][0] * (1 - t) + fallen[i][0] * t,
|
||||
standing[i][1] * (1 - t) + fallen[i][1] * t,
|
||||
standing[i][2] * (1 - t) + fallen[i][2] * t,
|
||||
]);
|
||||
}
|
||||
|
||||
// Apply head snap (tilt backward)
|
||||
kps[0][1] += headSnap;
|
||||
kps[1][1] += headSnap * 0.9;
|
||||
kps[2][1] += headSnap * 0.9;
|
||||
|
||||
// Apply arm flailing
|
||||
kps[7][0] += flailL; kps[7][2] += flailL * 0.5; // left elbow
|
||||
kps[8][0] += flailR; kps[8][2] -= flailR * 0.5; // right elbow
|
||||
kps[9][0] += flailL * 1.5; kps[9][2] += flailL; // left wrist
|
||||
kps[10][0] += flailR * 1.5; kps[10][2] -= flailR; // right wrist
|
||||
|
||||
return kps;
|
||||
}
|
||||
|
||||
// ---- Exercising --------------------------------------------------------
|
||||
|
||||
poseExercising(px, pz, elapsed, exerciseType, exerciseTime) {
|
||||
const et = exerciseTime || elapsed;
|
||||
|
||||
if (exerciseType === 'squats') {
|
||||
return this._poseSquats(px, pz, et);
|
||||
}
|
||||
return this._poseJumpingJacks(px, pz, et);
|
||||
}
|
||||
|
||||
// Squats: forward lean, hip hinge, arm counterbalance, depth variation
|
||||
|
||||
_poseSquats(px, pz, et) {
|
||||
const rawPhase = (Math.sin(et * 2.5) + 1) / 2; // 0=up, 1=down
|
||||
// Depth variation -- every other rep is shallower
|
||||
const repIndex = Math.floor(et * 2.5 / Math.PI);
|
||||
const depthMod = (repIndex % 2 === 0) ? 1.0 : 0.7;
|
||||
const phase = rawPhase * depthMod;
|
||||
|
||||
const squat = phase * 0.5;
|
||||
const armFwd = phase * 0.4;
|
||||
// Forward lean increases with squat depth
|
||||
const forwardLean = phase * 0.08;
|
||||
// Hip hinge -- hips push back
|
||||
const hipBack = phase * 0.12;
|
||||
|
||||
return [
|
||||
[px + forwardLean * 0.3, 1.72 - squat, pz + forwardLean], // 0 nose
|
||||
[px - 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 1 left eye
|
||||
[px + 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 2 right eye
|
||||
[px - 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 3 left ear
|
||||
[px + 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 4 right ear
|
||||
[px - 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 5 left shoulder
|
||||
[px + 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 6 right shoulder
|
||||
[px - 0.22, 1.25 - squat * 0.7, pz + armFwd], // 7 left elbow
|
||||
[px + 0.22, 1.25 - squat * 0.7, pz + armFwd], // 8 right elbow
|
||||
[px - 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 9 left wrist (counterbalance)
|
||||
[px + 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 10 right wrist
|
||||
[px - 0.11, 0.98 - squat * 0.3, pz - hipBack], // 11 left hip (pushed back)
|
||||
[px + 0.11, 0.98 - squat * 0.3, pz - hipBack], // 12 right hip
|
||||
[px - 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 13 left knee
|
||||
[px + 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 14 right knee
|
||||
[px - 0.13, 0.04, pz + 0.05], // 15 left ankle
|
||||
[px + 0.13, 0.04, pz + 0.05], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// Jumping jacks: full arm arc, hip sway, landing impact
|
||||
|
||||
_poseJumpingJacks(px, pz, et) {
|
||||
const rawPhase = (Math.sin(et * 3) + 1) / 2; // 0=closed, 1=open
|
||||
const phase = rawPhase;
|
||||
|
||||
// Full arm arc -- from sides to overhead in a smooth arc
|
||||
const armAngle = phase * Math.PI * 0.85; // 0 to ~153 degrees
|
||||
const armX = Math.sin(armAngle) * 0.55; // lateral spread
|
||||
const armY = Math.cos(armAngle) * 0.55; // vertical component
|
||||
|
||||
const legSpread = phase * 0.25;
|
||||
// Landing impact -- brief compression at bottom of cycle
|
||||
const impact = Math.max(0, -Math.sin(et * 3)) * 0.03;
|
||||
const jump = Math.max(0, Math.sin(et * 3)) * 0.06;
|
||||
// Hip sway at apex
|
||||
const hipSway = Math.sin(et * 3) * 0.015;
|
||||
|
||||
return [
|
||||
[px, 1.72 + jump - impact, pz], // 0 nose
|
||||
[px - 0.03, 1.74 + jump - impact, pz - 0.02], // 1 left eye
|
||||
[px + 0.03, 1.74 + jump - impact, pz - 0.02], // 2 right eye
|
||||
[px - 0.07, 1.72 + jump - impact, pz], // 3 left ear
|
||||
[px + 0.07, 1.72 + jump - impact, pz], // 4 right ear
|
||||
[px - 0.22, 1.48 + jump - impact, pz], // 5 left shoulder
|
||||
[px + 0.22, 1.48 + jump - impact, pz], // 6 right shoulder
|
||||
[px - 0.22 - armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 7 left elbow (arc)
|
||||
[px + 0.22 + armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 8 right elbow
|
||||
[px - 0.22 - armX, 1.48 - armY + 0.55 + jump, pz], // 9 left wrist (arc)
|
||||
[px + 0.22 + armX, 1.48 - armY + 0.55 + jump, pz], // 10 right wrist
|
||||
[px - 0.11 + hipSway, 0.98 + jump - impact, pz], // 11 left hip
|
||||
[px + 0.11 + hipSway, 0.98 + jump - impact, pz], // 12 right hip
|
||||
[px - 0.12 - legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 13 left knee
|
||||
[px + 0.12 + legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 14 right knee
|
||||
[px - 0.13 - legSpread * 1.3, 0.04 - impact * 0.3, pz], // 15 left ankle
|
||||
[px + 0.13 + legSpread * 1.3, 0.04 - impact * 0.3, pz], // 16 right ankle
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Gesturing ---------------------------------------------------------
|
||||
|
||||
poseGesturing(px, pz, elapsed, gestureType, intensity) {
|
||||
const base = this.poseStanding(px, pz, elapsed, 0, 0);
|
||||
if (intensity <= 0) return base;
|
||||
const gt = elapsed;
|
||||
|
||||
switch (gestureType) {
|
||||
case 'wave':
|
||||
return this._gestureWave(base, px, pz, gt, intensity);
|
||||
case 'swipe_left':
|
||||
return this._gestureSwipe(base, px, pz, gt, intensity);
|
||||
case 'circle':
|
||||
return this._gestureCircle(base, px, pz, gt, intensity);
|
||||
case 'point':
|
||||
return this._gesturePoint(base, px, pz, gt, intensity);
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
// Wave: fluid hand oscillation, elbow pivot, slight shoulder raise
|
||||
|
||||
_gestureWave(base, px, pz, gt, intensity) {
|
||||
const wave = Math.sin(gt * 6) * 0.15 * intensity;
|
||||
const waveSmooth = Math.sin(gt * 6 + 0.3) * 0.08 * intensity; // secondary harmonic
|
||||
const shoulderRaise = 0.04 * intensity;
|
||||
const elbowPivot = Math.sin(gt * 3) * 0.03 * intensity;
|
||||
|
||||
// Shoulder rises slightly during wave
|
||||
base[6][1] += shoulderRaise;
|
||||
// Elbow raised and pivoting
|
||||
base[8] = [
|
||||
px + 0.32 + elbowPivot,
|
||||
1.55 * intensity + 1.18 * (1 - intensity) + shoulderRaise,
|
||||
pz + 0.05,
|
||||
];
|
||||
// Wrist oscillates fluidly
|
||||
base[10] = [
|
||||
px + 0.32 + wave + waveSmooth * 0.3,
|
||||
1.7 * intensity + 0.92 * (1 - intensity) + shoulderRaise,
|
||||
pz + 0.08 + waveSmooth,
|
||||
];
|
||||
// Slight body lean away from waving arm
|
||||
base[0][0] -= 0.01 * intensity;
|
||||
base[5][0] -= 0.008 * intensity;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Swipe: full body rotation follow-through, arm extension
|
||||
|
||||
_gestureSwipe(base, px, pz, gt, intensity) {
|
||||
const sweep = Math.sin(gt * 2) * intensity;
|
||||
// Body rotation follows the arm
|
||||
const bodyRotation = sweep * 0.04;
|
||||
const shoulderTwist = sweep * 0.025;
|
||||
|
||||
// Upper body rotates
|
||||
for (let i = 0; i <= 4; i++) base[i][0] += bodyRotation * 0.5;
|
||||
base[5][0] -= shoulderTwist;
|
||||
base[6][0] += shoulderTwist;
|
||||
|
||||
// Arm extends fully during swipe
|
||||
base[8] = [px + 0.15 + sweep * 0.4, 1.3, pz + 0.3];
|
||||
base[10] = [px - 0.1 + sweep * 0.6, 1.3, pz + 0.55];
|
||||
|
||||
// Hip counter-rotation
|
||||
base[11][0] += bodyRotation * -0.2;
|
||||
base[12][0] += bodyRotation * -0.2;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Circle: smooth circular motion with forearm rotation
|
||||
|
||||
_gestureCircle(base, px, pz, gt, intensity) {
|
||||
const angle = gt * 2.5;
|
||||
const radius = 0.25 * intensity;
|
||||
const cx = Math.cos(angle) * radius;
|
||||
const cy = Math.sin(angle) * radius;
|
||||
// Forearm rotation -- wrist traces a smaller secondary circle
|
||||
const forearmAngle = angle * 1.5;
|
||||
const forearmR = 0.06 * intensity;
|
||||
|
||||
base[8] = [
|
||||
px + 0.3 + cx * 0.5,
|
||||
1.3 + cy * 0.5,
|
||||
pz + 0.2 + Math.sin(angle) * 0.05,
|
||||
];
|
||||
base[10] = [
|
||||
px + 0.3 + cx + Math.cos(forearmAngle) * forearmR,
|
||||
1.3 + cy + Math.sin(forearmAngle) * forearmR,
|
||||
pz + 0.35 + Math.sin(angle) * 0.08,
|
||||
];
|
||||
// Slight shoulder movement following arm
|
||||
base[6][0] += cx * 0.08;
|
||||
base[6][1] += cy * 0.04;
|
||||
return base;
|
||||
}
|
||||
|
||||
// Point: extended index finger simulation with arm sway
|
||||
|
||||
_gesturePoint(base, px, pz, gt, intensity) {
|
||||
const point = intensity;
|
||||
// Slight arm sway -- breathing/holding still
|
||||
const sway = Math.sin(gt * 1.5) * 0.01 * intensity;
|
||||
const vertSway = Math.cos(gt * 1.2) * 0.008 * intensity;
|
||||
|
||||
base[8] = [px + 0.15 + sway, 1.35 + vertSway, pz + 0.35 * point];
|
||||
base[10] = [px + 0.08 + sway * 0.5, 1.38 + vertSway * 0.5, pz + 0.70 * point];
|
||||
|
||||
// Lean slightly toward point direction
|
||||
base[0][2] += 0.02 * point;
|
||||
base[5][2] += 0.01 * point;
|
||||
base[6][2] += 0.01 * point;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---- Crouching ---------------------------------------------------------
|
||||
// Stealth-crawl option, weight transfer between legs
|
||||
|
||||
poseCrouching(px, pz, elapsed, bp) {
|
||||
const sway = Math.sin(elapsed * 1.5) * 0.005;
|
||||
|
||||
// Weight transfer between legs (slow rocking)
|
||||
const weightTransfer = Math.sin(elapsed * 0.8) * 0.025;
|
||||
const leftDown = Math.max(0, weightTransfer) * 0.03;
|
||||
const rightDown = Math.max(0, -weightTransfer) * 0.03;
|
||||
|
||||
// Stealth-crawl micro-movement (slow forward creep every ~4s)
|
||||
const crawlCycle = elapsed % 4.0;
|
||||
const crawlActive = crawlCycle > 3.0;
|
||||
const crawlAmt = crawlActive ? Math.sin((crawlCycle - 3.0) * Math.PI) * 0.02 : 0;
|
||||
|
||||
// Arms adjust for balance during weight transfer
|
||||
const armBalance = weightTransfer * 0.3;
|
||||
|
||||
return [
|
||||
[px + sway, 1.05 + bp, pz + 0.15 + crawlAmt], // 0 nose
|
||||
[px - 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 1 left eye
|
||||
[px + 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 2 right eye
|
||||
[px - 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 3 left ear
|
||||
[px + 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 4 right ear
|
||||
[px - 0.22, 0.88 + bp, pz + 0.05], // 5 left shoulder
|
||||
[px + 0.22, 0.88 + bp, pz + 0.05], // 6 right shoulder
|
||||
[px - 0.28 - armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 7 left elbow
|
||||
[px + 0.28 + armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 8 right elbow
|
||||
[px - 0.22 - armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 9 left wrist
|
||||
[px + 0.22 + armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 10 right wrist
|
||||
[px - 0.12 + weightTransfer, 0.42, pz - 0.05], // 11 left hip
|
||||
[px + 0.12 + weightTransfer, 0.42, pz - 0.05], // 12 right hip
|
||||
[px - 0.15 + weightTransfer * 0.5, 0.35 - leftDown, pz + 0.25], // 13 left knee
|
||||
[px + 0.15 + weightTransfer * 0.5, 0.35 - rightDown, pz + 0.25], // 14 right knee
|
||||
[px - 0.13, 0.04, pz + 0.1], // 15 left ankle
|
||||
[px + 0.13, 0.04, pz + 0.1], // 16 right ankle
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Post-Processing — Subtle bloom for green glow wireframe,
|
||||
* warm vignette, minimal grain. Foundation-style.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
|
||||
|
||||
const VignetteShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
uTime: { value: 0 },
|
||||
uVignetteStrength: { value: 0.5 },
|
||||
uChromaticStrength: { value: 0.0015 },
|
||||
uGrainStrength: { value: 0.03 },
|
||||
uWarmth: { value: 0.08 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float uTime;
|
||||
uniform float uVignetteStrength;
|
||||
uniform float uChromaticStrength;
|
||||
uniform float uGrainStrength;
|
||||
uniform float uWarmth;
|
||||
varying vec2 vUv;
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
vec2 center = uv - 0.5;
|
||||
float dist = length(center);
|
||||
|
||||
// Subtle chromatic aberration at edges only
|
||||
vec2 offset = center * dist * uChromaticStrength;
|
||||
float r = texture2D(tDiffuse, uv + offset).r;
|
||||
float g = texture2D(tDiffuse, uv).g;
|
||||
float b = texture2D(tDiffuse, uv - offset * 0.5).b;
|
||||
vec3 color = vec3(r, g, b);
|
||||
|
||||
// Warm vignette
|
||||
float vignette = 1.0 - dist * dist * uVignetteStrength * 1.8;
|
||||
color *= vignette;
|
||||
|
||||
// Very subtle warm shift in shadows
|
||||
float luma = dot(color, vec3(0.299, 0.587, 0.114));
|
||||
color.r += (1.0 - luma) * uWarmth * 0.5;
|
||||
color.g += (1.0 - luma) * uWarmth * 0.2;
|
||||
|
||||
// Minimal grain
|
||||
float grain = (rand(uv * uTime * 0.01) - 0.5) * uGrainStrength;
|
||||
color += grain;
|
||||
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export class PostProcessing {
|
||||
constructor(renderer, scene, camera) {
|
||||
const size = renderer.getSize(new THREE.Vector2());
|
||||
|
||||
this.composer = new EffectComposer(renderer);
|
||||
this.composer.addPass(new RenderPass(scene, camera));
|
||||
|
||||
// Bloom — tuned for green wireframe glow
|
||||
this._bloomPass = new UnrealBloomPass(
|
||||
new THREE.Vector2(size.x, size.y),
|
||||
0.08, // strength — subtle glow, overridden by settings
|
||||
0.2, // radius
|
||||
0.6 // threshold
|
||||
);
|
||||
this.composer.addPass(this._bloomPass);
|
||||
|
||||
// Vignette + warmth
|
||||
this._vignettePass = new ShaderPass(VignetteShader);
|
||||
this.composer.addPass(this._vignettePass);
|
||||
|
||||
this._bloomEnabled = true;
|
||||
}
|
||||
|
||||
update(elapsed) {
|
||||
this._vignettePass.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.composer.render();
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.composer.setSize(width, height);
|
||||
this._bloomPass.resolution.set(width, height);
|
||||
}
|
||||
|
||||
setQuality(level) {
|
||||
if (level === 0) {
|
||||
this._bloomPass.strength = 0;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0;
|
||||
} else if (level === 1) {
|
||||
this._bloomPass.strength = 0.6;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0.001;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0.02;
|
||||
} else {
|
||||
this._bloomPass.strength = 1.0;
|
||||
this._vignettePass.uniforms.uChromaticStrength.value = 0.0015;
|
||||
this._vignettePass.uniforms.uGrainStrength.value = 0.03;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.composer.dispose();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user