@@ -16,8 +16,6 @@ pub struct DiscoveryImpl;
1616impl Discovery for DiscoveryImpl {
1717 async fn discover ( ) -> io:: Result < Vec < DiscoveredIdentity > > {
1818 const ENIP_PORT : u16 = 44818 ;
19- const DISCOVERY_TIMEOUT : Duration = Duration :: from_secs ( 1 ) ;
20- const RECV_TIMEOUT : Duration = Duration :: from_millis ( 200 ) ;
2119
2220 let socket = UdpSocket :: bind ( "0.0.0.0:0" ) . await ?;
2321 socket. set_broadcast ( true ) ?;
@@ -39,92 +37,114 @@ impl Discovery for DiscoveryImpl {
3937 . send_to ( & msg, SocketAddr :: from ( ( [ 239 , 192 , 1 , 1 ] , ENIP_PORT ) ) )
4038 . await ;
4139
42- let mut results = Vec :: new ( ) ;
43- let mut buf = [ 0u8 ; 2048 ] ;
44- let start = Instant :: now ( ) ;
40+ discover_internal ( socket ) . await
41+ }
42+ }
4543
46- while start. elapsed ( ) < DISCOVERY_TIMEOUT {
47- if let Ok ( Ok ( ( len, addr) ) ) = timeout ( RECV_TIMEOUT , socket. recv_from ( & mut buf) ) . await {
48- if len < 24 {
49- continue ;
50- }
44+ impl DiscoveryImpl {
45+ /// Return the first discovered device, if any.
46+ pub async fn discover_one ( ) -> io:: Result < Option < DiscoveredIdentity > > {
47+ let all = Self :: discover ( ) . await ?;
48+ Ok ( all. into_iter ( ) . next ( ) )
49+ }
5150
52- // Parse encapsulation header
53- let hdr = match EncapsulationHeader :: from_bytes ( & buf [ .. 24 ] ) {
54- Some ( h ) => h ,
55- None => continue ,
56- } ;
51+ /// Test-only: run discovery using an injected socket.
52+ # [ cfg ( test ) ]
53+ pub async fn discover_with_socket ( socket : UdpSocket ) -> io :: Result < Vec < DiscoveredIdentity > > {
54+ discover_internal ( socket ) . await
55+ }
5756
58- if hdr. command != COMMAND_LIST_IDENTITY || hdr. status != 0 {
59- continue ;
60- }
57+ #[ cfg( test) ]
58+ pub async fn discover_one_with_socket (
59+ socket : UdpSocket ,
60+ ) -> io:: Result < Option < DiscoveredIdentity > > {
61+ let all = discover_internal ( socket) . await ?;
62+ Ok ( all. into_iter ( ) . next ( ) )
63+ }
64+ }
6165
62- let payload = & buf [ 24 ..len ] ;
63- if payload . len ( ) < 2 {
64- continue ;
65- }
66+ /// Shared discovery logic for production + tests.
67+ async fn discover_internal ( socket : UdpSocket ) -> io :: Result < Vec < DiscoveredIdentity > > {
68+ const DISCOVERY_TIMEOUT : Duration = Duration :: from_secs ( 1 ) ;
69+ const RECV_TIMEOUT : Duration = Duration :: from_millis ( 200 ) ;
6670
67- // CPF item count
68- let item_count = u16 :: from_le_bytes ( [ payload [ 0 ] , payload [ 1 ] ] ) as usize ;
69- let mut pos = 2 ;
71+ let mut results = Vec :: new ( ) ;
72+ let mut buf = [ 0u8 ; 2048 ] ;
73+ let start = Instant :: now ( ) ;
7074
71- for _ in 0 ..item_count {
72- if payload. len ( ) < pos + 4 {
73- break ;
74- }
75+ while start. elapsed ( ) < DISCOVERY_TIMEOUT {
76+ if let Ok ( Ok ( ( len, addr) ) ) = timeout ( RECV_TIMEOUT , socket. recv_from ( & mut buf) ) . await {
77+ if len < 24 {
78+ continue ;
79+ }
7580
76- let type_id = u16:: from_le_bytes ( [ payload[ pos] , payload[ pos + 1 ] ] ) ;
77- let item_len =
78- u16:: from_le_bytes ( [ payload[ pos + 2 ] , payload[ pos + 3 ] ] ) as usize ;
79- pos += 4 ;
81+ // Parse encapsulation header
82+ let hdr = match EncapsulationHeader :: from_bytes ( & buf[ ..24 ] ) {
83+ Some ( h) => h,
84+ None => continue ,
85+ } ;
8086
81- if payload . len ( ) < pos + item_len {
82- break ;
83- }
87+ if hdr . command != COMMAND_LIST_IDENTITY || hdr . status != 0 {
88+ continue ;
89+ }
8490
85- // Identity Item = 0x000C
86- if type_id == 0x000C {
87- if let Some ( info) = parse_discovery_identity (
88- & payload[ pos..pos + item_len] ,
89- addr. ip ( ) . to_string ( ) ,
90- ) {
91- results. push ( info) ;
92- }
93- }
91+ let payload = & buf[ 24 ..len] ;
92+ if payload. len ( ) < 2 {
93+ continue ;
94+ }
9495
95- pos += item_len;
96+ // CPF item count
97+ let item_count = u16:: from_le_bytes ( [ payload[ 0 ] , payload[ 1 ] ] ) as usize ;
98+ let mut pos = 2 ;
99+
100+ for _ in 0 ..item_count {
101+ if payload. len ( ) < pos + 4 {
102+ break ;
96103 }
104+
105+ let type_id = u16:: from_le_bytes ( [ payload[ pos] , payload[ pos + 1 ] ] ) ;
106+ let item_len = u16:: from_le_bytes ( [ payload[ pos + 2 ] , payload[ pos + 3 ] ] ) as usize ;
107+ pos += 4 ;
108+
109+ if payload. len ( ) < pos + item_len {
110+ break ;
111+ }
112+
113+ // Identity Item = 0x000C
114+ if type_id == 0x000C {
115+ if let Some ( info) = parse_discovery_identity (
116+ & payload[ pos..pos + item_len] ,
117+ addr. ip ( ) . to_string ( ) ,
118+ ) {
119+ results. push ( info) ;
120+ }
121+ }
122+
123+ pos += item_len;
97124 }
98125 }
99-
100- Ok ( results)
101126 }
102- }
103127
104- impl DiscoveryImpl {
105- /// Return the first discovered device, if any.
106- pub async fn discover_one ( ) -> io:: Result < Option < DiscoveredIdentity > > {
107- let all = Self :: discover ( ) . await ?;
108- Ok ( all. into_iter ( ) . next ( ) )
109- }
128+ results. dedup_by_key ( |d| ( d. ip . clone ( ) , d. serial ) ) ;
129+
130+ Ok ( results)
110131}
111132
112133/// Parse the Identity Item (0x000C) returned by ListIdentity (0x63).
113- ///
134+ // Identity Item layout (EtherNet/IP spec Vol 2, Table 2-4.4):
135+ // [0..1] encap protocol version
136+ // [2..9] socket address (sin_family, sin_port, sin_addr, padding)
137+ // [10..11] vendor ID
138+ // [12..13] device type
139+ // [14..15] product code
140+ // [16] revision major
141+ // [17] revision minor
142+ // [18..19] status
143+ // [20..23] serial number
144+ // [24] product name length
145+ // [25..] product name (UTF-8)
114146/// This is *not* the same as the Identity Object (Class 0x01).
115147fn parse_discovery_identity ( item : & [ u8 ] , ip : String ) -> Option < DiscoveredIdentity > {
116- // Minimum fields before product name:
117- // 0-1: Encapsulation Protocol Version
118- // 2-3: Socket Address Family
119- // 4-9: Socket Address (ignored)
120- // 10-11: Vendor ID
121- // 12-13: Device Type
122- // 14-15: Product Code
123- // 16: Revision Major
124- // 17: Revision Minor
125- // 18-19: Status
126- // 20-23: Serial Number
127- // 24: Product Name Length
128148 if item. len ( ) < 25 {
129149 return None ;
130150 }
@@ -156,3 +176,139 @@ fn parse_discovery_identity(item: &[u8], ip: String) -> Option<DiscoveredIdentit
156176 product_name,
157177 } )
158178}
179+
180+ #[ cfg( test) ]
181+ mod tests {
182+ use super :: * ;
183+ use tokio:: net:: UdpSocket ;
184+
185+ fn build_list_identity_packet ( name : & str , serial : u32 ) -> Vec < u8 > {
186+ let mut payload = Vec :: new ( ) ;
187+ payload. extend_from_slice ( & 1u16 . to_le_bytes ( ) ) ;
188+
189+ let mut identity = Vec :: new ( ) ;
190+ identity. extend_from_slice ( & 1u16 . to_le_bytes ( ) ) ;
191+ identity. extend_from_slice ( & 2u16 . to_le_bytes ( ) ) ;
192+ identity. extend_from_slice ( & [ 0 , 0 , 0 , 0 , 0 , 0 ] ) ;
193+ identity. extend_from_slice ( & 123u16 . to_le_bytes ( ) ) ;
194+ identity. extend_from_slice ( & 14u16 . to_le_bytes ( ) ) ;
195+ identity. extend_from_slice ( & 999u16 . to_le_bytes ( ) ) ;
196+ identity. push ( 3 ) ;
197+ identity. push ( 7 ) ;
198+ identity. extend_from_slice ( & 0x0044u16 . to_le_bytes ( ) ) ;
199+ identity. extend_from_slice ( & serial. to_le_bytes ( ) ) ; // <-- was hardcoded
200+ identity. push ( name. len ( ) as u8 ) ;
201+ identity. extend_from_slice ( name. as_bytes ( ) ) ;
202+
203+ payload. extend_from_slice ( & 0x000Cu16 . to_le_bytes ( ) ) ;
204+ payload. extend_from_slice ( & ( identity. len ( ) as u16 ) . to_le_bytes ( ) ) ;
205+ payload. extend_from_slice ( & identity) ;
206+
207+ let hdr =
208+ EncapsulationHeader :: new ( COMMAND_LIST_IDENTITY , payload. len ( ) as u16 , 0 ) . to_bytes ( ) ;
209+ let mut pkt = Vec :: new ( ) ;
210+ pkt. extend_from_slice ( & hdr) ;
211+ pkt. extend_from_slice ( & payload) ;
212+ pkt
213+ }
214+
215+ /// Returns (server, client).
216+ /// client is unconnected — recv_from will return the server's real addr.
217+ /// server is unconnected — must use send_to(src) to reply.
218+ async fn setup_socket_pair ( ) -> ( UdpSocket , SocketAddr , UdpSocket ) {
219+ let server = UdpSocket :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
220+ let client = UdpSocket :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
221+ let client_addr = client. local_addr ( ) . unwrap ( ) ;
222+ ( server, client_addr, client)
223+ }
224+
225+ #[ tokio:: test]
226+ async fn test_single_device_discovery ( ) {
227+ let ( server, client_addr, client) = setup_socket_pair ( ) . await ;
228+ tokio:: spawn ( async move {
229+ server
230+ . send_to (
231+ & build_list_identity_packet ( "TestPLC" , 0x11223344 ) ,
232+ client_addr,
233+ )
234+ . await
235+ . unwrap ( ) ;
236+ } ) ;
237+ let results = DiscoveryImpl :: discover_with_socket ( client) . await . unwrap ( ) ;
238+ assert_eq ! ( results. len( ) , 1 ) ;
239+ assert_eq ! ( results[ 0 ] . product_name, "TestPLC" ) ;
240+ }
241+
242+ #[ tokio:: test]
243+ async fn test_multiple_devices ( ) {
244+ let ( server, client_addr, client) = setup_socket_pair ( ) . await ;
245+ tokio:: spawn ( async move {
246+ server
247+ . send_to (
248+ & build_list_identity_packet ( "PLC_A" , 0x11223344 ) ,
249+ client_addr,
250+ )
251+ . await
252+ . unwrap ( ) ;
253+ server
254+ . send_to (
255+ & build_list_identity_packet ( "PLC_B" , 0x22334455 ) ,
256+ client_addr,
257+ )
258+ . await
259+ . unwrap ( ) ;
260+ } ) ;
261+ let results = DiscoveryImpl :: discover_with_socket ( client) . await . unwrap ( ) ;
262+ assert_eq ! ( results. len( ) , 2 ) ;
263+ }
264+
265+ #[ tokio:: test]
266+ async fn test_ignores_wrong_command ( ) {
267+ let ( server, client_addr, client) = setup_socket_pair ( ) . await ;
268+ tokio:: spawn ( async move {
269+ let mut pkt = build_list_identity_packet ( "Ignored" , 0x11223344 ) ;
270+ pkt[ 0 ] = 0xFF ;
271+ server. send_to ( & pkt, client_addr) . await . unwrap ( ) ;
272+ } ) ;
273+ let results = DiscoveryImpl :: discover_with_socket ( client) . await . unwrap ( ) ;
274+ assert ! ( results. is_empty( ) ) ;
275+ }
276+
277+ #[ tokio:: test]
278+ async fn test_ignores_malformed_identity_item ( ) {
279+ let ( server, client_addr, client) = setup_socket_pair ( ) . await ;
280+ tokio:: spawn ( async move {
281+ server. send_to ( & [ 1 , 2 , 3 ] , client_addr) . await . unwrap ( ) ;
282+ } ) ;
283+ let results = DiscoveryImpl :: discover_with_socket ( client) . await . unwrap ( ) ;
284+ assert ! ( results. is_empty( ) ) ;
285+ }
286+
287+ #[ tokio:: test]
288+ async fn test_discover_one ( ) {
289+ let ( server, client_addr, client) = setup_socket_pair ( ) . await ;
290+ tokio:: spawn ( async move {
291+ server
292+ . send_to (
293+ & build_list_identity_packet ( "OnePLC" , 0x11223344 ) ,
294+ client_addr,
295+ )
296+ . await
297+ . unwrap ( ) ;
298+ } ) ;
299+ let result = DiscoveryImpl :: discover_one_with_socket ( client)
300+ . await
301+ . unwrap ( ) ;
302+ assert ! ( result. is_some( ) ) ;
303+ assert_eq ! ( result. unwrap( ) . product_name, "OnePLC" ) ;
304+ }
305+
306+ #[ tokio:: test]
307+ async fn test_discover_one_empty ( ) {
308+ let ( _server, _client_addr, client) = setup_socket_pair ( ) . await ;
309+ let result = DiscoveryImpl :: discover_one_with_socket ( client)
310+ . await
311+ . unwrap ( ) ;
312+ assert ! ( result. is_none( ) ) ;
313+ }
314+ }
0 commit comments