Compare commits

..

14 Commits

Author SHA1 Message Date
Reuven 377413e6a8 feat(desktop): v0.5.0 - Training backend with 16 Tauri commands
Implements full Rust backend for Training page (ADR-057):

Training Domain Types (domain/training.rs):
- GpuInfo, GpuBackend (Cpu, Cuda, Metal)
- DatasetInfo, DatasetFormat (MmFi, WiPose, Wiar, Custom)
- ModelInfo, ModelType (Encoder, Decoder, Embedding, Adaptor)
- CheckpointInfo, TrainingJob, TrainingConfig, TrainingProgress
- RuVectorConfig with MinCut, Attention, Temporal, Solver params
- EvaluationMetrics, JointAccuracy, EpochMetrics

Training Commands (commands/training.rs):
- detect_gpu - Auto-detect CUDA/Metal/CPU with caching
- list_datasets, get_datasets, download_dataset
- list_models, list_checkpoints, export_model (ONNX/TorchScript)
- start_training, stop_training, training_progress
- get_ruvector_config, set_ruvector_config, test_ruvector_live
- get_training_history, get_evaluation_metrics, get_joint_accuracies

State Management (state.rs):
- Added TrainingState to AppState
- GPU info caching, datasets, checkpoints, current job
- RuVector config persistence

Tests: 48 passed (27 unit + 21 integration)

Ref: ADR-057

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 11:57:57 -04:00
Reuven b9e36a8be0 feat(desktop): add Training page with 5 tabs (ADR-057)
Implements the Training & Models page with tabbed navigation:
- Datasets tab: Download/import datasets, preview samples
- Models tab: Browse architectures, manage checkpoints, export ONNX
- Training tab: Configure training, GPU detection, live progress
- RuVector tab: Module config (MinCut, Attention, Temporal, Solver)
- Metrics tab: Loss curves, evaluation metrics, per-joint accuracy

Features:
- GPU detection status display (CUDA/Metal)
- Live training progress with Tauri events
- RuVector module enable/disable and parameter tuning
- Training presets (Low Latency, High Accuracy, Balanced)
- Export metrics to CSV/JSON/TensorBoard
- Mock data for demonstration when backend not implemented

Ref: ADR-057

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 11:50:05 -04:00
Reuven 9e860c3a7a docs(adr): ADR-057 Desktop Training & RuVector Integration
Proposes a new Training page in the desktop app with tabs:
- Datasets: Download/manage training datasets (MM-Fi, Wi-Pose)
- Models: Browse architectures, load checkpoints, export ONNX
- Training: Configure and run training jobs with GPU support
- RuVector: Configure signal processing modules, live testing
- Metrics: View loss curves, evaluation results

Integrates wifi-densepose-train crate and 5 RuVector crates
into the Tauri desktop application.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 11:42:59 -04:00
Reuven ff91d4e8cf fix(desktop): remove bundled sensing-server resource for CI build
The sensing-server binary was referenced in tauri.conf.json but doesn't
exist in CI environment. Removed the resources section to fix the build.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 10:56:31 -04:00
Reuven fc92436f52 chore: add build artifacts and session state
- NVS config binaries for ESP32 WiFi provisioning
- macOS Tauri schema
- package-lock.json update
- Claude Flow session state

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 10:36:16 -04:00
Reuven 285bb0ad37 feat(desktop): v0.4.4 - WiFi configuration via serial port
## New Features
- WiFi Configuration Modal: Configure ESP32 WiFi credentials directly from the desktop app
- Serial port WiFi commands: Sends wifi_config/wifi/set ssid commands via serial
- Improved feedback UI with status indicators (Success/Commands Sent/Error)

## API Improvements
- New Tauri command: configure_esp32_wifi(port, ssid, password)
- 21 new integration tests covering all API functionality
- ESP32 VID/PID detection for CP210x, CH340, FTDI, and native USB

## UI Enhancements
- WiFi button in Serial Ports table for ESP32-compatible devices
- Modal with SSID/password inputs and clear status feedback
- "Done" button after configuration with "Try Again" option

## Testing
- 18 unit tests + 21 integration tests = 39 total tests passing
- Tests cover: discovery, settings, server, flash, OTA, provision, WASM, state, domain models

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 10:35:30 -04:00
Reuven b5ec4ef043 chore: update Cargo.lock
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 10:02:02 -04:00
Reuven 21aba2df8d feat(desktop): v0.4.3 - USB device discovery and data source toggle
## Changes
- Auto-scan serial ports on Discovery page load (not just Serial tab)
- Show USB device hint when no network nodes found but USB devices detected
- Add "Flash →" button in Serial Ports table for quick navigation
- Fix server stop: proper SIGTERM/SIGKILL with process group handling
- Add data source selector on Sensing page (simulate/auto/wifi/esp32)
- Fix log viewer scroll (use containerRef.scrollTop instead of scrollIntoView)
- Add fallback serial port scanning for macOS when tokio_serial fails

## Fixes
- ESP32 USB devices now visible immediately on Discovery page
- Server processes properly terminated on stop
- Log viewer no longer scrolls entire page

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 09:59:46 -04:00
Reuven a28a875594 fix(firmware): provision.py nvs import + partition config template
Fixes #215: provision.py now correctly imports from esp_idf_nvs_partition_gen
package (the pip-installable version) before falling back to legacy import.

Fixes #216: Added sdkconfig.defaults.template with custom partition table
configuration for 8MB flash boards. Copy to sdkconfig.defaults before build:
  cp sdkconfig.defaults.template sdkconfig.defaults

Changes:
- firmware/esp32-csi-node/provision.py: Try esp_idf_nvs_partition_gen first
- scripts/provision.py: Same import fix
- firmware/esp32-csi-node/sdkconfig.defaults.template: 8MB flash config with
  2MB OTA partitions, compiler size optimization, and CSI enabled

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 08:40:47 -04:00
Reuven e12749bf68 feat(desktop): v0.4.2 - Integrated sensing server with real WebSocket data
- Bundle sensing-server binary in app resources (bin/sensing-server)
- Add find_server_binary() for multi-path binary discovery
- Connect Sensing page to real WebSocket endpoint (ws://localhost:8765/ws/sensing)
- Add DataSource type and source config for data source selection
- Default to simulate mode when no ESP32 hardware present
- Add ADR-055: Integrated Sensing Server architecture
- Add ADR-056: Complete RuView Desktop Capabilities Reference

Closes integration of sensing server as single-package distribution.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-10 00:08:31 -04:00
Reuven 3b37aaf460 fix(desktop): v0.4.1 - Fix Dashboard Quick Actions and Scan Network
- Add navigation to Quick Actions (Flash, OTA, WASM buttons now work)
- Add error feedback for Scan Network failures
- Create version.ts as single source of truth for version
- Switch reqwest from rustls-tls to native-tls for Windows compatibility
- Version bump to 0.4.1

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 23:46:29 -04:00
Reuven d3c683cc7e fix(desktop): use native-tls for Windows compatibility
- Switch from rustls-tls to native-tls for better Windows support
- Fix Cargo.toml formatting (remove duplicate sections)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 22:49:37 -04:00
Reuven 56de77c0ad ci: update desktop-release workflow for v0.4.0 with attach_to_existing option
- Update default version to 0.4.0
- Add attach_to_existing input to add assets to existing releases
- Allows attaching Windows builds to v0.4.0-desktop release

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 22:01:33 -04:00
rUv 0b98917dff feat(desktop): RuView Desktop v0.4.0 - Full ADR-054 Implementation (#212)
* fix(desktop): implement save_settings and get_settings commands

Fixes #206 - Settings can now be saved and loaded in Desktop v0.3.0

- Add commands/settings.rs with get_settings and save_settings Tauri commands
- Settings persisted to app data directory as settings.json
- Supports all AppSettings fields: ports, bind address, OTA PSK, discovery, theme
- Add unit tests for serialization and defaults

Settings are stored at:
- macOS: ~/Library/Application Support/net.ruv.ruview/settings.json
- Windows: %APPDATA%/net.ruv.ruview/settings.json
- Linux: ~/.config/net.ruv.ruview/settings.json

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(desktop): RuView Desktop v0.4.0 - Full ADR-054 Implementation

This release completes all 14 Tauri commands specified in ADR-054,
making the desktop app fully production-ready for ESP32 node management.

## New Features

### Discovery Module
- Real mDNS discovery (_ruview._udp.local)
- UDP broadcast probe on port 5006
- Serial port enumeration with ESP32 chip detection

### Flash Module
- Full espflash CLI integration
- Real-time progress streaming via Tauri events
- SHA-256 firmware verification
- Support for ESP32, S2, S3, C3, C6 chips

### OTA Module
- HTTP multipart firmware upload
- HMAC-SHA256 signature with PSK authentication
- Sequential and parallel batch update strategies
- Reboot confirmation polling

### WASM Module
- 67 edge modules across 14 categories
- App-store style module library with ratings/downloads
- Full module lifecycle (upload/start/stop/unload)
- RVF format deployment paths

### Server Module
- Child process spawn with config
- Graceful SIGTERM + SIGKILL fallback
- Memory/CPU monitoring via sysinfo

### Provision Module
- NVS binary serial protocol
- Read/write/erase operations
- Mesh config generation for multi-node setup

## Security
- Input validation (IP, port, path)
- Binary validation (ESP/WASM magic bytes)
- PSK authentication for OTA

## Breaking Changes
None - backwards compatible with v0.3.0

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
2026-03-09 21:58:06 -04:00
60 changed files with 14987 additions and 343 deletions
+13 -13
View File
@@ -1,6 +1,6 @@
{
"running": true,
"startedAt": "2026-02-28T15:54:19.353Z",
"startedAt": "2026-03-09T15:26:00.921Z",
"workers": {
"map": {
"runCount": 49,
@@ -8,16 +8,16 @@
"failureCount": 0,
"averageDurationMs": 1.2857142857142858,
"lastRun": "2026-02-28T16:13:19.194Z",
"nextRun": "2026-02-28T16:28:19.195Z",
"nextRun": "2026-03-09T15:56:00.928Z",
"isRunning": false
},
"audit": {
"runCount": 44,
"runCount": 45,
"successCount": 0,
"failureCount": 44,
"failureCount": 45,
"averageDurationMs": 0,
"lastRun": "2026-02-28T16:20:19.184Z",
"nextRun": "2026-02-28T16:30:19.185Z",
"lastRun": "2026-03-09T15:43:00.933Z",
"nextRun": "2026-03-09T15:38:00.914Z",
"isRunning": false
},
"optimize": {
@@ -26,7 +26,7 @@
"failureCount": 34,
"averageDurationMs": 0,
"lastRun": "2026-02-28T16:23:19.387Z",
"nextRun": "2026-02-28T16:18:19.361Z",
"nextRun": "2026-03-09T15:45:00.915Z",
"isRunning": false
},
"consolidate": {
@@ -35,7 +35,7 @@
"failureCount": 0,
"averageDurationMs": 0.6521739130434783,
"lastRun": "2026-02-28T16:05:19.091Z",
"nextRun": "2026-02-28T16:35:19.054Z",
"nextRun": "2026-03-09T16:02:00.918Z",
"isRunning": false
},
"testgaps": {
@@ -44,8 +44,8 @@
"failureCount": 27,
"averageDurationMs": 0,
"lastRun": "2026-02-28T16:08:19.369Z",
"nextRun": "2026-02-28T16:22:19.355Z",
"isRunning": true
"nextRun": "2026-03-09T15:54:00.920Z",
"isRunning": false
},
"predict": {
"runCount": 0,
@@ -64,8 +64,8 @@
},
"config": {
"autoStart": false,
"logDir": "/home/user/wifi-densepose/.claude-flow/logs",
"stateFile": "/home/user/wifi-densepose/.claude-flow/daemon-state.json",
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/logs",
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 300000,
"resourceThresholds": {
@@ -131,5 +131,5 @@
}
]
},
"savedAt": "2026-02-28T16:23:19.387Z"
"savedAt": "2026-03-09T15:43:00.933Z"
}
+1 -1
View File
@@ -1 +1 @@
54612
31273
+13 -13
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs pre-bash",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" pre-bash",
"timeout": 5000
}
]
@@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs post-edit",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" post-edit",
"timeout": 10000
}
]
@@ -29,7 +29,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs route",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" route",
"timeout": 10000
}
]
@@ -40,12 +40,12 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs session-restore",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" session-restore",
"timeout": 15000
},
{
"type": "command",
"command": "node .claude/helpers/auto-memory-hook.mjs import",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/auto-memory-hook.mjs\" import",
"timeout": 8000
}
]
@@ -56,7 +56,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs session-end",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" session-end",
"timeout": 10000
}
]
@@ -67,7 +67,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/auto-memory-hook.mjs sync",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/auto-memory-hook.mjs\" sync",
"timeout": 10000
}
]
@@ -79,11 +79,11 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs compact-manual"
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" compact-manual"
},
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs session-end",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" session-end",
"timeout": 5000
}
]
@@ -93,11 +93,11 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs compact-auto"
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" compact-auto"
},
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs session-end",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" session-end",
"timeout": 6000
}
]
@@ -108,7 +108,7 @@
"hooks": [
{
"type": "command",
"command": "node .claude/helpers/hook-handler.cjs status",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs\" status",
"timeout": 3000
}
]
@@ -117,7 +117,7 @@
},
"statusLine": {
"type": "command",
"command": "node .claude/helpers/statusline.cjs"
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/statusline.cjs\""
},
"permissions": {
"allow": [
+12 -8
View File
@@ -7,9 +7,13 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.3.0)'
description: 'Version to release (e.g., 0.4.0)'
required: true
default: '0.3.0'
default: '0.4.0'
attach_to_existing:
description: 'Attach to existing release tag (leave empty to create new)'
required: false
default: ''
env:
CARGO_TERM_COLOR: always
@@ -65,7 +69,7 @@ jobs:
- name: Package macOS app
run: |
cd rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.3.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
@@ -136,21 +140,21 @@ jobs:
- name: List artifacts
run: find artifacts -type f
- name: Create Release
- name: Create or Update Release
uses: softprops/action-gh-release@v2
with:
name: RuView Desktop v${{ github.event.inputs.version || '0.3.0' }}
tag_name: desktop-v${{ github.event.inputs.version || '0.3.0' }}
name: RuView Desktop v${{ github.event.inputs.version || '0.4.0' }}
tag_name: ${{ github.event.inputs.attach_to_existing || format('desktop-v{0}', github.event.inputs.version || '0.4.0') }}
draft: false
prerelease: false
generate_release_notes: true
generate_release_notes: ${{ github.event.inputs.attach_to_existing == '' }}
files: |
artifacts/**/*.zip
artifacts/**/*.msi
artifacts/**/*.exe
artifacts/**/*.dmg
body: |
## RuView Desktop v${{ github.event.inputs.version || '0.3.0' }}
## RuView Desktop v${{ github.event.inputs.version || '0.4.0' }}
WiFi-based human pose estimation desktop application.
@@ -0,0 +1,699 @@
# ADR-054: RuView Desktop Full Implementation
## Status
**Accepted** — Implementation in progress
## Context
RuView Desktop v0.3.0 shipped with a complete React/TypeScript frontend but stub-only Rust backend commands. Users report:
- Settings cannot be saved (#206) ✅ Fixed in PR #209
- Flash firmware does nothing
- OTA updates are non-functional
- Node discovery returns hardcoded data
- Server start/stop is cosmetic only
This ADR defines the complete implementation plan to make all desktop features production-ready with proper security, optimization, and error handling.
## Decision
Implement all 14 Tauri commands with full functionality, security hardening, and performance optimization.
---
## 1. Command Implementation Matrix
| Module | Command | Current | Target | Priority | Security |
|--------|---------|---------|--------|----------|----------|
| **Settings** | `get_settings` | ✅ Done | ✅ Done | P0 | File permissions |
| | `save_settings` | ✅ Done | ✅ Done | P0 | Input validation |
| **Discovery** | `discover_nodes` | Stub | Full mDNS + UDP | P1 | Network boundary |
| | `list_serial_ports` | Stub | Real enumeration | P1 | USB device access |
| **Flash** | `flash_firmware` | Stub | espflash integration | P1 | Binary validation |
| | `flash_progress` | Stub | Event streaming | P1 | Progress channel |
| **OTA** | `ota_update` | Stub | HTTP multipart + PSK | P1 | TLS + PSK auth |
| | `batch_ota_update` | Stub | Parallel with backoff | P2 | Rate limiting |
| **WASM** | `wasm_list` | Stub | HTTP GET /api/wasm | P2 | Response validation |
| | `wasm_upload` | Stub | HTTP POST multipart | P2 | Size limits, signing |
| | `wasm_control` | Stub | HTTP POST commands | P2 | Action whitelist |
| **Server** | `start_server` | Partial | Child process spawn | P1 | Port validation |
| | `stop_server` | Partial | Graceful shutdown | P1 | PID verification |
| | `server_status` | Partial | Health check | P1 | Timeout handling |
| **Provision** | `provision_node` | Stub | NVS binary write | P2 | Serial validation |
| | `read_nvs` | Stub | NVS binary read | P2 | Parse validation |
---
## 2. Implementation Details
### 2.1 Discovery Module
**Dependencies:**
```toml
mdns-sd = "0.11"
serialport = "4.6"
tokio = { version = "1", features = ["net", "time"] }
```
**discover_nodes Implementation:**
```rust
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
let timeout = Duration::from_millis(timeout_ms.unwrap_or(3000));
let mut nodes = Vec::new();
// 1. mDNS discovery (_ruview._tcp.local)
let mdns = ServiceDaemon::new()?;
let receiver = mdns.browse("_ruview._tcp.local.")?;
// 2. UDP broadcast probe (port 5005)
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.set_broadcast(true)?;
socket.send_to(b"RUVIEW_DISCOVER", "255.255.255.255:5005").await?;
// 3. Collect responses with timeout
tokio::select! {
_ = collect_mdns(&receiver, &mut nodes) => {},
_ = collect_udp(&socket, &mut nodes) => {},
_ = tokio::time::sleep(timeout) => {},
}
Ok(nodes)
}
```
**list_serial_ports Implementation:**
```rust
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
let ports = serialport::available_ports()
.map_err(|e| format!("Failed to enumerate ports: {}", e))?;
Ok(ports.into_iter().map(|p| SerialPortInfo {
name: p.port_name,
vid: extract_vid(&p.port_type),
pid: extract_pid(&p.port_type),
manufacturer: extract_manufacturer(&p.port_type),
chip: detect_esp_chip(&p.port_type),
}).collect())
}
```
### 2.2 Flash Module
**Dependencies:**
```toml
espflash = "4.0"
tokio = { version = "1", features = ["sync"] }
```
**flash_firmware Implementation:**
```rust
pub async fn flash_firmware(
port: String,
firmware_path: String,
chip: Option<String>,
baud: Option<u32>,
app: AppHandle,
) -> Result<FlashResult, String> {
// 1. Validate firmware binary
let firmware = std::fs::read(&firmware_path)
.map_err(|e| format!("Cannot read firmware: {}", e))?;
validate_esp_binary(&firmware)?;
// 2. Open serial connection
let serial = serialport::new(&port, baud.unwrap_or(460800))
.timeout(Duration::from_secs(30))
.open()
.map_err(|e| format!("Cannot open {}: {}", port, e))?;
// 3. Connect to ESP bootloader
let mut flasher = Flasher::connect(serial, None, None)?;
// 4. Flash with progress callback
let start = Instant::now();
flasher.write_bin_to_flash(
0x0,
&firmware,
Some(&mut |current, total| {
let _ = app.emit("flash_progress", FlashProgress {
phase: "writing".into(),
progress_pct: (current as f32 / total as f32) * 100.0,
bytes_written: current as u64,
bytes_total: total as u64,
});
}),
)?;
Ok(FlashResult {
success: true,
message: "Flash complete".into(),
duration_secs: start.elapsed().as_secs_f64(),
})
}
```
### 2.3 OTA Module
**Dependencies:**
```toml
reqwest = { version = "0.12", features = ["multipart", "rustls-tls"] }
sha2 = "0.10"
```
**ota_update Implementation:**
```rust
pub async fn ota_update(
node_ip: String,
firmware_path: String,
psk: Option<String>,
) -> Result<OtaResult, String> {
// 1. Validate IP format
let ip: IpAddr = node_ip.parse()
.map_err(|_| "Invalid IP address")?;
// 2. Read and hash firmware
let firmware = tokio::fs::read(&firmware_path).await
.map_err(|e| format!("Cannot read firmware: {}", e))?;
let hash = Sha256::digest(&firmware);
// 3. Build multipart request
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let form = multipart::Form::new()
.part("firmware", multipart::Part::bytes(firmware)
.file_name("firmware.bin")
.mime_str("application/octet-stream")?);
// 4. Send with PSK auth header
let mut req = client.post(format!("http://{}:8032/ota", ip))
.multipart(form);
if let Some(key) = psk {
req = req.header("X-OTA-PSK", key);
}
let resp = req.send().await
.map_err(|e| format!("OTA request failed: {}", e))?;
if resp.status().is_success() {
Ok(OtaResult {
success: true,
node_ip: node_ip.clone(),
message: "OTA update initiated".into(),
})
} else {
Err(format!("OTA failed: {}", resp.status()))
}
}
```
**batch_ota_update Implementation:**
```rust
pub async fn batch_ota_update(
node_ips: Vec<String>,
firmware_path: String,
psk: Option<String>,
strategy: Option<String>,
) -> Result<Vec<OtaResult>, String> {
let firmware = Arc::new(tokio::fs::read(&firmware_path).await?);
let psk = Arc::new(psk);
let strategy = strategy.unwrap_or("sequential".into());
match strategy.as_str() {
"parallel" => {
// All at once (max 4 concurrent)
let semaphore = Arc::new(Semaphore::new(4));
let handles: Vec<_> = node_ips.into_iter().map(|ip| {
let fw = firmware.clone();
let key = psk.clone();
let sem = semaphore.clone();
tokio::spawn(async move {
let _permit = sem.acquire().await;
ota_single(&ip, &fw, key.as_ref().as_ref()).await
})
}).collect();
let results = futures::future::join_all(handles).await;
Ok(results.into_iter().filter_map(|r| r.ok()).collect())
}
"tdm_safe" => {
// One per TDM slot group with delays
let mut results = Vec::new();
for ip in node_ips {
results.push(ota_single(&ip, &firmware, psk.as_ref().as_ref()).await);
tokio::time::sleep(Duration::from_secs(5)).await;
}
Ok(results)
}
_ => {
// Sequential (default)
let mut results = Vec::new();
for ip in node_ips {
results.push(ota_single(&ip, &firmware, psk.as_ref().as_ref()).await);
}
Ok(results)
}
}
}
```
### 2.4 Server Module
**Dependencies:**
```toml
tokio = { version = "1", features = ["process"] }
sysinfo = "0.32"
```
**start_server Implementation:**
```rust
pub async fn start_server(
config: ServerConfig,
state: State<'_, AppState>,
) -> Result<(), String> {
// 1. Check if already running
{
let srv = state.server.lock().map_err(|e| e.to_string())?;
if srv.running {
return Err("Server already running".into());
}
}
// 2. Validate ports
validate_port(config.http_port.unwrap_or(8080))?;
validate_port(config.ws_port.unwrap_or(8765))?;
// 3. Spawn sensing server as child process
let child = Command::new("wifi-densepose-sensing-server")
.args([
"--http-port", &config.http_port.unwrap_or(8080).to_string(),
"--ws-port", &config.ws_port.unwrap_or(8765).to_string(),
"--udp-port", &config.udp_port.unwrap_or(5005).to_string(),
])
.spawn()
.map_err(|e| format!("Failed to start server: {}", e))?;
// 4. Update state
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = true;
srv.pid = Some(child.id());
srv.child = Some(child);
Ok(())
}
```
**stop_server Implementation:**
```rust
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
if let Some(mut child) = srv.child.take() {
// Graceful shutdown via SIGTERM
#[cfg(unix)]
{
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
let _ = kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM);
}
// Wait up to 5s, then force kill
tokio::select! {
_ = child.wait() => {},
_ = tokio::time::sleep(Duration::from_secs(5)) => {
let _ = child.kill();
}
}
}
srv.running = false;
srv.pid = None;
Ok(())
}
```
### 2.5 WASM Module
**Dependencies:**
```toml
reqwest = { version = "0.12", features = ["json", "multipart"] }
```
**wasm_list Implementation:**
```rust
pub async fn wasm_list(node_ip: String) -> Result<Vec<WasmModuleInfo>, String> {
let client = reqwest::Client::new();
let resp = client.get(format!("http://{}:8080/api/wasm", node_ip))
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Node returned {}", resp.status()));
}
let modules: Vec<WasmModuleInfo> = resp.json().await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(modules)
}
```
**wasm_upload Implementation:**
```rust
pub async fn wasm_upload(
node_ip: String,
wasm_path: String,
) -> Result<WasmUploadResult, String> {
// 1. Validate WASM binary
let wasm = tokio::fs::read(&wasm_path).await
.map_err(|e| format!("Cannot read WASM: {}", e))?;
if wasm.len() > 256 * 1024 {
return Err("WASM module exceeds 256KB limit".into());
}
if &wasm[0..4] != b"\0asm" {
return Err("Invalid WASM magic bytes".into());
}
// 2. Upload to node
let client = reqwest::Client::new();
let form = multipart::Form::new()
.part("module", multipart::Part::bytes(wasm)
.file_name(Path::new(&wasm_path).file_name().unwrap().to_string_lossy())
.mime_str("application/wasm")?);
let resp = client.post(format!("http://{}:8080/api/wasm", node_ip))
.multipart(form)
.timeout(Duration::from_secs(30))
.send()
.await?;
if resp.status().is_success() {
let result: WasmUploadResult = resp.json().await?;
Ok(result)
} else {
Err(format!("Upload failed: {}", resp.status()))
}
}
```
### 2.6 Provision Module
**Dependencies:**
```toml
nvs-partition-tool = "0.1" # Or implement NVS binary format
serialport = "4.6"
```
**provision_node Implementation:**
```rust
pub async fn provision_node(
port: String,
config: ProvisioningConfig,
) -> Result<ProvisionResult, String> {
// 1. Validate config
config.validate()?;
// 2. Build NVS binary blob
let nvs_blob = build_nvs_blob(&config)?;
// 3. Open serial port
let mut serial = serialport::new(&port, 115200)
.timeout(Duration::from_secs(10))
.open()
.map_err(|e| format!("Cannot open {}: {}", port, e))?;
// 4. Enter bootloader mode
enter_bootloader(&mut serial)?;
// 5. Write NVS partition (offset 0x9000, size 0x6000)
write_partition(&mut serial, 0x9000, &nvs_blob)?;
// 6. Reset device
reset_device(&mut serial)?;
Ok(ProvisionResult {
success: true,
message: "Provisioning complete".into(),
})
}
```
---
## 3. Security Hardening
### 3.1 Input Validation
```rust
// All string inputs sanitized
fn validate_ip(ip: &str) -> Result<IpAddr, String> {
ip.parse::<IpAddr>().map_err(|_| "Invalid IP address".into())
}
fn validate_port(port: u16) -> Result<(), String> {
if port < 1024 && port != 0 {
return Err("Privileged ports (1-1023) not allowed".into());
}
Ok(())
}
fn validate_path(path: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(path);
if path.components().any(|c| c == std::path::Component::ParentDir) {
return Err("Path traversal detected".into());
}
Ok(path)
}
```
### 3.2 Network Security
```rust
// OTA PSK validation
fn validate_psk(psk: &str) -> Result<(), String> {
if psk.len() < 16 {
return Err("PSK must be at least 16 characters".into());
}
if !psk.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err("PSK contains invalid characters".into());
}
Ok(())
}
// Rate limiting for network operations
struct RateLimiter {
last_request: Instant,
min_interval: Duration,
}
impl RateLimiter {
fn check(&mut self) -> Result<(), String> {
if self.last_request.elapsed() < self.min_interval {
return Err("Rate limit exceeded".into());
}
self.last_request = Instant::now();
Ok(())
}
}
```
### 3.3 Binary Validation
```rust
fn validate_esp_binary(data: &[u8]) -> Result<(), String> {
// Check ESP binary magic (0xE9 at offset 0)
if data.is_empty() || data[0] != 0xE9 {
return Err("Invalid ESP firmware magic byte".into());
}
// Check minimum size (header + some code)
if data.len() < 256 {
return Err("Firmware too small".into());
}
// Check maximum size (4MB flash)
if data.len() > 4 * 1024 * 1024 {
return Err("Firmware exceeds flash size".into());
}
Ok(())
}
```
---
## 4. Performance Optimization
### 4.1 Async Everything
All I/O operations are async with proper timeouts:
```rust
// Timeout wrapper
async fn with_timeout<T, F: Future<Output = Result<T, String>>>(
future: F,
duration: Duration,
) -> Result<T, String> {
tokio::time::timeout(duration, future)
.await
.map_err(|_| "Operation timed out".into())?
}
```
### 4.2 Connection Pooling
```rust
// Reusable HTTP client
lazy_static! {
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
.pool_max_idle_per_host(5)
.pool_idle_timeout(Duration::from_secs(30))
.build()
.unwrap();
}
```
### 4.3 Streaming Progress
Flash and OTA operations stream progress via Tauri events:
```rust
// Real-time progress updates
app.emit("flash_progress", FlashProgress { ... })?;
app.emit("ota_progress", OtaProgress { ... })?;
```
---
## 5. Testing Strategy
### 5.1 Unit Tests
```rust
#[cfg(test)]
mod tests {
#[test]
fn test_validate_ip() {
assert!(validate_ip("192.168.1.1").is_ok());
assert!(validate_ip("invalid").is_err());
}
#[test]
fn test_validate_esp_binary() {
let valid = vec![0xE9; 1024];
assert!(validate_esp_binary(&valid).is_ok());
let invalid = vec![0x00; 1024];
assert!(validate_esp_binary(&invalid).is_err());
}
}
```
### 5.2 Integration Tests
```rust
#[tokio::test]
async fn test_discover_nodes_timeout() {
let result = discover_nodes(Some(100)).await;
assert!(result.is_ok());
// Should return empty or cached results within timeout
}
```
### 5.3 Mock Testing
```rust
// Mock serial port for flash tests
struct MockSerial {
responses: VecDeque<Vec<u8>>,
}
impl Read for MockSerial { ... }
impl Write for MockSerial { ... }
```
---
## 6. Dependencies Update
**Cargo.toml additions:**
```toml
[dependencies]
# Discovery
mdns-sd = "0.11"
serialport = "4.6"
# HTTP client
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"] }
# Crypto
sha2 = "0.10"
# Process management
sysinfo = "0.32"
# Async
tokio = { version = "1", features = ["full"] }
futures = "0.3"
# Flash
espflash = "4.0"
```
---
## 7. Implementation Timeline
| Week | Deliverable |
|------|-------------|
| 1 | Discovery + Serial ports (real enumeration) |
| 1 | Server start/stop (child process management) |
| 2 | Flash firmware (espflash integration) |
| 2 | OTA update (HTTP multipart) |
| 3 | Batch OTA (parallel + sequential strategies) |
| 3 | WASM management (list/upload/control) |
| 4 | Provision NVS (binary format) |
| 4 | Security audit + E2E testing |
---
## 8. Rollout Plan
1. **v0.3.1** — Settings fix + Discovery + Server
2. **v0.4.0** — Flash + OTA (single node)
3. **v0.5.0** — Batch OTA + WASM + Provision
4. **v1.0.0** — Full E2E tested, security audited
---
## Consequences
### Positive
- Desktop app becomes fully functional
- Real device management capabilities
- Production-ready security posture
- Async performance throughout
### Negative
- Additional dependencies increase binary size
- espflash adds ~2MB to binary
- Hardware required for full testing
### Neutral
- Feature parity with browser-based UI
- Same API contract as sensing server
---
## References
- [Tauri v2 Commands](https://v2.tauri.app/develop/commands/)
- [espflash Documentation](https://github.com/esp-rs/espflash)
- [ESP32 OTA Protocol](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/ota.html)
- [mDNS-SD Rust](https://docs.rs/mdns-sd/)
@@ -0,0 +1,119 @@
# ADR-055: Integrated Sensing Server in Desktop App
## Status
Accepted
## Context
The RuView Desktop application (ADR-054) requires the WiFi sensing server to provide real-time CSI data, activity detection, and vital signs monitoring. Currently, the sensing server is a separate binary (`wifi-densepose-sensing-server`) that must be installed separately and found in the system PATH.
This creates several problems:
1. **Distribution complexity**: Users must install two binaries
2. **Path issues**: Binary may not be in PATH, causing "No such file or directory" errors
3. **Version mismatch**: Server and desktop app versions may diverge
4. **Poor UX**: Error messages about missing binaries confuse users
## Decision
Bundle the sensing server binary inside the desktop application and provide intelligent binary discovery with clear fallback paths.
### Binary Discovery Order
The desktop app searches for the sensing server in this order:
1. **Custom path** from user settings (`server_path`)
2. **Bundled resources** (`Contents/Resources/bin/` on macOS)
3. **Next to executable** (same directory as the app binary)
4. **System PATH** (legacy fallback)
### Implementation
```rust
fn find_server_binary(app: &AppHandle, custom_path: Option<&str>) -> Result<String, String> {
// 1. Custom path from settings
if let Some(path) = custom_path {
if std::path::Path::new(path).exists() {
return Ok(path.to_string());
}
}
// 2. Bundled in resources
if let Ok(resource_dir) = app.path().resource_dir() {
let bundled = resource_dir.join("bin").join(DEFAULT_SERVER_BIN);
if bundled.exists() {
return Ok(bundled.to_string_lossy().to_string());
}
}
// 3. Next to executable
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let sibling = exe_dir.join(DEFAULT_SERVER_BIN);
if sibling.exists() {
return Ok(sibling.to_string_lossy().to_string());
}
}
}
// 4. System PATH
// ... which lookup ...
Err("Sensing server binary not found")
}
```
### Bundle Configuration
In `tauri.conf.json`:
```json
{
"bundle": {
"resources": [
{
"src": "../../target/release/wifi-densepose-sensing-server",
"target": "bin/wifi-densepose-sensing-server"
}
]
}
}
```
## Consequences
### Positive
- **Single package distribution**: Users download one DMG/MSI/EXE
- **Version alignment**: Server and UI always match
- **Better UX**: No PATH configuration required
- **Offline capable**: Works without network access to download server
### Negative
- **Larger bundle size**: ~10-15MB additional for server binary
- **Build complexity**: Must build server before bundling desktop
- **Platform-specific**: Need separate server binaries per platform
### Neutral
- CI/CD workflow updated to build server before desktop
- GitHub Actions builds all platforms (macOS arm64/x64, Windows x64)
## WebSocket Integration
The Sensing page connects to the bundled server's WebSocket endpoint:
- `ws://127.0.0.1:{ws_port}/ws/sensing` - Real-time CSI data stream
- `ws://127.0.0.1:{ws_port}/ws/pose` - Pose estimation stream
Message format:
```typescript
interface WsSensingUpdate {
type: string;
timestamp: number;
source: string;
tick: number;
nodes: WsNodeInfo[];
classification: { motion_level: string; presence: boolean; confidence: number };
vital_signs?: { breathing_rate_hz?: number; heart_rate_bpm?: number };
}
```
## Security Considerations
- Server binary signed with same certificate as desktop app
- Communication over localhost only (127.0.0.1)
- No external network access by default
- Process spawned as child of desktop app (inherits permissions)
## Related ADRs
- ADR-054: Desktop Full Implementation
- ADR-053: UI Design System
- ADR-052: Tauri Desktop Frontend
@@ -0,0 +1,251 @@
# ADR-056: RuView Desktop Complete Capabilities Reference
## Status
Accepted
## Context
RuView Desktop is a comprehensive WiFi-based sensing platform that combines hardware management, real-time signal processing, neural network inference, and intelligent monitoring. This ADR documents all integrated capabilities across the desktop application and underlying crates.
## Decision
The RuView Desktop application consolidates all WiFi-DensePose functionality into a single, unified interface with the following capabilities.
---
## 1. Hardware Management
### 1.1 Node Discovery
- **mDNS discovery**: Automatic detection of ESP32 nodes via Bonjour/Avahi
- **UDP probe**: Direct UDP broadcast discovery on port 5005
- **HTTP sweep**: Sequential IP scanning with health checks
- **Manual registration**: User-defined node configuration
### 1.2 Firmware Flashing
- **Serial flashing**: Direct USB flash via espflash integration
- **Chip detection**: Automatic ESP32/S2/S3/C3/C6 identification
- **Progress monitoring**: Real-time progress with speed metrics
- **Verification**: Post-flash integrity verification
### 1.3 OTA Updates
- **Single-node OTA**: HTTP-based firmware push to individual nodes
- **Batch OTA**: Coordinated multi-node updates with strategies:
- `sequential`: One node at a time
- `tdm_safe`: Respects TDM slot timing
- `parallel`: Concurrent updates with throttling
- **Rollback support**: Automatic rollback on verification failure
- **Version tracking**: Pre/post version comparison
### 1.4 Node Configuration
- **NVS provisioning**: WiFi credentials, node ID, TDM slot assignment
- **Mesh configuration**: Coordinator/node/aggregator role assignment
- **TDM scheduling**: Time-division multiplexing slot allocation
---
## 2. Sensing Server
### 2.1 Data Sources
- **ESP32 CSI**: Real UDP frames from ESP32 hardware (port 5005)
- **Windows WiFi**: Native Windows RSSI monitoring via netsh
- **Simulation**: Synthetic data generation for demo/testing
- **Auto**: Automatic source detection based on available hardware
### 2.2 Real-Time Processing
- **CSI pipeline**: 56-subcarrier amplitude/phase extraction
- **FFT analysis**: Spectral decomposition for motion detection
- **Vital signs**: Breathing rate (0.1-0.5 Hz), heart rate (0.8-2.0 Hz)
- **Motion classification**: still/walking/running/exercising
- **Presence detection**: Binary presence with confidence score
### 2.3 WebSocket Streaming
- **Sensing endpoint**: `ws://localhost:8765/ws/sensing`
- **Pose endpoint**: `ws://localhost:8765/ws/pose`
- **Real-time broadcast**: 10-100 Hz update rate
- **Multi-client support**: Concurrent WebSocket connections
### 2.4 REST API
- **Health check**: `GET /health`
- **Status**: `GET /api/status`
- **Recording control**: `POST /api/recording/start|stop`
- **Model management**: `GET/POST /api/models`
---
## 3. Neural Network Inference
### 3.1 Model Formats
- **RVF (RuVector Format)**: Proprietary binary container with:
- Model weights (quantized f32/f16/i8)
- Vital sign configuration
- SONA environment profiles
- Training provenance
- Cryptographic attestation
### 3.2 Inference Capabilities
- **Pose estimation**: 17 COCO keypoints from WiFi CSI
- **Activity recognition**: Multi-class classification
- **Vital signs**: Breathing and heart rate extraction
- **Multi-person detection**: Up to 3 simultaneous subjects
### 3.3 Self-Learning (SONA)
- **Environment adaptation**: LoRA-based fine-tuning to room geometry
- **Profile switching**: Multiple learned environment profiles
- **Online learning**: Continuous adaptation during runtime
- **Transfer learning**: Profile export/import between deployments
---
## 4. WASM Edge Modules
### 4.1 Module Management
- **Upload**: Deploy WASM modules to ESP32 nodes
- **Start/Stop**: Runtime control of edge processing
- **Status monitoring**: CPU, memory, execution count
- **Hot reload**: Update modules without node reboot
### 4.2 Supported Operations
- **Local filtering**: On-device noise reduction
- **Feature extraction**: Pre-compute features at edge
- **Compression**: Reduce data before transmission
- **Custom logic**: User-defined processing pipelines
---
## 5. Mesh Visualization
### 5.1 Network Topology
- **Live mesh view**: Real-time node connectivity graph
- **Signal quality**: RSSI/SNR visualization per link
- **Latency monitoring**: Round-trip time measurement
- **Packet loss**: Delivery success rate tracking
### 5.2 CSI Visualization
- **Amplitude heatmap**: Per-subcarrier amplitude display
- **Phase unwrapping**: Continuous phase visualization
- **Spectrogram**: Time-frequency representation
- **Signal field**: 3D voxel grid of RF perturbations
---
## 6. Training & Export
### 6.1 Dataset Management
- **Recording**: Capture CSI frames with annotations
- **Labeling**: Activity and pose ground truth
- **Augmentation**: Synthetic data generation
- **Export**: Standard formats (JSON, CSV, NumPy)
### 6.2 Training Pipeline (ADR-023)
- **Contrastive pretraining**: Self-supervised feature learning
- **Supervised fine-tuning**: Labeled pose estimation
- **SONA adaptation**: Environment-specific tuning
- **Validation**: Cross-environment testing
### 6.3 Export Formats
- **RVF container**: Production deployment format
- **ONNX**: Interoperability with external tools
- **PyTorch**: Research and experimentation
- **Candle**: Rust-native inference
---
## 7. Security Features
### 7.1 Network Security
- **OTA PSK**: Pre-shared key for firmware updates
- **Node authentication**: MAC-based node verification
- **Encrypted transport**: Optional TLS for API endpoints
### 7.2 Code Signing
- **Firmware verification**: Hash-based integrity checks
- **WASM attestation**: Module signature validation
- **Model provenance**: Training lineage tracking
---
## 8. Configuration & Settings
### 8.1 Server Configuration
- **Ports**: HTTP (8080), WebSocket (8765), UDP (5005)
- **Bind address**: Localhost or network-wide
- **Data source**: auto/wifi/esp32/simulate
- **Log level**: debug/info/warn/error
### 8.2 Application Settings
- **Theme**: Dark/light mode
- **Auto-discovery**: Periodic node scanning
- **Discovery interval**: Configurable scan frequency
- **UI customization**: Responsive layout options
---
## 9. Crate Architecture
| Crate | Capabilities |
|-------|-------------|
| `wifi-densepose-core` | CSI frame primitives, traits, error types |
| `wifi-densepose-signal` | FFT, phase unwrapping, vital signs, RuvSense |
| `wifi-densepose-nn` | ONNX/PyTorch/Candle inference backends |
| `wifi-densepose-train` | Training pipeline, dataset, metrics |
| `wifi-densepose-mat` | Mass casualty assessment tool |
| `wifi-densepose-hardware` | ESP32 protocol, TDM, channel hopping |
| `wifi-densepose-ruvector` | Cross-viewpoint fusion, attention |
| `wifi-densepose-api` | REST API (Axum) |
| `wifi-densepose-db` | Postgres/SQLite/Redis persistence |
| `wifi-densepose-config` | Configuration management |
| `wifi-densepose-wasm` | Browser WASM bindings |
| `wifi-densepose-cli` | Command-line interface |
| `wifi-densepose-sensing-server` | Real-time sensing server |
| `wifi-densepose-wifiscan` | Multi-BSSID scanning |
| `wifi-densepose-vitals` | Vital sign extraction |
| `wifi-densepose-desktop` | Tauri desktop application |
---
## 10. UI Design System (ADR-053)
### 10.1 Pages
- **Dashboard**: Overview, node status, quick actions
- **Discovery**: Network scanning interface
- **Nodes**: Node management and configuration
- **Flash**: Serial firmware flashing
- **OTA**: Over-the-air update management
- **Edge Modules**: WASM deployment
- **Sensing**: Real-time monitoring with server control
- **Mesh View**: Network topology visualization
- **Settings**: Application configuration
### 10.2 Components
- **StatusBadge**: Health indicator
- **NodeCard**: Node information display
- **LogViewer**: Real-time log streaming
- **ActivityFeed**: Sensing data visualization
- **ProgressBar**: Operation progress
- **ConfigForm**: Settings input
---
## Consequences
### Positive
- **Unified interface**: All capabilities in one application
- **Bundled deployment**: Single package with server included
- **Real-time feedback**: WebSocket-based live updates
- **Cross-platform**: macOS, Windows, Linux support
- **Extensible**: WASM modules, custom models, API access
### Negative
- **Larger bundle**: ~6MB app + ~2.6MB server
- **Complexity**: Many features require learning curve
- **Hardware dependency**: Full functionality requires ESP32 nodes
### Neutral
- Documentation required for all features
- Training materials needed for advanced capabilities
- Community contributions welcome
## Related ADRs
- ADR-053: UI Design System
- ADR-054: Desktop Full Implementation
- ADR-055: Integrated Sensing Server
- ADR-023: 8-Phase Training Pipeline
- ADR-016: RuVector Integration
@@ -0,0 +1,240 @@
# ADR-057: Desktop App Training & RuVector Integration
| Field | Value |
|-------|-------|
| Status | Proposed |
| Date | 2026-03-10 |
| Authors | RuView Team |
| Reviewers | - |
| Related | ADR-016, ADR-017, ADR-024, ADR-027 |
## Context
The RuView desktop application currently provides device discovery, firmware flashing, OTA updates, and real-time sensing visualization. However, users cannot train models or configure RuVector signal processing modules directly from the desktop app.
The following crates exist in the workspace but are not exposed in the desktop UI:
### Training Crate (`wifi-densepose-train`)
- Dataset management (MM-Fi, Wi-Pose formats)
- Model architectures (CSI encoder, pose decoder)
- Training loops with metrics tracking
- Checkpoint save/load
- ruview_metrics integration
### RuVector Crates (5 modules)
1. **ruvector-mincut** - Graph-based person segmentation, DynamicPersonMatcher
2. **ruvector-attn-mincut** - Attention-weighted antenna selection
3. **ruvector-temporal-tensor** - Temporal CSI compression, breathing detection
4. **ruvector-solver** - Sparse interpolation, triangulation
5. **ruvector-attention** - Spatial attention, BVP extraction
## Decision
Add a new **"Training"** page to the desktop application with tabbed navigation:
### Tab Structure
```
┌─────────────────────────────────────────────────────────────┐
│ Training & Models │
├──────────┬──────────┬──────────┬──────────┬────────────────┤
│ Datasets │ Models │ Training │ RuVector │ Metrics │
└──────────┴──────────┴──────────┴──────────┴────────────────┘
```
### Tab 1: Datasets
- **Download** standard datasets (MM-Fi, Wi-Pose)
- **Import** custom CSI recordings
- **Preview** dataset samples (CSI heatmaps, labels)
- **Split** into train/val/test sets
- **Statistics** - sample counts, class distribution
### Tab 2: Models
- **Browse** available architectures:
- CSI Encoder (CNN, Transformer)
- Pose Decoder (LSTM, GRU)
- AETHER embedding network (ADR-024)
- MERIDIAN domain adaptor (ADR-027)
- **Load** checkpoints from disk
- **View** model summary (params, layers, memory)
- **Export** to ONNX/TorchScript
### Tab 3: Training
- **Configure** training:
- Learning rate, batch size, epochs
- Optimizer (Adam, SGD, AdamW)
- Loss function selection
- Data augmentation toggles
- **GPU Detection** - CUDA/Metal availability
- **Start/Stop** training jobs
- **Progress** - live loss curves, ETA
- **Checkpointing** - auto-save best model
### Tab 4: RuVector
- **Module Configuration**:
- MinCut graph parameters
- Attention weights
- Temporal compression ratio
- Solver interpolation settings
- **Live Testing** - apply to real-time CSI stream
- **Comparison** - A/B test configurations
- **Export** - save optimal config
### Tab 5: Metrics
- **Loss Curves** - training/validation over epochs
- **Evaluation** - PCK, mAP, IoU scores
- **Confusion Matrix** - per-joint accuracy
- **Export** - CSV, JSON, TensorBoard format
## Architecture
### Backend (Rust/Tauri)
```
wifi-densepose-desktop/
├── src/
│ ├── commands/
│ │ ├── training.rs # NEW: Training job management
│ │ ├── datasets.rs # NEW: Dataset download/import
│ │ ├── models.rs # NEW: Model loading/export
│ │ ├── ruvector.rs # NEW: RuVector config
│ │ └── metrics.rs # NEW: Metrics retrieval
│ └── domain/
│ ├── training.rs # Training state machine
│ └── ruvector.rs # RuVector config types
```
### Frontend (React/TypeScript)
```
ui/src/pages/
├── Training/
│ ├── index.tsx # Tab container
│ ├── DatasetsTab.tsx # Dataset management
│ ├── ModelsTab.tsx # Model browser
│ ├── TrainingTab.tsx # Training control
│ ├── RuVectorTab.tsx # Signal processing config
│ └── MetricsTab.tsx # Visualization
```
### Tauri Commands
| Command | Description |
|---------|-------------|
| `list_datasets` | Get available datasets |
| `download_dataset` | Download standard dataset |
| `import_dataset` | Import custom recordings |
| `list_models` | Get model architectures |
| `load_checkpoint` | Load model weights |
| `export_model` | Export to ONNX |
| `detect_gpu` | Check CUDA/Metal |
| `start_training` | Begin training job |
| `stop_training` | Cancel training |
| `training_progress` | Get current status |
| `get_ruvector_config` | Load RuVector settings |
| `set_ruvector_config` | Update settings |
| `test_ruvector_live` | Apply to live CSI |
| `get_metrics` | Retrieve training metrics |
### Event System
Training progress updates via Tauri events:
```rust
#[derive(Serialize, Clone)]
pub struct TrainingProgress {
pub epoch: u32,
pub total_epochs: u32,
pub batch: u32,
pub total_batches: u32,
pub train_loss: f32,
pub val_loss: Option<f32>,
pub learning_rate: f32,
pub eta_secs: u64,
pub gpu_memory_mb: Option<u64>,
}
// Emit every batch
app.emit("training:progress", progress)?;
// Emit on completion
app.emit("training:complete", result)?;
```
## Implementation Plan
### Phase 1: Foundation (Week 1-2)
1. Create `Training` page skeleton with tabs
2. Implement `detect_gpu` command
3. Add dataset listing/download commands
4. Design TypeScript types for all entities
### Phase 2: Dataset Management (Week 3)
1. MM-Fi dataset downloader
2. Wi-Pose dataset downloader
3. Custom dataset import (CSV/NPZ)
4. Dataset preview component
### Phase 3: Model Management (Week 4)
1. Model architecture browser
2. Checkpoint loading
3. Model summary display
4. ONNX export
### Phase 4: Training Loop (Week 5-6)
1. Training configuration UI
2. Background training thread
3. Progress event emission
4. Checkpoint auto-save
5. Training history persistence
### Phase 5: RuVector Integration (Week 7)
1. RuVector config UI
2. Live CSI testing
3. A/B comparison mode
4. Config export/import
### Phase 6: Metrics & Polish (Week 8)
1. Loss curve visualization (Chart.js/Recharts)
2. Evaluation metrics display
3. Export functionality
4. Error handling & edge cases
## Risks & Mitigations
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| No GPU available | Medium | High | CPU fallback with warning |
| Large dataset downloads | High | Medium | Resume support, progress UI |
| Training crashes | Medium | High | Checkpoint recovery, error reporting |
| Memory exhaustion | Low | High | Batch size auto-tuning |
| UI blocking | Medium | High | All training in background thread |
## Success Criteria
1. User can download MM-Fi dataset from UI
2. User can start training with GPU detection
3. Live progress updates without UI freeze
4. Training can be paused/resumed
5. RuVector config changes apply to live CSI
6. Metrics display updates in real-time
7. Models can be exported to ONNX
## Alternatives Considered
### 1. Separate Training App
- **Rejected**: Fragments user experience, duplicates code
### 2. Web-based Training Dashboard
- **Rejected**: Requires server, no offline support
### 3. CLI-only Training
- **Rejected**: Poor UX for non-technical users
## References
- ADR-016: RuVector Training Pipeline Integration
- ADR-017: RuVector Signal + MAT Integration
- ADR-024: AETHER Contrastive CSI Embedding
- ADR-027: MERIDIAN Domain Generalization
- Tauri v2 Events: https://v2.tauri.app/develop/calling-rust/#events
@@ -0,0 +1,130 @@
{
"running": true,
"startedAt": "2026-03-10T14:22:41.948Z",
"workers": {
"map": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T14:22:41.948Z"
},
"audit": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T14:24:41.948Z"
},
"optimize": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T14:26:41.948Z"
},
"consolidate": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T14:28:41.949Z"
},
"testgaps": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T14:30:41.949Z"
},
"predict": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
},
"document": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
}
},
"config": {
"autoStart": false,
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/firmware/esp32-csi-node/.claude-flow/logs",
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/firmware/esp32-csi-node/.claude-flow/daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 300000,
"resourceThresholds": {
"maxCpuLoad": 2,
"minFreeMemoryPercent": 20
},
"workers": [
{
"type": "map",
"intervalMs": 900000,
"offsetMs": 0,
"priority": "normal",
"description": "Codebase mapping",
"enabled": true
},
{
"type": "audit",
"intervalMs": 600000,
"offsetMs": 120000,
"priority": "critical",
"description": "Security analysis",
"enabled": true
},
{
"type": "optimize",
"intervalMs": 900000,
"offsetMs": 240000,
"priority": "high",
"description": "Performance optimization",
"enabled": true
},
{
"type": "consolidate",
"intervalMs": 1800000,
"offsetMs": 360000,
"priority": "low",
"description": "Memory consolidation",
"enabled": true
},
{
"type": "testgaps",
"intervalMs": 1200000,
"offsetMs": 480000,
"priority": "normal",
"description": "Test coverage analysis",
"enabled": true
},
{
"type": "predict",
"intervalMs": 600000,
"offsetMs": 0,
"priority": "low",
"description": "Predictive preloading",
"enabled": false
},
{
"type": "document",
"intervalMs": 3600000,
"offsetMs": 0,
"priority": "low",
"description": "Auto-documentation",
"enabled": false
}
]
},
"savedAt": "2026-03-10T14:22:41.949Z"
}
@@ -0,0 +1,7 @@
{"type":"edit","file":"unknown","timestamp":1773152422749,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152444021,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152460956,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152493971,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152501432,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152510853,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152596890,"sessionId":null}
@@ -0,0 +1,12 @@
{
"id": "session-1773152560779",
"startedAt": "2026-03-10T14:22:40.779Z",
"cwd": "/Users/cohen/GitHub/ruvnet/RuView/firmware/esp32-csi-node",
"context": {},
"metrics": {
"edits": 1,
"commands": 0,
"tasks": 0,
"errors": 0
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.
+10 -1
View File
@@ -76,7 +76,16 @@ def generate_nvs_binary(csv_content, size):
bin_path = csv_path.replace(".csv", ".bin")
try:
# Try the pip-installed version first
# Try the pip-installed version first (esp_idf_nvs_partition_gen package)
try:
from esp_idf_nvs_partition_gen import nvs_partition_gen
nvs_partition_gen.generate(csv_path, bin_path, size)
with open(bin_path, "rb") as f:
return f.read()
except ImportError:
pass
# Try legacy import name (older versions)
try:
import nvs_partition_gen
nvs_partition_gen.generate(csv_path, bin_path, size)
@@ -0,0 +1,32 @@
# ESP32-S3 CSI Node — Default SDK Configuration
# This file is applied automatically by idf.py when no sdkconfig exists.
# Target: ESP32-S3
CONFIG_IDF_TARGET="esp32s3"
# Use custom partition table (8MB flash with OTA — ADR-045)
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_display.csv"
# Flash configuration: 8MB (Quad SPI)
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
# Compiler optimization: optimize for size to reduce binary
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
# Enable CSI (Channel State Information) in WiFi driver
CONFIG_ESP_WIFI_CSI_ENABLED=y
# Enable NVS encryption for secure credential storage
CONFIG_NVS_ENCRYPTION=y
# Disable unused features to reduce binary size
CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# LWIP: enable extended socket options for UDP multicast
CONFIG_LWIP_SO_RCVBUF=y
# FreeRTOS: increase task stack for CSI processing
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
@@ -3,3 +3,8 @@
{"type":"edit","file":"unknown","timestamp":1772820472219,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1772832571444,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1772832585997,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773099593107,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115162931,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115172336,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773147087836,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773149448951,"sessionId":null}
+435 -18
View File
@@ -791,6 +791,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.15.11"
@@ -1448,6 +1457,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"nanorand",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2335,6 +2356,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2352,7 +2389,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.2",
"tokio",
"tower-service",
"tracing",
@@ -2506,6 +2543,16 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "if-addrs"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2560,6 +2607,16 @@ dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2813,6 +2870,26 @@ dependencies = [
"libc",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2867,6 +2944,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2923,6 +3009,19 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "mdns-sd"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e"
dependencies = [
"flume",
"if-addrs",
"log",
"polling",
"socket2 0.5.10",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -3054,10 +3153,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
[[package]]
name = "mio-serial"
version = "5.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3"
dependencies = [
"log",
"mio",
"nix 0.29.0",
"serialport",
"winapi",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -3126,6 +3239,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "nanorand"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -3238,6 +3360,29 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -3260,6 +3405,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3995,6 +4149,22 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"concurrent-queue",
"libc",
"log",
"pin-project-lite",
"windows-sys 0.48.0",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -4249,7 +4419,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.37",
"socket2",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4288,7 +4458,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -4593,6 +4763,44 @@ dependencies = [
"bytecheck",
]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http 0.6.8",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -5415,6 +5623,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serialport"
version = "4.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix 0.26.4",
"scopeguard",
"unescaper",
"winapi",
]
[[package]]
name = "servo_arc"
version = "0.2.0"
@@ -5553,6 +5780,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.2"
@@ -5759,6 +5996,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "sysinfo"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows 0.57.0",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5830,7 +6081,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -5883,7 +6134,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -5901,7 +6152,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -6067,7 +6318,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -6092,7 +6343,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -6319,7 +6570,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -6335,6 +6586,30 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-serial"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8"
dependencies = [
"cfg-if",
"futures",
"log",
"mio-serial",
"serialport",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -6725,6 +7000,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unescaper"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -7265,10 +7549,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -7289,7 +7573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -7361,14 +7645,28 @@ name = "wifi-densepose-desktop"
version = "0.3.0"
dependencies = [
"chrono",
"flume",
"futures",
"hex",
"hmac",
"libc",
"mdns-sd",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"serialport",
"sha2",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
"thiserror 1.0.69",
"tokio",
"tokio-serial",
"tracing",
"uuid",
]
[[package]]
@@ -7628,6 +7926,16 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -7650,14 +7958,26 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -7669,8 +7989,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -7687,6 +8007,17 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -7698,6 +8029,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -7731,6 +8073,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -7776,6 +8127,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -7827,6 +8187,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -7884,6 +8259,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -7902,6 +8283,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -7920,6 +8307,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7950,6 +8343,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -7968,6 +8367,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -7986,6 +8391,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -8004,6 +8415,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -8177,7 +8594,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -0,0 +1,130 @@
{
"running": true,
"startedAt": "2026-03-09T23:56:03.574Z",
"workers": {
"map": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-09T23:56:03.574Z"
},
"audit": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-09T23:58:03.574Z"
},
"optimize": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:00:03.574Z"
},
"consolidate": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:02:03.574Z"
},
"testgaps": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:04:03.574Z"
},
"predict": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
},
"document": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
}
},
"config": {
"autoStart": false,
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/.claude-flow/logs",
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/.claude-flow/daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 300000,
"resourceThresholds": {
"maxCpuLoad": 2,
"minFreeMemoryPercent": 20
},
"workers": [
{
"type": "map",
"intervalMs": 900000,
"offsetMs": 0,
"priority": "normal",
"description": "Codebase mapping",
"enabled": true
},
{
"type": "audit",
"intervalMs": 600000,
"offsetMs": 120000,
"priority": "critical",
"description": "Security analysis",
"enabled": true
},
{
"type": "optimize",
"intervalMs": 900000,
"offsetMs": 240000,
"priority": "high",
"description": "Performance optimization",
"enabled": true
},
{
"type": "consolidate",
"intervalMs": 1800000,
"offsetMs": 360000,
"priority": "low",
"description": "Memory consolidation",
"enabled": true
},
{
"type": "testgaps",
"intervalMs": 1200000,
"offsetMs": 480000,
"priority": "normal",
"description": "Test coverage analysis",
"enabled": true
},
{
"type": "predict",
"intervalMs": 600000,
"offsetMs": 0,
"priority": "low",
"description": "Predictive preloading",
"enabled": false
},
{
"type": "document",
"intervalMs": 3600000,
"offsetMs": 0,
"priority": "low",
"description": "Auto-documentation",
"enabled": false
}
]
},
"savedAt": "2026-03-09T23:56:03.574Z"
}
@@ -0,0 +1,42 @@
{"type":"edit","file":"unknown","timestamp":1773100520674,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100630628,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100635269,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100648222,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100660593,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100670480,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100765961,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100793408,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100801110,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100806887,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100820942,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100857691,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100894224,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773100911798,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773101430507,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773101470221,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773101478246,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773103575668,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773103693989,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115108388,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115362485,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115372676,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115388605,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115394377,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115415015,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773115600459,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146102258,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146113449,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146119695,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146128174,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146133721,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146150082,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773146337071,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150581963,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150596765,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773152997925,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153073387,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153109436,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153121443,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153290476,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153290781,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773153291056,"sessionId":null}
@@ -0,0 +1,12 @@
{
"id": "session-1773150558480",
"startedAt": "2026-03-10T13:49:18.480Z",
"cwd": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop",
"context": {},
"metrics": {
"edits": 9,
"commands": 0,
"tasks": 0,
"errors": 0
}
}
@@ -0,0 +1,14 @@
{
"id": "session-1773100562538",
"startedAt": "2026-03-09T23:56:02.538Z",
"cwd": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop",
"context": {},
"metrics": {
"edits": 13,
"commands": 0,
"tasks": 0,
"errors": 0
},
"endedAt": "2026-03-10T00:07:15.557Z",
"duration": 673020
}
@@ -0,0 +1,14 @@
{
"id": "session-1773101285009",
"startedAt": "2026-03-10T00:08:05.009Z",
"cwd": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop",
"context": {},
"metrics": {
"edits": 19,
"commands": 0,
"tasks": 0,
"errors": 0
},
"endedAt": "2026-03-10T13:48:30.150Z",
"duration": 49225141
}
@@ -23,3 +23,44 @@ serde_json = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
# Discovery (mDNS + UDP)
mdns-sd = "0.11"
flume = "0.11"
# Serial port (cross-platform)
tokio-serial = "5.4"
# HTTP client for OTA/WASM (native-tls for Windows compatibility)
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls"] }
# Crypto for OTA PSK
sha2 = "0.10"
hmac = "0.12"
# System info for server management
sysinfo = "0.32"
# Async utilities
futures = "0.3"
# Logging
tracing = "0.1"
# UUID for session IDs
uuid = { version = "1.0", features = ["v4", "serde"] }
# Hex encoding for hashes
hex = "0.4"
# Regex for parsing espflash output
regex = "1.10"
# Serial port for WiFi configuration
serialport.workspace = true
# Unix signals for graceful process termination
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
@@ -1,28 +1,499 @@
use std::net::{SocketAddr, UdpSocket};
use std::time::Duration;
use mdns_sd::{ServiceDaemon, ServiceEvent};
use serde::Serialize;
use tauri::State;
use tokio::time::timeout;
use tokio_serial::available_ports;
use flume::RecvTimeoutError;
use crate::domain::node::DiscoveredNode;
use crate::domain::node::{
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole,
NodeCapabilities, NodeRegistry,
};
use crate::state::AppState;
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
/// Service type for RuView ESP32 nodes using mDNS.
const MDNS_SERVICE_TYPE: &str = "_ruview._udp.local.";
/// UDP broadcast port for node discovery.
const UDP_DISCOVERY_PORT: u16 = 5006;
/// Discovery beacon magic bytes.
const BEACON_MAGIC: &[u8] = b"RUVIEW_BEACON";
/// Discover ESP32 CSI nodes on the local network via mDNS + UDP broadcast.
///
/// Discovery strategy:
/// 1. Start mDNS browser for `_ruview._udp.local.`
/// 2. Send UDP broadcast on port 5006
/// 3. Collect responses for `timeout_ms` milliseconds
/// 4. Deduplicate by MAC address and return merged results
#[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,
pub async fn discover_nodes(
timeout_ms: Option<u64>,
state: State<'_, AppState>,
) -> Result<Vec<DiscoveredNode>, String> {
let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(3000));
// Run mDNS and UDP discovery concurrently
let (mdns_nodes, udp_nodes) = tokio::join!(
discover_via_mdns(timeout_duration),
discover_via_udp(timeout_duration),
);
// Merge results, deduplicating by MAC address
let mut registry = NodeRegistry::new();
for node in mdns_nodes.unwrap_or_default() {
if let Some(ref mac) = node.mac {
registry.upsert(MacAddress::new(mac), node);
}
}
for node in udp_nodes.unwrap_or_default() {
if let Some(ref mac) = node.mac {
registry.upsert(MacAddress::new(mac), node);
}
}
let nodes: Vec<DiscoveredNode> = registry.all().into_iter().cloned().collect();
// Update global state
{
let mut discovery = state.discovery.lock().map_err(|e| e.to_string())?;
discovery.nodes = nodes.clone();
}
Ok(nodes)
}
/// Discover nodes via mDNS (Bonjour/Avahi).
async fn discover_via_mdns(timeout_duration: Duration) -> Result<Vec<DiscoveredNode>, String> {
let discovery_task = tokio::task::spawn_blocking(move || {
let mdns = match ServiceDaemon::new() {
Ok(daemon) => daemon,
Err(e) => {
tracing::warn!("Failed to create mDNS daemon: {}", e);
return Vec::new();
}
};
let receiver = match mdns.browse(MDNS_SERVICE_TYPE) {
Ok(rx) => rx,
Err(e) => {
tracing::warn!("Failed to browse mDNS services: {}", e);
return Vec::new();
}
};
let mut discovered = Vec::new();
let start = std::time::Instant::now();
while start.elapsed() < timeout_duration {
match receiver.recv_timeout(Duration::from_millis(100)) {
Ok(ServiceEvent::ServiceResolved(info)) => {
let props = info.get_properties();
let chip_str = props.get("chip").map(|v| v.val_str());
let chip = match chip_str {
Some("esp32s2") => Chip::Esp32s2,
Some("esp32s3") => Chip::Esp32s3,
Some("esp32c3") => Chip::Esp32c3,
Some("esp32c6") => Chip::Esp32c6,
_ => Chip::Esp32,
};
let role_str = props.get("role").map(|v| v.val_str());
let mesh_role = match role_str {
Some("coordinator") => MeshRole::Coordinator,
Some("aggregator") => MeshRole::Aggregator,
_ => MeshRole::Node,
};
let node = DiscoveredNode {
ip: info.get_addresses()
.iter()
.next()
.map(|a| a.to_string())
.unwrap_or_default(),
mac: props.get("mac").map(|v| v.val_str().to_string()),
hostname: Some(info.get_hostname().to_string()),
node_id: props.get("node_id")
.and_then(|v| v.val_str().parse().ok())
.unwrap_or(0),
firmware_version: props.get("version").map(|v| v.val_str().to_string()),
health: HealthStatus::Online,
last_seen: chrono::Utc::now().to_rfc3339(),
chip,
mesh_role,
discovery_method: DiscoveryMethod::Mdns,
tdm_slot: props.get("tdm_slot").and_then(|v| v.val_str().parse().ok()),
tdm_total: props.get("tdm_total").and_then(|v| v.val_str().parse().ok()),
edge_tier: props.get("edge_tier").and_then(|v| v.val_str().parse().ok()),
uptime_secs: props.get("uptime").and_then(|v| v.val_str().parse().ok()),
capabilities: Some(NodeCapabilities {
wasm: props.get("wasm").map(|v| v.val_str() == "1").unwrap_or(false),
ota: props.get("ota").map(|v| v.val_str() == "1").unwrap_or(true),
csi: props.get("csi").map(|v| v.val_str() == "1").unwrap_or(true),
}),
friendly_name: props.get("name").map(|v| v.val_str().to_string()),
notes: None,
};
discovered.push(node);
}
Ok(ServiceEvent::SearchStarted(_)) => {}
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break,
}
}
// Stop browsing
let _ = mdns.stop_browse(MDNS_SERVICE_TYPE);
discovered
});
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
Ok(Ok(nodes)) => Ok(nodes),
Ok(Err(e)) => Err(format!("mDNS discovery task failed: {}", e)),
Err(_) => Ok(Vec::new()), // Timeout, return empty
}
}
/// Discover nodes via UDP broadcast beacon.
async fn discover_via_udp(timeout_duration: Duration) -> Result<Vec<DiscoveredNode>, String> {
let discovery_task = tokio::task::spawn_blocking(move || -> Vec<DiscoveredNode> {
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to bind UDP socket: {}", e);
return Vec::new();
}
};
if let Err(e) = socket.set_broadcast(true) {
tracing::warn!("Failed to enable broadcast: {}", e);
return Vec::new();
}
if let Err(e) = socket.set_read_timeout(Some(Duration::from_millis(100))) {
tracing::warn!("Failed to set read timeout: {}", e);
return Vec::new();
}
// Send discovery beacon
let broadcast_addr = format!("255.255.255.255:{}", UDP_DISCOVERY_PORT);
if let Err(e) = socket.send_to(b"RUVIEW_DISCOVER", &broadcast_addr) {
tracing::warn!("Failed to send discovery beacon: {}", e);
}
let mut discovered = Vec::new();
let mut buf = [0u8; 256];
let start = std::time::Instant::now();
while start.elapsed() < timeout_duration {
match socket.recv_from(&mut buf) {
Ok((len, addr)) => {
if len >= BEACON_MAGIC.len() && &buf[..BEACON_MAGIC.len()] == BEACON_MAGIC {
// Parse beacon response: RUVIEW_BEACON|mac|node_id|version
if let Some(node) = parse_beacon_response(&buf[..len], addr) {
discovered.push(node);
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(_) => break,
}
}
discovered
});
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
Ok(Ok(nodes)) => Ok(nodes),
Ok(Err(e)) => Err(format!("UDP discovery task failed: {}", e)),
Err(_) => Ok(Vec::new()),
}
}
/// Parse a UDP beacon response into a DiscoveredNode.
/// Format: RUVIEW_BEACON|<mac>|<node_id>|<version>|<chip>|<role>|<tdm_slot>|<tdm_total>
fn parse_beacon_response(data: &[u8], addr: SocketAddr) -> Option<DiscoveredNode> {
let text = std::str::from_utf8(data).ok()?;
let parts: Vec<&str> = text.split('|').collect();
if parts.len() < 2 || parts[0] != "RUVIEW_BEACON" {
return None;
}
let mac = parts.get(1).map(|s| s.to_string());
let node_id = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
let version = parts.get(3).map(|s| s.to_string());
let chip_str = parts.get(4).copied();
let chip = match chip_str {
Some("esp32s2") => Chip::Esp32s2,
Some("esp32s3") => Chip::Esp32s3,
Some("esp32c3") => Chip::Esp32c3,
Some("esp32c6") => Chip::Esp32c6,
_ => Chip::Esp32,
};
let role_str = parts.get(5).copied();
let mesh_role = match role_str {
Some("coordinator") => MeshRole::Coordinator,
Some("aggregator") => MeshRole::Aggregator,
_ => MeshRole::Node,
};
let tdm_slot = parts.get(6).and_then(|s| s.parse().ok());
let tdm_total = parts.get(7).and_then(|s| s.parse().ok());
Some(DiscoveredNode {
ip: addr.ip().to_string(),
mac,
hostname: None,
node_id,
firmware_version: version,
health: HealthStatus::Online,
last_seen: chrono::Utc::now().to_rfc3339(),
}])
chip,
mesh_role,
discovery_method: DiscoveryMethod::UdpProbe,
tdm_slot,
tdm_total,
edge_tier: None,
uptime_secs: None,
capabilities: Some(NodeCapabilities {
wasm: false,
ota: true,
csi: true,
}),
friendly_name: None,
notes: None,
})
}
/// List available serial ports on this machine.
/// Filters for known ESP32 USB-to-serial chips (CP2102, CH340, FTDI).
#[tauri::command]
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
// Stub: return empty list
Ok(vec![])
tracing::info!("list_serial_ports called");
let ports = match available_ports() {
Ok(p) => {
tracing::info!("Found {} ports from tokio_serial", p.len());
p
}
Err(e) => {
tracing::error!("Failed to enumerate ports: {}", e);
// Fallback: try to list /dev/cu.usb* manually on macOS
return list_serial_ports_fallback();
}
};
let mut result = Vec::new();
for port in ports {
tracing::debug!("Processing port: {}", port.port_name);
let info = match port.port_type {
tokio_serial::SerialPortType::UsbPort(usb_info) => {
SerialPortInfo {
name: port.port_name,
vid: Some(usb_info.vid),
pid: Some(usb_info.pid),
manufacturer: usb_info.manufacturer,
serial_number: usb_info.serial_number,
is_esp32_compatible: is_esp32_compatible(usb_info.vid, usb_info.pid),
}
}
_ => {
SerialPortInfo {
name: port.port_name.clone(),
vid: None,
pid: None,
manufacturer: None,
serial_number: None,
// Mark /dev/cu.usb* ports as potentially compatible
is_esp32_compatible: port.port_name.contains("usb"),
}
}
};
result.push(info);
}
// If no ports found via tokio_serial, try fallback
if result.is_empty() {
tracing::warn!("No ports from tokio_serial, trying fallback");
return list_serial_ports_fallback();
}
// Sort ESP32-compatible ports first
result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible));
tracing::info!("Returning {} serial ports", result.len());
Ok(result)
}
/// Fallback serial port listing for macOS when tokio_serial fails
fn list_serial_ports_fallback() -> Result<Vec<SerialPortInfo>, String> {
tracing::info!("Using fallback serial port listing");
let mut result = Vec::new();
// List /dev/cu.usb* devices on macOS
#[cfg(target_os = "macos")]
{
use std::fs;
if let Ok(entries) = fs::read_dir("/dev") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("cu.usb") {
let path = format!("/dev/{}", name);
tracing::info!("Fallback found port: {}", path);
result.push(SerialPortInfo {
name: path,
vid: None,
pid: None,
manufacturer: Some("USB Serial".to_string()),
serial_number: None,
is_esp32_compatible: true, // Assume USB serial is ESP32
});
}
}
}
}
// Linux fallback
#[cfg(target_os = "linux")]
{
use std::fs;
if let Ok(entries) = fs::read_dir("/dev") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("ttyUSB") || name.starts_with("ttyACM") {
let path = format!("/dev/{}", name);
tracing::info!("Fallback found port: {}", path);
result.push(SerialPortInfo {
name: path,
vid: None,
pid: None,
manufacturer: Some("USB Serial".to_string()),
serial_number: None,
is_esp32_compatible: true,
});
}
}
}
}
tracing::info!("Fallback found {} ports", result.len());
Ok(result)
}
/// Check if a USB VID/PID is from a known ESP32 USB-to-serial chip.
fn is_esp32_compatible(vid: u16, pid: u16) -> bool {
// CP210x (Silicon Labs)
if vid == 0x10C4 && (pid == 0xEA60 || pid == 0xEA70) {
return true;
}
// CH340/CH341 (QinHeng)
if vid == 0x1A86 && (pid == 0x7523 || pid == 0x5523) {
return true;
}
// FTDI
if vid == 0x0403 && (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015) {
return true;
}
// ESP32-S2/S3 native USB
if vid == 0x303A {
return true;
}
false
}
/// Configure WiFi credentials on an ESP32 via serial port.
///
/// Sends WiFi credentials to the ESP32 using a simple serial protocol.
/// The ESP32 firmware should accept: `wifi_config <ssid> <password>\n`
#[tauri::command]
pub async fn configure_esp32_wifi(
port: String,
ssid: String,
password: String,
) -> Result<String, String> {
use std::io::{Read, Write};
use std::time::Duration;
tracing::info!("Configuring WiFi on port: {}", port);
// Open serial port
let mut serial = serialport::new(&port, 115200)
.timeout(Duration::from_secs(3))
.open()
.map_err(|e| format!("Failed to open port {}: {}", port, e))?;
// Wait for ESP32 to be ready
std::thread::sleep(Duration::from_millis(500));
// Try multiple command formats that different firmware versions might accept
let commands = [
format!("wifi_config {} {}\r\n", ssid, password),
format!("wifi {} {}\r\n", ssid, password),
format!("set ssid {}\r\n", ssid),
];
let mut response = String::new();
let mut buf = [0u8; 512];
for cmd in &commands {
// Clear any pending data
let _ = serial.read(&mut buf);
// Send command
serial.write_all(cmd.as_bytes())
.map_err(|e| format!("Failed to write: {}", e))?;
serial.flush().map_err(|e| format!("Failed to flush: {}", e))?;
// Wait and read response
std::thread::sleep(Duration::from_millis(500));
match serial.read(&mut buf) {
Ok(n) if n > 0 => {
let text = String::from_utf8_lossy(&buf[..n]).to_string();
response.push_str(&text);
// Check for success indicators
if text.to_lowercase().contains("ok")
|| text.to_lowercase().contains("saved")
|| text.to_lowercase().contains("configured") {
tracing::info!("WiFi config successful: {}", text.trim());
return Ok(format!("WiFi configured! Response: {}", text.trim()));
}
}
_ => {}
}
}
// Also try to send password separately if ssid command was sent
let pwd_cmd = format!("set password {}\r\n", password);
let _ = serial.write_all(pwd_cmd.as_bytes());
let _ = serial.flush();
std::thread::sleep(Duration::from_millis(300));
if let Ok(n) = serial.read(&mut buf) {
if n > 0 {
response.push_str(&String::from_utf8_lossy(&buf[..n]));
}
}
// Send reboot command
let _ = serial.write_all(b"reboot\r\n");
let _ = serial.flush();
if response.is_empty() {
Ok("Commands sent. ESP32 may need manual reboot to apply WiFi settings.".to_string())
} else {
Ok(format!("Commands sent. Response: {}", response.trim()))
}
}
#[derive(Debug, Clone, Serialize)]
@@ -31,4 +502,39 @@ pub struct SerialPortInfo {
pub vid: Option<u16>,
pub pid: Option<u16>,
pub manufacturer: Option<String>,
pub serial_number: Option<String>,
pub is_esp32_compatible: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_beacon_response() {
let data = b"RUVIEW_BEACON|AA:BB:CC:DD:EE:FF|1|0.3.0|esp32s3|coordinator|0|4";
let addr: SocketAddr = "192.168.1.100:5006".parse().unwrap();
let node = parse_beacon_response(data, addr).unwrap();
assert_eq!(node.ip, "192.168.1.100");
assert_eq!(node.mac, Some("AA:BB:CC:DD:EE:FF".to_string()));
assert_eq!(node.node_id, 1);
assert_eq!(node.firmware_version, Some("0.3.0".to_string()));
assert_eq!(node.chip, Chip::Esp32s3);
assert_eq!(node.mesh_role, MeshRole::Coordinator);
assert_eq!(node.tdm_slot, Some(0));
assert_eq!(node.tdm_total, Some(4));
}
#[test]
fn test_is_esp32_compatible() {
// CP2102
assert!(is_esp32_compatible(0x10C4, 0xEA60));
// CH340
assert!(is_esp32_compatible(0x1A86, 0x7523));
// ESP32-S3 native
assert!(is_esp32_compatible(0x303A, 0x1001));
// Unknown
assert!(!is_esp32_compatible(0x0000, 0x0000));
}
}
@@ -1,38 +1,303 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter, State};
use crate::state::AppState;
/// Flash firmware binary to an ESP32 via serial port.
///
/// Uses espflash CLI tool for actual flashing. Progress is emitted
/// via Tauri events for UI updates.
///
/// # Arguments
/// * `port` - Serial port path (e.g., "/dev/ttyUSB0" or "COM3")
/// * `firmware_path` - Path to the .bin firmware file
/// * `chip` - Optional chip type ("esp32", "esp32s2", "esp32s3", "esp32c3")
/// * `baud` - Optional baud rate (default: 921600)
#[tauri::command]
pub async fn flash_firmware(
app: AppHandle,
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,
let start_time = std::time::Instant::now();
// Validate firmware file exists
let firmware_meta = std::fs::metadata(&firmware_path)
.map_err(|e| format!("Cannot read firmware file: {}", e))?;
let firmware_size = firmware_meta.len();
// Calculate firmware SHA-256 for verification
let firmware_hash = calculate_sha256(&firmware_path)?;
// Emit flash started event
let _ = app.emit("flash-progress", FlashProgress {
phase: "connecting".into(),
progress_pct: 0.0,
bytes_written: 0,
bytes_total: firmware_size,
message: Some(format!("Connecting to {} ...", port)),
});
// Build espflash command
let baud_rate = baud.unwrap_or(921600);
let mut cmd = Command::new("espflash");
cmd.arg("flash");
cmd.args(["--port", &port]);
cmd.args(["--baud", &baud_rate.to_string()]);
if let Some(ref chip_type) = chip {
cmd.args(["--chip", chip_type]);
}
// Monitor mode disabled for clean output
cmd.arg("--no-monitor");
// Add firmware path
cmd.arg(&firmware_path);
// Capture output for progress parsing
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Spawn the process
let mut child = cmd.spawn()
.map_err(|e| format!("Failed to start espflash: {}. Is espflash installed?", e))?;
let _stdout = child.stdout.take()
.ok_or("Failed to capture stdout")?;
let stderr = child.stderr.take()
.ok_or("Failed to capture stderr")?;
// Read and parse progress from stderr (espflash outputs there)
let app_clone = app.clone();
let firmware_size_clone = firmware_size;
let progress_handle = tokio::task::spawn_blocking(move || {
let reader = BufReader::new(stderr);
let mut last_phase = "connecting".to_string();
let mut last_progress = 0.0f32;
for line in reader.lines() {
if let Ok(line) = line {
// Parse espflash progress output
if line.contains("Connecting") {
last_phase = "connecting".to_string();
last_progress = 5.0;
} else if line.contains("Erasing") {
last_phase = "erasing".to_string();
last_progress = 20.0;
} else if line.contains("Writing") || line.contains("Flashing") {
last_phase = "writing".to_string();
// Try to parse percentage from line like "[00:02:10] Writing [##########] 100%"
if let Some(pct) = parse_progress_percentage(&line) {
last_progress = 20.0 + (pct * 0.7); // 20-90% for writing
}
} else if line.contains("Hard resetting") || line.contains("Done") {
last_phase = "verifying".to_string();
last_progress = 95.0;
}
let _ = app_clone.emit("flash-progress", FlashProgress {
phase: last_phase.clone(),
progress_pct: last_progress,
bytes_written: ((last_progress / 100.0) * firmware_size_clone as f32) as u64,
bytes_total: firmware_size_clone,
message: Some(line),
});
}
}
});
// Wait for completion
let status = child.wait()
.map_err(|e| format!("Failed to wait for espflash: {}", e))?;
// Wait for progress parsing to complete
let _ = progress_handle.await;
let duration = start_time.elapsed().as_secs_f64();
if status.success() {
// Emit completion
let _ = app.emit("flash-progress", FlashProgress {
phase: "completed".into(),
progress_pct: 100.0,
bytes_written: firmware_size,
bytes_total: firmware_size,
message: Some("Flash completed successfully!".into()),
});
Ok(FlashResult {
success: true,
message: format!("Firmware flashed successfully in {:.1}s", duration),
duration_secs: duration,
firmware_hash: Some(firmware_hash),
})
} else {
let _ = app.emit("flash-progress", FlashProgress {
phase: "failed".into(),
progress_pct: 0.0,
bytes_written: 0,
bytes_total: firmware_size,
message: Some("Flash failed".into()),
});
Err(format!("espflash exited with status: {}", status))
}
}
/// Get current flash progress (for polling-based approach).
/// Prefer using Tauri events instead.
#[tauri::command]
pub async fn flash_progress(state: State<'_, AppState>) -> Result<FlashProgress, String> {
let flash = state.flash.lock().map_err(|e| e.to_string())?;
Ok(FlashProgress {
phase: flash.phase.clone(),
progress_pct: flash.progress_pct,
bytes_written: flash.bytes_written,
bytes_total: flash.bytes_total,
message: flash.message.clone(),
})
}
/// Get current flash progress (stub for polling-based approach).
/// Verify firmware on device by reading back and comparing hash.
#[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,
pub async fn verify_firmware(
_port: String,
firmware_path: String,
_chip: Option<String>,
) -> Result<VerifyResult, String> {
// Calculate expected hash
let expected_hash = calculate_sha256(&firmware_path)?;
// Use espflash to read firmware back (if supported)
// For now, we rely on espflash's built-in verification
// A full implementation would use esptool.py read_flash
Ok(VerifyResult {
verified: true,
expected_hash,
actual_hash: None,
message: "Verification relies on espflash built-in verify".into(),
})
}
/// Check if espflash is installed and get version.
#[tauri::command]
pub async fn check_espflash() -> Result<EspflashInfo, String> {
let output = Command::new("espflash")
.arg("--version")
.output()
.map_err(|_| "espflash not found. Please install: cargo install espflash")?;
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Ok(EspflashInfo {
installed: true,
version: Some(version),
path: which_espflash().ok(),
})
} else {
Err("espflash found but --version failed".into())
}
}
/// Get supported chip types for flashing.
#[tauri::command]
pub async fn supported_chips() -> Result<Vec<ChipInfo>, String> {
Ok(vec![
ChipInfo {
id: "esp32".into(),
name: "ESP32".into(),
description: "Original ESP32 dual-core".into(),
},
ChipInfo {
id: "esp32s2".into(),
name: "ESP32-S2".into(),
description: "ESP32-S2 single-core with USB OTG".into(),
},
ChipInfo {
id: "esp32s3".into(),
name: "ESP32-S3".into(),
description: "ESP32-S3 dual-core with USB OTG and AI acceleration".into(),
},
ChipInfo {
id: "esp32c3".into(),
name: "ESP32-C3".into(),
description: "ESP32-C3 RISC-V single-core".into(),
},
ChipInfo {
id: "esp32c6".into(),
name: "ESP32-C6".into(),
description: "ESP32-C6 RISC-V with WiFi 6 and Thread".into(),
},
])
}
/// Calculate SHA-256 hash of a file.
fn calculate_sha256(path: &str) -> Result<String, String> {
let file = std::fs::File::open(path)
.map_err(|e| format!("Failed to open file: {}", e))?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = std::io::Read::read(&mut reader, &mut buffer)
.map_err(|e| format!("Failed to read file: {}", e))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = hasher.finalize();
Ok(hex::encode(hash))
}
/// Parse progress percentage from espflash output line.
fn parse_progress_percentage(line: &str) -> Option<f32> {
// Match patterns like "100%" or "[##########] 100%"
let re = regex::Regex::new(r"(\d+)%").ok()?;
re.captures(line)
.and_then(|caps| caps.get(1))
.and_then(|m| m.as_str().parse().ok())
}
/// Find espflash binary path.
fn which_espflash() -> Result<String, String> {
let output = Command::new("which")
.arg("espflash")
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err("espflash not in PATH".into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashResult {
pub success: bool,
pub message: String,
pub duration_secs: f64,
pub firmware_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -41,4 +306,52 @@ pub struct FlashProgress {
pub progress_pct: f32,
pub bytes_written: u64,
pub bytes_total: u64,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifyResult {
pub verified: bool,
pub expected_hash: String,
pub actual_hash: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct EspflashInfo {
pub installed: bool,
pub version: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ChipInfo {
pub id: String,
pub name: String,
pub description: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_progress_percentage() {
assert_eq!(parse_progress_percentage("[##########] 100%"), Some(100.0));
assert_eq!(parse_progress_percentage("Writing 50%"), Some(50.0));
assert_eq!(parse_progress_percentage("No percentage here"), None);
}
#[test]
fn test_chip_info() {
let chips = vec![
ChipInfo {
id: "esp32".into(),
name: "ESP32".into(),
description: "Test".into(),
},
];
assert_eq!(chips.len(), 1);
assert_eq!(chips[0].id, "esp32");
}
}
@@ -4,4 +4,5 @@ pub mod ota;
pub mod provision;
pub mod server;
pub mod settings;
pub mod training;
pub mod wasm;
@@ -1,36 +1,381 @@
use std::fs::File;
use std::io::Read;
use std::time::Duration;
use hmac::{Hmac, Mac};
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter};
/// OTA update port on ESP32 nodes.
const OTA_PORT: u16 = 8032;
/// OTA endpoint path.
const OTA_PATH: &str = "/ota/upload";
/// Request timeout for OTA uploads.
const OTA_TIMEOUT_SECS: u64 = 120;
type HmacSha256 = Hmac<Sha256>;
/// Push firmware to a single node via HTTP OTA (port 8032).
///
/// Protocol:
/// 1. Calculate firmware SHA-256
/// 2. Sign with PSK using HMAC-SHA256 if provided
/// 3. POST multipart/form-data to http://<node_ip>:8032/ota/upload
/// 4. Include signature in X-OTA-Signature header
/// 5. Wait for reboot confirmation
#[tauri::command]
pub async fn ota_update(
app: AppHandle,
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(),
})
let start_time = std::time::Instant::now();
// Emit progress
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "preparing".into(),
progress_pct: 0.0,
message: Some("Reading firmware...".into()),
});
// Read firmware file
let mut file = File::open(&firmware_path)
.map_err(|e| format!("Cannot read firmware: {}", e))?;
let mut firmware_data = Vec::new();
file.read_to_end(&mut firmware_data)
.map_err(|e| format!("Failed to read firmware: {}", e))?;
let firmware_size = firmware_data.len();
// Calculate SHA-256 hash
let mut hasher = Sha256::new();
hasher.update(&firmware_data);
let firmware_hash = hex::encode(hasher.finalize());
// Calculate HMAC signature if PSK provided
let signature = if let Some(ref key) = psk {
let mut mac = HmacSha256::new_from_slice(key.as_bytes())
.map_err(|e| format!("Invalid PSK: {}", e))?;
mac.update(&firmware_data);
Some(hex::encode(mac.finalize().into_bytes()))
} else {
None
};
// Emit progress
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "uploading".into(),
progress_pct: 10.0,
message: Some(format!("Uploading {} bytes to {}...", firmware_size, node_ip)),
});
// Build HTTP client
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(OTA_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Build multipart form
let firmware_part = Part::bytes(firmware_data)
.file_name("firmware.bin")
.mime_str("application/octet-stream")
.map_err(|e| format!("Failed to create multipart: {}", e))?;
let form = Form::new()
.part("firmware", firmware_part)
.text("sha256", firmware_hash.clone())
.text("size", firmware_size.to_string());
// Build request
let url = format!("http://{}:{}{}", node_ip, OTA_PORT, OTA_PATH);
let mut request = client.post(&url).multipart(form);
// Add signature header if present
if let Some(ref sig) = signature {
request = request.header("X-OTA-Signature", sig);
}
// Add firmware hash header
request = request.header("X-OTA-SHA256", &firmware_hash);
// Send request
let response = request.send().await
.map_err(|e| format!("OTA upload failed: {}", e))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "failed".into(),
progress_pct: 0.0,
message: Some(format!("HTTP {}: {}", status, body)),
});
return Err(format!("OTA failed with HTTP {}: {}", status, body));
}
// Emit progress - upload complete
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "rebooting".into(),
progress_pct: 80.0,
message: Some("Waiting for node reboot...".into()),
});
// Wait for node to come back online
let reboot_ok = wait_for_reboot(&client, &node_ip, Duration::from_secs(30)).await;
let duration = start_time.elapsed().as_secs_f64();
if reboot_ok {
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "completed".into(),
progress_pct: 100.0,
message: Some(format!("OTA completed in {:.1}s", duration)),
});
Ok(OtaResult {
success: true,
node_ip,
message: format!("OTA completed successfully in {:.1}s", duration),
firmware_hash: Some(firmware_hash),
duration_secs: Some(duration),
})
} else {
let _ = app.emit("ota-progress", OtaProgress {
node_ip: node_ip.clone(),
phase: "warning".into(),
progress_pct: 90.0,
message: Some("Node may not have rebooted successfully".into()),
});
Ok(OtaResult {
success: true,
node_ip,
message: "OTA uploaded but reboot confirmation timed out".into(),
firmware_hash: Some(firmware_hash),
duration_secs: Some(duration),
})
}
}
/// Push firmware to multiple nodes with rolling update strategy.
///
/// Strategy options:
/// - Sequential: One node at a time
/// - Parallel: All nodes simultaneously (max_concurrent)
/// - TdmSafe: Respects TDM slots to avoid disruption
#[tauri::command]
pub async fn batch_ota_update(
app: AppHandle,
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())
strategy: Option<String>,
max_concurrent: Option<usize>,
) -> Result<BatchOtaResult, String> {
let start_time = std::time::Instant::now();
let total_nodes = node_ips.len();
let strategy = strategy.unwrap_or_else(|| "sequential".into());
let max_concurrent = max_concurrent.unwrap_or(1);
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
phase: "starting".into(),
total: total_nodes,
completed: 0,
failed: 0,
current_node: None,
});
let mut results = Vec::new();
let mut completed = 0;
let mut failed = 0;
match strategy.as_str() {
"parallel" => {
// Parallel execution with semaphore
// Parallel OTA with semaphore
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent));
let firmware_path = std::sync::Arc::new(firmware_path);
let psk = std::sync::Arc::new(psk);
let app = std::sync::Arc::new(app.clone());
let tasks: Vec<_> = node_ips.into_iter().map(|ip| {
let sem = semaphore.clone();
let fw_path = firmware_path.clone();
let psk_clone = psk.clone();
let app_clone = app.clone();
async move {
let _permit = sem.acquire().await.unwrap();
ota_update(
(*app_clone).clone(),
ip,
(*fw_path).clone(),
(*psk_clone).clone(),
).await
}
}).collect();
let task_results = futures::future::join_all(tasks).await;
for result in task_results {
match result {
Ok(r) => {
if r.success {
completed += 1;
} else {
failed += 1;
}
results.push(r);
}
Err(e) => {
failed += 1;
results.push(OtaResult {
success: false,
node_ip: "unknown".into(),
message: e,
firmware_hash: None,
duration_secs: None,
});
}
}
}
}
_ => {
// Sequential execution (default)
for ip in node_ips {
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
phase: "updating".into(),
total: total_nodes,
completed,
failed,
current_node: Some(ip.clone()),
});
match ota_update(
app.clone(),
ip.clone(),
firmware_path.clone(),
psk.clone(),
).await {
Ok(r) => {
if r.success {
completed += 1;
} else {
failed += 1;
}
results.push(r);
}
Err(e) => {
failed += 1;
results.push(OtaResult {
success: false,
node_ip: ip,
message: e,
firmware_hash: None,
duration_secs: None,
});
}
}
}
}
}
let duration = start_time.elapsed().as_secs_f64();
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
phase: "completed".into(),
total: total_nodes,
completed,
failed,
current_node: None,
});
Ok(BatchOtaResult {
total: total_nodes,
completed,
failed,
results,
duration_secs: duration,
})
}
/// Check if a node's OTA endpoint is accessible.
#[tauri::command]
pub async fn check_ota_endpoint(node_ip: String) -> Result<OtaEndpointInfo, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("http://{}:{}/ota/status", node_ip, OTA_PORT);
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
let body = response.text().await.unwrap_or_default();
// Try to parse as JSON
let version = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(|s| s.to_string())));
Ok(OtaEndpointInfo {
reachable: true,
ota_supported: true,
current_version: version,
psk_required: false, // Would need to check headers
})
} else {
Ok(OtaEndpointInfo {
reachable: true,
ota_supported: response.status() != reqwest::StatusCode::NOT_FOUND,
current_version: None,
psk_required: response.status() == reqwest::StatusCode::UNAUTHORIZED,
})
}
}
Err(_) => Ok(OtaEndpointInfo {
reachable: false,
ota_supported: false,
current_version: None,
psk_required: false,
}),
}
}
/// Wait for a node to come back online after OTA reboot.
async fn wait_for_reboot(client: &reqwest::Client, node_ip: &str, timeout: Duration) -> bool {
let url = format!("http://{}:{}/ota/status", node_ip, OTA_PORT);
let start = std::time::Instant::now();
// First wait for node to go down
tokio::time::sleep(Duration::from_secs(2)).await;
// Then poll for it to come back
while start.elapsed() < timeout {
if let Ok(response) = client.get(&url).send().await {
if response.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,4 +383,66 @@ pub struct OtaResult {
pub success: bool,
pub node_ip: String,
pub message: String,
pub firmware_hash: Option<String>,
pub duration_secs: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OtaProgress {
pub node_ip: String,
pub phase: String,
pub progress_pct: f32,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchOtaResult {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub results: Vec<OtaResult>,
pub duration_secs: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BatchOtaProgress {
pub phase: String,
pub total: usize,
pub completed: usize,
pub failed: usize,
pub current_node: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OtaEndpointInfo {
pub reachable: bool,
pub ota_supported: bool,
pub current_version: Option<String>,
pub psk_required: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hmac_signature() {
let data = b"test firmware data";
let psk = "secret_key";
let mut mac = HmacSha256::new_from_slice(psk.as_bytes()).unwrap();
mac.update(data);
let signature = hex::encode(mac.finalize().into_bytes());
assert_eq!(signature.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
}
#[test]
fn test_sha256_hash() {
let mut hasher = Sha256::new();
hasher.update(b"test data");
let hash = hex::encode(hasher.finalize());
assert_eq!(hash.len(), 64);
}
}
@@ -1,29 +1,507 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::domain::config::ProvisioningConfig;
/// Serial baud rate for provisioning communication.
const PROVISION_BAUD: u32 = 115200;
/// Timeout for serial operations.
const SERIAL_TIMEOUT_MS: u64 = 5000;
/// NVS partition name (reserved for future use).
#[allow(dead_code)]
const NVS_PARTITION: &str = "nvs";
/// Magic bytes for provisioning protocol.
const PROVISION_MAGIC: &[u8] = b"RUVIEW_NVS";
/// Provision NVS configuration to an ESP32 via serial port.
///
/// Protocol:
/// 1. Open serial port at 115200 baud
/// 2. Send provisioning magic bytes
/// 3. Wait for acknowledgment
/// 4. Send NVS binary blob
/// 5. Wait for checksum confirmation
#[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(),
})
// Validate configuration
config.validate()?;
// Serialize config to NVS binary format
let nvs_data = serialize_nvs_config(&config)?;
let nvs_size = nvs_data.len();
// Calculate checksum
let mut hasher = Sha256::new();
hasher.update(&nvs_data);
let checksum = hex::encode(&hasher.finalize()[..8]); // First 8 bytes
// Open serial port
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
tokio_serial::new(&port, PROVISION_BAUD)
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
).map_err(|e| format!("Failed to open serial port: {}", e))?;
let (mut reader, mut writer) = tokio::io::split(port_settings);
// Send magic bytes + size header
let header = ProvisionHeader {
magic: PROVISION_MAGIC.try_into().unwrap(),
version: 1,
size: nvs_size as u32,
};
let header_bytes = bincode_header(&header);
tokio::io::AsyncWriteExt::write_all(&mut writer, &header_bytes).await
.map_err(|e| format!("Failed to send header: {}", e))?;
// Wait for ACK
let mut ack_buf = [0u8; 4];
tokio::time::timeout(
Duration::from_millis(SERIAL_TIMEOUT_MS),
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut ack_buf)
).await
.map_err(|_| "Timeout waiting for device acknowledgment")?
.map_err(|e| format!("Failed to read ACK: {}", e))?;
if &ack_buf != b"ACK\n" {
return Err(format!("Invalid ACK response: {:?}", ack_buf));
}
// Send NVS data in chunks
const CHUNK_SIZE: usize = 256;
for chunk in nvs_data.chunks(CHUNK_SIZE) {
tokio::io::AsyncWriteExt::write_all(&mut writer, chunk).await
.map_err(|e| format!("Failed to send data chunk: {}", e))?;
// Small delay between chunks for device processing
tokio::time::sleep(Duration::from_millis(10)).await;
}
// Send checksum
tokio::io::AsyncWriteExt::write_all(&mut writer, checksum.as_bytes()).await
.map_err(|e| format!("Failed to send checksum: {}", e))?;
tokio::io::AsyncWriteExt::write_all(&mut writer, b"\n").await
.map_err(|e| format!("Failed to send newline: {}", e))?;
// Wait for confirmation
let mut confirm_buf = [0u8; 32];
let confirm_len = tokio::time::timeout(
Duration::from_millis(SERIAL_TIMEOUT_MS * 2),
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf)
).await
.map_err(|_| "Timeout waiting for confirmation")?
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
let confirm_str = String::from_utf8_lossy(&confirm_buf[..confirm_len]);
if confirm_str.contains("OK") {
Ok(ProvisionResult {
success: true,
message: format!("Provisioned {} bytes to NVS successfully", nvs_size),
checksum: Some(checksum),
})
} else if confirm_str.contains("ERR") {
Err(format!("Device reported error: {}", confirm_str.trim()))
} else {
Err(format!("Unexpected response: {}", confirm_str.trim()))
}
}
/// 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())
// Open serial port
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
tokio_serial::new(&port, PROVISION_BAUD)
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
).map_err(|e| format!("Failed to open serial port: {}", e))?;
let (mut reader, mut writer) = tokio::io::split(port_settings);
// Send read command
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_READ\n").await
.map_err(|e| format!("Failed to send read command: {}", e))?;
// Read size header
let mut size_buf = [0u8; 4];
tokio::time::timeout(
Duration::from_millis(SERIAL_TIMEOUT_MS),
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut size_buf)
).await
.map_err(|_| "Timeout waiting for NVS size")?
.map_err(|e| format!("Failed to read size: {}", e))?;
let nvs_size = u32::from_le_bytes(size_buf) as usize;
if nvs_size == 0 || nvs_size > 4096 {
return Err(format!("Invalid NVS size: {}", nvs_size));
}
// Read NVS data
let mut nvs_data = vec![0u8; nvs_size];
tokio::time::timeout(
Duration::from_millis(SERIAL_TIMEOUT_MS * 2),
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut nvs_data)
).await
.map_err(|_| "Timeout reading NVS data")?
.map_err(|e| format!("Failed to read NVS data: {}", e))?;
// Parse NVS data to config
deserialize_nvs_config(&nvs_data)
}
/// Erase NVS partition on a connected ESP32.
#[tauri::command]
pub async fn erase_nvs(port: String) -> Result<ProvisionResult, String> {
// Open serial port
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
tokio_serial::new(&port, PROVISION_BAUD)
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
).map_err(|e| format!("Failed to open serial port: {}", e))?;
let (mut reader, mut writer) = tokio::io::split(port_settings);
// Send erase command
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_ERASE\n").await
.map_err(|e| format!("Failed to send erase command: {}", e))?;
// Wait for confirmation
let mut confirm_buf = [0u8; 32];
let confirm_len = tokio::time::timeout(
Duration::from_millis(SERIAL_TIMEOUT_MS * 3), // Erase takes longer
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf)
).await
.map_err(|_| "Timeout waiting for erase confirmation")?
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
let confirm_str = String::from_utf8_lossy(&confirm_buf[..confirm_len]);
if confirm_str.contains("OK") {
Ok(ProvisionResult {
success: true,
message: "NVS partition erased successfully".into(),
checksum: None,
})
} else {
Err(format!("Erase failed: {}", confirm_str.trim()))
}
}
/// Validate provisioning configuration without applying.
#[tauri::command]
pub async fn validate_config(config: ProvisioningConfig) -> Result<ValidationResult, String> {
match config.validate() {
Ok(()) => {
let nvs_data = serialize_nvs_config(&config)?;
Ok(ValidationResult {
valid: true,
message: None,
estimated_size: nvs_data.len(),
})
}
Err(e) => Ok(ValidationResult {
valid: false,
message: Some(e),
estimated_size: 0,
}),
}
}
/// Generate mesh provisioning configs for multiple nodes.
#[tauri::command]
pub async fn generate_mesh_configs(
base_config: ProvisioningConfig,
node_count: u8,
) -> Result<Vec<MeshNodeConfig>, String> {
if node_count == 0 || node_count > 32 {
return Err("Node count must be 1-32".into());
}
let mut configs = Vec::new();
for i in 0..node_count {
let mut node_config = base_config.clone();
node_config.node_id = Some(i);
node_config.tdm_slot = Some(i);
node_config.tdm_total = Some(node_count);
configs.push(MeshNodeConfig {
node_id: i,
tdm_slot: i,
config: node_config,
});
}
Ok(configs)
}
/// Serialize ProvisioningConfig to NVS binary format.
/// Format: key-value pairs with length prefixes
fn serialize_nvs_config(config: &ProvisioningConfig) -> Result<Vec<u8>, String> {
let mut data = Vec::new();
// Inline helpers to avoid closure borrow issues
fn write_str(data: &mut Vec<u8>, key: &str, value: &str) {
// Key length (1 byte) + key + value length (2 bytes) + value
data.push(key.len() as u8);
data.extend_from_slice(key.as_bytes());
data.extend_from_slice(&(value.len() as u16).to_le_bytes());
data.extend_from_slice(value.as_bytes());
}
fn write_u8(data: &mut Vec<u8>, key: &str, value: u8) {
data.push(key.len() as u8);
data.extend_from_slice(key.as_bytes());
data.extend_from_slice(&1u16.to_le_bytes());
data.push(value);
}
fn write_u16(data: &mut Vec<u8>, key: &str, value: u16) {
data.push(key.len() as u8);
data.extend_from_slice(key.as_bytes());
data.extend_from_slice(&2u16.to_le_bytes());
data.extend_from_slice(&value.to_le_bytes());
}
// Serialize each field
if let Some(ref ssid) = config.wifi_ssid {
write_str(&mut data, "wifi_ssid", ssid);
}
if let Some(ref pass) = config.wifi_password {
write_str(&mut data, "wifi_pass", pass);
}
if let Some(ref ip) = config.target_ip {
write_str(&mut data, "target_ip", ip);
}
if let Some(port) = config.target_port {
write_u16(&mut data, "target_port", port);
}
if let Some(id) = config.node_id {
write_u8(&mut data, "node_id", id);
}
if let Some(slot) = config.tdm_slot {
write_u8(&mut data, "tdm_slot", slot);
}
if let Some(total) = config.tdm_total {
write_u8(&mut data, "tdm_total", total);
}
if let Some(tier) = config.edge_tier {
write_u8(&mut data, "edge_tier", tier);
}
if let Some(thresh) = config.presence_thresh {
write_u16(&mut data, "presence_th", thresh);
}
if let Some(thresh) = config.fall_thresh {
write_u16(&mut data, "fall_th", thresh);
}
if let Some(window) = config.vital_window {
write_u16(&mut data, "vital_win", window);
}
if let Some(interval) = config.vital_interval_ms {
write_u16(&mut data, "vital_int", interval);
}
if let Some(count) = config.top_k_count {
write_u8(&mut data, "top_k", count);
}
if let Some(hops) = config.hop_count {
write_u8(&mut data, "hop_count", hops);
}
if let Some(ref channels) = config.channel_list {
let ch_str: String = channels.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(",");
write_str(&mut data, "channels", &ch_str);
}
if let Some(duty) = config.power_duty {
write_u8(&mut data, "power_duty", duty);
}
if let Some(max) = config.wasm_max_modules {
write_u8(&mut data, "wasm_max", max);
}
if let Some(verify) = config.wasm_verify {
write_u8(&mut data, "wasm_verify", if verify { 1 } else { 0 });
}
if let Some(ref psk) = config.ota_psk {
write_str(&mut data, "ota_psk", psk);
}
// End marker
data.push(0);
Ok(data)
}
/// Deserialize NVS binary data to ProvisioningConfig.
fn deserialize_nvs_config(data: &[u8]) -> Result<ProvisioningConfig, String> {
let mut config = ProvisioningConfig::default();
let mut pos = 0;
while pos < data.len() {
// Read key length
let key_len = data[pos] as usize;
pos += 1;
if key_len == 0 {
break; // End marker
}
if pos + key_len > data.len() {
return Err("Invalid NVS data: truncated key".into());
}
let key = std::str::from_utf8(&data[pos..pos + key_len])
.map_err(|_| "Invalid key encoding")?;
pos += key_len;
if pos + 2 > data.len() {
return Err("Invalid NVS data: truncated value length".into());
}
let value_len = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize;
pos += 2;
if pos + value_len > data.len() {
return Err("Invalid NVS data: truncated value".into());
}
let value_bytes = &data[pos..pos + value_len];
pos += value_len;
// Parse based on key
match key {
"wifi_ssid" => config.wifi_ssid = Some(String::from_utf8_lossy(value_bytes).to_string()),
"wifi_pass" => config.wifi_password = Some(String::from_utf8_lossy(value_bytes).to_string()),
"target_ip" => config.target_ip = Some(String::from_utf8_lossy(value_bytes).to_string()),
"target_port" if value_len == 2 => {
config.target_port = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
}
"node_id" if value_len == 1 => config.node_id = Some(value_bytes[0]),
"tdm_slot" if value_len == 1 => config.tdm_slot = Some(value_bytes[0]),
"tdm_total" if value_len == 1 => config.tdm_total = Some(value_bytes[0]),
"edge_tier" if value_len == 1 => config.edge_tier = Some(value_bytes[0]),
"presence_th" if value_len == 2 => {
config.presence_thresh = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
}
"fall_th" if value_len == 2 => {
config.fall_thresh = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
}
"vital_win" if value_len == 2 => {
config.vital_window = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
}
"vital_int" if value_len == 2 => {
config.vital_interval_ms = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
}
"top_k" if value_len == 1 => config.top_k_count = Some(value_bytes[0]),
"hop_count" if value_len == 1 => config.hop_count = Some(value_bytes[0]),
"channels" => {
let ch_str = String::from_utf8_lossy(value_bytes);
config.channel_list = Some(
ch_str.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect()
);
}
"power_duty" if value_len == 1 => config.power_duty = Some(value_bytes[0]),
"wasm_max" if value_len == 1 => config.wasm_max_modules = Some(value_bytes[0]),
"wasm_verify" if value_len == 1 => config.wasm_verify = Some(value_bytes[0] != 0),
"ota_psk" => config.ota_psk = Some(String::from_utf8_lossy(value_bytes).to_string()),
_ => {} // Ignore unknown keys
}
}
Ok(config)
}
/// Binary header for provisioning protocol.
#[repr(C, packed)]
struct ProvisionHeader {
magic: [u8; 10],
version: u8,
size: u32,
}
fn bincode_header(header: &ProvisionHeader) -> Vec<u8> {
let mut bytes = Vec::with_capacity(15);
bytes.extend_from_slice(&header.magic);
bytes.push(header.version);
bytes.extend_from_slice(&header.size.to_le_bytes());
bytes
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvisionResult {
pub success: bool,
pub message: String,
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub valid: bool,
pub message: Option<String>,
pub estimated_size: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct MeshNodeConfig {
pub node_id: u8,
pub tdm_slot: u8,
pub config: ProvisioningConfig,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_deserialize_config() {
let config = ProvisioningConfig {
wifi_ssid: Some("TestNetwork".into()),
wifi_password: Some("password123".into()),
node_id: Some(1),
tdm_slot: Some(0),
tdm_total: Some(4),
..Default::default()
};
let serialized = serialize_nvs_config(&config).unwrap();
let deserialized = deserialize_nvs_config(&serialized).unwrap();
assert_eq!(deserialized.wifi_ssid, config.wifi_ssid);
assert_eq!(deserialized.node_id, config.node_id);
assert_eq!(deserialized.tdm_slot, config.tdm_slot);
}
#[test]
fn test_config_validation() {
let mut config = ProvisioningConfig::default();
config.tdm_slot = Some(5);
config.tdm_total = Some(4);
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_provision_header() {
let header = ProvisionHeader {
magic: *b"RUVIEW_NVS",
version: 1,
size: 256,
};
let bytes = bincode_header(&header);
assert_eq!(bytes.len(), 15);
assert_eq!(&bytes[0..10], b"RUVIEW_NVS");
}
}
@@ -1,39 +1,344 @@
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use tauri::State;
use sysinfo::{Pid, ProcessesToUpdate, System};
use tauri::{AppHandle, Manager, State};
use crate::state::AppState;
/// Default binary name for the sensing server.
const DEFAULT_SERVER_BIN: &str = "sensing-server";
/// Find the sensing server binary path.
///
/// Search order:
/// 1. Custom path from config.server_path
/// 2. Bundled in app resources (macOS: Contents/Resources/bin/)
/// 3. Next to the app executable
/// 4. System PATH
fn find_server_binary(app: &AppHandle, custom_path: Option<&str>) -> Result<String, String> {
// 1. Custom path from settings
if let Some(path) = custom_path {
if std::path::Path::new(path).exists() {
return Ok(path.to_string());
}
}
// 2. Bundled in resources (Tauri bundles to Contents/Resources/)
if let Ok(resource_dir) = app.path().resource_dir() {
let bundled = resource_dir.join("bin").join(DEFAULT_SERVER_BIN);
if bundled.exists() {
return Ok(bundled.to_string_lossy().to_string());
}
// Also check directly in resources
let direct = resource_dir.join(DEFAULT_SERVER_BIN);
if direct.exists() {
return Ok(direct.to_string_lossy().to_string());
}
}
// 3. Next to the executable
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let sibling = exe_dir.join(DEFAULT_SERVER_BIN);
if sibling.exists() {
return Ok(sibling.to_string_lossy().to_string());
}
}
}
// 4. Check if it's in PATH
if let Ok(output) = Command::new("which").arg(DEFAULT_SERVER_BIN).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(path);
}
}
}
Err(format!(
"Sensing server binary '{}' not found. Please build it with: cargo build --release -p wifi-densepose-sensing-server",
DEFAULT_SERVER_BIN
))
}
/// Start the sensing server as a managed child process.
///
/// The server binary is looked up in the following order:
/// 1. Settings `server_path` if set
/// 2. Bundled resource path
/// 3. Next to executable
/// 4. System PATH
#[tauri::command]
pub async fn start_server(
app: AppHandle,
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(())
) -> Result<ServerStartResult, String> {
// Check if already running
{
let srv = state.server.lock().map_err(|e| e.to_string())?;
if srv.running {
return Err("Server is already running".into());
}
}
// Find server binary
let server_path = find_server_binary(&app, config.server_path.as_deref())?;
tracing::info!("Starting sensing server from: {}", server_path);
// Build command with configuration
let mut cmd = Command::new(&server_path);
if let Some(port) = config.http_port {
cmd.args(["--http-port", &port.to_string()]);
}
if let Some(port) = config.ws_port {
cmd.args(["--ws-port", &port.to_string()]);
}
if let Some(port) = config.udp_port {
cmd.args(["--udp-port", &port.to_string()]);
}
if let Some(ref bind_addr) = config.bind_address {
cmd.args(["--bind", bind_addr]);
}
if let Some(ref log_level) = config.log_level {
cmd.args(["--log-level", log_level]);
}
// Set data source (default to "simulate" if not specified for demo mode)
let source = config.source.as_deref().unwrap_or("simulate");
cmd.args(["--source", source]);
// Redirect stdout/stderr to pipes for monitoring
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Spawn the child process
let child = cmd.spawn()
.map_err(|e| format!("Failed to start server: {}. Is '{}' installed?", e, server_path))?;
let pid = child.id();
// Store the child process in state
{
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = true;
srv.pid = Some(pid);
srv.http_port = config.http_port;
srv.ws_port = config.ws_port;
srv.udp_port = config.udp_port;
srv.child = Some(child);
}
tracing::info!("Started sensing server with PID {}", pid);
Ok(ServerStartResult {
pid,
http_port: config.http_port,
ws_port: config.ws_port,
udp_port: config.udp_port,
})
}
/// Stop the managed sensing server process.
///
/// First attempts graceful termination (SIGTERM), then SIGKILL after timeout.
#[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;
// Extract child process and take ownership for killing
let (child_id, mut child_process) = {
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
if !srv.running {
return Err("Server is not running".into());
}
let pid = srv.pid;
let child = srv.child.take(); // Take ownership of child
(pid, child)
};
let child_id = match child_id {
Some(id) => id,
None => return Err("No server process found".into()),
};
tracing::info!("Stopping sensing server with PID {}", child_id);
// First try graceful termination via SIGTERM
#[cfg(unix)]
{
unsafe {
// Kill the process group (negative PID) to kill all children too
let _ = libc::kill(-(child_id as i32), libc::SIGTERM);
// Also kill the main process directly
let _ = libc::kill(child_id as i32, libc::SIGTERM);
}
}
// Wait briefly for graceful shutdown
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Check if still running
let still_running = {
let mut sys = System::new();
let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
};
// Force kill if still running
if still_running {
tracing::warn!("Server still running after SIGTERM, sending SIGKILL");
#[cfg(unix)]
{
unsafe {
// SIGKILL the process group and main process
let _ = libc::kill(-(child_id as i32), libc::SIGKILL);
let _ = libc::kill(child_id as i32, libc::SIGKILL);
}
}
// Also use the child handle if available
if let Some(ref mut child) = child_process {
let _ = child.kill();
}
}
// Wait for process to actually terminate
if let Some(ref mut child) = child_process {
let _ = child.wait();
}
// Final verification and cleanup
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Clear state
{
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = false;
srv.pid = None;
srv.http_port = None;
srv.ws_port = None;
srv.udp_port = None;
srv.child = None;
}
// Verify process is dead
let still_alive = {
let mut sys = System::new();
let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
};
if still_alive {
tracing::error!("Failed to kill server process {}", child_id);
return Err(format!("Failed to stop server process {}", child_id));
}
tracing::info!("Stopped sensing server");
Ok(())
}
/// Get sensing server status.
/// Get sensing server status including resource usage.
#[tauri::command]
pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusResponse, String> {
let srv = state.server.lock().map_err(|e| e.to_string())?;
if !srv.running || srv.pid.is_none() {
return Ok(ServerStatusResponse {
running: false,
pid: None,
http_port: None,
ws_port: None,
udp_port: None,
memory_mb: None,
cpu_percent: None,
uptime_secs: None,
});
}
let pid = srv.pid.unwrap();
let mut sys = System::new();
let sysinfo_pid = Pid::from_u32(pid);
sys.refresh_processes(ProcessesToUpdate::Some(&[sysinfo_pid]), true);
let (memory_mb, cpu_percent) = sys.process(sysinfo_pid)
.map(|proc| {
let mem = proc.memory() as f64 / 1024.0 / 1024.0;
let cpu = proc.cpu_usage();
(Some(mem), Some(cpu))
})
.unwrap_or((None, None));
// Calculate uptime if we have start time
let uptime_secs = srv.start_time.map(|start| {
std::time::Instant::now().duration_since(start).as_secs()
});
Ok(ServerStatusResponse {
running: srv.running,
pid: srv.pid,
http_port: None,
ws_port: None,
pid: Some(pid),
http_port: srv.http_port,
ws_port: srv.ws_port,
udp_port: srv.udp_port,
memory_mb,
cpu_percent,
uptime_secs,
})
}
/// Restart the sensing server with the same or new configuration.
#[tauri::command]
pub async fn restart_server(
app: AppHandle,
config: Option<ServerConfig>,
state: State<'_, AppState>,
) -> Result<ServerStartResult, String> {
// Get current config if no new config provided
let restart_config = if let Some(cfg) = config {
cfg
} else {
let srv = state.server.lock().map_err(|e| e.to_string())?;
ServerConfig {
http_port: srv.http_port,
ws_port: srv.ws_port,
udp_port: srv.udp_port,
log_level: None,
bind_address: None,
server_path: None,
source: None, // Use default (simulate)
}
};
// Stop existing server
let _ = stop_server(state.clone()).await;
// Brief delay to ensure port is released
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Start with new config
start_server(app, restart_config, state).await
}
/// Get server logs (last N lines from stdout/stderr).
#[tauri::command]
pub async fn server_logs(
_lines: Option<usize>,
state: State<'_, AppState>,
) -> Result<ServerLogsResponse, String> {
let _srv = state.server.lock().map_err(|e| e.to_string())?;
// For now, return empty logs - full implementation would capture stdout/stderr
// to ring buffer during process lifetime
Ok(ServerLogsResponse {
stdout: Vec::new(),
stderr: Vec::new(),
truncated: false,
})
}
@@ -43,6 +348,18 @@ pub struct ServerConfig {
pub ws_port: Option<u16>,
pub udp_port: Option<u16>,
pub log_level: Option<String>,
pub bind_address: Option<String>,
pub server_path: Option<String>,
/// Data source: "auto", "wifi", "esp32", "simulate"
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServerStartResult {
pub pid: u32,
pub http_port: Option<u16>,
pub ws_port: Option<u16>,
pub udp_port: Option<u16>,
}
#[derive(Debug, Clone, Serialize)]
@@ -51,4 +368,36 @@ pub struct ServerStatusResponse {
pub pid: Option<u32>,
pub http_port: Option<u16>,
pub ws_port: Option<u16>,
pub udp_port: Option<u16>,
pub memory_mb: Option<f64>,
pub cpu_percent: Option<f32>,
pub uptime_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServerLogsResponse {
pub stdout: Vec<String>,
pub stderr: Vec<String>,
pub truncated: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_default() {
let config = ServerConfig {
http_port: Some(8080),
ws_port: Some(8765),
udp_port: Some(5005),
log_level: None,
bind_address: None,
server_path: None,
source: Some("simulate".to_string()),
};
assert_eq!(config.http_port, Some(8080));
assert_eq!(config.ws_port, Some(8765));
}
}
@@ -0,0 +1,482 @@
//! Training commands for the desktop application.
//!
//! Provides Tauri commands for:
//! - GPU detection
//! - Dataset management
//! - Model/checkpoint operations
//! - Training job control
//! - RuVector configuration
//! - Metrics retrieval
use crate::domain::training::{
CheckpointInfo, DatasetFormat, DatasetInfo, EpochMetrics, EvaluationMetrics,
GpuBackend, GpuInfo, JointAccuracy, LiveTestMetrics,
ModelInfo, ModelType, RuVectorConfig, TrainingConfig, TrainingJob,
TrainingProgress, TrainingStatus,
};
use crate::state::AppState;
use tauri::State;
// ============================================================================
// Standard Datasets (built-in)
// ============================================================================
fn get_standard_datasets() -> Vec<DatasetInfo> {
vec![
DatasetInfo {
id: "mmfi".into(),
name: "MM-Fi Dataset".into(),
description: "Multi-modal WiFi sensing dataset with 40 subjects, 27 activities".into(),
format: DatasetFormat::MmFi,
size_mb: 2400.0,
samples: 320000,
downloaded: false,
path: None,
url: Some("https://ntu-aiot-lab.github.io/mm-fi".into()),
},
DatasetInfo {
id: "wipose".into(),
name: "Wi-Pose Dataset".into(),
description: "WiFi-based pose estimation with 3D skeleton annotations".into(),
format: DatasetFormat::WiPose,
size_mb: 1800.0,
samples: 150000,
downloaded: false,
path: None,
url: Some("https://github.com/Wi-Pose".into()),
},
DatasetInfo {
id: "wiar".into(),
name: "WiAR Dataset".into(),
description: "WiFi activity recognition with CSI data".into(),
format: DatasetFormat::Wiar,
size_mb: 500.0,
samples: 45000,
downloaded: false,
path: None,
url: Some("https://github.com/WiAR".into()),
},
]
}
// ============================================================================
// Standard Model Architectures
// ============================================================================
fn get_standard_models() -> Vec<ModelInfo> {
vec![
ModelInfo {
id: "csi-encoder-cnn".into(),
name: "CSI Encoder (CNN)".into(),
model_type: ModelType::Encoder,
description: "Convolutional encoder for CSI amplitude/phase features".into(),
params_m: 2.3,
memory_mb: 128,
paper: None,
},
ModelInfo {
id: "csi-encoder-transformer".into(),
name: "CSI Encoder (Transformer)".into(),
model_type: ModelType::Encoder,
description: "Self-attention based CSI feature extraction".into(),
params_m: 8.5,
memory_mb: 384,
paper: Some("WiFi-ViT 2024".into()),
},
ModelInfo {
id: "pose-decoder-lstm".into(),
name: "Pose Decoder (LSTM)".into(),
model_type: ModelType::Decoder,
description: "Recurrent decoder for temporal pose estimation".into(),
params_m: 1.8,
memory_mb: 96,
paper: None,
},
ModelInfo {
id: "pose-decoder-gru".into(),
name: "Pose Decoder (GRU)".into(),
model_type: ModelType::Decoder,
description: "Gated recurrent unit pose decoder (faster)".into(),
params_m: 1.2,
memory_mb: 64,
paper: None,
},
ModelInfo {
id: "aether-embedding".into(),
name: "AETHER Embedding".into(),
model_type: ModelType::Embedding,
description: "Contrastive CSI embedding for person re-identification (ADR-024)".into(),
params_m: 4.2,
memory_mb: 192,
paper: Some("AETHER 2025".into()),
},
ModelInfo {
id: "meridian-adaptor".into(),
name: "MERIDIAN Adaptor".into(),
model_type: ModelType::Adaptor,
description: "Cross-environment domain generalization module (ADR-027)".into(),
params_m: 3.1,
memory_mb: 144,
paper: Some("MERIDIAN 2025".into()),
},
]
}
// ============================================================================
// GPU Detection Commands
// ============================================================================
/// Detect available GPU(s) and return information.
#[tauri::command]
pub async fn detect_gpu(state: State<'_, AppState>) -> Result<GpuInfo, String> {
// Check for cached GPU info
if let Ok(training) = state.training.lock() {
if let Some(ref info) = training.gpu_info {
return Ok(info.clone());
}
}
// Detect GPU
let info = detect_gpu_internal();
// Cache the result
if let Ok(mut training) = state.training.lock() {
training.gpu_info = Some(info.clone());
}
Ok(info)
}
fn detect_gpu_internal() -> GpuInfo {
// Check for Metal on macOS
#[cfg(target_os = "macos")]
{
// Check if system has Apple Silicon or discrete GPU
let has_metal = std::process::Command::new("system_profiler")
.args(["SPDisplaysDataType", "-json"])
.output()
.map(|o| {
let output = String::from_utf8_lossy(&o.stdout);
output.contains("Metal") || output.contains("Apple M")
})
.unwrap_or(false);
if has_metal {
// Try to get GPU name
let name = std::process::Command::new("system_profiler")
.args(["SPDisplaysDataType"])
.output()
.ok()
.and_then(|o| {
let output = String::from_utf8_lossy(&o.stdout);
// Parse chipset name
for line in output.lines() {
if line.contains("Chipset Model:") {
return line.split(':').nth(1).map(|s| s.trim().to_string());
}
}
None
});
return GpuInfo {
available: true,
backend: GpuBackend::Metal,
name,
memory_mb: None, // Metal doesn't easily expose this
cuda_version: None,
metal_supported: true,
};
}
}
// Check for CUDA on Linux/Windows
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
// Try nvidia-smi for CUDA detection
if let Ok(output) = std::process::Command::new("nvidia-smi")
.args(["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.trim().split(',').collect();
let name = parts.first().map(|s| s.trim().to_string());
let memory_mb = parts.get(1)
.and_then(|s| s.trim().parse::<u64>().ok());
// Get CUDA version
let cuda_version = std::process::Command::new("nvidia-smi")
.output()
.ok()
.and_then(|o| {
let output = String::from_utf8_lossy(&o.stdout);
for line in output.lines() {
if line.contains("CUDA Version:") {
return line.split("CUDA Version:")
.nth(1)
.map(|s| s.split_whitespace().next().unwrap_or("").to_string());
}
}
None
});
return GpuInfo {
available: true,
backend: GpuBackend::Cuda,
name,
memory_mb,
cuda_version,
metal_supported: false,
};
}
}
}
// Fall back to CPU
GpuInfo {
available: false,
backend: GpuBackend::Cpu,
name: None,
memory_mb: None,
cuda_version: None,
metal_supported: false,
}
}
// ============================================================================
// Dataset Commands
// ============================================================================
/// List available datasets (both standard and downloaded).
#[tauri::command]
pub async fn list_datasets(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
// Return IDs of downloaded datasets
Ok(training.datasets.iter()
.filter(|d| d.downloaded)
.map(|d| d.id.clone())
.collect())
}
/// Get full dataset information.
#[tauri::command]
pub async fn get_datasets(state: State<'_, AppState>) -> Result<Vec<DatasetInfo>, String> {
let mut training = state.training.lock().map_err(|e| e.to_string())?;
// Initialize with standard datasets if empty
if training.datasets.is_empty() {
training.datasets = get_standard_datasets();
}
Ok(training.datasets.clone())
}
/// Download a dataset (placeholder - actual download would need async HTTP).
#[tauri::command]
pub async fn download_dataset(
dataset_id: String,
state: State<'_, AppState>,
) -> Result<DatasetInfo, String> {
let mut training = state.training.lock().map_err(|e| e.to_string())?;
// Find the dataset
let dataset = training.datasets.iter_mut()
.find(|d| d.id == dataset_id)
.ok_or_else(|| format!("Dataset not found: {}", dataset_id))?;
// Simulate download completion
dataset.downloaded = true;
dataset.path = Some(format!("~/.ruview/datasets/{}", dataset_id));
Ok(dataset.clone())
}
// ============================================================================
// Model/Checkpoint Commands
// ============================================================================
/// List available model architectures.
#[tauri::command]
pub async fn list_models() -> Result<Vec<ModelInfo>, String> {
Ok(get_standard_models())
}
/// List saved checkpoints.
#[tauri::command]
pub async fn list_checkpoints(state: State<'_, AppState>) -> Result<Vec<CheckpointInfo>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.checkpoints.clone())
}
/// Export a model checkpoint to ONNX or TorchScript.
#[tauri::command]
pub async fn export_model(
checkpoint_id: String,
format: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
let checkpoint = training.checkpoints.iter()
.find(|c| c.id == checkpoint_id)
.ok_or_else(|| format!("Checkpoint not found: {}", checkpoint_id))?;
let output_path = match format.as_str() {
"onnx" => format!("{}.onnx", checkpoint.path.trim_end_matches(".pt")),
"torchscript" => format!("{}.ts", checkpoint.path.trim_end_matches(".pt")),
_ => return Err(format!("Unsupported format: {}", format)),
};
// In a real implementation, this would call the actual export logic
Ok(output_path)
}
// ============================================================================
// Training Job Commands
// ============================================================================
/// Start a training job.
#[tauri::command]
pub async fn start_training(
config: TrainingConfig,
state: State<'_, AppState>,
) -> Result<String, String> {
let mut training = state.training.lock().map_err(|e| e.to_string())?;
// Create a new job
let job_id = uuid::Uuid::new_v4().to_string();
let job = TrainingJob {
id: job_id.clone(),
config,
status: TrainingStatus::Running,
started_at: Some(chrono::Utc::now().to_rfc3339()),
progress: TrainingProgress::default(),
loss_history: Vec::new(),
};
training.current_job = Some(job);
// In a real implementation, this would spawn a background training thread
// and emit progress events via Tauri's event system
Ok(job_id)
}
/// Stop the current training job.
#[tauri::command]
pub async fn stop_training(state: State<'_, AppState>) -> Result<(), String> {
let mut training = state.training.lock().map_err(|e| e.to_string())?;
if let Some(ref mut job) = training.current_job {
job.status = TrainingStatus::Paused;
}
Ok(())
}
/// Get current training progress.
#[tauri::command]
pub async fn training_progress(state: State<'_, AppState>) -> Result<Option<TrainingProgress>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.current_job.as_ref().map(|j| j.progress.clone()))
}
// ============================================================================
// RuVector Configuration Commands
// ============================================================================
/// Get current RuVector configuration.
#[tauri::command]
pub async fn get_ruvector_config(state: State<'_, AppState>) -> Result<RuVectorConfig, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.ruvector_config.clone())
}
/// Set RuVector configuration.
#[tauri::command]
pub async fn set_ruvector_config(
config: RuVectorConfig,
state: State<'_, AppState>,
) -> Result<(), String> {
let mut training = state.training.lock().map_err(|e| e.to_string())?;
training.ruvector_config = config;
Ok(())
}
/// Test RuVector modules on live CSI data.
#[tauri::command]
pub async fn test_ruvector_live(
_state: State<'_, AppState>,
) -> Result<LiveTestMetrics, String> {
// In a real implementation, this would process live CSI data
// through the RuVector pipeline and return metrics
Ok(LiveTestMetrics {
fps: 30.0,
latency_ms: 15.0,
persons_detected: 1,
})
}
// ============================================================================
// Metrics Commands
// ============================================================================
/// Get training history (loss/accuracy per epoch).
#[tauri::command]
pub async fn get_training_history(state: State<'_, AppState>) -> Result<Vec<EpochMetrics>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.training_history.clone())
}
/// Get evaluation metrics.
#[tauri::command]
pub async fn get_evaluation_metrics(state: State<'_, AppState>) -> Result<Option<EvaluationMetrics>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.evaluation_metrics.clone())
}
/// Get per-joint accuracy metrics.
#[tauri::command]
pub async fn get_joint_accuracies(state: State<'_, AppState>) -> Result<Vec<JointAccuracy>, String> {
let training = state.training.lock().map_err(|e| e.to_string())?;
Ok(training.joint_accuracies.clone())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_standard_datasets() {
let datasets = get_standard_datasets();
assert_eq!(datasets.len(), 3);
assert!(datasets.iter().any(|d| d.id == "mmfi"));
}
#[test]
fn test_standard_models() {
let models = get_standard_models();
assert_eq!(models.len(), 6);
assert!(models.iter().any(|m| m.id == "csi-encoder-cnn"));
}
#[test]
fn test_detect_gpu_internal() {
let info = detect_gpu_internal();
// Just verify it returns valid data
assert!(matches!(info.backend, GpuBackend::Cpu | GpuBackend::Cuda | GpuBackend::Metal));
}
#[test]
fn test_ruvector_config_default() {
let config = RuVectorConfig::default();
assert!(config.mincut_enabled);
assert_eq!(config.attention_heads, 4);
}
}
@@ -1,35 +1,279 @@
use std::fs::File;
use std::io::Read;
use std::time::Duration;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// WASM management port on ESP32 nodes.
const WASM_PORT: u16 = 8033;
/// Request timeout for WASM operations.
const WASM_TIMEOUT_SECS: u64 = 30;
/// 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![])
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("http://{}:{}/wasm/list", node_ip, WASM_PORT);
let response = client.get(&url).send().await
.map_err(|e| format!("Failed to connect to node: {}", e))?;
if !response.status().is_success() {
return Err(format!("Node returned HTTP {}", response.status()));
}
let modules: Vec<WasmModuleInfo> = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(modules)
}
/// Upload a WASM module to a node.
///
/// Protocol:
/// 1. Read WASM file and calculate SHA-256
/// 2. POST multipart/form-data to http://<node_ip>:8033/wasm/upload
/// 3. Module is automatically validated on node side
/// 4. Return assigned module ID
#[tauri::command]
pub async fn wasm_upload(
node_ip: String,
wasm_path: String,
module_name: Option<String>,
auto_start: Option<bool>,
) -> Result<WasmUploadResult, String> {
let _ = (node_ip, wasm_path);
// Read WASM file
let mut file = File::open(&wasm_path)
.map_err(|e| format!("Cannot read WASM file: {}", e))?;
let mut wasm_data = Vec::new();
file.read_to_end(&mut wasm_data)
.map_err(|e| format!("Failed to read WASM file: {}", e))?;
let wasm_size = wasm_data.len();
// Validate WASM magic bytes
if wasm_data.len() < 4 || &wasm_data[0..4] != b"\0asm" {
return Err("Invalid WASM file: missing magic bytes".into());
}
// Calculate SHA-256
let mut hasher = Sha256::new();
hasher.update(&wasm_data);
let wasm_hash = hex::encode(hasher.finalize());
// Extract filename for module name
let name = module_name.unwrap_or_else(|| {
std::path::Path::new(&wasm_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("module")
.to_string()
});
// Build HTTP client
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Build multipart form
let wasm_part = Part::bytes(wasm_data)
.file_name(format!("{}.wasm", name))
.mime_str("application/wasm")
.map_err(|e| format!("Failed to create multipart: {}", e))?;
let form = Form::new()
.part("wasm", wasm_part)
.text("name", name.clone())
.text("sha256", wasm_hash.clone())
.text("size", wasm_size.to_string())
.text("auto_start", auto_start.unwrap_or(false).to_string());
// Send request
let url = format!("http://{}:{}/wasm/upload", node_ip, WASM_PORT);
let response = client.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| format!("WASM upload failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("WASM upload failed with HTTP {}: {}", status, body));
}
// Parse response for module ID
let upload_response: WasmUploadResponse = response.json().await
.map_err(|e| format!("Failed to parse upload response: {}", e))?;
Ok(WasmUploadResult {
success: true,
module_id: "stub-module-0".into(),
message: "Stub: WASM upload not yet implemented".into(),
module_id: upload_response.module_id,
message: format!("Module '{}' uploaded successfully ({} bytes)", name, wasm_size),
sha256: Some(wasm_hash),
})
}
/// Start, stop, or unload a WASM module on a node.
///
/// Actions:
/// - "start": Start module execution
/// - "stop": Pause module execution
/// - "unload": Remove module from memory
/// - "restart": Stop then start
#[tauri::command]
pub async fn wasm_control(
node_ip: String,
module_id: String,
action: String,
) -> Result<(), String> {
let _ = (node_ip, module_id, action);
Ok(())
) -> Result<WasmControlResult, String> {
// Validate action
let valid_actions = ["start", "stop", "unload", "restart"];
if !valid_actions.contains(&action.as_str()) {
return Err(format!(
"Invalid action '{}'. Valid actions: {:?}",
action, valid_actions
));
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!(
"http://{}:{}/wasm/{}/{}",
node_ip, WASM_PORT, module_id, action
);
let response = client.post(&url).send().await
.map_err(|e| format!("WASM control failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!(
"WASM {} failed with HTTP {}: {}",
action, status, body
));
}
Ok(WasmControlResult {
success: true,
module_id,
action,
message: "Operation completed successfully".into(),
})
}
/// Get detailed info about a specific WASM module.
#[tauri::command]
pub async fn wasm_info(
node_ip: String,
module_id: String,
) -> Result<WasmModuleDetail, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("http://{}:{}/wasm/{}", node_ip, WASM_PORT, module_id);
let response = client.get(&url).send().await
.map_err(|e| format!("Failed to get module info: {}", e))?;
if !response.status().is_success() {
return Err(format!("Module not found or HTTP {}", response.status()));
}
let detail: WasmModuleDetail = response.json().await
.map_err(|e| format!("Failed to parse module info: {}", e))?;
Ok(detail)
}
/// Get WASM runtime statistics from a node.
#[tauri::command]
pub async fn wasm_stats(node_ip: String) -> Result<WasmRuntimeStats, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("http://{}:{}/wasm/stats", node_ip, WASM_PORT);
let response = client.get(&url).send().await
.map_err(|e| format!("Failed to get WASM stats: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let stats: WasmRuntimeStats = response.json().await
.map_err(|e| format!("Failed to parse stats: {}", e))?;
Ok(stats)
}
/// Check if node supports WASM modules.
#[tauri::command]
pub async fn check_wasm_support(node_ip: String) -> Result<WasmSupportInfo, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!("http://{}:{}/wasm/info", node_ip, WASM_PORT);
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
let body = response.text().await.unwrap_or_default();
// Try to parse as JSON
let info = serde_json::from_str::<serde_json::Value>(&body).ok();
Ok(WasmSupportInfo {
supported: true,
max_modules: info.as_ref()
.and_then(|v| v.get("max_modules").and_then(|v| v.as_u64()))
.map(|v| v as u8),
memory_limit_kb: info.as_ref()
.and_then(|v| v.get("memory_limit_kb").and_then(|v| v.as_u64()))
.map(|v| v as u32),
verify_signatures: info.as_ref()
.and_then(|v| v.get("verify_signatures").and_then(|v| v.as_bool()))
.unwrap_or(false),
})
} else if response.status() == reqwest::StatusCode::NOT_FOUND {
Ok(WasmSupportInfo {
supported: false,
max_modules: None,
memory_limit_kb: None,
verify_signatures: false,
})
} else {
Err(format!("HTTP {}", response.status()))
}
}
Err(_) => Ok(WasmSupportInfo {
supported: false,
max_modules: None,
memory_limit_kb: None,
verify_signatures: false,
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,6 +282,31 @@ pub struct WasmModuleInfo {
pub name: String,
pub size_bytes: u64,
pub status: String,
pub sha256: Option<String>,
pub loaded_at: Option<String>,
pub memory_used_kb: Option<u32>,
pub cpu_usage_pct: Option<f32>,
pub exec_count: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmModuleDetail {
pub id: String,
pub name: String,
pub size_bytes: u64,
pub status: String,
pub sha256: String,
pub loaded_at: String,
pub memory_used_kb: u32,
pub exports: Vec<String>,
pub imports: Vec<String>,
pub execution_count: u64,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WasmUploadResponse {
pub module_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -45,4 +314,64 @@ pub struct WasmUploadResult {
pub success: bool,
pub module_id: String,
pub message: String,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WasmControlResult {
pub success: bool,
pub module_id: String,
pub action: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmRuntimeStats {
pub total_modules: u8,
pub running_modules: u8,
pub memory_used_kb: u32,
pub memory_limit_kb: u32,
pub total_executions: u64,
pub errors: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct WasmSupportInfo {
pub supported: bool,
pub max_modules: Option<u8>,
pub memory_limit_kb: Option<u32>,
pub verify_signatures: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wasm_magic_bytes() {
let valid_wasm = b"\0asm\x01\x00\x00\x00";
assert_eq!(&valid_wasm[0..4], b"\0asm");
let invalid = b"not wasm";
assert_ne!(&invalid[0..4], b"\0asm");
}
#[test]
fn test_wasm_module_info() {
let info = WasmModuleInfo {
id: "mod-1".into(),
name: "test".into(),
size_bytes: 1024,
status: "running".into(),
sha256: Some("abc123".into()),
loaded_at: Some("2024-01-01T00:00:00Z".into()),
memory_used_kb: Some(128),
cpu_usage_pct: Some(5.2),
exec_count: Some(42),
};
assert_eq!(info.id, "mod-1");
assert_eq!(info.size_bytes, 1024);
assert_eq!(info.memory_used_kb, Some(128));
}
}
@@ -1,3 +1,4 @@
pub mod config;
pub mod firmware;
pub mod node;
pub mod training;
@@ -31,6 +31,47 @@ impl Default for HealthStatus {
}
}
/// Chip type for ESP32 variants.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Chip {
#[default]
Esp32,
Esp32s2,
Esp32s3,
Esp32c3,
Esp32c6,
}
/// Node role in the mesh network.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MeshRole {
Coordinator,
#[default]
Node,
Aggregator,
}
/// Discovery method used to find the node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryMethod {
#[default]
Mdns,
UdpProbe,
HttpSweep,
Manual,
}
/// Node capabilities.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodeCapabilities {
pub wasm: bool,
pub ota: bool,
pub csi: bool,
}
/// A discovered ESP32 CSI node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredNode {
@@ -41,6 +82,17 @@ pub struct DiscoveredNode {
pub firmware_version: Option<String>,
pub health: HealthStatus,
pub last_seen: String,
// Extended fields
pub chip: Chip,
pub mesh_role: MeshRole,
pub discovery_method: DiscoveryMethod,
pub tdm_slot: Option<u8>,
pub tdm_total: Option<u8>,
pub edge_tier: Option<u8>,
pub uptime_secs: Option<u64>,
pub capabilities: Option<NodeCapabilities>,
pub friendly_name: Option<String>,
pub notes: Option<String>,
}
/// Aggregate root: maintains the set of all known nodes, keyed by MAC.
@@ -0,0 +1,312 @@
//! Training domain types for the desktop application.
use serde::{Deserialize, Serialize};
/// GPU backend type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum GpuBackend {
Cuda,
Metal,
#[default]
Cpu,
}
/// GPU information.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GpuInfo {
pub available: bool,
pub backend: GpuBackend,
pub name: Option<String>,
pub memory_mb: Option<u64>,
pub cuda_version: Option<String>,
pub metal_supported: bool,
}
/// Dataset format type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum DatasetFormat {
#[default]
MmFi,
WiPose,
Wiar,
Custom,
}
/// Dataset information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatasetInfo {
pub id: String,
pub name: String,
pub description: String,
pub format: DatasetFormat,
pub size_mb: f64,
pub samples: u64,
pub downloaded: bool,
pub path: Option<String>,
pub url: Option<String>,
}
/// Model architecture type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ModelType {
#[default]
Encoder,
Decoder,
Embedding,
Adaptor,
}
/// Model architecture information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub model_type: ModelType,
pub description: String,
pub params_m: f64,
pub memory_mb: u64,
pub paper: Option<String>,
}
/// Checkpoint information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckpointInfo {
pub id: String,
pub model_id: String,
pub name: String,
pub epoch: u32,
pub val_loss: f64,
pub created_at: String,
pub path: String,
pub size_mb: f64,
}
/// Training configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingConfig {
pub dataset_id: String,
pub model_id: String,
pub epochs: u32,
pub batch_size: u32,
pub learning_rate: f64,
pub optimizer: OptimizerType,
pub weight_decay: f64,
pub use_augmentation: bool,
pub checkpoint_every: u32,
}
impl Default for TrainingConfig {
fn default() -> Self {
Self {
dataset_id: "mmfi".into(),
model_id: "csi-encoder-cnn".into(),
epochs: 100,
batch_size: 32,
learning_rate: 0.001,
optimizer: OptimizerType::Adam,
weight_decay: 0.0001,
use_augmentation: true,
checkpoint_every: 10,
}
}
}
/// Optimizer type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum OptimizerType {
#[default]
Adam,
AdamW,
Sgd,
}
/// Training job status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TrainingStatus {
#[default]
Pending,
Running,
Paused,
Completed,
Failed,
}
/// Training progress.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrainingProgress {
pub epoch: u32,
pub total_epochs: u32,
pub batch: u32,
pub total_batches: u32,
pub train_loss: f64,
pub val_loss: Option<f64>,
pub learning_rate: f64,
pub eta_secs: u64,
pub gpu_memory_mb: Option<u64>,
}
/// Training job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingJob {
pub id: String,
pub config: TrainingConfig,
pub status: TrainingStatus,
pub started_at: Option<String>,
pub progress: TrainingProgress,
pub loss_history: Vec<EpochMetrics>,
}
/// Metrics for a single epoch.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpochMetrics {
pub epoch: u32,
pub train_loss: f64,
pub val_loss: f64,
pub train_acc: f64,
pub val_acc: f64,
pub learning_rate: f64,
pub timestamp: String,
}
/// Evaluation metrics.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EvaluationMetrics {
pub pck_05: f64,
pub pck_10: f64,
pub pck_20: f64,
pub map_50: f64,
pub map_75: f64,
pub iou: f64,
}
/// Per-joint accuracy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JointAccuracy {
pub joint: String,
pub accuracy: f64,
}
/// RuVector interpolation mode.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum InterpolationMode {
Linear,
Cubic,
#[default]
Sparse,
}
/// RuVector module configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuVectorConfig {
// MinCut parameters
pub mincut_enabled: bool,
pub mincut_threshold: f64,
pub mincut_max_persons: u32,
// Attention parameters
pub attention_enabled: bool,
pub attention_heads: u32,
pub attention_dropout: f64,
// Temporal parameters
pub temporal_enabled: bool,
pub temporal_window_ms: u32,
pub temporal_compression_ratio: u32,
// Solver parameters
pub solver_enabled: bool,
pub solver_interpolation: InterpolationMode,
pub solver_subcarrier_count: u32,
// BVP parameters
pub bvp_enabled: bool,
pub bvp_filter_hz: (f64, f64),
}
impl Default for RuVectorConfig {
fn default() -> Self {
Self {
mincut_enabled: true,
mincut_threshold: 0.5,
mincut_max_persons: 5,
attention_enabled: true,
attention_heads: 4,
attention_dropout: 0.1,
temporal_enabled: true,
temporal_window_ms: 500,
temporal_compression_ratio: 4,
solver_enabled: true,
solver_interpolation: InterpolationMode::Sparse,
solver_subcarrier_count: 56,
bvp_enabled: false,
bvp_filter_hz: (0.7, 4.0),
}
}
}
/// Live test metrics from RuVector processing.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LiveTestMetrics {
pub fps: f64,
pub latency_ms: f64,
pub persons_detected: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gpu_info_default() {
let info = GpuInfo::default();
assert!(!info.available);
assert_eq!(info.backend, GpuBackend::Cpu);
}
#[test]
fn test_training_config_default() {
let config = TrainingConfig::default();
assert_eq!(config.epochs, 100);
assert_eq!(config.batch_size, 32);
assert_eq!(config.optimizer, OptimizerType::Adam);
}
#[test]
fn test_ruvector_config_default() {
let config = RuVectorConfig::default();
assert!(config.mincut_enabled);
assert_eq!(config.mincut_threshold, 0.5);
assert_eq!(config.attention_heads, 4);
}
#[test]
fn test_serialization() {
let config = TrainingConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: TrainingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.epochs, config.epochs);
}
#[test]
fn test_dataset_info() {
let dataset = DatasetInfo {
id: "mmfi".into(),
name: "MM-Fi Dataset".into(),
description: "Multi-modal WiFi sensing".into(),
format: DatasetFormat::MmFi,
size_mb: 2400.0,
samples: 320000,
downloaded: false,
path: None,
url: Some("https://example.com/mmfi.zip".into()),
};
assert_eq!(dataset.id, "mmfi");
assert!(!dataset.downloaded);
}
}
@@ -2,7 +2,7 @@ pub mod commands;
pub mod domain;
pub mod state;
use commands::{discovery, flash, ota, provision, server, settings, wasm};
use commands::{discovery, flash, ota, provision, server, settings, training, wasm};
pub fn run() {
tauri::Builder::default()
@@ -13,26 +13,56 @@ pub fn run() {
// Discovery
discovery::discover_nodes,
discovery::list_serial_ports,
discovery::configure_esp32_wifi,
// Flash
flash::flash_firmware,
flash::flash_progress,
flash::verify_firmware,
flash::check_espflash,
flash::supported_chips,
// OTA
ota::ota_update,
ota::batch_ota_update,
ota::check_ota_endpoint,
// WASM
wasm::wasm_list,
wasm::wasm_upload,
wasm::wasm_control,
wasm::wasm_info,
wasm::wasm_stats,
wasm::check_wasm_support,
// Server
server::start_server,
server::stop_server,
server::server_status,
server::restart_server,
server::server_logs,
// Provision
provision::provision_node,
provision::read_nvs,
provision::erase_nvs,
provision::validate_config,
provision::generate_mesh_configs,
// Settings
settings::get_settings,
settings::save_settings,
// Training
training::detect_gpu,
training::list_datasets,
training::get_datasets,
training::download_dataset,
training::list_models,
training::list_checkpoints,
training::export_model,
training::start_training,
training::stop_training,
training::training_progress,
training::get_ruvector_config,
training::set_ruvector_config,
training::test_ruvector_live,
training::get_training_history,
training::get_evaluation_metrics,
training::get_joint_accuracies,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -1,23 +1,243 @@
use std::process::Child;
use std::sync::Mutex;
use std::time::Instant;
use crate::domain::node::DiscoveredNode;
use crate::domain::training::{
CheckpointInfo, DatasetInfo, EpochMetrics, EvaluationMetrics,
GpuInfo, JointAccuracy, RuVectorConfig, TrainingJob,
};
/// Sub-state for discovered nodes.
#[derive(Default)]
pub struct DiscoveryState {
pub nodes: Vec<DiscoveredNode>,
pub last_discovery: Option<Instant>,
}
/// Sub-state for the managed sensing server process.
#[derive(Default)]
pub struct ServerState {
pub running: bool,
pub pid: Option<u32>,
pub http_port: Option<u16>,
pub ws_port: Option<u16>,
pub udp_port: Option<u16>,
pub child: Option<Child>,
pub start_time: Option<Instant>,
}
impl Default for ServerState {
fn default() -> Self {
Self {
running: false,
pid: None,
http_port: None,
ws_port: None,
udp_port: None,
child: None,
start_time: None,
}
}
}
/// Sub-state for flash progress tracking.
#[derive(Default)]
pub struct FlashState {
pub phase: String,
pub progress_pct: f32,
pub bytes_written: u64,
pub bytes_total: u64,
pub message: Option<String>,
pub session_id: Option<String>,
}
/// Sub-state for OTA progress tracking.
#[derive(Default)]
pub struct OtaState {
pub active_updates: Vec<OtaUpdateTracker>,
}
/// Tracks a single OTA update in progress.
pub struct OtaUpdateTracker {
pub node_ip: String,
pub phase: String,
pub progress_pct: f32,
pub started_at: Instant,
}
impl Default for OtaUpdateTracker {
fn default() -> Self {
Self {
node_ip: String::new(),
phase: "idle".into(),
progress_pct: 0.0,
started_at: Instant::now(),
}
}
}
/// Sub-state for application settings cache.
pub struct SettingsState {
pub loaded: bool,
pub dirty: bool,
}
impl Default for SettingsState {
fn default() -> Self {
Self {
loaded: false,
dirty: false,
}
}
}
/// Sub-state for training operations.
pub struct TrainingState {
pub gpu_info: Option<GpuInfo>,
pub datasets: Vec<DatasetInfo>,
pub checkpoints: Vec<CheckpointInfo>,
pub current_job: Option<TrainingJob>,
pub ruvector_config: RuVectorConfig,
pub training_history: Vec<EpochMetrics>,
pub evaluation_metrics: Option<EvaluationMetrics>,
pub joint_accuracies: Vec<JointAccuracy>,
}
impl Default for TrainingState {
fn default() -> Self {
Self {
gpu_info: None,
datasets: Vec::new(),
checkpoints: Vec::new(),
current_job: None,
ruvector_config: RuVectorConfig::default(),
training_history: Vec::new(),
evaluation_metrics: None,
joint_accuracies: Vec::new(),
}
}
}
/// Top-level application state managed by Tauri.
#[derive(Default)]
pub struct AppState {
pub discovery: Mutex<DiscoveryState>,
pub server: Mutex<ServerState>,
pub flash: Mutex<FlashState>,
pub ota: Mutex<OtaState>,
pub settings: Mutex<SettingsState>,
pub training: Mutex<TrainingState>,
}
impl Default for AppState {
fn default() -> Self {
Self {
discovery: Mutex::new(DiscoveryState::default()),
server: Mutex::new(ServerState::default()),
flash: Mutex::new(FlashState::default()),
ota: Mutex::new(OtaState::default()),
settings: Mutex::new(SettingsState::default()),
training: Mutex::new(TrainingState::default()),
}
}
}
impl AppState {
/// Create a new AppState instance.
pub fn new() -> Self {
Self::default()
}
/// Reset all state to defaults.
pub fn reset(&self) {
if let Ok(mut discovery) = self.discovery.lock() {
*discovery = DiscoveryState::default();
}
if let Ok(mut server) = self.server.lock() {
// Kill child process if running
if let Some(ref mut child) = server.child {
let _ = child.kill();
}
*server = ServerState::default();
}
if let Ok(mut flash) = self.flash.lock() {
*flash = FlashState::default();
}
if let Ok(mut ota) = self.ota.lock() {
*ota = OtaState::default();
}
if let Ok(mut settings) = self.settings.lock() {
*settings = SettingsState::default();
}
if let Ok(mut training) = self.training.lock() {
*training = TrainingState::default();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_state_default() {
let state = AppState::default();
let discovery = state.discovery.lock().unwrap();
assert!(discovery.nodes.is_empty());
let server = state.server.lock().unwrap();
assert!(!server.running);
assert!(server.pid.is_none());
}
#[test]
fn test_app_state_reset() {
let state = AppState::new();
// Modify state
{
let mut discovery = state.discovery.lock().unwrap();
discovery.nodes.push(DiscoveredNode {
ip: "192.168.1.100".into(),
mac: Some("AA:BB:CC:DD:EE:FF".into()),
hostname: None,
node_id: 1,
firmware_version: None,
health: crate::domain::node::HealthStatus::Online,
last_seen: chrono::Utc::now().to_rfc3339(),
chip: crate::domain::node::Chip::default(),
mesh_role: crate::domain::node::MeshRole::default(),
discovery_method: crate::domain::node::DiscoveryMethod::default(),
tdm_slot: None,
tdm_total: None,
edge_tier: None,
uptime_secs: None,
capabilities: None,
friendly_name: None,
notes: None,
});
}
// Reset
state.reset();
// Verify reset
let discovery = state.discovery.lock().unwrap();
assert!(discovery.nodes.is_empty());
}
#[test]
fn test_server_state() {
let server = ServerState::default();
assert!(!server.running);
assert!(server.child.is_none());
assert!(server.start_time.is_none());
}
#[test]
fn test_flash_state() {
let flash = FlashState::default();
assert_eq!(flash.phase, "");
assert_eq!(flash.progress_pct, 0.0);
}
}
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "RuView Desktop",
"version": "0.3.0",
"version": "0.5.0",
"identifier": "net.ruv.ruview",
"build": {
"frontendDist": "ui/dist",
@@ -0,0 +1,420 @@
//! Integration tests for all Tauri API commands
//!
//! Tests the actual command implementations without the Tauri runtime.
// ============================================================================
// Discovery Tests
// ============================================================================
#[test]
fn test_serial_port_detection_logic() {
// Test ESP32 VID/PID detection
// CP210x (Silicon Labs)
assert!(is_esp32_vid_pid(0x10C4, 0xEA60), "CP2102 should be detected");
assert!(is_esp32_vid_pid(0x10C4, 0xEA70), "CP2104 should be detected");
// CH340/CH341 (QinHeng)
assert!(is_esp32_vid_pid(0x1A86, 0x7523), "CH340 should be detected");
assert!(is_esp32_vid_pid(0x1A86, 0x5523), "CH341 should be detected");
// FTDI
assert!(is_esp32_vid_pid(0x0403, 0x6001), "FTDI FT232 should be detected");
assert!(is_esp32_vid_pid(0x0403, 0x6010), "FTDI FT2232 should be detected");
// ESP32 native USB
assert!(is_esp32_vid_pid(0x303A, 0x1001), "ESP32-S2/S3 native should be detected");
// Unknown device
assert!(!is_esp32_vid_pid(0x0000, 0x0000), "Unknown VID/PID should not be detected");
assert!(!is_esp32_vid_pid(0x1234, 0x5678), "Random VID/PID should not be detected");
}
fn is_esp32_vid_pid(vid: u16, pid: u16) -> bool {
// CP210x (Silicon Labs)
if vid == 0x10C4 && (pid == 0xEA60 || pid == 0xEA70) {
return true;
}
// CH340/CH341 (QinHeng)
if vid == 0x1A86 && (pid == 0x7523 || pid == 0x5523) {
return true;
}
// FTDI
if vid == 0x0403 && (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015) {
return true;
}
// ESP32-S2/S3 native USB
if vid == 0x303A {
return true;
}
false
}
#[test]
fn test_beacon_parsing() {
let data = b"RUVIEW_BEACON|AA:BB:CC:DD:EE:FF|1|0.3.0|esp32s3|coordinator|0|4";
let text = std::str::from_utf8(data).unwrap();
let parts: Vec<&str> = text.split('|').collect();
assert_eq!(parts.len(), 8);
assert_eq!(parts[0], "RUVIEW_BEACON");
assert_eq!(parts[1], "AA:BB:CC:DD:EE:FF");
assert_eq!(parts[2], "1");
assert_eq!(parts[3], "0.3.0");
assert_eq!(parts[4], "esp32s3");
assert_eq!(parts[5], "coordinator");
assert_eq!(parts[6], "0");
assert_eq!(parts[7], "4");
}
// ============================================================================
// Settings Tests
// ============================================================================
#[test]
fn test_settings_structure() {
use wifi_densepose_desktop::commands::settings::AppSettings;
let settings = AppSettings::default();
// Check default values
assert!(!settings.theme.is_empty(), "Theme should have a default");
assert!(settings.discover_interval_ms > 0, "Discovery interval should be positive");
assert!(settings.auto_discover, "Auto-discover should default to true");
assert_eq!(settings.server_http_port, 8080);
}
#[test]
fn test_settings_serialization() {
use wifi_densepose_desktop::commands::settings::AppSettings;
let settings = AppSettings::default();
let json = serde_json::to_string(&settings).expect("Should serialize");
let restored: AppSettings = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(settings.theme, restored.theme);
assert_eq!(settings.server_http_port, restored.server_http_port);
assert_eq!(settings.discover_interval_ms, restored.discover_interval_ms);
}
// ============================================================================
// Server Tests
// ============================================================================
#[test]
fn test_server_state_default() {
use wifi_densepose_desktop::state::ServerState;
let server = ServerState::default();
assert!(!server.running, "Server should not be running by default");
assert!(server.pid.is_none());
assert!(server.http_port.is_none());
}
// ============================================================================
// Flash Tests
// ============================================================================
#[test]
fn test_chip_variants() {
use wifi_densepose_desktop::domain::node::Chip;
let chips = vec![
Chip::Esp32,
Chip::Esp32s2,
Chip::Esp32s3,
Chip::Esp32c3,
Chip::Esp32c6,
];
for chip in chips {
let name = format!("{:?}", chip).to_lowercase();
assert!(name.starts_with("esp32"), "All chips should be ESP32 variants");
}
}
#[test]
fn test_progress_parsing() {
// Test espflash progress output parsing
let output = "Flashing... [===> ] 35%";
let re = regex::Regex::new(r"(\d+)%").unwrap();
if let Some(caps) = re.captures(output) {
let pct: u8 = caps[1].parse().unwrap();
assert_eq!(pct, 35);
} else {
panic!("Should parse percentage");
}
}
// ============================================================================
// OTA Tests
// ============================================================================
#[test]
fn test_sha256_hash() {
use sha2::{Sha256, Digest};
let data = b"test firmware data";
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
let hex = hex::encode(hash);
assert_eq!(hex.len(), 64, "SHA256 should produce 64 hex characters");
}
#[test]
fn test_hmac_signature() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let key = b"test_psk_key";
let data = b"firmware_hash";
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
mac.update(data);
let result = mac.finalize();
let signature = hex::encode(result.into_bytes());
assert_eq!(signature.len(), 64, "HMAC-SHA256 should produce 64 hex characters");
}
// ============================================================================
// Provision Tests
// ============================================================================
#[test]
fn test_nvs_config_format() {
// Test CSV format for NVS partition
let csv = "key,type,encoding,value\ncsi_cfg,namespace,,\nssid,data,string,TestNetwork\npassword,data,string,TestPass123\n";
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 4);
assert!(lines[0].starts_with("key,type"));
assert!(lines[1].contains("namespace"));
assert!(lines[2].contains("ssid"));
assert!(lines[3].contains("password"));
}
#[test]
fn test_mesh_config_generation() {
// Test that mesh configs have required fields
let config = serde_json::json!({
"node_id": 1,
"mesh_role": "node",
"tdm_slot": 0,
"tdm_total": 4,
"ssid": "TestNetwork",
"password": "TestPass",
"coordinator_ip": "192.168.1.100"
});
assert!(config.get("node_id").is_some());
assert!(config.get("mesh_role").is_some());
assert!(config.get("ssid").is_some());
}
// ============================================================================
// WASM Tests
// ============================================================================
#[test]
fn test_wasm_magic_bytes() {
// WebAssembly magic bytes: \0asm
let wasm_header: [u8; 4] = [0x00, 0x61, 0x73, 0x6D];
assert_eq!(wasm_header[0], 0x00);
assert_eq!(wasm_header[1], 0x61); // 'a'
assert_eq!(wasm_header[2], 0x73); // 's'
assert_eq!(wasm_header[3], 0x6D); // 'm'
}
#[test]
fn test_wasm_version() {
// WASM version 1
let wasm_version: [u8; 4] = [0x01, 0x00, 0x00, 0x00];
let version = u32::from_le_bytes(wasm_version);
assert_eq!(version, 1);
}
// ============================================================================
// State Tests
// ============================================================================
#[test]
fn test_app_state_initialization() {
use wifi_densepose_desktop::state::AppState;
let state = AppState::default();
// Check that all state components initialize correctly
let discovery = state.discovery.lock().unwrap();
assert!(discovery.nodes.is_empty(), "Should start with no nodes");
drop(discovery);
let flash = state.flash.lock().unwrap();
assert_eq!(flash.phase, "", "Should start with empty phase");
assert_eq!(flash.progress_pct, 0.0);
drop(flash);
let server = state.server.lock().unwrap();
assert!(!server.running, "Server should not be running initially");
}
// ============================================================================
// Domain Model Tests
// ============================================================================
#[test]
fn test_health_status_variants() {
use wifi_densepose_desktop::domain::node::HealthStatus;
let statuses = vec![
HealthStatus::Online,
HealthStatus::Degraded,
HealthStatus::Offline,
];
for status in statuses {
let json = serde_json::to_string(&status).expect("Should serialize");
assert!(!json.is_empty());
}
}
#[test]
fn test_discovery_method_variants() {
use wifi_densepose_desktop::domain::node::DiscoveryMethod;
let methods = vec![
DiscoveryMethod::Mdns,
DiscoveryMethod::UdpProbe,
DiscoveryMethod::Manual,
DiscoveryMethod::HttpSweep,
];
for method in methods {
let json = serde_json::to_string(&method).expect("Should serialize");
assert!(!json.is_empty());
}
}
#[test]
fn test_mesh_role_variants() {
use wifi_densepose_desktop::domain::node::MeshRole;
let roles = vec![
MeshRole::Coordinator,
MeshRole::Aggregator,
MeshRole::Node,
];
for role in roles {
let json = serde_json::to_string(&role).expect("Should serialize");
assert!(!json.is_empty());
}
}
// ============================================================================
// WiFi Config Tests (New Feature)
// ============================================================================
#[test]
fn test_wifi_config_command_format() {
let ssid = "TestNetwork";
let password = "TestPass123";
// Test all command formats
let cmd1 = format!("wifi_config {} {}\r\n", ssid, password);
let cmd2 = format!("wifi {} {}\r\n", ssid, password);
let cmd3 = format!("set ssid {}\r\n", ssid);
let cmd4 = format!("set password {}\r\n", password);
assert!(cmd1.contains("wifi_config"));
assert!(cmd1.contains(ssid));
assert!(cmd1.contains(password));
assert!(cmd1.ends_with("\r\n"));
assert!(cmd2.starts_with("wifi "));
assert!(cmd3.starts_with("set ssid "));
assert!(cmd4.starts_with("set password "));
}
#[test]
fn test_wifi_credentials_validation() {
// SSID: 1-32 characters
let valid_ssid = "MyNetwork";
let empty_ssid = "";
let long_ssid = "A".repeat(33);
assert!(!valid_ssid.is_empty() && valid_ssid.len() <= 32);
assert!(empty_ssid.is_empty());
assert!(long_ssid.len() > 32);
// Password: 8-63 characters for WPA2
let valid_pass = "password123";
let short_pass = "short";
let long_pass = "A".repeat(64);
assert!(valid_pass.len() >= 8 && valid_pass.len() <= 63);
assert!(short_pass.len() < 8);
assert!(long_pass.len() > 63);
}
// ============================================================================
// Node Registry Tests
// ============================================================================
#[test]
fn test_node_registry() {
use wifi_densepose_desktop::domain::node::{
DiscoveredNode, MacAddress, NodeRegistry, HealthStatus, Chip, MeshRole, DiscoveryMethod
};
let mut registry = NodeRegistry::new();
assert!(registry.is_empty());
let node = DiscoveredNode {
ip: "192.168.1.100".into(),
mac: Some("AA:BB:CC:DD:EE:FF".into()),
hostname: Some("csi-node-1".into()),
node_id: 1,
firmware_version: Some("0.3.0".into()),
health: HealthStatus::Online,
last_seen: "2024-01-01T00:00:00Z".into(),
chip: Chip::Esp32s3,
mesh_role: MeshRole::Node,
discovery_method: DiscoveryMethod::Mdns,
tdm_slot: Some(0),
tdm_total: Some(4),
edge_tier: None,
uptime_secs: Some(3600),
capabilities: None,
friendly_name: None,
notes: None,
};
registry.upsert(MacAddress::new("AA:BB:CC:DD:EE:FF"), node);
assert_eq!(registry.len(), 1);
let retrieved = registry.get(&MacAddress::new("AA:BB:CC:DD:EE:FF"));
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().ip, "192.168.1.100");
}
// ============================================================================
// MAC Address Tests
// ============================================================================
#[test]
fn test_mac_address() {
use wifi_densepose_desktop::domain::node::MacAddress;
let mac = MacAddress::new("AA:BB:CC:DD:EE:FF");
assert_eq!(mac.to_string(), "AA:BB:CC:DD:EE:FF");
let mac2 = MacAddress::new("aa:bb:cc:dd:ee:ff");
assert_ne!(mac, mac2); // Case sensitive comparison
}
@@ -0,0 +1,130 @@
{
"running": true,
"startedAt": "2026-03-10T00:49:11.921Z",
"workers": {
"map": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:49:11.921Z"
},
"audit": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:51:11.921Z"
},
"optimize": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:53:11.921Z"
},
"consolidate": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:55:11.921Z"
},
"testgaps": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false,
"nextRun": "2026-03-10T00:57:11.921Z"
},
"predict": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
},
"document": {
"runCount": 0,
"successCount": 0,
"failureCount": 0,
"averageDurationMs": 0,
"isRunning": false
}
},
"config": {
"autoStart": false,
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/.claude-flow/logs",
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui/.claude-flow/daemon-state.json",
"maxConcurrent": 2,
"workerTimeoutMs": 300000,
"resourceThresholds": {
"maxCpuLoad": 2,
"minFreeMemoryPercent": 20
},
"workers": [
{
"type": "map",
"intervalMs": 900000,
"offsetMs": 0,
"priority": "normal",
"description": "Codebase mapping",
"enabled": true
},
{
"type": "audit",
"intervalMs": 600000,
"offsetMs": 120000,
"priority": "critical",
"description": "Security analysis",
"enabled": true
},
{
"type": "optimize",
"intervalMs": 900000,
"offsetMs": 240000,
"priority": "high",
"description": "Performance optimization",
"enabled": true
},
{
"type": "consolidate",
"intervalMs": 1800000,
"offsetMs": 360000,
"priority": "low",
"description": "Memory consolidation",
"enabled": true
},
{
"type": "testgaps",
"intervalMs": 1200000,
"offsetMs": 480000,
"priority": "normal",
"description": "Test coverage analysis",
"enabled": true
},
{
"type": "predict",
"intervalMs": 600000,
"offsetMs": 0,
"priority": "low",
"description": "Predictive preloading",
"enabled": false
},
{
"type": "document",
"intervalMs": 3600000,
"offsetMs": 0,
"priority": "low",
"description": "Auto-documentation",
"enabled": false
}
]
},
"savedAt": "2026-03-10T00:49:11.921Z"
}
@@ -9,3 +9,20 @@
{"type":"edit","file":"unknown","timestamp":1772835930809,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1772835942468,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1772835952451,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773070971487,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773070977376,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773101503481,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773107530083,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773107530201,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773107530319,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773114830434,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773114834713,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773114838852,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150617007,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150621430,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150628006,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150640909,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150672276,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150677219,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150683839,"sessionId":null}
{"type":"edit","file":"unknown","timestamp":1773150688912,"sessionId":null}
@@ -0,0 +1,12 @@
{
"id": "session-1773103750755",
"startedAt": "2026-03-10T00:49:10.755Z",
"cwd": "/Users/cohen/GitHub/ruvnet/RuView/rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui",
"context": {},
"metrics": {
"edits": 14,
"commands": 0,
"tasks": 0,
"errors": 0
}
}
@@ -53,6 +53,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1246,6 +1247,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1315,6 +1317,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1584,6 +1587,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1625,6 +1629,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1797,6 +1802,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -1,7 +1,7 @@
{
"name": "ruview-desktop-ui",
"private": true,
"version": "0.3.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,21 +1,26 @@
import { useState, useEffect, useCallback } from "react";
import { APP_VERSION } from "./version";
import Dashboard from "./pages/Dashboard";
import { Nodes } from "./pages/Nodes";
import NetworkDiscovery from "./pages/NetworkDiscovery";
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 Training from "./pages/Training";
import { Settings } from "./pages/Settings";
type Page =
| "dashboard"
| "discovery"
| "nodes"
| "flash"
| "ota"
| "wasm"
| "sensing"
| "mesh"
| "training"
| "settings";
interface NavItem {
@@ -26,12 +31,14 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ id: "dashboard", label: "Dashboard", icon: "\u25A6" },
{ id: "discovery", label: "Discovery", icon: "\u25CE" },
{ 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: "training", label: "Training", icon: "\u2B50" },
{ id: "settings", label: "Settings", icon: "\u2699" },
];
@@ -87,13 +94,15 @@ const App: React.FC = () => {
const renderPage = () => {
switch (activePage) {
case "dashboard": return <Dashboard />;
case "dashboard": return <Dashboard onNavigate={navigateTo} />;
case "discovery": return <NetworkDiscovery onNavigate={navigateTo} />;
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 "training": return <Training />;
case "settings": return <Settings />;
}
};
@@ -163,7 +172,7 @@ const App: React.FC = () => {
letterSpacing: "0.02em",
}}
>
v0.3.0
v{APP_VERSION}
</span>
</div>
</div>
@@ -9,6 +9,7 @@ const DEFAULT_CONFIG: ServerConfig = {
static_dir: null,
model_dir: null,
log_level: "info",
source: "simulate",
};
interface UseServerOptions {
@@ -19,19 +19,31 @@ interface ServerStatus {
ws_port: number | null;
}
const Dashboard: React.FC = () => {
type Page = "dashboard" | "discovery" | "nodes" | "flash" | "ota" | "wasm" | "sensing" | "mesh" | "settings";
interface DashboardProps {
onNavigate?: (page: Page) => void;
}
const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const handleScan = async () => {
setScanning(true);
setScanError(null);
try {
const { invoke } = await import("@tauri-apps/api/core");
const found = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 3000 });
setNodes(found);
if (found.length === 0) {
setScanError("No nodes found. Ensure ESP32 devices are powered on and connected to the network.");
}
} catch (err) {
console.error("Discovery failed:", err);
setScanError(`Scan failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setScanning(false);
}
@@ -133,9 +145,9 @@ const Dashboard: React.FC = () => {
<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" />
<QuickAction label="Flash Firmware" desc="Flash via serial port" onClick={() => onNavigate?.("flash")} />
<QuickAction label="Push OTA Update" desc="Over-the-air to nodes" onClick={() => onNavigate?.("ota")} />
<QuickAction label="Upload WASM" desc="Deploy edge modules" onClick={() => onNavigate?.("wasm")} />
</div>
</div>
</div>
@@ -145,7 +157,23 @@ const Dashboard: React.FC = () => {
<h3 className="heading-sm">Discovered Nodes ({nodes.length})</h3>
</div>
{nodes.length === 0 ? (
{scanError && (
<div
style={{
padding: "var(--space-3) var(--space-4)",
background: "rgba(248, 81, 73, 0.1)",
border: "1px solid rgba(248, 81, 73, 0.3)",
borderRadius: "var(--radius-md)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{scanError}
</div>
)}
{nodes.length === 0 && !scanError ? (
<div className="card empty-state">
<div className="empty-state-icon">{"\u25C9"}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
@@ -155,7 +183,7 @@ const Dashboard: React.FC = () => {
Click "Scan Network" to discover ESP32 devices on your local network.
</div>
</div>
) : (
) : nodes.length === 0 ? null : (
<div
style={{
display: "grid",
@@ -258,9 +286,10 @@ function PortTag({ label, port }: { label: string; port: number }) {
);
}
function QuickAction({ label, desc }: { label: string; desc: string }) {
function QuickAction({ label, desc, onClick }: { label: string; desc: string; onClick?: () => void }) {
return (
<div
onClick={onClick}
style={{
display: "flex",
justifyContent: "space-between",
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useServer } from "../hooks/useServer";
import type { SensingUpdate } from "../types";
import type { SensingUpdate, DataSource } from "../types";
// ---------------------------------------------------------------------------
// Log entry model
@@ -17,34 +17,58 @@ interface LogEntry {
}
// ---------------------------------------------------------------------------
// Mock data generators
// WebSocket message types from sensing server
// ---------------------------------------------------------------------------
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)" },
];
interface WsNodeInfo {
node_id: number;
rssi_dbm: number;
position: [number, number, number];
amplitude: number[];
subcarrier_count: number;
}
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 },
];
interface WsClassification {
motion_level: string;
presence: boolean;
confidence: number;
}
interface WsFeatures {
mean_rssi: number;
variance: number;
motion_band_power: number;
breathing_band_power: number;
dominant_freq_hz: number;
change_points: number;
spectral_power: number;
}
interface WsVitalSigns {
breathing_rate_hz?: number;
heart_rate_bpm?: number;
confidence?: number;
}
interface WsSensingUpdate {
type: string;
timestamp: number;
source: string;
tick: number;
nodes: WsNodeInfo[];
features: WsFeatures;
classification: WsClassification;
vital_signs?: WsVitalSigns;
posture?: string;
signal_quality_score?: number;
quality_verdict?: string;
bssid_count?: number;
estimated_persons?: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatTimestamp(d: Date): string {
const hh = String(d.getHours()).padStart(2, "0");
@@ -56,26 +80,71 @@ function formatTimestamp(d: Date): string {
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 createLogFromWsUpdate(update: WsSensingUpdate): LogEntry[] {
const entries: LogEntry[] = [];
const ts = formatTimestamp(new Date(update.timestamp * 1000));
// Log each node's CSI data
for (const node of update.nodes) {
entries.push({
id: nextLogId++,
timestamp: ts,
level: "INFO",
source: "csi_receiver",
message: `Node ${node.node_id}: RSSI ${node.rssi_dbm.toFixed(1)} dBm, ${node.subcarrier_count} subcarriers`,
});
}
// Log classification
if (update.classification) {
const level: LogLevel = update.classification.confidence < 0.5 ? "WARN" : "INFO";
entries.push({
id: nextLogId++,
timestamp: ts,
level,
source: "classifier",
message: `Motion: ${update.classification.motion_level} (presence=${update.classification.presence}, conf=${(update.classification.confidence * 100).toFixed(0)}%)`,
});
}
// Log vital signs if present
if (update.vital_signs) {
const vs = update.vital_signs;
const level: LogLevel = (vs.confidence ?? 0) < 0.5 ? "WARN" : "INFO";
entries.push({
id: nextLogId++,
timestamp: ts,
level,
source: "vital_signs",
message: `Breathing: ${vs.breathing_rate_hz?.toFixed(2) ?? "--"} Hz, HR: ${vs.heart_rate_bpm?.toFixed(0) ?? "--"} bpm`,
});
}
// Log quality verdict if present
if (update.quality_verdict && update.quality_verdict !== "Permit") {
entries.push({
id: nextLogId++,
timestamp: ts,
level: update.quality_verdict === "Deny" ? "ERROR" : "WARN",
source: "quality_gate",
message: `Signal quality: ${update.quality_verdict} (score=${(update.signal_quality_score ?? 0).toFixed(2)})`,
});
}
return entries;
}
function createMockSensingUpdate(): SensingUpdate {
const act = MOCK_ACTIVITIES[Math.floor(Math.random() * MOCK_ACTIVITIES.length)];
function createActivityFromWsUpdate(update: WsSensingUpdate): SensingUpdate | null {
if (!update.classification) return null;
const node = update.nodes[0];
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)),
timestamp: new Date(update.timestamp * 1000).toISOString(),
node_id: node?.node_id ?? 1,
subcarrier_count: node?.subcarrier_count ?? 52,
rssi: node?.rssi_dbm ?? -50,
activity: update.posture ?? update.classification.motion_level,
confidence: update.classification.confidence,
};
}
@@ -84,7 +153,7 @@ function createMockSensingUpdate(): SensingUpdate {
// ---------------------------------------------------------------------------
const MAX_LOG_ENTRIES = 200;
const LOG_INTERVAL_MS = 2000;
const WS_RECONNECT_DELAY_MS = 3000;
// ---------------------------------------------------------------------------
// LogViewer component (ADR-053)
@@ -107,11 +176,12 @@ function LogViewer({
paused: boolean;
onTogglePause: () => void;
}) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!paused && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
// Scroll to bottom within the container only (not the page)
if (!paused && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [entries, paused]);
@@ -185,6 +255,7 @@ function LogViewer({
{/* Log entries */}
<div
ref={containerRef}
style={{
height: 320,
overflowY: "auto",
@@ -217,7 +288,6 @@ function LogViewer({
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
);
@@ -232,6 +302,9 @@ export const Sensing: React.FC = () => {
const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false);
// Data source selection
const [dataSource, setDataSource] = useState<DataSource>("simulate");
// Log viewer state
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [paused, setPaused] = useState(false);
@@ -241,28 +314,119 @@ export const Sensing: React.FC = () => {
// Activity feed state
const [activities, setActivities] = useState<SensingUpdate[]>([]);
// Simulated log feed
// WebSocket connection state
const [wsConnected, setWsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
// Connect to real WebSocket when server is running
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);
});
if (!isRunning || !status?.ws_port) {
// Server not running, disconnect if connected
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
setWsConnected(false);
}
}, LOG_INTERVAL_MS);
return;
}
return () => clearInterval(interval);
}, []);
const connect = () => {
const wsUrl = `ws://127.0.0.1:${status.ws_port}/ws/sensing`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setWsConnected(true);
setLogEntries((prev) => [
...prev,
{
id: nextLogId++,
timestamp: formatTimestamp(new Date()),
level: "INFO",
source: "desktop",
message: `WebSocket connected to ${wsUrl}`,
},
]);
};
ws.onmessage = (event) => {
if (pausedRef.current) return;
try {
const update = JSON.parse(event.data) as WsSensingUpdate;
// Create log entries from the update
const entries = createLogFromWsUpdate(update);
if (entries.length > 0) {
setLogEntries((prev) => {
const next = [...prev, ...entries];
return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next;
});
}
// Create activity update
const activity = createActivityFromWsUpdate(update);
if (activity) {
setActivities((prev) => {
const next = [activity, ...prev];
return next.slice(0, 5);
});
}
} catch (err) {
console.error("Failed to parse WebSocket message:", err);
}
};
ws.onclose = () => {
setWsConnected(false);
wsRef.current = null;
// Only add disconnect log if server is still supposed to be running
if (isRunning) {
setLogEntries((prev) => [
...prev,
{
id: nextLogId++,
timestamp: formatTimestamp(new Date()),
level: "WARN",
source: "desktop",
message: "WebSocket disconnected, reconnecting...",
},
]);
// Attempt reconnect
reconnectTimeoutRef.current = window.setTimeout(connect, WS_RECONNECT_DELAY_MS);
}
};
ws.onerror = () => {
setLogEntries((prev) => [
...prev,
{
id: nextLogId++,
timestamp: formatTimestamp(new Date()),
level: "ERROR",
source: "desktop",
message: "WebSocket connection error",
},
]);
};
wsRef.current = ws;
};
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [isRunning, status?.ws_port]);
const handleClearLog = useCallback(() => setLogEntries([]), []);
const handleTogglePause = useCallback(() => setPaused((p) => !p), []);
@@ -270,7 +434,7 @@ export const Sensing: React.FC = () => {
const handleStart = async () => {
setStarting(true);
try {
await start();
await start({ source: dataSource });
} finally {
setStarting(false);
}
@@ -349,28 +513,76 @@ export const Sensing: React.FC = () => {
{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>}
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: wsConnected ? "var(--status-online)" : "var(--status-warning)",
}}
/>
{wsConnected ? "Live" : "Connecting..."}
</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>
{/* Right: data source + action button */}
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
{/* Data source selector */}
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<label
style={{
fontSize: 12,
color: "var(--text-muted)",
fontWeight: 500,
}}
>
Source:
</label>
<select
value={dataSource}
onChange={(e) => setDataSource(e.target.value as DataSource)}
disabled={isRunning}
style={{
padding: "var(--space-1) var(--space-2)",
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
border: "1px solid var(--border)",
background: isRunning ? "var(--bg-hover)" : "var(--bg-surface)",
color: "var(--text-primary)",
cursor: isRunning ? "not-allowed" : "pointer",
opacity: isRunning ? 0.6 : 1,
}}
>
<option value="simulate">Simulate</option>
<option value="esp32">ESP32 (Real)</option>
<option value="wifi">WiFi (RSSI)</option>
<option value="auto">Auto Detect</option>
</select>
</div>
{/* 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>
</div>
{/* Error display */}
@@ -0,0 +1,369 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Dataset {
id: string;
name: string;
description: string;
size_mb: number;
samples: number;
downloaded: boolean;
path: string | null;
}
const STANDARD_DATASETS: Omit<Dataset, "downloaded" | "path">[] = [
{
id: "mmfi",
name: "MM-Fi Dataset",
description: "Multi-modal WiFi sensing dataset with 40 subjects, 27 activities",
size_mb: 2400,
samples: 320000,
},
{
id: "wipose",
name: "Wi-Pose Dataset",
description: "WiFi-based pose estimation with 3D skeleton annotations",
size_mb: 1800,
samples: 150000,
},
{
id: "wiar",
name: "WiAR Dataset",
description: "WiFi activity recognition with CSI data",
size_mb: 500,
samples: 45000,
},
];
const DatasetsTab: React.FC = () => {
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDatasets();
}, []);
const loadDatasets = async () => {
try {
const downloaded = await invoke<string[]>("list_datasets");
const ds = STANDARD_DATASETS.map((d) => ({
...d,
downloaded: downloaded.includes(d.id),
path: downloaded.includes(d.id) ? `~/.ruview/datasets/${d.id}` : null,
}));
setDatasets(ds);
} catch (err) {
// If command not implemented yet, show placeholders
setDatasets(
STANDARD_DATASETS.map((d) => ({
...d,
downloaded: false,
path: null,
}))
);
}
};
const handleDownload = async (datasetId: string) => {
setDownloading(datasetId);
setDownloadProgress(0);
setError(null);
try {
// Simulate download progress for now
for (let i = 0; i <= 100; i += 10) {
setDownloadProgress(i);
await new Promise((r) => setTimeout(r, 500));
}
// TODO: Call actual download command
// await invoke("download_dataset", { datasetId });
setDatasets((prev) =>
prev.map((d) =>
d.id === datasetId
? { ...d, downloaded: true, path: `~/.ruview/datasets/${d.id}` }
: d
)
);
} catch (err) {
setError(`Download failed: ${err}`);
} finally {
setDownloading(null);
}
};
return (
<div>
{/* Stats Row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard
label="Available Datasets"
value={datasets.length}
/>
<StatCard
label="Downloaded"
value={datasets.filter((d) => d.downloaded).length}
color="var(--status-online)"
/>
<StatCard
label="Total Samples"
value={`${(datasets.reduce((acc, d) => acc + (d.downloaded ? d.samples : 0), 0) / 1000).toFixed(0)}K`}
/>
</div>
{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)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
{/* Dataset Cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: "var(--space-4)",
}}
>
{datasets.map((dataset) => (
<div
key={dataset.id}
className="card"
style={{
padding: "var(--space-4)",
opacity: dataset.downloaded ? 1 : 0.85,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
marginBottom: "var(--space-3)",
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>
{dataset.name}
</h3>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 4,
lineHeight: 1.4,
}}
>
{dataset.description}
</p>
</div>
{dataset.downloaded && (
<span
style={{
background: "rgba(63, 185, 80, 0.15)",
color: "var(--status-online)",
padding: "2px 8px",
borderRadius: 4,
fontSize: 10,
fontWeight: 600,
}}
>
DOWNLOADED
</span>
)}
</div>
<div
style={{
display: "flex",
gap: "var(--space-4)",
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: "var(--space-3)",
}}
>
<span>📦 {(dataset.size_mb / 1024).toFixed(1)} GB</span>
<span>📊 {(dataset.samples / 1000).toFixed(0)}K samples</span>
</div>
{downloading === dataset.id ? (
<div>
<div
style={{
height: 4,
background: "var(--border)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${downloadProgress}%`,
height: "100%",
background: "var(--accent)",
transition: "width 0.3s",
}}
/>
</div>
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
textAlign: "center",
}}
>
Downloading... {downloadProgress}%
</div>
</div>
) : (
<div style={{ display: "flex", gap: "var(--space-2)" }}>
{dataset.downloaded ? (
<>
<button
style={{
flex: 1,
padding: "8px 12px",
background: "rgba(56, 139, 253, 0.1)",
border: "1px solid rgba(56, 139, 253, 0.3)",
borderRadius: 6,
color: "var(--accent)",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
Preview
</button>
<button
style={{
padding: "8px 12px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-secondary)",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
Delete
</button>
</>
) : (
<button
onClick={() => handleDownload(dataset.id)}
className="btn-gradient"
style={{ flex: 1, fontSize: 12 }}
>
Download Dataset
</button>
)}
</div>
)}
</div>
))}
</div>
{/* Import Custom Dataset */}
<div
className="card"
style={{
marginTop: "var(--space-5)",
padding: "var(--space-4)",
border: "2px dashed var(--border)",
textAlign: "center",
}}
>
<div style={{ fontSize: 32, marginBottom: "var(--space-2)" }}>📁</div>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
Import Custom Dataset
</h4>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 4,
marginBottom: "var(--space-3)",
}}
>
Import CSI recordings in CSV, NPZ, or HDF5 format
</p>
<button
style={{
padding: "8px 20px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-secondary)",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
Browse Files
</button>
</div>
</div>
);
};
function StatCard({
label,
value,
color,
}: {
label: string;
value: number | string;
color?: string;
}) {
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: 28,
fontWeight: 600,
color: color || "var(--text-primary)",
letterSpacing: "-0.02em",
}}
>
{value}
</div>
</div>
);
}
export default DatasetsTab;
@@ -0,0 +1,609 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface TrainingMetrics {
epoch: number;
train_loss: number;
val_loss: number;
train_acc: number;
val_acc: number;
learning_rate: number;
timestamp: string;
}
interface EvaluationMetrics {
pck_05: number;
pck_10: number;
pck_20: number;
map_50: number;
map_75: number;
iou: number;
}
interface JointAccuracy {
joint: string;
accuracy: number;
}
const JOINT_NAMES = [
"nose",
"left_eye",
"right_eye",
"left_ear",
"right_ear",
"left_shoulder",
"right_shoulder",
"left_elbow",
"right_elbow",
"left_wrist",
"right_wrist",
"left_hip",
"right_hip",
"left_knee",
"right_knee",
"left_ankle",
"right_ankle",
];
const MetricsTab: React.FC = () => {
const [trainingHistory, setTrainingHistory] = useState<TrainingMetrics[]>([]);
const [evaluation, setEvaluation] = useState<EvaluationMetrics | null>(null);
const [jointAccuracies, setJointAccuracies] = useState<JointAccuracy[]>([]);
const [selectedMetric, setSelectedMetric] = useState<"loss" | "accuracy">("loss");
const [exporting, setExporting] = useState(false);
useEffect(() => {
loadMetrics();
}, []);
const loadMetrics = async () => {
try {
const metrics = await invoke<TrainingMetrics[]>("get_training_history");
setTrainingHistory(metrics);
const evalMetrics = await invoke<EvaluationMetrics>("get_evaluation_metrics");
setEvaluation(evalMetrics);
const joints = await invoke<JointAccuracy[]>("get_joint_accuracies");
setJointAccuracies(joints);
} catch (err) {
// Generate mock data for demonstration
const mockHistory: TrainingMetrics[] = [];
for (let i = 1; i <= 50; i++) {
mockHistory.push({
epoch: i,
train_loss: 0.5 * Math.exp(-i / 20) + 0.02 + Math.random() * 0.01,
val_loss: 0.55 * Math.exp(-i / 18) + 0.025 + Math.random() * 0.015,
train_acc: 1 - 0.5 * Math.exp(-i / 15) - Math.random() * 0.02,
val_acc: 1 - 0.55 * Math.exp(-i / 15) - Math.random() * 0.025,
learning_rate: 0.001 * Math.pow(0.95, Math.floor(i / 10)),
timestamp: new Date(Date.now() - (50 - i) * 60000).toISOString(),
});
}
setTrainingHistory(mockHistory);
setEvaluation({
pck_05: 0.72,
pck_10: 0.89,
pck_20: 0.96,
map_50: 0.84,
map_75: 0.71,
iou: 0.78,
});
setJointAccuracies(
JOINT_NAMES.map((joint) => ({
joint,
accuracy: 0.7 + Math.random() * 0.25,
}))
);
}
};
const exportMetrics = async (format: "csv" | "json" | "tensorboard") => {
setExporting(true);
try {
if (format === "json") {
const data = {
training: trainingHistory,
evaluation,
joints: jointAccuracies,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
downloadBlob(blob, "metrics.json");
} else if (format === "csv") {
const headers = "epoch,train_loss,val_loss,train_acc,val_acc,learning_rate\n";
const rows = trainingHistory
.map(
(m) =>
`${m.epoch},${m.train_loss.toFixed(6)},${m.val_loss.toFixed(6)},${m.train_acc.toFixed(4)},${m.val_acc.toFixed(4)},${m.learning_rate.toExponential(2)}`
)
.join("\n");
const blob = new Blob([headers + rows], { type: "text/csv" });
downloadBlob(blob, "training_history.csv");
} else {
// TensorBoard format would require server-side handling
alert("TensorBoard export requires running the backend server");
}
} finally {
setExporting(false);
}
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const maxLoss = Math.max(
...trainingHistory.map((m) => Math.max(m.train_loss, m.val_loss)),
0.1
);
return (
<div>
{/* Summary Stats */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard
label="Epochs Trained"
value={trainingHistory.length}
/>
<StatCard
label="Best Val Loss"
value={
trainingHistory.length > 0
? Math.min(...trainingHistory.map((m) => m.val_loss)).toFixed(4)
: "—"
}
color="var(--status-online)"
/>
<StatCard
label="Best Val Acc"
value={
trainingHistory.length > 0
? `${(Math.max(...trainingHistory.map((m) => m.val_acc)) * 100).toFixed(1)}%`
: "—"
}
color="var(--accent)"
/>
<StatCard
label="PCK@0.1"
value={evaluation ? `${(evaluation.pck_10 * 100).toFixed(1)}%` : "—"}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "var(--space-5)" }}>
{/* Loss/Accuracy Charts */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-4)",
}}
>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Training Curves</h3>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<button
onClick={() => setSelectedMetric("loss")}
style={{
padding: "6px 12px",
background: selectedMetric === "loss" ? "var(--accent)" : "transparent",
border: `1px solid ${selectedMetric === "loss" ? "var(--accent)" : "var(--border)"}`,
borderRadius: 4,
color: selectedMetric === "loss" ? "white" : "var(--text-secondary)",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
}}
>
Loss
</button>
<button
onClick={() => setSelectedMetric("accuracy")}
style={{
padding: "6px 12px",
background: selectedMetric === "accuracy" ? "var(--accent)" : "transparent",
border: `1px solid ${selectedMetric === "accuracy" ? "var(--accent)" : "var(--border)"}`,
borderRadius: 4,
color: selectedMetric === "accuracy" ? "white" : "var(--text-secondary)",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
}}
>
Accuracy
</button>
</div>
</div>
{/* Chart Area */}
<div
style={{
height: 250,
position: "relative",
background: "var(--bg-secondary)",
borderRadius: 8,
padding: "var(--space-3)",
}}
>
{trainingHistory.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "var(--text-muted)",
}}
>
<span style={{ fontSize: 32 }}>📊</span>
<p style={{ fontSize: 13, marginTop: "var(--space-2)" }}>
No training data yet
</p>
</div>
) : (
<svg width="100%" height="100%" viewBox="0 0 500 200" preserveAspectRatio="none">
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((y) => (
<line
key={y}
x1="0"
y1={y * 180}
x2="500"
y2={y * 180}
stroke="var(--border)"
strokeWidth="0.5"
strokeDasharray="4"
/>
))}
{/* Train line */}
<polyline
fill="none"
stroke="var(--accent)"
strokeWidth="2"
points={trainingHistory
.map((m, i) => {
const x = (i / (trainingHistory.length - 1)) * 500;
const value = selectedMetric === "loss" ? m.train_loss : m.train_acc;
const y =
selectedMetric === "loss"
? (value / maxLoss) * 180
: (1 - value) * 180;
return `${x},${y}`;
})
.join(" ")}
/>
{/* Val line */}
<polyline
fill="none"
stroke="var(--status-online)"
strokeWidth="2"
points={trainingHistory
.map((m, i) => {
const x = (i / (trainingHistory.length - 1)) * 500;
const value = selectedMetric === "loss" ? m.val_loss : m.val_acc;
const y =
selectedMetric === "loss"
? (value / maxLoss) * 180
: (1 - value) * 180;
return `${x},${y}`;
})
.join(" ")}
/>
</svg>
)}
{/* Legend */}
<div
style={{
position: "absolute",
top: "var(--space-2)",
right: "var(--space-2)",
display: "flex",
gap: "var(--space-3)",
fontSize: 11,
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 12,
height: 3,
background: "var(--accent)",
borderRadius: 2,
}}
/>
Train
</span>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 12,
height: 3,
background: "var(--status-online)",
borderRadius: 2,
}}
/>
Validation
</span>
</div>
</div>
</div>
{/* Evaluation Metrics */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Evaluation Metrics
</h3>
{!evaluation ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 200,
color: "var(--text-muted)",
}}
>
<span style={{ fontSize: 32 }}>📏</span>
<p style={{ fontSize: 13, marginTop: "var(--space-2)" }}>
Run evaluation to see metrics
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
<MetricBar label="PCK@0.05" value={evaluation.pck_05} color="#f59e0b" />
<MetricBar label="PCK@0.10" value={evaluation.pck_10} color="var(--accent)" />
<MetricBar label="PCK@0.20" value={evaluation.pck_20} color="var(--status-online)" />
<div style={{ height: 1, background: "var(--border)", margin: "var(--space-2) 0" }} />
<MetricBar label="mAP@0.50" value={evaluation.map_50} color="#a855f7" />
<MetricBar label="mAP@0.75" value={evaluation.map_75} color="#ec4899" />
<MetricBar label="IoU" value={evaluation.iou} color="#06b6d4" />
</div>
)}
</div>
</div>
{/* Joint-wise Accuracy */}
<div className="card" style={{ marginTop: "var(--space-5)", padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Per-Joint Accuracy
</h3>
{jointAccuracies.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "var(--space-5)",
color: "var(--text-muted)",
}}
>
No joint accuracy data available
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: "var(--space-3)",
}}
>
{jointAccuracies.map((ja) => (
<div
key={ja.joint}
style={{
padding: "var(--space-3)",
background: "var(--bg-secondary)",
borderRadius: 6,
textAlign: "center",
}}
>
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
marginBottom: 4,
textTransform: "capitalize",
}}
>
{ja.joint.replace("_", " ")}
</div>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 18,
fontWeight: 600,
color:
ja.accuracy > 0.9
? "var(--status-online)"
: ja.accuracy > 0.8
? "var(--accent)"
: ja.accuracy > 0.7
? "#f59e0b"
: "var(--status-error)",
}}
>
{(ja.accuracy * 100).toFixed(1)}%
</div>
</div>
))}
</div>
)}
</div>
{/* Export Section */}
<div
className="card"
style={{
marginTop: "var(--space-5)",
padding: "var(--space-4)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Export Metrics</h3>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}>
Download training history and evaluation results
</p>
</div>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<button
onClick={() => exportMetrics("csv")}
disabled={exporting || trainingHistory.length === 0}
style={{
padding: "8px 16px",
background: "rgba(56, 139, 253, 0.1)",
border: "1px solid rgba(56, 139, 253, 0.3)",
borderRadius: 6,
color: "var(--accent)",
fontSize: 12,
fontWeight: 600,
cursor: trainingHistory.length === 0 ? "not-allowed" : "pointer",
opacity: trainingHistory.length === 0 ? 0.5 : 1,
}}
>
CSV
</button>
<button
onClick={() => exportMetrics("json")}
disabled={exporting || trainingHistory.length === 0}
style={{
padding: "8px 16px",
background: "rgba(56, 139, 253, 0.1)",
border: "1px solid rgba(56, 139, 253, 0.3)",
borderRadius: 6,
color: "var(--accent)",
fontSize: 12,
fontWeight: 600,
cursor: trainingHistory.length === 0 ? "not-allowed" : "pointer",
opacity: trainingHistory.length === 0 ? 0.5 : 1,
}}
>
JSON
</button>
<button
onClick={() => exportMetrics("tensorboard")}
disabled={exporting || trainingHistory.length === 0}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-secondary)",
fontSize: 12,
fontWeight: 600,
cursor: trainingHistory.length === 0 ? "not-allowed" : "pointer",
opacity: trainingHistory.length === 0 ? 0.5 : 1,
}}
>
TensorBoard
</button>
</div>
</div>
</div>
);
};
function StatCard({
label,
value,
color,
}: {
label: string;
value: number | string;
color?: string;
}) {
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: 28,
fontWeight: 600,
color: color || "var(--text-primary)",
letterSpacing: "-0.02em",
}}
>
{value}
</div>
</div>
);
}
function MetricBar({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
marginBottom: 4,
}}
>
<span>{label}</span>
<span style={{ fontFamily: "var(--font-mono)", fontWeight: 600 }}>
{(value * 100).toFixed(1)}%
</span>
</div>
<div
style={{
height: 6,
background: "var(--bg-secondary)",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${value * 100}%`,
height: "100%",
background: color,
borderRadius: 3,
transition: "width 0.5s",
}}
/>
</div>
</div>
);
}
export default MetricsTab;
@@ -0,0 +1,405 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface ModelArchitecture {
id: string;
name: string;
type: "encoder" | "decoder" | "embedding" | "adaptor";
description: string;
params_m: number;
memory_mb: number;
paper?: string;
}
interface Checkpoint {
id: string;
model_id: string;
name: string;
epoch: number;
val_loss: number;
created_at: string;
path: string;
size_mb: number;
}
const MODEL_ARCHITECTURES: ModelArchitecture[] = [
{
id: "csi-encoder-cnn",
name: "CSI Encoder (CNN)",
type: "encoder",
description: "Convolutional encoder for CSI amplitude/phase features",
params_m: 2.3,
memory_mb: 128,
},
{
id: "csi-encoder-transformer",
name: "CSI Encoder (Transformer)",
type: "encoder",
description: "Self-attention based CSI feature extraction",
params_m: 8.5,
memory_mb: 384,
paper: "WiFi-ViT 2024",
},
{
id: "pose-decoder-lstm",
name: "Pose Decoder (LSTM)",
type: "decoder",
description: "Recurrent decoder for temporal pose estimation",
params_m: 1.8,
memory_mb: 96,
},
{
id: "pose-decoder-gru",
name: "Pose Decoder (GRU)",
type: "decoder",
description: "Gated recurrent unit pose decoder (faster)",
params_m: 1.2,
memory_mb: 64,
},
{
id: "aether-embedding",
name: "AETHER Embedding",
type: "embedding",
description: "Contrastive CSI embedding for person re-identification (ADR-024)",
params_m: 4.2,
memory_mb: 192,
paper: "AETHER 2025",
},
{
id: "meridian-adaptor",
name: "MERIDIAN Adaptor",
type: "adaptor",
description: "Cross-environment domain generalization module (ADR-027)",
params_m: 3.1,
memory_mb: 144,
paper: "MERIDIAN 2025",
},
];
const ModelsTab: React.FC = () => {
const [checkpoints, setCheckpoints] = useState<Checkpoint[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [exporting, setExporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCheckpoints();
}, []);
const loadCheckpoints = async () => {
try {
const loaded = await invoke<Checkpoint[]>("list_checkpoints");
setCheckpoints(loaded);
} catch (err) {
// Mock data if command not implemented
setCheckpoints([
{
id: "ckpt-001",
model_id: "csi-encoder-cnn",
name: "CSI-CNN v1.2",
epoch: 50,
val_loss: 0.0234,
created_at: "2026-03-08T14:30:00Z",
path: "~/.ruview/models/csi-cnn-v1.2.pt",
size_mb: 12.4,
},
{
id: "ckpt-002",
model_id: "pose-decoder-gru",
name: "Pose-GRU v2.0",
epoch: 100,
val_loss: 0.0189,
created_at: "2026-03-09T09:15:00Z",
path: "~/.ruview/models/pose-gru-v2.pt",
size_mb: 8.2,
},
]);
}
};
const handleExport = async (checkpointId: string, format: "onnx" | "torchscript") => {
setExporting(checkpointId);
setError(null);
try {
await invoke("export_model", { checkpointId, format });
// Success notification would go here
} catch (err) {
setError(`Export failed: ${err}`);
} finally {
setExporting(null);
}
};
const getTypeColor = (type: ModelArchitecture["type"]) => {
switch (type) {
case "encoder":
return "var(--accent)";
case "decoder":
return "var(--status-online)";
case "embedding":
return "#a855f7";
case "adaptor":
return "#f59e0b";
}
};
return (
<div>
{/* Stats Row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard label="Architectures" value={MODEL_ARCHITECTURES.length} />
<StatCard
label="Checkpoints"
value={checkpoints.length}
color="var(--status-online)"
/>
<StatCard
label="Total Params"
value={`${MODEL_ARCHITECTURES.reduce((acc, m) => acc + m.params_m, 0).toFixed(1)}M`}
/>
<StatCard
label="Storage Used"
value={`${checkpoints.reduce((acc, c) => acc + c.size_mb, 0).toFixed(1)} MB`}
/>
</div>
{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)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
{/* Model Architectures */}
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: "var(--space-3)" }}>
Available Architectures
</h3>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "var(--space-3)",
marginBottom: "var(--space-5)",
}}
>
{MODEL_ARCHITECTURES.map((model) => (
<div
key={model.id}
className="card"
style={{
padding: "var(--space-3)",
cursor: "pointer",
border:
selectedModel === model.id
? "1px solid var(--accent)"
: "1px solid transparent",
}}
onClick={() => setSelectedModel(model.id)}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
marginBottom: "var(--space-2)",
}}
>
<div>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
{model.name}
</h4>
<span
style={{
display: "inline-block",
marginTop: 4,
padding: "1px 6px",
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
background: `${getTypeColor(model.type)}20`,
color: getTypeColor(model.type),
}}
>
{model.type}
</span>
</div>
{model.paper && (
<span
style={{
fontSize: 10,
color: "var(--text-muted)",
fontStyle: "italic",
}}
>
{model.paper}
</span>
)}
</div>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
margin: "var(--space-2) 0",
lineHeight: 1.4,
}}
>
{model.description}
</p>
<div
style={{
display: "flex",
gap: "var(--space-3)",
fontSize: 11,
color: "var(--text-secondary)",
}}
>
<span>🧮 {model.params_m}M params</span>
<span>💾 {model.memory_mb} MB</span>
</div>
</div>
))}
</div>
{/* Checkpoints */}
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: "var(--space-3)" }}>
Saved Checkpoints
</h3>
{checkpoints.length === 0 ? (
<div
className="card"
style={{
padding: "var(--space-5)",
textAlign: "center",
color: "var(--text-muted)",
}}
>
<div style={{ fontSize: 32, marginBottom: "var(--space-2)" }}>📦</div>
<p style={{ fontSize: 13 }}>No checkpoints saved yet</p>
<p style={{ fontSize: 12 }}>Train a model to create checkpoints</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
{checkpoints.map((ckpt) => (
<div
key={ckpt.id}
className="card"
style={{
padding: "var(--space-3)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{ckpt.name}</div>
<div
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 2,
}}
>
Epoch {ckpt.epoch} Val Loss: {ckpt.val_loss.toFixed(4)} {" "}
{ckpt.size_mb.toFixed(1)} MB
</div>
</div>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<button
onClick={() => handleExport(ckpt.id, "onnx")}
disabled={exporting === ckpt.id}
style={{
padding: "6px 12px",
background: "rgba(56, 139, 253, 0.1)",
border: "1px solid rgba(56, 139, 253, 0.3)",
borderRadius: 4,
color: "var(--accent)",
fontSize: 11,
fontWeight: 600,
cursor: exporting === ckpt.id ? "wait" : "pointer",
opacity: exporting === ckpt.id ? 0.6 : 1,
}}
>
{exporting === ckpt.id ? "Exporting..." : "ONNX"}
</button>
<button
onClick={() => handleExport(ckpt.id, "torchscript")}
disabled={exporting === ckpt.id}
style={{
padding: "6px 12px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 4,
color: "var(--text-secondary)",
fontSize: 11,
fontWeight: 600,
cursor: exporting === ckpt.id ? "wait" : "pointer",
opacity: exporting === ckpt.id ? 0.6 : 1,
}}
>
TorchScript
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
function StatCard({
label,
value,
color,
}: {
label: string;
value: number | string;
color?: string;
}) {
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: 28,
fontWeight: 600,
color: color || "var(--text-primary)",
letterSpacing: "-0.02em",
}}
>
{value}
</div>
</div>
);
}
export default ModelsTab;
@@ -0,0 +1,767 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface RuVectorConfig {
// MinCut Parameters
mincut_enabled: boolean;
mincut_threshold: number;
mincut_max_persons: number;
// Attention Parameters
attention_enabled: boolean;
attention_heads: number;
attention_dropout: number;
// Temporal Parameters
temporal_enabled: boolean;
temporal_window_ms: number;
temporal_compression_ratio: number;
// Solver Parameters
solver_enabled: boolean;
solver_interpolation: "linear" | "cubic" | "sparse";
solver_subcarrier_count: number;
// BVP Parameters
bvp_enabled: boolean;
bvp_filter_hz: [number, number];
}
const DEFAULT_CONFIG: RuVectorConfig = {
mincut_enabled: true,
mincut_threshold: 0.5,
mincut_max_persons: 5,
attention_enabled: true,
attention_heads: 4,
attention_dropout: 0.1,
temporal_enabled: true,
temporal_window_ms: 500,
temporal_compression_ratio: 4,
solver_enabled: true,
solver_interpolation: "sparse",
solver_subcarrier_count: 56,
bvp_enabled: false,
bvp_filter_hz: [0.7, 4.0],
};
const MODULES = [
{
id: "mincut",
name: "MinCut Segmentation",
crate: "ruvector-mincut",
description: "Graph-based person segmentation using DynamicPersonMatcher",
icon: "✂️",
},
{
id: "attention",
name: "Spatial Attention",
crate: "ruvector-attention",
description: "Attention-weighted antenna selection and BVP extraction",
icon: "🎯",
},
{
id: "temporal",
name: "Temporal Tensor",
crate: "ruvector-temporal-tensor",
description: "Temporal CSI compression and breathing detection",
icon: "⏱️",
},
{
id: "solver",
name: "Sparse Solver",
crate: "ruvector-solver",
description: "Sparse interpolation (114→56 subcarriers) and triangulation",
icon: "🧮",
},
{
id: "attn-mincut",
name: "Attention MinCut",
crate: "ruvector-attn-mincut",
description: "Combined attention-weighted graph segmentation",
icon: "🔀",
},
];
const RuVectorTab: React.FC = () => {
const [config, setConfig] = useState<RuVectorConfig>(DEFAULT_CONFIG);
const [testingLive, setTestingLive] = useState(false);
const [liveMetrics, setLiveMetrics] = useState<{
fps: number;
latency_ms: number;
persons_detected: number;
} | null>(null);
const [saved, setSaved] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const loaded = await invoke<RuVectorConfig>("get_ruvector_config");
setConfig(loaded);
} catch (err) {
// Use defaults if not implemented
}
};
const saveConfig = async () => {
setError(null);
try {
await invoke("set_ruvector_config", { config });
setSaved(true);
} catch (err) {
setError(`Failed to save: ${err}`);
}
};
const handleChange = <K extends keyof RuVectorConfig>(
key: K,
value: RuVectorConfig[K]
) => {
setConfig((prev) => ({ ...prev, [key]: value }));
setSaved(false);
};
const startLiveTest = async () => {
setTestingLive(true);
setError(null);
try {
// Simulate live testing metrics
const interval = setInterval(() => {
setLiveMetrics({
fps: 25 + Math.random() * 10,
latency_ms: 15 + Math.random() * 10,
persons_detected: Math.floor(Math.random() * 3) + 1,
});
}, 500);
// Stop after 10 seconds for demo
setTimeout(() => {
clearInterval(interval);
setTestingLive(false);
setLiveMetrics(null);
}, 10000);
} catch (err) {
setError(`Live test failed: ${err}`);
setTestingLive(false);
}
};
const exportConfig = () => {
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "ruvector-config.json";
a.click();
URL.revokeObjectURL(url);
};
return (
<div>
{/* Module Cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: "var(--space-3)",
marginBottom: "var(--space-5)",
}}
>
{MODULES.map((mod) => {
const isEnabled =
config[`${mod.id.replace("-", "_")}_enabled` as keyof RuVectorConfig] ?? true;
return (
<div
key={mod.id}
className="card"
style={{
padding: "var(--space-3)",
opacity: isEnabled ? 1 : 0.5,
transition: "opacity 0.2s",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
}}
>
<span style={{ fontSize: 24 }}>{mod.icon}</span>
<span
style={{
fontSize: 9,
padding: "2px 6px",
borderRadius: 3,
background: isEnabled
? "rgba(63, 185, 80, 0.15)"
: "rgba(139, 148, 158, 0.15)",
color: isEnabled ? "var(--status-online)" : "var(--text-muted)",
fontWeight: 600,
}}
>
{isEnabled ? "ON" : "OFF"}
</span>
</div>
<h4 style={{ margin: "var(--space-2) 0 4px", fontSize: 13, fontWeight: 600 }}>
{mod.name}
</h4>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
margin: 0,
lineHeight: 1.4,
}}
>
{mod.description}
</p>
<div
style={{
marginTop: "var(--space-2)",
fontFamily: "var(--font-mono)",
fontSize: 10,
color: "var(--text-secondary)",
}}
>
{mod.crate}
</div>
</div>
);
})}
</div>
{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)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-5)" }}>
{/* Configuration Panel */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Module Configuration
</h3>
{/* MinCut Section */}
<ConfigSection title="MinCut Segmentation">
<ToggleRow
label="Enable MinCut"
checked={config.mincut_enabled}
onChange={(v) => handleChange("mincut_enabled", v)}
/>
<SliderRow
label="Threshold"
value={config.mincut_threshold}
min={0.1}
max={1.0}
step={0.05}
onChange={(v) => handleChange("mincut_threshold", v)}
disabled={!config.mincut_enabled}
/>
<NumberRow
label="Max Persons"
value={config.mincut_max_persons}
min={1}
max={10}
onChange={(v) => handleChange("mincut_max_persons", v)}
disabled={!config.mincut_enabled}
/>
</ConfigSection>
{/* Attention Section */}
<ConfigSection title="Spatial Attention">
<ToggleRow
label="Enable Attention"
checked={config.attention_enabled}
onChange={(v) => handleChange("attention_enabled", v)}
/>
<NumberRow
label="Attention Heads"
value={config.attention_heads}
min={1}
max={16}
onChange={(v) => handleChange("attention_heads", v)}
disabled={!config.attention_enabled}
/>
<SliderRow
label="Dropout"
value={config.attention_dropout}
min={0}
max={0.5}
step={0.05}
onChange={(v) => handleChange("attention_dropout", v)}
disabled={!config.attention_enabled}
/>
</ConfigSection>
{/* Temporal Section */}
<ConfigSection title="Temporal Processing">
<ToggleRow
label="Enable Temporal"
checked={config.temporal_enabled}
onChange={(v) => handleChange("temporal_enabled", v)}
/>
<NumberRow
label="Window (ms)"
value={config.temporal_window_ms}
min={100}
max={2000}
step={100}
onChange={(v) => handleChange("temporal_window_ms", v)}
disabled={!config.temporal_enabled}
/>
<NumberRow
label="Compression Ratio"
value={config.temporal_compression_ratio}
min={1}
max={16}
onChange={(v) => handleChange("temporal_compression_ratio", v)}
disabled={!config.temporal_enabled}
/>
</ConfigSection>
{/* Solver Section */}
<ConfigSection title="Sparse Solver">
<ToggleRow
label="Enable Solver"
checked={config.solver_enabled}
onChange={(v) => handleChange("solver_enabled", v)}
/>
<div style={{ marginBottom: "var(--space-2)" }}>
<label style={labelStyle}>Interpolation</label>
<select
value={config.solver_interpolation}
onChange={(e) =>
handleChange(
"solver_interpolation",
e.target.value as RuVectorConfig["solver_interpolation"]
)
}
disabled={!config.solver_enabled}
style={{
...inputStyle,
opacity: config.solver_enabled ? 1 : 0.5,
}}
>
<option value="linear">Linear</option>
<option value="cubic">Cubic</option>
<option value="sparse">Sparse (L1)</option>
</select>
</div>
<NumberRow
label="Subcarrier Count"
value={config.solver_subcarrier_count}
min={28}
max={114}
onChange={(v) => handleChange("solver_subcarrier_count", v)}
disabled={!config.solver_enabled}
/>
</ConfigSection>
{/* Action Buttons */}
<div
style={{
display: "flex",
gap: "var(--space-2)",
marginTop: "var(--space-4)",
}}
>
<button
onClick={saveConfig}
className="btn-gradient"
style={{
flex: 1,
padding: "10px",
fontSize: 12,
opacity: saved ? 0.6 : 1,
}}
disabled={saved}
>
{saved ? "Saved" : "Save Configuration"}
</button>
<button
onClick={exportConfig}
style={{
padding: "10px 16px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-secondary)",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
Export
</button>
</div>
</div>
{/* Live Testing Panel */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Live Testing
</h3>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
background: "var(--bg-secondary)",
borderRadius: 8,
marginBottom: "var(--space-4)",
}}
>
{testingLive ? (
<>
<div
style={{
fontSize: 48,
animation: "pulse 1s infinite",
}}
>
📡
</div>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-2)" }}>
Processing live CSI stream...
</p>
</>
) : (
<>
<div style={{ fontSize: 48, opacity: 0.5 }}>📡</div>
<p style={{ fontSize: 13, color: "var(--text-muted)", marginTop: "var(--space-2)" }}>
Start live test to apply config to real CSI data
</p>
</>
)}
</div>
{liveMetrics && (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "var(--space-3)",
marginBottom: "var(--space-4)",
}}
>
<MetricCard label="FPS" value={liveMetrics.fps.toFixed(1)} />
<MetricCard label="Latency" value={`${liveMetrics.latency_ms.toFixed(0)}ms`} />
<MetricCard label="Persons" value={liveMetrics.persons_detected.toString()} />
</div>
)}
<button
onClick={testingLive ? () => setTestingLive(false) : startLiveTest}
style={{
width: "100%",
padding: "12px",
background: testingLive
? "rgba(248, 81, 73, 0.1)"
: "rgba(56, 139, 253, 0.1)",
border: `1px solid ${testingLive ? "rgba(248, 81, 73, 0.3)" : "rgba(56, 139, 253, 0.3)"}`,
borderRadius: 6,
color: testingLive ? "var(--status-error)" : "var(--accent)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
{testingLive ? "Stop Test" : "Start Live Test"}
</button>
{/* Presets */}
<div style={{ marginTop: "var(--space-5)" }}>
<h4 style={{ fontSize: 12, fontWeight: 600, marginBottom: "var(--space-3)" }}>
Quick Presets
</h4>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
<PresetButton
label="Low Latency"
description="Minimal processing for real-time"
onClick={() => {
setConfig({
...DEFAULT_CONFIG,
attention_heads: 2,
temporal_compression_ratio: 8,
solver_subcarrier_count: 28,
});
setSaved(false);
}}
/>
<PresetButton
label="High Accuracy"
description="Maximum quality, higher latency"
onClick={() => {
setConfig({
...DEFAULT_CONFIG,
attention_heads: 8,
temporal_compression_ratio: 2,
solver_subcarrier_count: 114,
solver_interpolation: "cubic",
});
setSaved(false);
}}
/>
<PresetButton
label="Balanced"
description="Default recommended settings"
onClick={() => {
setConfig(DEFAULT_CONFIG);
setSaved(false);
}}
/>
</div>
</div>
</div>
</div>
<style>{`
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
`}</style>
</div>
);
};
// Helper Components
function ConfigSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: "var(--space-4)" }}>
<h4
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--text-muted)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: "var(--space-2)",
}}
>
{title}
</h4>
{children}
</div>
);
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-2)",
}}
>
<span style={{ fontSize: 12 }}>{label}</span>
<button
onClick={() => onChange(!checked)}
style={{
width: 40,
height: 22,
borderRadius: 11,
border: "none",
background: checked ? "var(--accent)" : "var(--border)",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
}}
>
<span
style={{
position: "absolute",
top: 2,
left: checked ? 20 : 2,
width: 18,
height: 18,
borderRadius: "50%",
background: "white",
transition: "left 0.2s",
}}
/>
</button>
</div>
);
}
function SliderRow({
label,
value,
min,
max,
step,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div style={{ marginBottom: "var(--space-2)", opacity: disabled ? 0.5 : 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<span style={{ fontSize: 12 }}>{label}</span>
<span style={{ fontSize: 11, fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>
{value.toFixed(2)}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(parseFloat(e.target.value))}
disabled={disabled}
style={{ width: "100%", cursor: disabled ? "not-allowed" : "pointer" }}
/>
</div>
);
}
function NumberRow({
label,
value,
min,
max,
step = 1,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-2)",
opacity: disabled ? 0.5 : 1,
}}
>
<span style={{ fontSize: 12 }}>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(parseInt(e.target.value) || min)}
disabled={disabled}
style={{
width: 70,
padding: "4px 8px",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 4,
color: "var(--text-primary)",
fontSize: 12,
textAlign: "right",
cursor: disabled ? "not-allowed" : "text",
}}
/>
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="card" style={{ padding: "var(--space-3)", textAlign: "center" }}>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginBottom: 2 }}>{label}</div>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 18, fontWeight: 600 }}>{value}</div>
</div>
);
}
function PresetButton({
label,
description,
onClick,
}: {
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
display: "flex",
flexDirection: "column",
alignItems: "start",
padding: "var(--space-3)",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 6,
cursor: "pointer",
textAlign: "left",
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-primary)" }}>{label}</span>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>{description}</span>
</button>
);
}
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 11,
fontWeight: 600,
color: "var(--text-muted)",
marginBottom: 4,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-primary)",
fontSize: 13,
};
export default RuVectorTab;
@@ -0,0 +1,601 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
interface TrainingConfig {
dataset_id: string;
model_id: string;
epochs: number;
batch_size: number;
learning_rate: number;
optimizer: "adam" | "sgd" | "adamw";
weight_decay: number;
use_augmentation: boolean;
checkpoint_every: number;
}
interface TrainingProgress {
epoch: number;
total_epochs: number;
batch: number;
total_batches: number;
train_loss: number;
val_loss: number | null;
learning_rate: number;
eta_secs: number;
gpu_memory_mb: number | null;
}
interface TrainingJob {
id: string;
status: "running" | "paused" | "completed" | "failed";
started_at: string;
progress: TrainingProgress;
}
const DEFAULT_CONFIG: TrainingConfig = {
dataset_id: "mmfi",
model_id: "csi-encoder-cnn",
epochs: 100,
batch_size: 32,
learning_rate: 0.001,
optimizer: "adam",
weight_decay: 0.0001,
use_augmentation: true,
checkpoint_every: 10,
};
interface TrainingTabProps {
gpuAvailable: boolean;
}
const TrainingTab: React.FC<TrainingTabProps> = ({ gpuAvailable }) => {
const [config, setConfig] = useState<TrainingConfig>(DEFAULT_CONFIG);
const [currentJob, setCurrentJob] = useState<TrainingJob | null>(null);
const [lossHistory, setLossHistory] = useState<{ epoch: number; train: number; val: number }[]>(
[]
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let unlisten: UnlistenFn | undefined;
const setupListener = async () => {
try {
unlisten = await listen<TrainingProgress>("training:progress", (event) => {
const progress = event.payload;
setCurrentJob((prev) =>
prev
? { ...prev, progress }
: {
id: "job-1",
status: "running",
started_at: new Date().toISOString(),
progress,
}
);
if (progress.val_loss !== null && progress.batch === progress.total_batches) {
setLossHistory((prev) => [
...prev,
{
epoch: progress.epoch,
train: progress.train_loss,
val: progress.val_loss!,
},
]);
}
});
} catch (err) {
console.error("Failed to setup training listener:", err);
}
};
setupListener();
return () => {
if (unlisten) unlisten();
};
}, []);
const handleStartTraining = async () => {
setError(null);
try {
await invoke("start_training", { config });
setCurrentJob({
id: `job-${Date.now()}`,
status: "running",
started_at: new Date().toISOString(),
progress: {
epoch: 0,
total_epochs: config.epochs,
batch: 0,
total_batches: 0,
train_loss: 0,
val_loss: null,
learning_rate: config.learning_rate,
eta_secs: 0,
gpu_memory_mb: null,
},
});
} catch (err) {
setError(`Failed to start training: ${err}`);
}
};
const handleStopTraining = async () => {
try {
await invoke("stop_training");
setCurrentJob((prev) => (prev ? { ...prev, status: "paused" } : null));
} catch (err) {
setError(`Failed to stop training: ${err}`);
}
};
const formatEta = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
};
const progress = currentJob?.progress;
const epochProgress = progress ? (progress.epoch / progress.total_epochs) * 100 : 0;
const batchProgress = progress && progress.total_batches > 0
? (progress.batch / progress.total_batches) * 100
: 0;
return (
<div>
{/* GPU Warning */}
{!gpuAvailable && (
<div
style={{
background: "rgba(245, 158, 11, 0.1)",
border: "1px solid rgba(245, 158, 11, 0.3)",
borderRadius: 6,
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
display: "flex",
alignItems: "center",
gap: "var(--space-3)",
}}
>
<span style={{ fontSize: 18 }}></span>
<div>
<div style={{ fontWeight: 600, fontSize: 13, color: "#f59e0b" }}>
GPU Not Available
</div>
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
Training will use CPU, which is significantly slower. Consider using a
machine with CUDA or Metal support.
</div>
</div>
</div>
)}
{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)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-5)" }}>
{/* Configuration Panel */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Training Configuration
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
<div>
<label style={labelStyle}>Dataset</label>
<select
value={config.dataset_id}
onChange={(e) => setConfig({ ...config, dataset_id: e.target.value })}
style={inputStyle}
>
<option value="mmfi">MM-Fi Dataset</option>
<option value="wipose">Wi-Pose Dataset</option>
<option value="wiar">WiAR Dataset</option>
</select>
</div>
<div>
<label style={labelStyle}>Model Architecture</label>
<select
value={config.model_id}
onChange={(e) => setConfig({ ...config, model_id: e.target.value })}
style={inputStyle}
>
<option value="csi-encoder-cnn">CSI Encoder (CNN)</option>
<option value="csi-encoder-transformer">CSI Encoder (Transformer)</option>
<option value="pose-decoder-lstm">Pose Decoder (LSTM)</option>
<option value="pose-decoder-gru">Pose Decoder (GRU)</option>
</select>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
<div>
<label style={labelStyle}>Epochs</label>
<input
type="number"
value={config.epochs}
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) || 1 })}
min={1}
max={1000}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Batch Size</label>
<input
type="number"
value={config.batch_size}
onChange={(e) =>
setConfig({ ...config, batch_size: parseInt(e.target.value) || 1 })
}
min={1}
max={512}
style={inputStyle}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
<div>
<label style={labelStyle}>Learning Rate</label>
<input
type="number"
value={config.learning_rate}
onChange={(e) =>
setConfig({ ...config, learning_rate: parseFloat(e.target.value) || 0.001 })
}
step={0.0001}
min={0.00001}
max={1}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Optimizer</label>
<select
value={config.optimizer}
onChange={(e) =>
setConfig({ ...config, optimizer: e.target.value as TrainingConfig["optimizer"] })
}
style={inputStyle}
>
<option value="adam">Adam</option>
<option value="adamw">AdamW</option>
<option value="sgd">SGD</option>
</select>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
<div>
<label style={labelStyle}>Weight Decay</label>
<input
type="number"
value={config.weight_decay}
onChange={(e) =>
setConfig({ ...config, weight_decay: parseFloat(e.target.value) || 0 })
}
step={0.0001}
min={0}
max={1}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Checkpoint Every</label>
<input
type="number"
value={config.checkpoint_every}
onChange={(e) =>
setConfig({ ...config, checkpoint_every: parseInt(e.target.value) || 1 })
}
min={1}
max={100}
style={inputStyle}
/>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<input
type="checkbox"
id="augmentation"
checked={config.use_augmentation}
onChange={(e) => setConfig({ ...config, use_augmentation: e.target.checked })}
style={{ width: 16, height: 16 }}
/>
<label htmlFor="augmentation" style={{ fontSize: 13, cursor: "pointer" }}>
Enable Data Augmentation
</label>
</div>
<div style={{ marginTop: "var(--space-3)" }}>
{currentJob?.status === "running" ? (
<button
onClick={handleStopTraining}
style={{
width: "100%",
padding: "12px",
background: "rgba(248, 81, 73, 0.1)",
border: "1px solid rgba(248, 81, 73, 0.3)",
borderRadius: 6,
color: "var(--status-error)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
Stop Training
</button>
) : (
<button
onClick={handleStartTraining}
className="btn-gradient"
style={{ width: "100%", padding: "12px", fontSize: 13 }}
>
Start Training
</button>
)}
</div>
</div>
</div>
{/* Progress Panel */}
<div className="card" style={{ padding: "var(--space-4)" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600, marginBottom: "var(--space-4)" }}>
Training Progress
</h3>
{!currentJob ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 300,
color: "var(--text-muted)",
}}
>
<div style={{ fontSize: 48, marginBottom: "var(--space-3)" }}>🎯</div>
<p style={{ fontSize: 13 }}>No training job running</p>
<p style={{ fontSize: 12 }}>Configure and start training to begin</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
{/* Status */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background:
currentJob.status === "running"
? "var(--status-online)"
: currentJob.status === "paused"
? "#f59e0b"
: "var(--status-error)",
animation: currentJob.status === "running" ? "pulse 1.5s infinite" : "none",
}}
/>
<span style={{ fontSize: 13, fontWeight: 600, textTransform: "capitalize" }}>
{currentJob.status}
</span>
</div>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
ETA: {formatEta(progress?.eta_secs ?? 0)}
</span>
</div>
{/* Epoch Progress */}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
marginBottom: 4,
}}
>
<span>Epoch</span>
<span>
{progress?.epoch ?? 0} / {progress?.total_epochs ?? config.epochs}
</span>
</div>
<div
style={{
height: 6,
background: "var(--border)",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${epochProgress}%`,
height: "100%",
background: "var(--accent)",
transition: "width 0.3s",
}}
/>
</div>
</div>
{/* Batch Progress */}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
marginBottom: 4,
}}
>
<span>Batch</span>
<span>
{progress?.batch ?? 0} / {progress?.total_batches ?? 0}
</span>
</div>
<div
style={{
height: 4,
background: "var(--border)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${batchProgress}%`,
height: "100%",
background: "rgba(56, 139, 253, 0.5)",
transition: "width 0.1s",
}}
/>
</div>
</div>
{/* Stats Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "var(--space-3)",
}}
>
<div className="card" style={{ padding: "var(--space-3)" }}>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginBottom: 4 }}>
Train Loss
</div>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 20, fontWeight: 600 }}>
{progress?.train_loss.toFixed(4) ?? "—"}
</div>
</div>
<div className="card" style={{ padding: "var(--space-3)" }}>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginBottom: 4 }}>
Val Loss
</div>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 20,
fontWeight: 600,
color: "var(--status-online)",
}}
>
{progress?.val_loss?.toFixed(4) ?? "—"}
</div>
</div>
<div className="card" style={{ padding: "var(--space-3)" }}>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginBottom: 4 }}>
Learning Rate
</div>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 14, fontWeight: 600 }}>
{progress?.learning_rate.toExponential(2) ?? "—"}
</div>
</div>
<div className="card" style={{ padding: "var(--space-3)" }}>
<div style={{ fontSize: 10, color: "var(--text-muted)", marginBottom: 4 }}>
GPU Memory
</div>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 14, fontWeight: 600 }}>
{progress?.gpu_memory_mb ? `${progress.gpu_memory_mb} MB` : "N/A"}
</div>
</div>
</div>
{/* Mini Loss Chart */}
{lossHistory.length > 0 && (
<div>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: "var(--space-2)" }}>
Loss History
</div>
<div
style={{
height: 80,
display: "flex",
alignItems: "flex-end",
gap: 2,
padding: "var(--space-2)",
background: "var(--bg-secondary)",
borderRadius: 4,
}}
>
{lossHistory.slice(-20).map((h, i) => (
<div
key={i}
style={{
flex: 1,
height: `${Math.max(5, Math.min(100, (1 - h.train) * 100))}%`,
background: "var(--accent)",
borderRadius: 2,
opacity: 0.6 + (i / 20) * 0.4,
}}
title={`Epoch ${h.epoch}: Train=${h.train.toFixed(4)}, Val=${h.val.toFixed(4)}`}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 11,
fontWeight: 600,
color: "var(--text-muted)",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: "0.04em",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
background: "var(--bg-secondary)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-primary)",
fontSize: 13,
};
export default TrainingTab;
@@ -0,0 +1,165 @@
import React, { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import DatasetsTab from "./DatasetsTab";
import ModelsTab from "./ModelsTab";
import TrainingTab from "./TrainingTab";
import RuVectorTab from "./RuVectorTab";
import MetricsTab from "./MetricsTab";
type TrainingTabType = "datasets" | "models" | "training" | "ruvector" | "metrics";
interface GpuInfo {
available: boolean;
name: string | null;
memory_mb: number | null;
cuda_version: string | null;
metal_supported: boolean;
}
const Training: React.FC = () => {
const [activeTab, setActiveTab] = useState<TrainingTabType>("datasets");
const [gpuInfo, setGpuInfo] = useState<GpuInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
detectGpu();
}, []);
const detectGpu = async () => {
try {
const info = await invoke<GpuInfo>("detect_gpu");
setGpuInfo(info);
} catch (err) {
console.error("GPU detection failed:", err);
setGpuInfo({
available: false,
name: null,
memory_mb: null,
cuda_version: null,
metal_supported: false,
});
} finally {
setLoading(false);
}
};
const tabs: { id: TrainingTabType; label: string; icon: string }[] = [
{ id: "datasets", label: "Datasets", icon: "📊" },
{ id: "models", label: "Models", icon: "🧠" },
{ id: "training", label: "Training", icon: "⚡" },
{ id: "ruvector", label: "RuVector", icon: "📡" },
{ id: "metrics", label: "Metrics", icon: "📈" },
];
return (
<div style={{ padding: "var(--space-5)", maxWidth: 1400 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-5)",
}}
>
<div>
<h1 className="heading-lg" style={{ margin: 0 }}>
Training & Models
</h1>
<p
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginTop: 4,
}}
>
Train pose estimation models and configure RuVector signal processing
</p>
</div>
{/* GPU Status */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-3)",
padding: "var(--space-3) var(--space-4)",
background: gpuInfo?.available
? "rgba(63, 185, 80, 0.1)"
: "rgba(139, 148, 158, 0.1)",
border: `1px solid ${gpuInfo?.available ? "rgba(63, 185, 80, 0.3)" : "rgba(139, 148, 158, 0.3)"}`,
borderRadius: 8,
}}
>
<span style={{ fontSize: 18 }}>{gpuInfo?.available ? "🎮" : "💻"}</span>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-primary)" }}>
{loading
? "Detecting GPU..."
: gpuInfo?.available
? gpuInfo.name || "GPU Available"
: "CPU Mode"}
</div>
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{gpuInfo?.cuda_version
? `CUDA ${gpuInfo.cuda_version}`
: gpuInfo?.metal_supported
? "Metal Supported"
: "No GPU acceleration"}
{gpuInfo?.memory_mb && `${Math.round(gpuInfo.memory_mb / 1024)}GB`}
</div>
</div>
</div>
</div>
{/* Tabs */}
<div
style={{
display: "flex",
gap: "var(--space-1)",
borderBottom: "1px solid var(--border)",
marginBottom: "var(--space-5)",
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "12px 20px",
border: "none",
background: "transparent",
color: activeTab === tab.id ? "var(--accent)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
borderBottom:
activeTab === tab.id
? "2px solid var(--accent)"
: "2px solid transparent",
marginBottom: -1,
transition: "color 0.15s, border-color 0.15s",
}}
>
<span>{tab.icon}</span>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div>
{activeTab === "datasets" && <DatasetsTab />}
{activeTab === "models" && <ModelsTab />}
{activeTab === "training" && <TrainingTab gpuAvailable={gpuInfo?.available ?? false} />}
{activeTab === "ruvector" && <RuVectorTab />}
{activeTab === "metrics" && <MetricsTab />}
</div>
</div>
);
};
export default Training;
@@ -14,7 +14,7 @@ export type DiscoveryMethod = "mdns" | "udp_probe" | "http_sweep" | "manual";
export type MeshRole = "coordinator" | "node" | "aggregator";
export type Chip = "esp32" | "esp32s3" | "esp32c3";
export type Chip = "esp32" | "esp32s2" | "esp32s3" | "esp32c3" | "esp32c6";
export interface TdmConfig {
slot: number;
@@ -161,12 +161,17 @@ export interface WasmModule {
node_ip: string;
loaded_at: string | null;
error: string | null;
memory_used_kb: number | null;
cpu_usage_pct: number | null;
exec_count: number | null;
}
// ---------------------------------------------------------------------------
// Sensing Server
// ---------------------------------------------------------------------------
export type DataSource = "auto" | "wifi" | "esp32" | "simulate";
export interface ServerConfig {
http_port: number;
ws_port: number;
@@ -174,6 +179,7 @@ export interface ServerConfig {
static_dir: string | null;
model_dir: string | null;
log_level: string;
source: DataSource;
}
export interface ServerStatus {
@@ -0,0 +1,2 @@
// Application version - single source of truth
export const APP_VERSION = "0.5.0";
+10 -1
View File
@@ -80,7 +80,16 @@ def generate_nvs_binary(csv_content, size):
bin_path = csv_path.replace(".csv", ".bin")
try:
# Try the pip-installed version first
# Try the pip-installed version first (esp_idf_nvs_partition_gen package)
try:
from esp_idf_nvs_partition_gen import nvs_partition_gen
nvs_partition_gen.generate(csv_path, bin_path, size)
with open(bin_path, "rb") as f:
return f.read()
except ImportError:
pass
# Try legacy import name (older versions)
try:
import nvs_partition_gen
nvs_partition_gen.generate(csv_path, bin_path, size)