feat(desktop): v0.4.3 - USB device discovery and data source toggle

## Changes
- Auto-scan serial ports on Discovery page load (not just Serial tab)
- Show USB device hint when no network nodes found but USB devices detected
- Add "Flash →" button in Serial Ports table for quick navigation
- Fix server stop: proper SIGTERM/SIGKILL with process group handling
- Add data source selector on Sensing page (simulate/auto/wifi/esp32)
- Fix log viewer scroll (use containerRef.scrollTop instead of scrollIntoView)
- Add fallback serial port scanning for macOS when tokio_serial fails

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Reuven 2026-03-10 09:59:46 -04:00
parent a28a875594
commit 21aba2df8d
8 changed files with 286 additions and 67 deletions

View File

@ -276,11 +276,24 @@ fn parse_beacon_response(data: &[u8], addr: SocketAddr) -> Option<DiscoveredNode
/// Filters for known ESP32 USB-to-serial chips (CP2102, CH340, FTDI). /// Filters for known ESP32 USB-to-serial chips (CP2102, CH340, FTDI).
#[tauri::command] #[tauri::command]
pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> { pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
let ports = available_ports().map_err(|e| format!("Failed to enumerate ports: {}", e))?; tracing::info!("list_serial_ports called");
let ports = match available_ports() {
Ok(p) => {
tracing::info!("Found {} ports from tokio_serial", p.len());
p
}
Err(e) => {
tracing::error!("Failed to enumerate ports: {}", e);
// Fallback: try to list /dev/cu.usb* manually on macOS
return list_serial_ports_fallback();
}
};
let mut result = Vec::new(); let mut result = Vec::new();
for port in ports { for port in ports {
tracing::debug!("Processing port: {}", port.port_name);
let info = match port.port_type { let info = match port.port_type {
tokio_serial::SerialPortType::UsbPort(usb_info) => { tokio_serial::SerialPortType::UsbPort(usb_info) => {
SerialPortInfo { SerialPortInfo {
@ -294,12 +307,13 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
} }
_ => { _ => {
SerialPortInfo { SerialPortInfo {
name: port.port_name, name: port.port_name.clone(),
vid: None, vid: None,
pid: None, pid: None,
manufacturer: None, manufacturer: None,
serial_number: None, serial_number: None,
is_esp32_compatible: false, // Mark /dev/cu.usb* ports as potentially compatible
is_esp32_compatible: port.port_name.contains("usb"),
} }
} }
}; };
@ -307,9 +321,72 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
result.push(info); result.push(info);
} }
// If no ports found via tokio_serial, try fallback
if result.is_empty() {
tracing::warn!("No ports from tokio_serial, trying fallback");
return list_serial_ports_fallback();
}
// Sort ESP32-compatible ports first // Sort ESP32-compatible ports first
result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible)); result.sort_by(|a, b| b.is_esp32_compatible.cmp(&a.is_esp32_compatible));
tracing::info!("Returning {} serial ports", result.len());
Ok(result)
}
/// Fallback serial port listing for macOS when tokio_serial fails
fn list_serial_ports_fallback() -> Result<Vec<SerialPortInfo>, String> {
tracing::info!("Using fallback serial port listing");
let mut result = Vec::new();
// List /dev/cu.usb* devices on macOS
#[cfg(target_os = "macos")]
{
use std::fs;
if let Ok(entries) = fs::read_dir("/dev") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("cu.usb") {
let path = format!("/dev/{}", name);
tracing::info!("Fallback found port: {}", path);
result.push(SerialPortInfo {
name: path,
vid: None,
pid: None,
manufacturer: Some("USB Serial".to_string()),
serial_number: None,
is_esp32_compatible: true, // Assume USB serial is ESP32
});
}
}
}
}
// Linux fallback
#[cfg(target_os = "linux")]
{
use std::fs;
if let Ok(entries) = fs::read_dir("/dev") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("ttyUSB") || name.starts_with("ttyACM") {
let path = format!("/dev/{}", name);
tracing::info!("Fallback found port: {}", path);
result.push(SerialPortInfo {
name: path,
vid: None,
pid: None,
manufacturer: Some("USB Serial".to_string()),
serial_number: None,
is_esp32_compatible: true,
});
}
}
}
}
tracing::info!("Fallback found {} ports", result.len());
Ok(result) Ok(result)
} }

View File

@ -148,14 +148,15 @@ pub async fn start_server(
/// First attempts graceful termination (SIGTERM), then SIGKILL after timeout. /// First attempts graceful termination (SIGTERM), then SIGKILL after timeout.
#[tauri::command] #[tauri::command]
pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> { pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
// Extract child process ID and take ownership of child for killing // Extract child process and take ownership for killing
// This releases the lock before any await points let (child_id, mut child_process) = {
let child_id = { let mut srv = state.server.lock().map_err(|e| e.to_string())?;
let srv = state.server.lock().map_err(|e| e.to_string())?;
if !srv.running { if !srv.running {
return Err("Server is not running".into()); return Err("Server is not running".into());
} }
srv.pid let pid = srv.pid;
let child = srv.child.take(); // Take ownership of child
(pid, child)
}; };
let child_id = match child_id { let child_id = match child_id {
@ -163,46 +164,60 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
None => return Err("No server process found".into()), None => return Err("No server process found".into()),
}; };
// First try graceful termination tracing::info!("Stopping sensing server with PID {}", child_id);
// First try graceful termination via SIGTERM
#[cfg(unix)] #[cfg(unix)]
{ {
unsafe { unsafe {
libc::kill(child_id as i32, libc::SIGTERM); // Kill the process group (negative PID) to kill all children too
let _ = libc::kill(-(child_id as i32), libc::SIGTERM);
// Also kill the main process directly
let _ = libc::kill(child_id as i32, libc::SIGTERM);
} }
} }
// Wait briefly for graceful shutdown (async operation - no lock held) // Wait briefly for graceful shutdown
let wait_result: Result<Result<bool, _>, _> = tokio::time::timeout( tokio::time::sleep(std::time::Duration::from_millis(500)).await;
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 // Check if still running
let still_running = match wait_result { let still_running = {
Ok(Ok(running)) => running, let mut sys = System::new();
_ => true, let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
}; };
{ // Force kill if still running
let mut srv = state.server.lock().map_err(|e| e.to_string())?; if still_running {
tracing::warn!("Server still running after SIGTERM, sending SIGKILL");
if still_running { #[cfg(unix)]
if let Some(ref mut child) = srv.child { {
let _ = child.kill(); unsafe {
let _ = child.wait(); // SIGKILL the process group and main process
let _ = libc::kill(-(child_id as i32), libc::SIGKILL);
let _ = libc::kill(child_id as i32, libc::SIGKILL);
} }
} }
// Clear state // Also use the child handle if available
if let Some(ref mut child) = child_process {
let _ = child.kill();
}
}
// Wait for process to actually terminate
if let Some(ref mut child) = child_process {
let _ = child.wait();
}
// Final verification and cleanup
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Clear state
{
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
srv.running = false; srv.running = false;
srv.pid = None; srv.pid = None;
srv.http_port = None; srv.http_port = None;
@ -211,6 +226,19 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
srv.child = None; srv.child = None;
} }
// Verify process is dead
let still_alive = {
let mut sys = System::new();
let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
};
if still_alive {
tracing::error!("Failed to kill server process {}", child_id);
return Err(format!("Failed to stop server process {}", child_id));
}
tracing::info!("Stopped sensing server"); tracing::info!("Stopped sensing server");
Ok(()) Ok(())
@ -366,6 +394,7 @@ mod tests {
log_level: None, log_level: None,
bind_address: None, bind_address: None,
server_path: None, server_path: None,
source: Some("simulate".to_string()),
}; };
assert_eq!(config.http_port, Some(8080)); assert_eq!(config.http_port, Some(8080));

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "RuView Desktop", "productName": "RuView Desktop",
"version": "0.4.2", "version": "0.4.3",
"identifier": "net.ruv.ruview", "identifier": "net.ruv.ruview",
"build": { "build": {
"frontendDist": "ui/dist", "frontendDist": "ui/dist",

View File

@ -1,7 +1,7 @@
{ {
"name": "ruview-desktop-ui", "name": "ruview-desktop-ui",
"private": true, "private": true,
"version": "0.4.2", "version": "0.4.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -92,7 +92,7 @@ const App: React.FC = () => {
const renderPage = () => { const renderPage = () => {
switch (activePage) { switch (activePage) {
case "dashboard": return <Dashboard onNavigate={navigateTo} />; case "dashboard": return <Dashboard onNavigate={navigateTo} />;
case "discovery": return <NetworkDiscovery />; case "discovery": return <NetworkDiscovery onNavigate={navigateTo} />;
case "nodes": return <Nodes />; case "nodes": return <Nodes />;
case "flash": return <FlashFirmware />; case "flash": return <FlashFirmware />;
case "ota": return <OtaUpdate />; case "ota": return <OtaUpdate />;

View File

@ -3,6 +3,12 @@ import { invoke } from "@tauri-apps/api/core";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import type { HealthStatus, Chip, MeshRole, DiscoveryMethod } from "../types"; import type { HealthStatus, Chip, MeshRole, DiscoveryMethod } from "../types";
type Page = "dashboard" | "discovery" | "nodes" | "flash" | "ota" | "wasm" | "sensing" | "mesh" | "settings";
interface NetworkDiscoveryProps {
onNavigate?: (page: Page) => void;
}
interface DiscoveredNode { interface DiscoveredNode {
ip: string; ip: string;
mac: string | null; mac: string | null;
@ -34,7 +40,7 @@ interface SerialPortInfo {
type DiscoveryTab = "network" | "serial" | "manual"; type DiscoveryTab = "network" | "serial" | "manual";
const NetworkDiscovery: React.FC = () => { const NetworkDiscovery: React.FC<NetworkDiscoveryProps> = ({ onNavigate }) => {
const [activeTab, setActiveTab] = useState<DiscoveryTab>("network"); const [activeTab, setActiveTab] = useState<DiscoveryTab>("network");
const [nodes, setNodes] = useState<DiscoveredNode[]>([]); const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
const [serialPorts, setSerialPorts] = useState<SerialPortInfo[]>([]); const [serialPorts, setSerialPorts] = useState<SerialPortInfo[]>([]);
@ -112,16 +118,22 @@ const NetworkDiscovery: React.FC = () => {
} }
}, [manualIp, manualMac]); }, [manualIp, manualMac]);
// Scan both network and serial ports on mount
useEffect(() => { useEffect(() => {
scanNetwork(); scanNetwork();
scanSerialPorts();
}, []); }, []);
// Also refresh serial ports when switching to that tab
useEffect(() => { useEffect(() => {
if (activeTab === "serial") { if (activeTab === "serial") {
scanSerialPorts(); scanSerialPorts();
} }
}, [activeTab, scanSerialPorts]); }, [activeTab, scanSerialPorts]);
// Count ESP32-compatible serial ports
const esp32SerialCount = serialPorts.filter((p) => p.is_esp32_compatible).length;
const filteredNodes = nodes.filter((node) => { const filteredNodes = nodes.filter((node) => {
if (filterOnline && node.health !== "online") return false; if (filterOnline && node.health !== "online") return false;
if (searchQuery) { if (searchQuery) {
@ -302,21 +314,61 @@ const NetworkDiscovery: React.FC = () => {
<div className="card empty-state"> <div className="card empty-state">
<div className="empty-state-icon">{"◉"}</div> <div className="empty-state-icon">{"◉"}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}> <div style={{ fontSize: 14, fontWeight: 600, color: "var(--text-secondary)" }}>
{isScanning ? "Scanning for nodes..." : "No nodes discovered"} {isScanning ? "Scanning for nodes..." : "No network nodes found"}
</div> </div>
<div <div
style={{ style={{
fontSize: 13, fontSize: 13,
color: "var(--text-muted)", color: "var(--text-muted)",
maxWidth: 300, maxWidth: 340,
textAlign: "center", textAlign: "center",
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
{isScanning {isScanning
? "Please wait while we search for ESP32 devices on your network." ? "Please wait while we search for ESP32 devices on your network."
: "Click 'Scan Network' to discover ESP32 devices using mDNS and UDP broadcast."} : "Network discovery uses mDNS/UDP to find ESP32 devices running firmware on WiFi."}
</div> </div>
{/* USB device hint */}
{!isScanning && esp32SerialCount > 0 && (
<div
style={{
marginTop: "var(--space-4)",
padding: "var(--space-3) var(--space-4)",
background: "rgba(56, 139, 253, 0.1)",
border: "1px solid rgba(56, 139, 253, 0.3)",
borderRadius: 8,
maxWidth: 340,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 16 }}>🔌</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--accent)" }}>
{esp32SerialCount} USB device{esp32SerialCount > 1 ? "s" : ""} detected!
</span>
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)", lineHeight: 1.5, marginBottom: 10 }}>
Your ESP32 is connected via USB. To flash firmware or configure it:
</div>
<button
onClick={() => setActiveTab("serial")}
style={{
padding: "8px 16px",
background: "var(--accent)",
border: "none",
borderRadius: 6,
color: "#fff",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
width: "100%",
}}
>
View Serial Ports
</button>
</div>
)}
</div> </div>
) : ( ) : (
<div <div
@ -384,6 +436,7 @@ const NetworkDiscovery: React.FC = () => {
<Th>Manufacturer</Th> <Th>Manufacturer</Th>
<Th>VID:PID</Th> <Th>VID:PID</Th>
<Th>Compatible</Th> <Th>Compatible</Th>
<Th>Actions</Th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -417,6 +470,25 @@ const NetworkDiscovery: React.FC = () => {
<span style={{ color: "var(--text-muted)" }}>--</span> <span style={{ color: "var(--text-muted)" }}>--</span>
)} )}
</Td> </Td>
<Td>
{port.is_esp32_compatible && onNavigate && (
<button
onClick={() => onNavigate("flash")}
style={{
padding: "4px 12px",
background: "var(--accent)",
border: "none",
borderRadius: 4,
color: "#fff",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
}}
>
Flash
</button>
)}
</Td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from "react"; import React, { useEffect, useState, useRef, useCallback } from "react";
import { useServer } from "../hooks/useServer"; import { useServer } from "../hooks/useServer";
import type { SensingUpdate } from "../types"; import type { SensingUpdate, DataSource } from "../types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Log entry model // Log entry model
@ -176,11 +176,12 @@ function LogViewer({
paused: boolean; paused: boolean;
onTogglePause: () => void; onTogglePause: () => void;
}) { }) {
const bottomRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!paused && bottomRef.current) { // Scroll to bottom within the container only (not the page)
bottomRef.current.scrollIntoView({ behavior: "smooth" }); if (!paused && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
} }
}, [entries, paused]); }, [entries, paused]);
@ -254,6 +255,7 @@ function LogViewer({
{/* Log entries */} {/* Log entries */}
<div <div
ref={containerRef}
style={{ style={{
height: 320, height: 320,
overflowY: "auto", overflowY: "auto",
@ -286,7 +288,6 @@ function LogViewer({
</div> </div>
)) ))
)} )}
<div ref={bottomRef} />
</div> </div>
</div> </div>
); );
@ -301,6 +302,9 @@ export const Sensing: React.FC = () => {
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [stopping, setStopping] = useState(false); const [stopping, setStopping] = useState(false);
// Data source selection
const [dataSource, setDataSource] = useState<DataSource>("simulate");
// Log viewer state // Log viewer state
const [logEntries, setLogEntries] = useState<LogEntry[]>([]); const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
@ -430,7 +434,7 @@ export const Sensing: React.FC = () => {
const handleStart = async () => { const handleStart = async () => {
setStarting(true); setStarting(true);
try { try {
await start(); await start({ source: dataSource });
} finally { } finally {
setStarting(false); setStarting(false);
} }
@ -524,24 +528,61 @@ export const Sensing: React.FC = () => {
)} )}
</div> </div>
{/* Right: action button */} {/* Right: data source + action button */}
<button <div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
onClick={isRunning ? handleStop : handleStart} {/* Data source selector */}
disabled={starting || stopping} <div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
style={{ <label
padding: "var(--space-2) var(--space-4)", style={{
borderRadius: 6, fontSize: 12,
fontSize: 13, color: "var(--text-muted)",
fontWeight: 600, fontWeight: 500,
cursor: starting || stopping ? "not-allowed" : "pointer", }}
border: "none", >
background: isRunning ? "var(--status-error)" : "var(--accent)", Source:
color: "#fff", </label>
opacity: starting || stopping ? 0.6 : 1, <select
}} value={dataSource}
> onChange={(e) => setDataSource(e.target.value as DataSource)}
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"} disabled={isRunning}
</button> style={{
padding: "var(--space-1) var(--space-2)",
borderRadius: 4,
fontSize: 12,
fontWeight: 500,
border: "1px solid var(--border)",
background: isRunning ? "var(--bg-hover)" : "var(--bg-surface)",
color: "var(--text-primary)",
cursor: isRunning ? "not-allowed" : "pointer",
opacity: isRunning ? 0.6 : 1,
}}
>
<option value="simulate">Simulate</option>
<option value="esp32">ESP32 (Real)</option>
<option value="wifi">WiFi (RSSI)</option>
<option value="auto">Auto Detect</option>
</select>
</div>
{/* Action button */}
<button
onClick={isRunning ? handleStop : handleStart}
disabled={starting || stopping}
style={{
padding: "var(--space-2) var(--space-4)",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: starting || stopping ? "not-allowed" : "pointer",
border: "none",
background: isRunning ? "var(--status-error)" : "var(--accent)",
color: "#fff",
opacity: starting || stopping ? 0.6 : 1,
}}
>
{starting ? "Starting..." : stopping ? "Stopping..." : isRunning ? "Stop Server" : "Start Server"}
</button>
</div>
</div> </div>
{/* Error display */} {/* Error display */}

View File

@ -1,2 +1,2 @@
// Application version - single source of truth // Application version - single source of truth
export const APP_VERSION = "0.4.2"; export const APP_VERSION = "0.4.3";