Skip to content

Commit 0b98917

Browse files
ruvnetReuven
andauthored
feat(desktop): RuView Desktop v0.4.0 - Full ADR-054 Implementation (#212)
* fix(desktop): implement save_settings and get_settings commands Fixes #206 - Settings can now be saved and loaded in Desktop v0.3.0 - Add commands/settings.rs with get_settings and save_settings Tauri commands - Settings persisted to app data directory as settings.json - Supports all AppSettings fields: ports, bind address, OTA PSK, discovery, theme - Add unit tests for serialization and defaults Settings are stored at: - macOS: ~/Library/Application Support/net.ruv.ruview/settings.json - Windows: %APPDATA%/net.ruv.ruview/settings.json - Linux: ~/.config/net.ruv.ruview/settings.json Co-Authored-By: claude-flow <ruv@ruv.net> * feat(desktop): RuView Desktop v0.4.0 - Full ADR-054 Implementation This release completes all 14 Tauri commands specified in ADR-054, making the desktop app fully production-ready for ESP32 node management. ## New Features ### Discovery Module - Real mDNS discovery (_ruview._udp.local) - UDP broadcast probe on port 5006 - Serial port enumeration with ESP32 chip detection ### Flash Module - Full espflash CLI integration - Real-time progress streaming via Tauri events - SHA-256 firmware verification - Support for ESP32, S2, S3, C3, C6 chips ### OTA Module - HTTP multipart firmware upload - HMAC-SHA256 signature with PSK authentication - Sequential and parallel batch update strategies - Reboot confirmation polling ### WASM Module - 67 edge modules across 14 categories - App-store style module library with ratings/downloads - Full module lifecycle (upload/start/stop/unload) - RVF format deployment paths ### Server Module - Child process spawn with config - Graceful SIGTERM + SIGKILL fallback - Memory/CPU monitoring via sysinfo ### Provision Module - NVS binary serial protocol - Read/write/erase operations - Mesh config generation for multi-node setup ## Security - Input validation (IP, port, path) - Binary validation (ESP/WASM magic bytes) - PSK authentication for OTA ## Breaking Changes None - backwards compatible with v0.3.0 Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
1 parent da4255a commit 0b98917

File tree

19 files changed

+6048
-207
lines changed

19 files changed

+6048
-207
lines changed

docs/adr/ADR-054-desktop-full-implementation.md

Lines changed: 699 additions & 0 deletions
Large diffs are not rendered by default.

rust-port/wifi-densepose-rs/Cargo.lock

Lines changed: 425 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 359 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,337 @@
1+
use std::net::{SocketAddr, UdpSocket};
2+
use std::time::Duration;
3+
4+
use mdns_sd::{ServiceDaemon, ServiceEvent};
15
use serde::Serialize;
6+
use tauri::State;
7+
use tokio::time::timeout;
8+
use tokio_serial::available_ports;
9+
use flume::RecvTimeoutError;
10+
11+
use crate::domain::node::{
12+
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole,
13+
NodeCapabilities, NodeRegistry,
14+
};
15+
use crate::state::AppState;
16+
17+
/// Service type for RuView ESP32 nodes using mDNS.
18+
const MDNS_SERVICE_TYPE: &str = "_ruview._udp.local.";
219

3-
use crate::domain::node::DiscoveredNode;
20+
/// UDP broadcast port for node discovery.
21+
const UDP_DISCOVERY_PORT: u16 = 5006;
422

5-
/// Discover ESP32 CSI nodes on the local network via mDNS / UDP broadcast.
23+
/// Discovery beacon magic bytes.
24+
const BEACON_MAGIC: &[u8] = b"RUVIEW_BEACON";
25+
26+
/// Discover ESP32 CSI nodes on the local network via mDNS + UDP broadcast.
27+
///
28+
/// Discovery strategy:
29+
/// 1. Start mDNS browser for `_ruview._udp.local.`
30+
/// 2. Send UDP broadcast on port 5006
31+
/// 3. Collect responses for `timeout_ms` milliseconds
32+
/// 4. Deduplicate by MAC address and return merged results
633
#[tauri::command]
7-
pub async fn discover_nodes(timeout_ms: Option<u64>) -> Result<Vec<DiscoveredNode>, String> {
8-
let _timeout = timeout_ms.unwrap_or(3000);
9-
// Stub: return placeholder data
10-
Ok(vec![DiscoveredNode {
11-
ip: "192.168.1.100".into(),
12-
mac: Some("AA:BB:CC:DD:EE:FF".into()),
13-
hostname: Some("ruview-node-1".into()),
14-
node_id: 1,
15-
firmware_version: Some("0.3.0".into()),
16-
health: crate::domain::node::HealthStatus::Online,
34+
pub async fn discover_nodes(
35+
timeout_ms: Option<u64>,
36+
state: State<'_, AppState>,
37+
) -> Result<Vec<DiscoveredNode>, String> {
38+
let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(3000));
39+
40+
// Run mDNS and UDP discovery concurrently
41+
let (mdns_nodes, udp_nodes) = tokio::join!(
42+
discover_via_mdns(timeout_duration),
43+
discover_via_udp(timeout_duration),
44+
);
45+
46+
// Merge results, deduplicating by MAC address
47+
let mut registry = NodeRegistry::new();
48+
49+
for node in mdns_nodes.unwrap_or_default() {
50+
if let Some(ref mac) = node.mac {
51+
registry.upsert(MacAddress::new(mac), node);
52+
}
53+
}
54+
55+
for node in udp_nodes.unwrap_or_default() {
56+
if let Some(ref mac) = node.mac {
57+
registry.upsert(MacAddress::new(mac), node);
58+
}
59+
}
60+
61+
let nodes: Vec<DiscoveredNode> = registry.all().into_iter().cloned().collect();
62+
63+
// Update global state
64+
{
65+
let mut discovery = state.discovery.lock().map_err(|e| e.to_string())?;
66+
discovery.nodes = nodes.clone();
67+
}
68+
69+
Ok(nodes)
70+
}
71+
72+
/// Discover nodes via mDNS (Bonjour/Avahi).
73+
async fn discover_via_mdns(timeout_duration: Duration) -> Result<Vec<DiscoveredNode>, String> {
74+
let discovery_task = tokio::task::spawn_blocking(move || {
75+
let mdns = match ServiceDaemon::new() {
76+
Ok(daemon) => daemon,
77+
Err(e) => {
78+
tracing::warn!("Failed to create mDNS daemon: {}", e);
79+
return Vec::new();
80+
}
81+
};
82+
83+
let receiver = match mdns.browse(MDNS_SERVICE_TYPE) {
84+
Ok(rx) => rx,
85+
Err(e) => {
86+
tracing::warn!("Failed to browse mDNS services: {}", e);
87+
return Vec::new();
88+
}
89+
};
90+
91+
let mut discovered = Vec::new();
92+
let start = std::time::Instant::now();
93+
94+
while start.elapsed() < timeout_duration {
95+
match receiver.recv_timeout(Duration::from_millis(100)) {
96+
Ok(ServiceEvent::ServiceResolved(info)) => {
97+
let props = info.get_properties();
98+
let chip_str = props.get("chip").map(|v| v.val_str());
99+
let chip = match chip_str {
100+
Some("esp32s2") => Chip::Esp32s2,
101+
Some("esp32s3") => Chip::Esp32s3,
102+
Some("esp32c3") => Chip::Esp32c3,
103+
Some("esp32c6") => Chip::Esp32c6,
104+
_ => Chip::Esp32,
105+
};
106+
let role_str = props.get("role").map(|v| v.val_str());
107+
let mesh_role = match role_str {
108+
Some("coordinator") => MeshRole::Coordinator,
109+
Some("aggregator") => MeshRole::Aggregator,
110+
_ => MeshRole::Node,
111+
};
112+
let node = DiscoveredNode {
113+
ip: info.get_addresses()
114+
.iter()
115+
.next()
116+
.map(|a| a.to_string())
117+
.unwrap_or_default(),
118+
mac: props.get("mac").map(|v| v.val_str().to_string()),
119+
hostname: Some(info.get_hostname().to_string()),
120+
node_id: props.get("node_id")
121+
.and_then(|v| v.val_str().parse().ok())
122+
.unwrap_or(0),
123+
firmware_version: props.get("version").map(|v| v.val_str().to_string()),
124+
health: HealthStatus::Online,
125+
last_seen: chrono::Utc::now().to_rfc3339(),
126+
chip,
127+
mesh_role,
128+
discovery_method: DiscoveryMethod::Mdns,
129+
tdm_slot: props.get("tdm_slot").and_then(|v| v.val_str().parse().ok()),
130+
tdm_total: props.get("tdm_total").and_then(|v| v.val_str().parse().ok()),
131+
edge_tier: props.get("edge_tier").and_then(|v| v.val_str().parse().ok()),
132+
uptime_secs: props.get("uptime").and_then(|v| v.val_str().parse().ok()),
133+
capabilities: Some(NodeCapabilities {
134+
wasm: props.get("wasm").map(|v| v.val_str() == "1").unwrap_or(false),
135+
ota: props.get("ota").map(|v| v.val_str() == "1").unwrap_or(true),
136+
csi: props.get("csi").map(|v| v.val_str() == "1").unwrap_or(true),
137+
}),
138+
friendly_name: props.get("name").map(|v| v.val_str().to_string()),
139+
notes: None,
140+
};
141+
discovered.push(node);
142+
}
143+
Ok(ServiceEvent::SearchStarted(_)) => {}
144+
Ok(_) => {}
145+
Err(RecvTimeoutError::Timeout) => continue,
146+
Err(RecvTimeoutError::Disconnected) => break,
147+
}
148+
}
149+
150+
// Stop browsing
151+
let _ = mdns.stop_browse(MDNS_SERVICE_TYPE);
152+
153+
discovered
154+
});
155+
156+
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
157+
Ok(Ok(nodes)) => Ok(nodes),
158+
Ok(Err(e)) => Err(format!("mDNS discovery task failed: {}", e)),
159+
Err(_) => Ok(Vec::new()), // Timeout, return empty
160+
}
161+
}
162+
163+
/// Discover nodes via UDP broadcast beacon.
164+
async fn discover_via_udp(timeout_duration: Duration) -> Result<Vec<DiscoveredNode>, String> {
165+
let discovery_task = tokio::task::spawn_blocking(move || -> Vec<DiscoveredNode> {
166+
let socket = match UdpSocket::bind("0.0.0.0:0") {
167+
Ok(s) => s,
168+
Err(e) => {
169+
tracing::warn!("Failed to bind UDP socket: {}", e);
170+
return Vec::new();
171+
}
172+
};
173+
174+
if let Err(e) = socket.set_broadcast(true) {
175+
tracing::warn!("Failed to enable broadcast: {}", e);
176+
return Vec::new();
177+
}
178+
179+
if let Err(e) = socket.set_read_timeout(Some(Duration::from_millis(100))) {
180+
tracing::warn!("Failed to set read timeout: {}", e);
181+
return Vec::new();
182+
}
183+
184+
// Send discovery beacon
185+
let broadcast_addr = format!("255.255.255.255:{}", UDP_DISCOVERY_PORT);
186+
if let Err(e) = socket.send_to(b"RUVIEW_DISCOVER", &broadcast_addr) {
187+
tracing::warn!("Failed to send discovery beacon: {}", e);
188+
}
189+
190+
let mut discovered = Vec::new();
191+
let mut buf = [0u8; 256];
192+
let start = std::time::Instant::now();
193+
194+
while start.elapsed() < timeout_duration {
195+
match socket.recv_from(&mut buf) {
196+
Ok((len, addr)) => {
197+
if len >= BEACON_MAGIC.len() && &buf[..BEACON_MAGIC.len()] == BEACON_MAGIC {
198+
// Parse beacon response: RUVIEW_BEACON|mac|node_id|version
199+
if let Some(node) = parse_beacon_response(&buf[..len], addr) {
200+
discovered.push(node);
201+
}
202+
}
203+
}
204+
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
205+
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
206+
Err(_) => break,
207+
}
208+
}
209+
210+
discovered
211+
});
212+
213+
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
214+
Ok(Ok(nodes)) => Ok(nodes),
215+
Ok(Err(e)) => Err(format!("UDP discovery task failed: {}", e)),
216+
Err(_) => Ok(Vec::new()),
217+
}
218+
}
219+
220+
/// Parse a UDP beacon response into a DiscoveredNode.
221+
/// Format: RUVIEW_BEACON|<mac>|<node_id>|<version>|<chip>|<role>|<tdm_slot>|<tdm_total>
222+
fn parse_beacon_response(data: &[u8], addr: SocketAddr) -> Option<DiscoveredNode> {
223+
let text = std::str::from_utf8(data).ok()?;
224+
let parts: Vec<&str> = text.split('|').collect();
225+
226+
if parts.len() < 2 || parts[0] != "RUVIEW_BEACON" {
227+
return None;
228+
}
229+
230+
let mac = parts.get(1).map(|s| s.to_string());
231+
let node_id = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
232+
let version = parts.get(3).map(|s| s.to_string());
233+
let chip_str = parts.get(4).copied();
234+
let chip = match chip_str {
235+
Some("esp32s2") => Chip::Esp32s2,
236+
Some("esp32s3") => Chip::Esp32s3,
237+
Some("esp32c3") => Chip::Esp32c3,
238+
Some("esp32c6") => Chip::Esp32c6,
239+
_ => Chip::Esp32,
240+
};
241+
let role_str = parts.get(5).copied();
242+
let mesh_role = match role_str {
243+
Some("coordinator") => MeshRole::Coordinator,
244+
Some("aggregator") => MeshRole::Aggregator,
245+
_ => MeshRole::Node,
246+
};
247+
let tdm_slot = parts.get(6).and_then(|s| s.parse().ok());
248+
let tdm_total = parts.get(7).and_then(|s| s.parse().ok());
249+
250+
Some(DiscoveredNode {
251+
ip: addr.ip().to_string(),
252+
mac,
253+
hostname: None,
254+
node_id,
255+
firmware_version: version,
256+
health: HealthStatus::Online,
17257
last_seen: chrono::Utc::now().to_rfc3339(),
18-
}])
258+
chip,
259+
mesh_role,
260+
discovery_method: DiscoveryMethod::UdpProbe,
261+
tdm_slot,
262+
tdm_total,
263+
edge_tier: None,
264+
uptime_secs: None,
265+
capabilities: Some(NodeCapabilities {
266+
wasm: false,
267+
ota: true,
268+
csi: true,
269+
}),
270+
friendly_name: None,
271+
notes: None,
272+
})
19273
}
20274

21275
/// List available serial ports on this machine.
276+
/// Filters for known ESP32 USB-to-serial chips (CP2102, CH340, FTDI).
22277
#[tauri::command]
23278
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
24-
// Stub: return empty list
25-
Ok(vec![])
279+
let ports = available_ports().map_err(|e| format!("Failed to enumerate ports: {}", e))?;
280+
281+
let mut result = Vec::new();
282+
283+
for port in ports {
284+
let info = match port.port_type {
285+
tokio_serial::SerialPortType::UsbPort(usb_info) => {
286+
SerialPortInfo {
287+
name: port.port_name,
288+
vid: Some(usb_info.vid),
289+
pid: Some(usb_info.pid),
290+
manufacturer: usb_info.manufacturer,
291+
serial_number: usb_info.serial_number,
292+
is_esp32_compatible: is_esp32_compatible(usb_info.vid, usb_info.pid),
293+
}
294+
}
295+
_ => {
296+
SerialPortInfo {
297+
name: port.port_name,
298+
vid: None,
299+
pid: None,
300+
manufacturer: None,
301+
serial_number: None,
302+
is_esp32_compatible: false,
303+
}
304+
}
305+
};
306+
307+
result.push(info);
308+
}
309+
310+
// Sort ESP32-compatible ports first
311+
result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible));
312+
313+
Ok(result)
314+
}
315+
316+
/// Check if a USB VID/PID is from a known ESP32 USB-to-serial chip.
317+
fn is_esp32_compatible(vid: u16, pid: u16) -> bool {
318+
// CP210x (Silicon Labs)
319+
if vid == 0x10C4 && (pid == 0xEA60 || pid == 0xEA70) {
320+
return true;
321+
}
322+
// CH340/CH341 (QinHeng)
323+
if vid == 0x1A86 && (pid == 0x7523 || pid == 0x5523) {
324+
return true;
325+
}
326+
// FTDI
327+
if vid == 0x0403 && (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015) {
328+
return true;
329+
}
330+
// ESP32-S2/S3 native USB
331+
if vid == 0x303A {
332+
return true;
333+
}
334+
false
26335
}
27336

28337
#[derive(Debug, Clone, Serialize)]
@@ -31,4 +340,39 @@ pub struct SerialPortInfo {
31340
pub vid: Option<u16>,
32341
pub pid: Option<u16>,
33342
pub manufacturer: Option<String>,
343+
pub serial_number: Option<String>,
344+
pub is_esp32_compatible: bool,
345+
}
346+
347+
#[cfg(test)]
348+
mod tests {
349+
use super::*;
350+
351+
#[test]
352+
fn test_parse_beacon_response() {
353+
let data = b"RUVIEW_BEACON|AA:BB:CC:DD:EE:FF|1|0.3.0|esp32s3|coordinator|0|4";
354+
let addr: SocketAddr = "192.168.1.100:5006".parse().unwrap();
355+
356+
let node = parse_beacon_response(data, addr).unwrap();
357+
assert_eq!(node.ip, "192.168.1.100");
358+
assert_eq!(node.mac, Some("AA:BB:CC:DD:EE:FF".to_string()));
359+
assert_eq!(node.node_id, 1);
360+
assert_eq!(node.firmware_version, Some("0.3.0".to_string()));
361+
assert_eq!(node.chip, Chip::Esp32s3);
362+
assert_eq!(node.mesh_role, MeshRole::Coordinator);
363+
assert_eq!(node.tdm_slot, Some(0));
364+
assert_eq!(node.tdm_total, Some(4));
365+
}
366+
367+
#[test]
368+
fn test_is_esp32_compatible() {
369+
// CP2102
370+
assert!(is_esp32_compatible(0x10C4, 0xEA60));
371+
// CH340
372+
assert!(is_esp32_compatible(0x1A86, 0x7523));
373+
// ESP32-S3 native
374+
assert!(is_esp32_compatible(0x303A, 0x1001));
375+
// Unknown
376+
assert!(!is_esp32_compatible(0x0000, 0x0000));
377+
}
34378
}

0 commit comments

Comments
 (0)