1+ use std:: net:: { SocketAddr , UdpSocket } ;
2+ use std:: time:: Duration ;
3+
4+ use mdns_sd:: { ServiceDaemon , ServiceEvent } ;
15use 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]
23278pub 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