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

View File

@ -148,14 +148,15 @@ pub async fn start_server(
/// 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())?;
// Extract child process and take ownership for killing
let (child_id, mut child_process) = {
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
if !srv.running {
return Err("Server is not running".into());
}
srv.pid
let pid = srv.pid;
let child = srv.child.take(); // Take ownership of child
(pid, child)
};
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()),
};
// First try graceful termination
tracing::info!("Stopping sensing server with PID {}", child_id);
// First try graceful termination via SIGTERM
#[cfg(unix)]
{
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)
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;
// Wait briefly for graceful shutdown
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Force kill if still running - re-acquire lock
let still_running = match wait_result {
Ok(Ok(running)) => running,
_ => true,
// Check if still running
let still_running = {
let mut sys = System::new();
let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
};
{
let mut srv = state.server.lock().map_err(|e| e.to_string())?;
// Force kill if still running
if still_running {
tracing::warn!("Server still running after SIGTERM, sending SIGKILL");
if still_running {
if let Some(ref mut child) = srv.child {
let _ = child.kill();
let _ = child.wait();
#[cfg(unix)]
{
unsafe {
// SIGKILL the process group and main process
let _ = libc::kill(-(child_id as i32), libc::SIGKILL);
let _ = libc::kill(child_id as i32, libc::SIGKILL);
}
}
// 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.pid = None;
srv.http_port = None;
@ -211,6 +226,19 @@ pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
srv.child = None;
}
// Verify process is dead
let still_alive = {
let mut sys = System::new();
let pid = Pid::from_u32(child_id);
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).is_some()
};
if still_alive {
tracing::error!("Failed to kill server process {}", child_id);
return Err(format!("Failed to stop server process {}", child_id));
}
tracing::info!("Stopped sensing server");
Ok(())
@ -366,6 +394,7 @@ mod tests {
log_level: None,
bind_address: None,
server_path: None,
source: Some("simulate".to_string()),
};
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",
"productName": "RuView Desktop",
"version": "0.4.2",
"version": "0.4.3",
"identifier": "net.ruv.ruview",
"build": {
"frontendDist": "ui/dist",

View File

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

View File

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

View File

@ -3,6 +3,12 @@ import { invoke } from "@tauri-apps/api/core";
import { StatusBadge } from "../components/StatusBadge";
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 {
ip: string;
mac: string | null;
@ -34,7 +40,7 @@ interface SerialPortInfo {
type DiscoveryTab = "network" | "serial" | "manual";
const NetworkDiscovery: React.FC = () => {
const NetworkDiscovery: React.FC<NetworkDiscoveryProps> = ({ onNavigate }) => {
const [activeTab, setActiveTab] = useState<DiscoveryTab>("network");
const [nodes, setNodes] = useState<DiscoveredNode[]>([]);
const [serialPorts, setSerialPorts] = useState<SerialPortInfo[]>([]);
@ -112,16 +118,22 @@ const NetworkDiscovery: React.FC = () => {
}
}, [manualIp, manualMac]);
// Scan both network and serial ports on mount
useEffect(() => {
scanNetwork();
scanSerialPorts();
}, []);
// Also refresh serial ports when switching to that tab
useEffect(() => {
if (activeTab === "serial") {
scanSerialPorts();
}
}, [activeTab, scanSerialPorts]);
// Count ESP32-compatible serial ports
const esp32SerialCount = serialPorts.filter((p) => p.is_esp32_compatible).length;
const filteredNodes = nodes.filter((node) => {
if (filterOnline && node.health !== "online") return false;
if (searchQuery) {
@ -302,21 +314,61 @@ const NetworkDiscovery: React.FC = () => {
<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"}
{isScanning ? "Scanning for nodes..." : "No network nodes found"}
</div>
<div
style={{
fontSize: 13,
color: "var(--text-muted)",
maxWidth: 300,
maxWidth: 340,
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."}
: "Network discovery uses mDNS/UDP to find ESP32 devices running firmware on WiFi."}
</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
@ -384,6 +436,7 @@ const NetworkDiscovery: React.FC = () => {
<Th>Manufacturer</Th>
<Th>VID:PID</Th>
<Th>Compatible</Th>
<Th>Actions</Th>
</tr>
</thead>
<tbody>
@ -417,6 +470,25 @@ const NetworkDiscovery: React.FC = () => {
<span style={{ color: "var(--text-muted)" }}>--</span>
)}
</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>
))}
</tbody>

View File

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

View File

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