Compare commits

..

23 Commits

Author SHA1 Message Date
ruv d949d9e57b merge: resolve icon conflicts — keep RGBA versions (fixes #200)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 10:51:49 -04:00
ruv 17de6885de docs: add crates.io badges and install instructions to ruv-neural README
Add version badges linking to each published crate on crates.io,
cargo add instructions, and crate search link in the Crate Map table.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 10:50:22 -04:00
ruv 614d242967 feat: complete ruv-neural implementation — physics models, security, witness verification
Replace all stubs/mocks with production physics-based signal models:
- NV Diamond: ODMR Lorentzian dip, 1/f pink noise (Voss-McCartney), brain oscillations
- OPM: SERF-mode, 50/60Hz powerline harmonics, full cross-talk compensation
  via Gaussian elimination with partial pivoting
- EEG: 5 frequency bands, eye blink artifacts (Fp1/Fp2), muscle artifacts,
  impedance-based thermal noise floor
- ESP32 ADC: ring-buffer reader with calibration signal generator, i16 clamp

Security hardening (SEC-001 through SEC-005):
- RVF bounded allocation (16MB metadata, 256MB payload)
- sample_rate validation (>0, finite)
- Signal NaN/Inf rejection
- ADC resolution_bits overflow clamp
- HNSW HashSet visited tracking + bounds checks

Performance optimizations (PERF-001 through PERF-005):
- 67x fewer FFTs via pre-computed analytic signals
- VecDeque O(1) eviction in memory store
- Thread-local FFT planner caching
- BrainGraph::validate() for edge/weight integrity
- Eigenvalue convergence early termination

Ed25519 witness verification system:
- 41 capability attestations across all 12 crates
- SHA-256 digest + Ed25519 signature
- CLI commands: `witness --output` and `witness --verify`

README: ethics warning, hardware parts list (AliExpress), assembly instructions

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 09:38:52 -04:00
ruv d23007120e Add Subcarrier Manifold and Vitals Oracle modules for 3D visualizations
- Implemented Subcarrier Manifold to visualize amplitude data as a 3D surface with height and age attributes.
- Created Vitals Oracle to represent vital signs using toroidal rings and particle trails, incorporating breathing and heart rate dynamics.
- Both modules utilize Three.js for rendering and include custom shaders for visual effects.
2026-03-09 08:41:09 -04:00
ruv decd97b6ab fix: convert desktop icons from RGB to RGBA for Tauri build
Tauri's generate_context!() macro requires RGBA PNG icons. All 5 icon
files (32x32.png, 128x128.png, 128x128@2x.png, icon.icns, icon.ico)
were RGB-only, causing a proc macro panic on Linux builds.

Fixes #200

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 08:31:17 -04:00
Claude f6102650ac Add ruv-neural-cli README
https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 02:14:49 +00:00
Claude 6007c42794 Fix ruv-neural crate compilation: all 12 crates build and 1200+ tests pass
- Fix node2vec.rs type inference error (Vec<_> → Vec<Vec<f64>>)
- Fix artifact.rs with full filter-based detection implementations
- Fix signal crate ConnectivityMetric re-export and trait method names
- Fix embed crate EmbeddingGenerator trait implementations
- Complete spectral, topology, and node2vec embedders with tests
- Complete preprocessing pipeline with sequential stage processing
- All workspace crates compile cleanly, 0 test failures

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 02:14:06 +00:00
Claude 8c7afe9b0f Add ruv-neural crate ecosystem — 12 mix-and-match crates (WIP)
Initial implementation of the rUv Neural brain topology analysis system:
- ruv-neural-core: Core types, traits, errors, RVF format (compiles)
- ruv-neural-sensor: NV diamond, OPM, EEG sensor interfaces (in progress)
- ruv-neural-signal: DSP, filtering, spectral, connectivity (in progress)
- ruv-neural-graph: Brain connectivity graph construction (in progress)
- ruv-neural-mincut: Dynamic minimum cut topology analysis (in progress)
- ruv-neural-embed: RuVector graph embeddings (in progress)
- ruv-neural-memory: Persistent neural state memory + HNSW (compiles)
- ruv-neural-decoder: Cognitive state classification + BCI (in progress)
- ruv-neural-esp32: ESP32 edge sensor integration (compiles)
- ruv-neural-wasm: WebAssembly browser bindings (in progress)
- ruv-neural-viz: Visualization + ASCII rendering (in progress)
- ruv-neural-cli: CLI tool (in progress)

Agents still writing remaining modules. Next: fix compilation, tests, push.

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 02:03:30 +00:00
Claude 8ec54a997b Add ruv-neural workspace Cargo.toml with 12 crate definitions
Workspace structure for the rUv Neural brain topology analysis system.
12 mix-and-match crates with shared dependencies including RuVector
integration, petgraph, rustfft, and WASM/ESP32 support.

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 02:01:42 +00:00
Claude 0a3acd315f Add NV diamond neural magnetometry research document (13/22)
Comprehensive 600+ line document covering NV center physics, neural
magnetic field sources, sensor architecture, SQUID comparison, signal
processing pipeline, RuVector integration, and development roadmap.

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 01:53:38 +00:00
Claude 6d850b99c9 Add SOTA neural decoding landscape and 10 application domains research
- Doc 21: Comprehensive SOTA map (2023-2026) of brain sensors, decoders,
  and visualization systems with RuVector/mincut positioning analysis
- Doc 22: Ten application domains for brain state observatory including
  disease detection, BCI, cognitive monitoring, mental health diagnostics,
  neurofeedback, dream reconstruction, cognitive research, HCI, wearables,
  and brain network digital twins with strategic roadmap

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-09 01:19:35 +00:00
Claude 990b3b131a Add research index synthesizing all 12 documents (14,322 lines)
Master index for RF Topological Sensing research compendium covering:
graph theory foundations, CSI edge weights, attention mechanisms,
transformers, sublinear algorithms, ESP32 hardware, contrastive learning,
temporal graphs, resolution analysis, system architecture, quantum sensors,
and quantum biomedical sensing. Includes key findings, proposed ADRs
(044, 045), and 5-phase implementation roadmap.

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 22:20:15 +00:00
Claude ae96f1e793 Add quantum sensing and quantum biomedical research documents
Agent 11: Quantum-level sensors (729 lines) — NV centers, SQUIDs, Rydberg
atoms, quantum illumination, quantum graph theory (walks, spectral, QAOA),
hybrid classical-quantum architecture, quantum ML (VQC, kernels, reservoir
computing), NISQ applications (D-Wave, VQE), hardware roadmap.

Agent 12: Quantum biomedical sensing (827 lines) — whole body biomagnetic
mapping, neural field imaging without electrodes, circulation sensing,
cellular EM signaling, non-contact diagnostics, coherence-based diagnostics
(disease as coherence breakdown), neural interfaces, multimodal observatory,
room-scale ambient health monitoring, graph-based biomedical analysis.

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 22:19:18 +00:00
Claude 240ca3ac14 Add system architecture and prototype design research
GOAP Agent 10 output: End-to-end architecture with pipeline diagrams,
existing crate integration mapping, new rf_topology module design (DDD
aggregate roots), 100ms latency budget breakdown, 3-phase prototype plan
(4-node POC → 16-node room → 72-node multi-room), benchmark design with
8 metrics, ADR-044 draft, and Rust trait definitions (EdgeWeightComputer,
TopologyGraph, MinCutSolver, BoundaryInterpolator).

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 21:15:49 +00:00
Claude 978c81f233 Add ESP32 mesh hardware constraints research
GOAP Agent 6 output: ESP32 CSI capabilities (52/114 subcarriers), 16-node
mesh topology with 120 edges, TDM synchronized sensing (3ms slots),
computational budget (Stoer-Wagner uses 0.07% of one core), channel hopping,
power analysis (0.44W/node), dual-core firmware architecture, and edge vs
server computing with 100x data reduction on-device.

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 21:10:45 +00:00
Claude caa3b48d0d Add RF graph theory and minimum cut foundations research
GOAP Agent 1 output: Graph-theoretic foundations covering max-flow/min-cut
for RF (Ford-Fulkerson, Stoer-Wagner, Karger), RF as dynamic graph with
CSI coherence weights, topological change detection via Fiedler vector and
Cheeger inequality, dynamic graph algorithms, comparison to classical RF
sensing, formal mathematical framework, and 9 open research questions.

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:57:23 +00:00
Claude 80351de3fa Add resolution and spatial granularity analysis research
GOAP Agent 9 output: 1,383-line document covering Fresnel zone analysis,
node density vs resolution (16-node/5m room → 30-60cm), Cramer-Rao lower
bounds with Fisher Information Matrix, graph cut resolution theory,
multi-frequency enhancement (6cm coherent dual-band limit), RF tomography
comparison, experimental validation protocols, and resolution scaling laws
(8.8cm theoretical limit).

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:27:58 +00:00
Claude 6482f9ed75 Add contrastive learning for RF coherence research
GOAP Agent 7 output: 1,226-line document covering SimCLR/MoCo/BYOL for CSI,
AETHER-Topo dual-head extension, coherence boundary detection with multi-scale
analysis, delta-driven updates (2-12x efficiency), self-supervised pre-training
protocol, triplet networks for 5-state edge classification, and MERIDIAN
cross-environment transfer with EWC continual learning.

Part of RF Topological Sensing research swarm (12 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:18:46 +00:00
Claude a3b4590fff Add CSI edge weight computation research
GOAP Agent 2 output: ~700-line document covering CSI feature extraction,
coherence metrics (cross-correlation, mutual information, phasor coherence),
multipath stability scoring (MUSIC, ESPRIT, ISTA), temporal windowing
(EMA, Welford, Kalman), noise robustness (phase noise, AGC, clock drift),
edge weight normalization, and implementation architecture showing 32KB
memory for 120 edges within ESP32-S3 capability.

Part of RF Topological Sensing research swarm (10 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:09:07 +00:00
Claude 85a93eee39 Add sublinear mincut algorithms research
GOAP Agent 5 output: 698-line document covering classical mincut complexity,
sublinear approximation (sampling, sparsifiers), dynamic mincut with lazy
recomputation hybrid, streaming sketch algorithms, Benczur-Karger
sparsification, local partitioning (PageRank-guided cuts), randomized
methods reliability analysis, and Rust implementation with const-generic
RfGraph, zero-alloc Stoer-Wagner, SIMD batch updates.

Part of RF Topological Sensing research swarm (10 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:08:25 +00:00
Claude 1a3c6b4d11 Add attention mechanisms for RF sensing research
GOAP Agent 3 output: 1,110-line document covering GAT for RF graphs,
self-attention for CSI sequences, cross-attention multi-link fusion,
attention-weighted differentiable mincut, spatial node attention,
antenna-level subcarrier attention, and efficient attention variants
(linear, sparse, LSH, S4/Mamba). 8 ASCII architecture diagrams.

Part of RF Topological Sensing research swarm (10 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:05:55 +00:00
Claude 5e83d70d06 Add transformer architectures for graph sensing research
GOAP Agent 4 output: 896-line SOTA document covering Graph Transformers
(Graphormer, SAN, GPS, TokenGT), Temporal Graph Transformers (TGN, TGAT,
DyRep), ViT for RF spectrograms, transformer-based mincut prediction,
positional encoding for RF graphs, foundation models for RF sensing, and
efficient edge deployment with INT8 quantization.

Part of RF Topological Sensing research swarm (10 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 20:05:02 +00:00
Claude e4f82329a9 Add temporal graph evolution & RuVector integration research
GOAP Agent 8 output: 1,528-line SOTA research document covering temporal
graph models (TGN, JODIE, DyRep), RuVector graph memory design, mincut
trajectory tracking with Kalman filtering, event detection pipelines,
compressed temporal storage, cross-room transition graphs, and a 5-phase
integration roadmap.

Part of RF Topological Sensing research swarm (10 agents).

https://claude.ai/code/session_01DGUAowNScGVp88bK2eiuRv
2026-03-08 19:11:16 +00:00
20 changed files with 224 additions and 6245 deletions
-180
View File
@@ -1,180 +0,0 @@
name: Desktop Release
on:
push:
tags:
- 'desktop-v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.3.0)'
required: true
default: '0.3.0'
env:
CARGO_TERM_COLOR: always
jobs:
build-macos:
name: Build macOS
runs-on: macos-latest
strategy:
matrix:
target: [aarch64-apple-darwin, x86_64-apple-darwin]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install frontend dependencies
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
run: npm ci
- name: Build frontend
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
run: npm run build
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0.0"
- name: Build Tauri app
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
run: cargo tauri build --target ${{ matrix.target }}
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Get architecture name
id: arch
run: |
if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then
echo "arch=arm64" >> $GITHUB_OUTPUT
else
echo "arch=x64" >> $GITHUB_OUTPUT
fi
- 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"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: ruview-macos-${{ steps.arch.outputs.arch }}
path: rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip
build-windows:
name: Build Windows
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
run: npm ci
- name: Build frontend
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
run: npm run build
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0.0"
- name: Build Tauri app
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
run: cargo tauri build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Upload Windows MSI artifact
uses: actions/upload-artifact@v4
with:
name: ruview-windows-msi
path: rust-port/wifi-densepose-rs/target/release/bundle/msi/*.msi
- name: Upload Windows NSIS artifact
uses: actions/upload-artifact@v4
with:
name: ruview-windows-nsis
path: rust-port/wifi-densepose-rs/target/release/bundle/nsis/*.exe
create-release:
name: Create Release
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts
run: find artifacts -type f
- name: Create 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' }}
draft: false
prerelease: false
generate_release_notes: true
files: |
artifacts/**/*.zip
artifacts/**/*.msi
artifacts/**/*.exe
artifacts/**/*.dmg
body: |
## RuView Desktop v${{ github.event.inputs.version || '0.3.0' }}
WiFi-based human pose estimation desktop application.
### Downloads
| Platform | Architecture | Download |
|----------|--------------|----------|
| macOS | Apple Silicon (M1/M2/M3) | `RuView-Desktop-*-macos-arm64.zip` |
| macOS | Intel | `RuView-Desktop-*-macos-x64.zip` |
| Windows | x64 | `RuView-Desktop-*.msi` or `RuView-Desktop-*.exe` |
### Installation
**macOS:**
1. Download the appropriate `.zip` file for your Mac
2. Extract the zip file
3. Move `RuView Desktop.app` to your Applications folder
4. Right-click and select "Open" (first time only, to bypass Gatekeeper)
**Windows:**
1. Download the `.msi` installer
2. Run the installer
3. Launch RuView Desktop from the Start menu
### Requirements
- macOS 11.0+ (Big Sur or later)
- Windows 10/11 (64-bit)
@@ -1,699 +0,0 @@
# 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/)
+18 -425
View File
@@ -791,15 +791,6 @@ 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"
@@ -1457,18 +1448,6 @@ 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"
@@ -2356,23 +2335,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls 0.23.37",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2390,7 +2352,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.2",
"socket2",
"tokio",
"tower-service",
"tracing",
@@ -2544,16 +2506,6 @@ 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"
@@ -2608,16 +2560,6 @@ 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"
@@ -2925,15 +2867,6 @@ 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"
@@ -2990,19 +2923,6 @@ 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"
@@ -3134,24 +3054,10 @@ 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"
@@ -3220,15 +3126,6 @@ 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"
@@ -3341,29 +3238,6 @@ 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"
@@ -3386,15 +3260,6 @@ 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"
@@ -4130,22 +3995,6 @@ 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"
@@ -4400,7 +4249,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.37",
"socket2 0.6.2",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4439,7 +4288,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.2",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
@@ -4744,46 +4593,6 @@ 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-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http 0.6.8",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -5606,24 +5415,6 @@ 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",
"mach2",
"nix 0.26.4",
"scopeguard",
"unescaper",
"winapi",
]
[[package]]
name = "servo_arc"
version = "0.2.0"
@@ -5762,16 +5553,6 @@ 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"
@@ -5978,20 +5759,6 @@ 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"
@@ -6063,7 +5830,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -6116,7 +5883,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.2",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -6134,7 +5901,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows 0.61.3",
"windows",
]
[[package]]
@@ -6300,7 +6067,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows",
]
[[package]]
@@ -6325,7 +6092,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows",
"wry",
]
@@ -6552,7 +6319,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.2",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -6568,30 +6335,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.37",
"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"
@@ -6982,15 +6725,6 @@ 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"
@@ -7523,15 +7257,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -7540,10 +7265,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
]
[[package]]
@@ -7564,7 +7289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
]
@@ -7636,27 +7361,14 @@ 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",
"sha2",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
"thiserror 1.0.69",
"tokio",
"tokio-serial",
"tracing",
"uuid",
]
[[package]]
@@ -7916,16 +7628,6 @@ 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"
@@ -7948,26 +7650,14 @@ 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 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -7979,8 +7669,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -7997,17 +7687,6 @@ 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"
@@ -8019,17 +7698,6 @@ 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"
@@ -8063,15 +7731,6 @@ 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"
@@ -8117,15 +7776,6 @@ 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"
@@ -8177,21 +7827,6 @@ 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"
@@ -8249,12 +7884,6 @@ 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"
@@ -8273,12 +7902,6 @@ 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"
@@ -8297,12 +7920,6 @@ 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"
@@ -8333,12 +7950,6 @@ 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"
@@ -8357,12 +7968,6 @@ 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"
@@ -8381,12 +7986,6 @@ 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"
@@ -8405,12 +8004,6 @@ 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"
@@ -8584,7 +8177,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -1,337 +1,28 @@
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::{
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole,
NodeCapabilities, NodeRegistry,
};
use crate::state::AppState;
use crate::domain::node::DiscoveredNode;
/// 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
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
#[tauri::command]
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,
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
let _timeout = timeout_ms.unwrap_or(3000);
// Stub: return placeholder data
Ok(vec![DiscoveredNode {
ip: "192.168.1.100".into(),
mac: Some("AA:BB:CC:DD:EE:FF".into()),
hostname: Some("ruview-node-1".into()),
node_id: 1,
firmware_version: Some("0.3.0".into()),
health: crate::domain::node::HealthStatus::Online,
last_seen: chrono::Utc::now().to_rfc3339(),
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> {
let ports = available_ports().map_err(|e| format!("Failed to enumerate ports: {}", e))?;
let mut result = Vec::new();
for port in ports {
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,
vid: None,
pid: None,
manufacturer: None,
serial_number: None,
is_esp32_compatible: false,
}
}
};
result.push(info);
}
// Sort ESP32-compatible ports first
result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible));
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
// Stub: return empty list
Ok(vec![])
}
#[derive(Debug, Clone, Serialize)]
@@ -340,39 +31,4 @@ 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,303 +1,38 @@
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 start_time = std::time::Instant::now();
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,
})
}
// 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(),
/// Get current flash progress (stub for polling-based approach).
#[tauri::command]
pub async fn flash_progress() -> Result<FlashProgress, String> {
Ok(FlashProgress {
phase: "idle".into(),
progress_pct: 0.0,
bytes_written: 0,
bytes_total: 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(),
bytes_total: 0,
})
}
/// Verify firmware on device by reading back and comparing hash.
#[tauri::command]
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)]
@@ -306,52 +41,4 @@ 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");
}
}
@@ -3,5 +3,4 @@ pub mod flash;
pub mod ota;
pub mod provision;
pub mod server;
pub mod settings;
pub mod wasm;
@@ -1,381 +1,36 @@
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 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>,
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,
let _ = (node_ip, firmware_path, psk);
Ok(OtaResult {
success: true,
node_ip: "stub".into(),
message: "Stub: OTA not yet implemented".into(),
})
}
/// Check if a node's OTA endpoint is accessible.
/// Push firmware to multiple nodes with rolling update strategy.
#[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
pub async fn batch_ota_update(
node_ips: Vec<String>,
firmware_path: String,
psk: Option<String>,
) -> Result<Vec<OtaResult>, String> {
let _ = (firmware_path, psk);
Ok(node_ips
.into_iter()
.map(|ip| OtaResult {
success: true,
node_ip: ip,
message: "Stub: batch OTA not yet implemented".into(),
})
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -383,66 +38,4 @@ 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,507 +1,29 @@
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> {
// 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()))
}
let _ = (port, config);
Ok(ProvisionResult {
success: true,
message: "Stub: provisioning not yet implemented".into(),
})
}
/// Read current NVS configuration from a connected ESP32.
#[tauri::command]
pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
// 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
let _ = port;
Ok(ProvisioningConfig::default())
}
#[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,254 +1,39 @@
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use sysinfo::{Pid, ProcessesToUpdate, System};
use tauri::State;
use crate::state::AppState;
/// Default path to the sensing server binary (relative to resources).
const DEFAULT_SERVER_BIN: &str = "wifi-densepose-sensing-server";
/// 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. System PATH
#[tauri::command]
pub async fn start_server(
config: ServerConfig,
state: State<'_, AppState>,
) -> 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());
}
}
// Determine server binary path
let server_path = config.server_path
.clone()
.unwrap_or_else(|| DEFAULT_SERVER_BIN.to_string());
// 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]);
}
// 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> {
// Extract child process ID and take ownership of child for killing
// This releases the lock before any await points
let child_id = {
let srv = state.server.lock().map_err(|e| e.to_string())?;
if !srv.running {
return Err("Server is not running".into());
}
srv.pid
};
let child_id = match child_id {
Some(id) => id,
None => return Err("No server process found".into()),
};
// First try graceful termination
#[cfg(unix)]
{
unsafe {
libc::kill(child_id as i32, libc::SIGTERM);
}
}
// Wait briefly for graceful shutdown (async operation - no lock held)
let wait_result: Result<Result<bool, _>, _> = tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::task::spawn_blocking({
move || {
std::thread::sleep(std::time::Duration::from_millis(100));
// Check if process is 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()
}
})
).await;
// Force kill if still running - re-acquire lock
let still_running = match wait_result {
Ok(Ok(running)) => running,
_ => true,
};
{
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
if still_running {
if let Some(ref mut child) = srv.child {
let _ = child.kill();
let _ = child.wait();
}
}
// Clear state
srv.running = false;
srv.pid = None;
srv.http_port = None;
srv.ws_port = None;
srv.udp_port = None;
srv.child = None;
}
tracing::info!("Stopped sensing server");
) -> 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(())
}
/// Get sensing server status including resource usage.
/// Stop the managed sensing server process.
#[tauri::command]
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = false;
srv.pid = None;
Ok(())
}
/// Get sensing server status.
#[tauri::command]
pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusResponse, String> {
let srv = state.server.lock().map_err(|e| e.to_string())?;
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: 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(
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,
}
};
// 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(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,
pid: srv.pid,
http_port: None,
ws_port: None,
})
}
@@ -258,16 +43,6 @@ 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>,
}
#[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)]
@@ -276,35 +51,4 @@ 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,
};
assert_eq!(config.http_port, Some(8080));
assert_eq!(config.ws_port, Some(8765));
}
}
@@ -1,101 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// Application settings that persist across restarts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub server_http_port: u16,
pub server_ws_port: u16,
pub server_udp_port: u16,
pub bind_address: String,
pub ui_path: String,
pub ota_psk: String,
pub auto_discover: bool,
pub discover_interval_ms: u32,
pub theme: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
server_http_port: 8080,
server_ws_port: 8765,
server_udp_port: 5005,
bind_address: "127.0.0.1".into(),
ui_path: String::new(),
ota_psk: String::new(),
auto_discover: true,
discover_interval_ms: 10_000,
theme: "dark".into(),
}
}
}
/// Get the settings file path in the app data directory.
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Ensure directory exists
fs::create_dir_all(&app_dir)
.map_err(|e| format!("Failed to create app data dir: {}", e))?;
Ok(app_dir.join("settings.json"))
}
/// Load settings from disk.
#[tauri::command]
pub async fn get_settings(app: AppHandle) -> Result<Option<AppSettings>, String> {
let path = settings_path(&app)?;
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
let settings: AppSettings = serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse settings: {}", e))?;
Ok(Some(settings))
}
/// Save settings to disk.
#[tauri::command]
pub async fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
let path = settings_path(&app)?;
let contents = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
fs::write(&path, contents)
.map_err(|e| format!("Failed to write settings: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_settings() {
let settings = AppSettings::default();
assert_eq!(settings.server_http_port, 8080);
assert_eq!(settings.bind_address, "127.0.0.1");
assert!(settings.auto_discover);
}
#[test]
fn test_settings_serialization() {
let settings = AppSettings::default();
let json = serde_json::to_string(&settings).unwrap();
let parsed: AppSettings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.server_http_port, settings.server_http_port);
}
}
@@ -1,279 +1,35 @@
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 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)
let _ = node_ip;
Ok(vec![])
}
/// 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> {
// 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))?;
let _ = (node_ip, wasm_path);
Ok(WasmUploadResult {
success: true,
module_id: upload_response.module_id,
message: format!("Module '{}' uploaded successfully ({} bytes)", name, wasm_size),
sha256: Some(wasm_hash),
module_id: "stub-module-0".into(),
message: "Stub: WASM upload not yet implemented".into(),
})
}
/// 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<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,
}),
}
) -> Result<(), String> {
let _ = (node_ip, module_id, action);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -282,31 +38,6 @@ 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)]
@@ -314,64 +45,4 @@ 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));
}
}
@@ -31,47 +31,6 @@ 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 {
@@ -82,17 +41,6 @@ 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.
@@ -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, wasm};
pub fn run() {
tauri::Builder::default()
@@ -16,35 +16,20 @@ pub fn run() {
// 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,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -1,6 +1,4 @@
use std::process::Child;
use std::sync::Mutex;
use std::time::Instant;
use crate::domain::node::DiscoveredNode;
@@ -8,200 +6,18 @@ use crate::domain::node::DiscoveredNode;
#[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,
}
}
}
/// 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>,
}
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()),
}
}
}
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();
}
}
}
#[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,10 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "RuView Desktop",
"version": "0.4.0",
"version": "0.3.0",
"identifier": "net.ruv.ruview",
"build": {
"frontendDist": "ui/dist",
"frontendDist": "../ui/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../ui && npm run dev",
"beforeBuildCommand": "cd ../ui && npm run build"
@@ -1,7 +1,7 @@
{
"name": "ruview-desktop-ui",
"private": true,
"version": "0.4.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from "react";
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";
@@ -11,7 +10,6 @@ import { Settings } from "./pages/Settings";
type Page =
| "dashboard"
| "discovery"
| "nodes"
| "flash"
| "ota"
@@ -28,7 +26,6 @@ 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" },
@@ -91,7 +88,6 @@ const App: React.FC = () => {
const renderPage = () => {
switch (activePage) {
case "dashboard": return <Dashboard />;
case "discovery": return <NetworkDiscovery />;
case "nodes": return <Nodes />;
case "flash": return <FlashFirmware />;
case "ota": return <OtaUpdate />;
@@ -167,7 +163,7 @@ const App: React.FC = () => {
letterSpacing: "0.02em",
}}
>
v0.4.0
v0.3.0
</span>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -1,926 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { StatusBadge } from "../components/StatusBadge";
import type { HealthStatus, Chip, MeshRole, DiscoveryMethod } from "../types";
interface DiscoveredNode {
ip: string;
mac: string | null;
hostname: string | null;
node_id: number;
firmware_version: string | null;
health: HealthStatus;
last_seen: string;
chip: Chip;
mesh_role: MeshRole;
discovery_method: DiscoveryMethod;
tdm_slot: number | null;
tdm_total: number | null;
edge_tier: number | null;
uptime_secs: number | null;
capabilities: { wasm: boolean; ota: boolean; csi: boolean } | null;
friendly_name: string | null;
notes: string | null;
}
interface SerialPortInfo {
name: string;
vid: number | null;
pid: number | null;
manufacturer: string | null;
serial_number: string | null;
is_esp32_compatible: boolean;
}
type DiscoveryTab = "network" | "serial" | "manual";
const NetworkDiscovery: React.FC = () => {
const [activeTab, setActiveTab] = useState<DiscoveryTab>("network");
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
const [serialPorts, setSerialPorts] = useState<SerialPortInfo[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [scanDuration, setScanDuration] = useState(3000);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<DiscoveredNode | null>(null);
const [filterOnline, setFilterOnline] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
// Manual add state
const [manualIp, setManualIp] = useState("");
const [manualMac, setManualMac] = useState("");
const [addingManual, setAddingManual] = useState(false);
const scanNetwork = useCallback(async () => {
setIsScanning(true);
setError(null);
try {
const found = await invoke<DiscoveredNode[]>("discover_nodes", {
timeoutMs: scanDuration,
});
setNodes(found);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsScanning(false);
}
}, [scanDuration]);
const scanSerialPorts = useCallback(async () => {
setIsScanning(true);
setError(null);
try {
const ports = await invoke<SerialPortInfo[]>("list_serial_ports");
setSerialPorts(ports);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsScanning(false);
}
}, []);
const addManualNode = useCallback(async () => {
if (!manualIp.trim()) return;
setAddingManual(true);
setError(null);
try {
// Try to ping or probe the node
const newNode: DiscoveredNode = {
ip: manualIp.trim(),
mac: manualMac.trim() || null,
hostname: null,
node_id: 0,
firmware_version: null,
health: "unknown" as HealthStatus,
last_seen: new Date().toISOString(),
chip: "esp32" as Chip,
mesh_role: "node" as MeshRole,
discovery_method: "manual" as DiscoveryMethod,
tdm_slot: null,
tdm_total: null,
edge_tier: null,
uptime_secs: null,
capabilities: null,
friendly_name: null,
notes: "Manually added",
};
setNodes((prev) => [...prev.filter((n) => n.ip !== newNode.ip), newNode]);
setManualIp("");
setManualMac("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setAddingManual(false);
}
}, [manualIp, manualMac]);
useEffect(() => {
scanNetwork();
}, []);
useEffect(() => {
if (activeTab === "serial") {
scanSerialPorts();
}
}, [activeTab, scanSerialPorts]);
const filteredNodes = nodes.filter((node) => {
if (filterOnline && node.health !== "online") return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
node.ip.toLowerCase().includes(q) ||
(node.mac?.toLowerCase().includes(q) ?? false) ||
(node.hostname?.toLowerCase().includes(q) ?? false) ||
(node.friendly_name?.toLowerCase().includes(q) ?? false)
);
}
return true;
});
const onlineCount = nodes.filter((n) => n.health === "online").length;
return (
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-5)",
}}
>
<div>
<h1 className="heading-lg" style={{ margin: 0 }}>
Network Discovery
</h1>
<p
style={{
fontSize: 13,
color: "var(--text-secondary)",
marginTop: 4,
}}
>
Discover and manage ESP32 CSI nodes on your network
</p>
</div>
</div>
{/* Stats Row */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "var(--space-4)",
marginBottom: "var(--space-5)",
}}
>
<StatCard label="Total Nodes" value={nodes.length} />
<StatCard label="Online" value={onlineCount} color="var(--status-online)" />
<StatCard
label="Offline"
value={nodes.length - onlineCount}
color={nodes.length - onlineCount > 0 ? "var(--status-error)" : "var(--text-muted)"}
/>
<StatCard label="Serial Ports" value={serialPorts.filter((p) => p.is_esp32_compatible).length} />
</div>
{/* Tabs */}
<div
style={{
display: "flex",
gap: "var(--space-2)",
borderBottom: "1px solid var(--border)",
marginBottom: "var(--space-4)",
}}
>
<TabButton active={activeTab === "network"} onClick={() => setActiveTab("network")}>
Network Discovery
</TabButton>
<TabButton active={activeTab === "serial"} onClick={() => setActiveTab("serial")}>
Serial Ports
</TabButton>
<TabButton active={activeTab === "manual"} onClick={() => setActiveTab("manual")}>
Manual Add
</TabButton>
</div>
{/* Error Display */}
{error && (
<div
style={{
background: "rgba(248, 81, 73, 0.1)",
border: "1px solid rgba(248, 81, 73, 0.3)",
borderRadius: 6,
padding: "var(--space-3) var(--space-4)",
marginBottom: "var(--space-4)",
fontSize: 13,
color: "var(--status-error)",
}}
>
{error}
</div>
)}
{/* Network Tab */}
{activeTab === "network" && (
<>
{/* Controls */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "var(--space-4)",
gap: "var(--space-4)",
}}
>
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center" }}>
<input
type="text"
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
padding: "8px 12px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-surface)",
color: "var(--text-primary)",
fontSize: 13,
width: 200,
}}
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--text-secondary)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={filterOnline}
onChange={(e) => setFilterOnline(e.target.checked)}
/>
Online only
</label>
</div>
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center" }}>
<select
value={scanDuration}
onChange={(e) => setScanDuration(Number(e.target.value))}
style={{
padding: "8px 12px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-surface)",
color: "var(--text-primary)",
fontSize: 13,
}}
>
<option value={1000}>1s scan</option>
<option value={3000}>3s scan</option>
<option value={5000}>5s scan</option>
<option value={10000}>10s scan</option>
</select>
<button
onClick={scanNetwork}
disabled={isScanning}
className="btn-gradient"
style={{ opacity: isScanning ? 0.6 : 1 }}
>
{isScanning ? "Scanning..." : "Scan Network"}
</button>
</div>
</div>
{/* Nodes Grid */}
{filteredNodes.length === 0 ? (
<div className="card empty-state">
<div className="empty-state-icon">{"◉"}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
{isScanning ? "Scanning for nodes..." : "No nodes discovered"}
</div>
<div
style={{
fontSize: 13,
color: "var(--text-muted)",
maxWidth: 300,
textAlign: "center",
lineHeight: 1.5,
}}
>
{isScanning
? "Please wait while we search for ESP32 devices on your network."
: "Click 'Scan Network' to discover ESP32 devices using mDNS and UDP broadcast."}
</div>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
gap: "var(--space-4)",
}}
>
{filteredNodes.map((node, i) => (
<NodeCard
key={node.mac || node.ip || i}
node={node}
onClick={() => setSelectedNode(node)}
/>
))}
</div>
)}
</>
)}
{/* Serial Tab */}
{activeTab === "serial" && (
<>
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: "var(--space-4)",
}}
>
<button
onClick={scanSerialPorts}
disabled={isScanning}
className="btn-gradient"
style={{ opacity: isScanning ? 0.6 : 1 }}
>
{isScanning ? "Scanning..." : "Refresh Ports"}
</button>
</div>
{serialPorts.length === 0 ? (
<div className="card empty-state">
<div className="empty-state-icon">{"⌁"}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
No serial ports found
</div>
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>
Connect an ESP32 device via USB to see available ports.
</div>
</div>
) : (
<div
style={{
background: "var(--bg-surface)",
border: "1px solid var(--border)",
borderRadius: 8,
overflow: "hidden",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
<Th>Port</Th>
<Th>Manufacturer</Th>
<Th>VID:PID</Th>
<Th>Compatible</Th>
</tr>
</thead>
<tbody>
{serialPorts.map((port) => (
<tr
key={port.name}
style={{ borderBottom: "1px solid var(--border)" }}
>
<Td mono>{port.name}</Td>
<Td>{port.manufacturer || "--"}</Td>
<Td mono>
{port.vid && port.pid
? `${port.vid.toString(16).padStart(4, "0").toUpperCase()}:${port.pid.toString(16).padStart(4, "0").toUpperCase()}`
: "--"}
</Td>
<Td>
{port.is_esp32_compatible ? (
<span
style={{
background: "rgba(63, 185, 80, 0.15)",
color: "var(--status-online)",
padding: "2px 8px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
>
ESP32 Compatible
</span>
) : (
<span style={{ color: "var(--text-muted)" }}>--</span>
)}
</Td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{/* Manual Tab */}
{activeTab === "manual" && (
<div className="card" style={{ maxWidth: 500 }}>
<h3 className="heading-sm" style={{ marginBottom: "var(--space-4)" }}>
Add Node Manually
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
IP Address *
</label>
<input
type="text"
placeholder="192.168.1.100"
value={manualIp}
onChange={(e) => setManualIp(e.target.value)}
style={{
width: "100%",
padding: "10px 12px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-base)",
color: "var(--text-primary)",
fontSize: 13,
fontFamily: "var(--font-mono)",
}}
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
MAC Address (optional)
</label>
<input
type="text"
placeholder="AA:BB:CC:DD:EE:FF"
value={manualMac}
onChange={(e) => setManualMac(e.target.value)}
style={{
width: "100%",
padding: "10px 12px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--bg-base)",
color: "var(--text-primary)",
fontSize: 13,
fontFamily: "var(--font-mono)",
}}
/>
</div>
<button
onClick={addManualNode}
disabled={!manualIp.trim() || addingManual}
className="btn-gradient"
style={{ marginTop: "var(--space-2)", opacity: !manualIp.trim() ? 0.5 : 1 }}
>
{addingManual ? "Adding..." : "Add Node"}
</button>
</div>
</div>
)}
{/* Node Detail Modal */}
{selectedNode && (
<NodeDetailModal node={selectedNode} onClose={() => setSelectedNode(null)} />
)}
</div>
);
};
function StatCard({
label,
value,
color,
}: {
label: string;
value: number;
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 TabButton({
children,
active,
onClick,
}: {
children: React.ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
padding: "10px 16px",
border: "none",
background: "transparent",
color: active ? "var(--accent)" : "var(--text-secondary)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: -1,
transition: "color 0.15s, border-color 0.15s",
}}
>
{children}
</button>
);
}
function NodeCard({ node, onClick }: { node: DiscoveredNode; onClick: () => void }) {
const chipColors: Record<string, string> = {
esp32: "#4CAF50",
esp32s2: "#2196F3",
esp32s3: "#9C27B0",
esp32c3: "#FF9800",
esp32c6: "#E91E63",
};
return (
<div
className="card"
onClick={onClick}
style={{
padding: "var(--space-4)",
cursor: "pointer",
opacity: node.health === "online" ? 1 : 0.7,
transition: "transform 0.1s, box-shadow 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
marginBottom: "var(--space-3)",
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
</div>
<div className="mono" style={{ fontSize: 12, color: "var(--text-muted)" }}>
{node.ip}
</div>
</div>
<StatusBadge status={node.health} />
</div>
<div
style={{
display: "flex",
gap: "var(--space-2)",
flexWrap: "wrap",
marginBottom: "var(--space-3)",
}}
>
<ChipBadge label={node.chip.toUpperCase()} color={chipColors[node.chip] || "#666"} />
<ChipBadge label={node.mesh_role} color="var(--text-muted)" />
{node.tdm_slot != null && node.tdm_total != null && (
<ChipBadge label={`TDM ${node.tdm_slot}/${node.tdm_total}`} color="var(--accent)" />
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 16px", fontSize: 12 }}>
<KV label="MAC" value={node.mac || "--"} mono />
<KV label="Firmware" value={node.firmware_version || "--"} mono />
<KV label="Discovery" value={node.discovery_method} />
{node.uptime_secs && (
<KV label="Uptime" value={formatUptime(node.uptime_secs)} mono />
)}
</div>
</div>
);
}
function ChipBadge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
padding: "2px 8px",
borderRadius: 4,
fontSize: 10,
fontWeight: 600,
background: `${color}20`,
color: color,
textTransform: "uppercase",
}}
>
{label}
</span>
);
}
function KV({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>{label}</span>
<span className={mono ? "mono" : ""} style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{value}
</span>
</div>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th
style={{
padding: "10px var(--space-4)",
fontSize: 10,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted)",
}}
>
{children}
</th>
);
}
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
return (
<td
style={{
padding: "10px var(--space-4)",
color: "var(--text-secondary)",
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
fontSize: 13,
}}
>
{children}
</td>
);
}
function formatUptime(secs: number): string {
const hours = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function NodeDetailModal({
node,
onClose,
}: {
node: DiscoveredNode;
onClose: () => void;
}) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: "var(--space-5)",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
background: "var(--bg-surface)",
borderRadius: 12,
padding: "var(--space-5)",
maxWidth: 600,
width: "100%",
maxHeight: "80vh",
overflow: "auto",
border: "1px solid var(--border)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
marginBottom: "var(--space-4)",
}}
>
<div>
<h2 className="heading-md" style={{ margin: 0 }}>
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
</h2>
<p className="mono" style={{ color: "var(--text-muted)", marginTop: 4, fontSize: 13 }}>
{node.ip}
</p>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
fontSize: 20,
cursor: "pointer",
color: "var(--text-muted)",
padding: 4,
}}
>
×
</button>
</div>
<div style={{ display: "flex", gap: "var(--space-2)", marginBottom: "var(--space-4)" }}>
<StatusBadge status={node.health} />
<ChipBadge label={node.chip.toUpperCase()} color="#4CAF50" />
<ChipBadge label={node.mesh_role} color="var(--accent)" />
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "var(--space-4)",
}}
>
<DetailSection title="Network">
<DetailRow label="IP Address" value={node.ip} mono />
<DetailRow label="MAC Address" value={node.mac || "--"} mono />
<DetailRow label="Hostname" value={node.hostname || "--"} />
</DetailSection>
<DetailSection title="Hardware">
<DetailRow label="Chip" value={node.chip.toUpperCase()} />
<DetailRow label="Firmware" value={node.firmware_version || "--"} mono />
<DetailRow label="Node ID" value={String(node.node_id)} mono />
</DetailSection>
<DetailSection title="Mesh Configuration">
<DetailRow label="Role" value={node.mesh_role} />
<DetailRow
label="TDM Slot"
value={
node.tdm_slot != null && node.tdm_total != null
? `${node.tdm_slot} / ${node.tdm_total}`
: "--"
}
mono
/>
<DetailRow label="Edge Tier" value={node.edge_tier != null ? String(node.edge_tier) : "--"} mono />
</DetailSection>
<DetailSection title="Status">
<DetailRow label="Discovery" value={node.discovery_method} />
<DetailRow label="Uptime" value={node.uptime_secs ? formatUptime(node.uptime_secs) : "--"} mono />
<DetailRow label="Last Seen" value={formatLastSeen(node.last_seen)} />
</DetailSection>
</div>
{node.capabilities && (
<div style={{ marginTop: "var(--space-4)" }}>
<h4 style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: "var(--space-2)" }}>
Capabilities
</h4>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
{node.capabilities.csi && <CapabilityBadge label="CSI" enabled />}
{node.capabilities.ota && <CapabilityBadge label="OTA" enabled />}
{node.capabilities.wasm && <CapabilityBadge label="WASM" enabled />}
</div>
</div>
)}
{node.notes && (
<div style={{ marginTop: "var(--space-4)" }}>
<h4 style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: "var(--space-2)" }}>
Notes
</h4>
<p style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.5 }}>
{node.notes}
</p>
</div>
)}
</div>
</div>
);
}
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h4
style={{
fontSize: 12,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--text-muted)",
marginBottom: "var(--space-2)",
}}
>
{title}
</h4>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
{children}
</div>
</div>
);
}
function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
<span style={{ color: "var(--text-muted)" }}>{label}</span>
<span className={mono ? "mono" : ""} style={{ color: "var(--text-primary)" }}>
{value}
</span>
</div>
);
}
function CapabilityBadge({ label, enabled }: { label: string; enabled: boolean }) {
return (
<span
style={{
padding: "4px 10px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
background: enabled ? "rgba(63, 185, 80, 0.15)" : "rgba(139, 148, 158, 0.15)",
color: enabled ? "var(--status-online)" : "var(--text-muted)",
}}
>
{label}
</span>
);
}
function formatLastSeen(iso: string): string {
try {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return d.toLocaleDateString();
} catch {
return "--";
}
}
export default NetworkDiscovery;
@@ -14,7 +14,7 @@ export type DiscoveryMethod = "mdns" | "udp_probe" | "http_sweep" | "manual";
export type MeshRole = "coordinator" | "node" | "aggregator";
export type Chip = "esp32" | "esp32s2" | "esp32s3" | "esp32c3" | "esp32c6";
export type Chip = "esp32" | "esp32s3" | "esp32c3";
export interface TdmConfig {
slot: number;
@@ -161,9 +161,6 @@ 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;
}
// ---------------------------------------------------------------------------