Skip to main content

rust_ethernet_ip/
client.rs

1use crate::EtherNetIpStream;
2use crate::batch::{BatchConfig, BatchOperation};
3use crate::error::{EtherNetIpError, Result};
4use crate::protocol::cip::{
5    CipRequest, CipResponse, MULTIPLE_SERVICE_PACKET, READ_TAG, SendDataRequest, WRITE_TAG,
6};
7use crate::protocol::encap::{EncapsulationHeader, REGISTER_SESSION, UNREGISTER_SESSION};
8use crate::protocol::values;
9use crate::protocol::{Decode, Encode};
10use crate::route::RoutePath;
11use crate::subscription::TagSubscription;
12use crate::tag_group::TagGroupConfig;
13use crate::tag_manager::{TagManager, TagMetadata, TagPermissions, TagScope};
14use crate::types::{PlcValue, UdtData};
15use crate::udt::{TagAttributes, UdtDefinition, UdtManager};
16use crate::{TagPath, udt};
17use bytes::BytesMut;
18use std::collections::HashMap;
19use std::net::SocketAddr;
20#[cfg(feature = "ffi")]
21use std::sync::LazyLock;
22use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
23use std::sync::{Arc, Mutex as StdMutex};
24use std::time::{SystemTime, UNIX_EPOCH};
25use tokio::io::{AsyncReadExt, AsyncWriteExt};
26use tokio::net::TcpStream;
27#[cfg(feature = "ffi")]
28use tokio::runtime::Runtime;
29use tokio::sync::Mutex;
30use tokio::time::{Duration, Instant, timeout};
31
32mod actor;
33mod batch_exec;
34mod diagnostics;
35mod schema_export;
36mod service_layer;
37mod string;
38mod subscriptions;
39
40pub use actor::{Backoff, Client, ConnectionEvent, RetryClient, RetryPolicy};
41
42const READ_TAG_FRAGMENTED: u8 = 0x52;
43const WRITE_TAG_FRAGMENTED: u8 = 0x53;
44const READ_TAG_FRAGMENTED_REPLY: u8 = 0xD2;
45const WRITE_TAG_FRAGMENTED_REPLY: u8 = 0xD3;
46const CIP_STATUS_SUCCESS: u8 = 0x00;
47const CIP_STATUS_PARTIAL_TRANSFER: u8 = 0x06;
48
49#[derive(Debug)]
50struct TagListPage {
51    tags: Vec<TagAttributes>,
52    last_instance_id: Option<u32>,
53    partial_transfer: bool,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57struct TemplateAttributes {
58    structure_handle: u16,
59    member_count: u16,
60    definition_size_words: u32,
61    structure_size_bytes: u32,
62}
63
64#[derive(Debug, Clone, Copy)]
65enum DiagnosticOperation {
66    Read,
67    Write,
68    Batch,
69}
70
71#[derive(Debug, Default)]
72struct DiagnosticCounters {
73    total_reads: AtomicU64,
74    total_writes: AtomicU64,
75    successful_reads: AtomicU64,
76    successful_writes: AtomicU64,
77    failed_reads: AtomicU64,
78    failed_writes: AtomicU64,
79    batch_operations: AtomicU64,
80    partial_batch_failures: AtomicU64,
81    network_errors: AtomicU64,
82    protocol_errors: AtomicU64,
83    timeout_errors: AtomicU64,
84    tag_not_found_errors: AtomicU64,
85    data_type_errors: AtomicU64,
86    session_errors: AtomicU64,
87    route_path_errors: AtomicU64,
88    embedded_service_errors: AtomicU64,
89    known_controller_limitation_errors: AtomicU64,
90    retriable_errors: AtomicU64,
91    non_retriable_errors: AtomicU64,
92    last_successful_read_time: AtomicU64,
93    last_failed_read_time: AtomicU64,
94    last_successful_write_time: AtomicU64,
95    last_failed_write_time: AtomicU64,
96    last_error_time: AtomicU64,
97    last_error_category: AtomicU8,
98    schema_refreshes: AtomicU64,
99    array_cache_hits: AtomicU64,
100    array_cache_misses: AtomicU64,
101    array_cache_evictions: AtomicU64,
102    schema_type_contradictions: AtomicU64,
103    schema_read_recoveries_succeeded: AtomicU64,
104    schema_read_recoveries_failed: AtomicU64,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108struct ArrayTypeCacheEntry {
109    generation: u64,
110    is_packed_bool: bool,
111}
112
113impl DiagnosticCounters {
114    fn record_success(&self, operation: Option<DiagnosticOperation>) {
115        let now = current_unix_seconds();
116        match operation {
117            Some(DiagnosticOperation::Read) => {
118                self.total_reads.fetch_add(1, Ordering::Relaxed);
119                self.successful_reads.fetch_add(1, Ordering::Relaxed);
120                self.last_successful_read_time.store(now, Ordering::Relaxed);
121            }
122            Some(DiagnosticOperation::Write) => {
123                self.total_writes.fetch_add(1, Ordering::Relaxed);
124                self.successful_writes.fetch_add(1, Ordering::Relaxed);
125                self.last_successful_write_time
126                    .store(now, Ordering::Relaxed);
127            }
128            Some(DiagnosticOperation::Batch) => {
129                self.batch_operations.fetch_add(1, Ordering::Relaxed);
130            }
131            None => {}
132        }
133    }
134
135    fn record_cip_failure(&self, operation: Option<DiagnosticOperation>) {
136        let category = crate::ErrorCategory::CipProtocol;
137        self.record_operation_failure(operation, current_unix_seconds());
138        self.protocol_errors.fetch_add(1, Ordering::Relaxed);
139        self.non_retriable_errors.fetch_add(1, Ordering::Relaxed);
140        self.store_last_error(category);
141    }
142
143    fn record_failure(&self, operation: Option<DiagnosticOperation>, error: &EtherNetIpError) {
144        let now = current_unix_seconds();
145        let category = diagnostic_error_category(error);
146        self.record_operation_failure(operation, now);
147
148        match category {
149            crate::ErrorCategory::Network => self.network_errors.fetch_add(1, Ordering::Relaxed),
150            crate::ErrorCategory::Timeout => self.timeout_errors.fetch_add(1, Ordering::Relaxed),
151            crate::ErrorCategory::Session => self.session_errors.fetch_add(1, Ordering::Relaxed),
152            crate::ErrorCategory::RoutePath => {
153                self.route_path_errors.fetch_add(1, Ordering::Relaxed)
154            }
155            crate::ErrorCategory::CipProtocol => {
156                self.protocol_errors.fetch_add(1, Ordering::Relaxed)
157            }
158            crate::ErrorCategory::BatchEmbeddedService => {
159                self.embedded_service_errors.fetch_add(1, Ordering::Relaxed)
160            }
161            crate::ErrorCategory::KnownControllerLimitation => self
162                .known_controller_limitation_errors
163                .fetch_add(1, Ordering::Relaxed),
164            crate::ErrorCategory::DataType => self.data_type_errors.fetch_add(1, Ordering::Relaxed),
165            crate::ErrorCategory::NotFound => {
166                self.tag_not_found_errors.fetch_add(1, Ordering::Relaxed)
167            }
168            crate::ErrorCategory::Unknown => self.protocol_errors.fetch_add(1, Ordering::Relaxed),
169        };
170
171        if error.is_retriable() || category.is_retriable() {
172            self.retriable_errors.fetch_add(1, Ordering::Relaxed);
173        } else {
174            self.non_retriable_errors.fetch_add(1, Ordering::Relaxed);
175        }
176        self.store_last_error(category);
177    }
178
179    fn record_operation_failure(&self, operation: Option<DiagnosticOperation>, now: u64) {
180        match operation {
181            Some(DiagnosticOperation::Read) => {
182                self.total_reads.fetch_add(1, Ordering::Relaxed);
183                self.failed_reads.fetch_add(1, Ordering::Relaxed);
184                self.last_failed_read_time.store(now, Ordering::Relaxed);
185            }
186            Some(DiagnosticOperation::Write) => {
187                self.total_writes.fetch_add(1, Ordering::Relaxed);
188                self.failed_writes.fetch_add(1, Ordering::Relaxed);
189                self.last_failed_write_time.store(now, Ordering::Relaxed);
190            }
191            Some(DiagnosticOperation::Batch) => {
192                self.batch_operations.fetch_add(1, Ordering::Relaxed);
193                self.partial_batch_failures.fetch_add(1, Ordering::Relaxed);
194            }
195            None => {}
196        }
197    }
198
199    fn store_last_error(&self, category: crate::ErrorCategory) {
200        self.last_error_time
201            .store(current_unix_seconds(), Ordering::Relaxed);
202        self.last_error_category
203            .store(error_category_to_code(category), Ordering::Relaxed);
204    }
205
206    fn operation_metrics(&self) -> crate::OperationMetrics {
207        crate::OperationMetrics {
208            total_reads: self.total_reads.load(Ordering::Relaxed),
209            total_writes: self.total_writes.load(Ordering::Relaxed),
210            successful_reads: self.successful_reads.load(Ordering::Relaxed),
211            successful_writes: self.successful_writes.load(Ordering::Relaxed),
212            failed_reads: self.failed_reads.load(Ordering::Relaxed),
213            failed_writes: self.failed_writes.load(Ordering::Relaxed),
214            batch_operations: self.batch_operations.load(Ordering::Relaxed),
215            subscription_updates: 0,
216            partial_batch_failures: self.partial_batch_failures.load(Ordering::Relaxed),
217            last_successful_read_time: unix_seconds_to_system_time(
218                self.last_successful_read_time.load(Ordering::Relaxed),
219            ),
220            last_failed_read_time: unix_seconds_to_system_time(
221                self.last_failed_read_time.load(Ordering::Relaxed),
222            ),
223            last_successful_write_time: unix_seconds_to_system_time(
224                self.last_successful_write_time.load(Ordering::Relaxed),
225            ),
226            last_failed_write_time: unix_seconds_to_system_time(
227                self.last_failed_write_time.load(Ordering::Relaxed),
228            ),
229        }
230    }
231
232    fn error_metrics(&self) -> crate::ErrorMetrics {
233        let last_error_category =
234            error_category_from_code(self.last_error_category.load(Ordering::Relaxed));
235        crate::ErrorMetrics {
236            network_errors: self.network_errors.load(Ordering::Relaxed),
237            protocol_errors: self.protocol_errors.load(Ordering::Relaxed),
238            timeout_errors: self.timeout_errors.load(Ordering::Relaxed),
239            tag_not_found_errors: self.tag_not_found_errors.load(Ordering::Relaxed),
240            data_type_errors: self.data_type_errors.load(Ordering::Relaxed),
241            session_errors: self.session_errors.load(Ordering::Relaxed),
242            route_path_errors: self.route_path_errors.load(Ordering::Relaxed),
243            embedded_service_errors: self.embedded_service_errors.load(Ordering::Relaxed),
244            known_controller_limitation_errors: self
245                .known_controller_limitation_errors
246                .load(Ordering::Relaxed),
247            retriable_errors: self.retriable_errors.load(Ordering::Relaxed),
248            non_retriable_errors: self.non_retriable_errors.load(Ordering::Relaxed),
249            last_error_time: unix_seconds_to_system_time(
250                self.last_error_time.load(Ordering::Relaxed),
251            ),
252            last_error_message: last_error_category.map(|category| {
253                format!("Most recent counted client operation failed: {category:?}")
254            }),
255            last_error_category,
256            last_retriable_error_time: if last_error_category.is_some_and(|c| c.is_retriable()) {
257                unix_seconds_to_system_time(self.last_error_time.load(Ordering::Relaxed))
258            } else {
259                None
260            },
261        }
262    }
263}
264
265fn current_unix_seconds() -> u64 {
266    SystemTime::now()
267        .duration_since(UNIX_EPOCH)
268        .unwrap_or_default()
269        .as_secs()
270}
271
272fn unix_seconds_to_system_time(seconds: u64) -> Option<SystemTime> {
273    (seconds != 0).then(|| UNIX_EPOCH + Duration::from_secs(seconds))
274}
275
276fn error_category_to_code(category: crate::ErrorCategory) -> u8 {
277    match category {
278        crate::ErrorCategory::Network => 1,
279        crate::ErrorCategory::Timeout => 2,
280        crate::ErrorCategory::Session => 3,
281        crate::ErrorCategory::RoutePath => 4,
282        crate::ErrorCategory::CipProtocol => 5,
283        crate::ErrorCategory::BatchEmbeddedService => 6,
284        crate::ErrorCategory::KnownControllerLimitation => 7,
285        crate::ErrorCategory::DataType => 8,
286        crate::ErrorCategory::NotFound => 9,
287        crate::ErrorCategory::Unknown => 10,
288    }
289}
290
291fn error_category_from_code(code: u8) -> Option<crate::ErrorCategory> {
292    match code {
293        1 => Some(crate::ErrorCategory::Network),
294        2 => Some(crate::ErrorCategory::Timeout),
295        3 => Some(crate::ErrorCategory::Session),
296        4 => Some(crate::ErrorCategory::RoutePath),
297        5 => Some(crate::ErrorCategory::CipProtocol),
298        6 => Some(crate::ErrorCategory::BatchEmbeddedService),
299        7 => Some(crate::ErrorCategory::KnownControllerLimitation),
300        8 => Some(crate::ErrorCategory::DataType),
301        9 => Some(crate::ErrorCategory::NotFound),
302        10 => Some(crate::ErrorCategory::Unknown),
303        _ => None,
304    }
305}
306
307fn diagnostic_error_category(error: &EtherNetIpError) -> crate::ErrorCategory {
308    match error {
309        EtherNetIpError::Io(_) | EtherNetIpError::ConnectionLost(_) => {
310            crate::ErrorCategory::Network
311        }
312        EtherNetIpError::Timeout(_) => crate::ErrorCategory::Timeout,
313        EtherNetIpError::Connection(_) => crate::ErrorCategory::Session,
314        EtherNetIpError::TagNotFound(_) => crate::ErrorCategory::NotFound,
315        EtherNetIpError::DataTypeMismatch { .. }
316        | EtherNetIpError::StringTooLong { .. }
317        | EtherNetIpError::InvalidString { .. } => crate::ErrorCategory::DataType,
318        EtherNetIpError::ReadError { .. }
319        | EtherNetIpError::WriteError { .. }
320        | EtherNetIpError::CipError { .. } => crate::ErrorCategory::CipProtocol,
321        EtherNetIpError::Protocol(message)
322            if message.contains("route") || message.contains("Route") =>
323        {
324            crate::ErrorCategory::RoutePath
325        }
326        EtherNetIpError::Protocol(message)
327            if message.contains("Embedded service") || message.contains("Multiple Service") =>
328        {
329            crate::ErrorCategory::BatchEmbeddedService
330        }
331        EtherNetIpError::Protocol(_) | EtherNetIpError::InvalidResponse { .. } => {
332            crate::ErrorCategory::CipProtocol
333        }
334        EtherNetIpError::Udt(_)
335        | EtherNetIpError::Tag(_)
336        | EtherNetIpError::Permission(_)
337        | EtherNetIpError::Utf8(_)
338        | EtherNetIpError::Other(_)
339        | EtherNetIpError::Subscription(_)
340        | EtherNetIpError::Unsupported { .. } => crate::ErrorCategory::Unknown,
341    }
342}
343
344/// Global Tokio runtime for handling async operations in FFI context
345#[cfg(feature = "ffi")]
346pub(crate) static RUNTIME: LazyLock<std::io::Result<Runtime>> = LazyLock::new(Runtime::new);
347
348/// High-performance EtherNet/IP client for PLC communication
349///
350/// This struct provides the core functionality for communicating with Allen-Bradley
351/// PLCs using the EtherNet/IP protocol. It handles connection management, session
352/// registration, and tag operations.
353///
354/// # Thread Safety
355///
356/// The `EipClient` is **NOT** thread-safe. For multi-threaded applications:
357///
358/// ```rust,no_run
359/// use std::sync::Arc;
360/// use tokio::sync::Mutex;
361/// use rust_ethernet_ip::EipClient;
362///
363/// #[tokio::main]
364/// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
365///     // Create a thread-safe wrapper
366///     let client = Arc::new(Mutex::new(EipClient::connect("192.168.1.100:44818").await?));
367///
368///     // Use in multiple threads
369///     let client_clone = client.clone();
370///     tokio::spawn(async move {
371///         let mut client = client_clone.lock().await;
372///         let _ = client.read_tag("Tag1").await?;
373///         Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
374///     });
375///     Ok(())
376/// }
377/// ```
378///
379/// # Performance Characteristics
380///
381/// | Operation | Latency | Throughput | Memory |
382/// |-----------|---------|------------|---------|
383/// | Connect | 100-500ms | N/A | ~8KB |
384/// | Read Tag | 1-5ms | 1,500+ ops/sec | ~2KB |
385/// | Write Tag | 2-10ms | 600+ ops/sec | ~2KB |
386/// | Batch Read | 5-20ms | 2,000+ ops/sec | ~4KB |
387///
388/// # Known Limitations
389///
390/// The following nested write paths have controller-specific caveats:
391///
392/// ## UDT Array Element Member Writes
393///
394/// Historical tests classified direct writes to UDT array element members
395/// (e.g., `gTestUDT_Array[0].Member1_DINT`) as firmware-blocked `0x2107`
396/// cases. CODEX-AM and CODEX-AV disproved the blanket rule on a
397/// 5069-L330ERM fw38: scalar DINT/REAL/BOOL/INT member writes succeeded with
398/// corrected paths, verified read-back, preserved sibling members, and restored
399/// cleanly.
400///
401/// ## STRING Tags and STRING Members in UDTs
402///
403/// Standalone Logix `STRING` tags can be written directly with the standard structure
404/// encoding. Direct writes to `STRING` members within UDTs (e.g.,
405/// `gTestUDT.Member5_String`) are still rejected with `0xFF/0x2107` under the
406/// current member encoding on 5069-L330ERM fw38. Whether a member-specific direct
407/// encoding exists remains under CODEX-AO investigation. For those nested members,
408/// write the entire UDT structure instead of writing the member path directly.
409///
410/// **What works:**
411/// - ✅ Reading UDT array element members: `gTestUDT_Array[0].Member1_DINT` (read)
412/// - ✅ Writing entire UDT array elements: `gTestUDT_Array[0]` (write full UDT)
413/// - ✅ Writing UDT members (non-STRING): `gTestUDT.Member1_DINT` (write DINT/REAL/BOOL/INT members)
414/// - ✅ Writing scalar UDT array element members on 5069-L330ERM fw38 with corrected paths
415/// - ✅ Writing array elements: `gArray[5]` (write element of simple array)
416/// - ✅ Reading STRING tags: `gTest_STRING` (read)
417/// - ✅ Writing STRING tags: `gTest_STRING` (write standard top-level STRING)
418/// - ✅ Reading STRING members in UDTs: `gTestUDT.Member5_String` (read)
419///
420/// **What doesn't work:**
421/// - ❌ Writing STRING members in UDTs: `gTestUDT.Member5_String` (write) - must write entire UDT
422/// - ❌ Writing program-scoped STRING members: `Program:TestProgram.gTestUDT.Member5_String` (write) - must write entire UDT
423/// - ❌ Writing UDT array element STRING members under the current member encoding - must write entire UDT
424///
425/// **Conservative workaround:**
426/// For rejected nested STRING-member writes, read the entire UDT array
427/// element, modify the member in memory, then write the entire UDT array
428/// element back:
429///
430/// ```rust,no_run
431/// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
432/// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
433/// use rust_ethernet_ip::{PlcValue, UdtData};
434///
435/// // Read the entire UDT array element
436/// let udt_value = client.read_tag("gTestUDT_Array[0]").await?;
437/// if let PlcValue::Udt(mut udt_data) = udt_value {
438///     let udt_def = client.get_udt_definition("gTestUDT_Array").await?;
439///     // Convert UdtDefinition to UserDefinedType
440///     let mut user_def = rust_ethernet_ip::udt::UserDefinedType::new(udt_def.name.clone());
441///     for member in &udt_def.members {
442///         user_def.add_member(member.clone());
443///     }
444///     let mut members = udt_data.parse(&user_def)?;
445///     
446///     // Modify the member
447///     members.insert("Member1_DINT".to_string(), PlcValue::Dint(100));
448///     
449///     // Write the entire UDT array element back
450///     let modified_udt = UdtData::from_hash_map(&members, &user_def, udt_data.symbol_id)?;
451///     client.write_tag("gTestUDT_Array[0]", PlcValue::Udt(modified_udt)).await?;
452/// }
453/// # Ok(())
454/// # }
455/// ```
456///
457/// # Error Handling
458///
459/// All operations return `Result<T, EtherNetIpError>`. Common errors include:
460///
461/// ```rust,no_run
462/// use rust_ethernet_ip::{EipClient, EtherNetIpError};
463///
464/// #[tokio::main]
465/// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
466///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
467///     match client.read_tag("Tag1").await {
468///         Ok(value) => println!("Tag value: {:?}", value),
469///         Err(EtherNetIpError::Protocol(_)) => println!("Tag does not exist"),
470///         Err(EtherNetIpError::Connection(_)) => println!("Lost connection to PLC"),
471///         Err(EtherNetIpError::Timeout(_)) => println!("Operation timed out"),
472///         Err(e) => println!("Other error: {}", e),
473///     }
474///     Ok(())
475/// }
476/// ```
477///
478/// # Examples
479///
480/// Basic usage:
481/// ```rust,no_run
482/// use rust_ethernet_ip::{EipClient, PlcValue};
483///
484/// #[tokio::main]
485/// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
486///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
487///
488///     // Read a boolean tag
489///     let motor_running = client.read_tag("MotorRunning").await?;
490///
491///     // Write an integer tag
492///     client.write_tag("SetPoint", PlcValue::Dint(1500)).await?;
493///
494///     // Read multiple tags in sequence
495///     let tag1 = client.read_tag("Tag1").await?;
496///     let tag2 = client.read_tag("Tag2").await?;
497///     let tag3 = client.read_tag("Tag3").await?;
498///     Ok(())
499/// }
500/// ```
501///
502/// Advanced usage with error recovery:
503/// ```rust
504/// use rust_ethernet_ip::{EipClient, PlcValue, EtherNetIpError};
505/// use tokio::time::Duration;
506///
507/// async fn read_with_retry(client: &mut EipClient, tag: &str, retries: u32) -> Result<PlcValue, EtherNetIpError> {
508///     for attempt in 0..retries {
509///         match client.read_tag(tag).await {
510///             Ok(value) => return Ok(value),
511///             Err(EtherNetIpError::Connection(_)) => {
512///                 if attempt < retries - 1 {
513///                     tokio::time::sleep(Duration::from_secs(1)).await;
514///                     continue;
515///                 }
516///                 return Err(EtherNetIpError::Protocol("Max retries exceeded".to_string()));
517///             }
518///             Err(e) => return Err(e),
519///         }
520///     }
521///     Err(EtherNetIpError::Protocol("Max retries exceeded".to_string()))
522/// }
523/// ```
524#[derive(Clone)]
525pub struct EipClient {
526    /// SHARED ON CLONE: network communication state.
527    stream: Arc<Mutex<Box<dyn EtherNetIpStream>>>,
528    /// SHARED ON CLONE: one registered session handle belongs to the shared stream.
529    session_handle: Arc<AtomicU32>,
530    /// SHARED ON CLONE: fail-fast marker for a stream that may contain stale response bytes.
531    stream_poisoned: Arc<AtomicBool>,
532    /// SHARED ON CLONE: monotonic sender_context counter for SendRRData correlation.
533    sender_context_counter: Arc<AtomicU64>,
534    /// SHARED ON CLONE: operation/error counters surfaced by diagnostics snapshots.
535    diagnostic_counters: Arc<DiagnosticCounters>,
536    /// SHARED ON CLONE: tag discovery/cache state.
537    tag_manager: Arc<Mutex<TagManager>>,
538    /// SHARED ON CLONE: UDT discovery/cache state.
539    udt_manager: Arc<Mutex<UdtManager>>,
540    /// SHARED ON CLONE: monotonic controller-schema generation.
541    schema_generation: Arc<AtomicU64>,
542    /// SHARED ON CLONE: generation owning TagManager's current contents.
543    tag_manager_generation: Arc<AtomicU64>,
544    /// SHARED ON CLONE: generation owning UdtManager's current contents.
545    udt_manager_generation: Arc<AtomicU64>,
546    /// SHARED ON CLONE: generation-scoped packed-BOOL/non-BOOL classifications.
547    array_type_cache: Arc<StdMutex<HashMap<String, ArrayTypeCacheEntry>>>,
548    /// SHARED ON CLONE: route-path mutations must be visible through later registry lookups.
549    route_path: Arc<StdMutex<Option<RoutePath>>>,
550    /// SHARED ON CLONE: max packet size is cheap scalar state and may be configured through FFI.
551    max_packet_size: Arc<AtomicU32>,
552    /// SHARED ON CLONE: last activity timestamp.
553    last_activity: Arc<Mutex<Instant>>,
554    /// COPIED ON CLONE: persistent FFI config would require Arc/RwLock; current use is per-call only.
555    batch_config: BatchConfig,
556    /// SHARED ON CLONE: active tag subscriptions.
557    subscriptions: Arc<Mutex<Vec<TagSubscription>>>,
558    /// SHARED ON CLONE: registered tag-group polling definitions.
559    tag_groups: Arc<Mutex<HashMap<String, TagGroupConfig>>>,
560}
561
562#[cfg(test)]
563const _: fn() = || {
564    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
565    assert_send_sync_static::<EipClient>();
566};
567
568impl std::fmt::Debug for EipClient {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        f.debug_struct("EipClient")
571            .field("session_handle", &self.session_handle())
572            .field("stream_poisoned", &self.stream_poisoned())
573            .field("route_path", &self.route_path_snapshot())
574            .field("max_packet_size", &self.max_packet_size())
575            .field("batch_config", &self.batch_config)
576            .field("stream", &"<stream>")
577            .field("diagnostic_counters", &"<diagnostic_counters>")
578            .field("tag_manager", &"<tag_manager>")
579            .field("udt_manager", &"<udt_manager>")
580            .field("schema_generation", &self.schema_generation())
581            .field("array_type_cache", &"<array_type_cache>")
582            .field("subscriptions", &"<subscriptions>")
583            .field("tag_groups", &"<tag_groups>")
584            .finish()
585    }
586}
587
588/// Returns the bare program name behind a caller-supplied program identifier.
589///
590/// [`EipClient::discover_program_tags`] accepts both spellings of the same
591/// program -- `"Dashboard"` and the wire form `"Program:Dashboard"` -- so the
592/// prefix has to be stripped exactly once, in one place: the request path
593/// re-adds it, and [`udt::TagScope::Program`] holds the BARE name everywhere
594/// else in the crate (see `schema::SchemaScope`). Normalizing here keeps the
595/// two spellings from producing two different scopes for one program.
596fn program_scope_name(program_name: &str) -> &str {
597    program_name
598        .strip_prefix("Program:")
599        .unwrap_or(program_name)
600}
601
602impl EipClient {
603    /// Internal constructor that initializes an EipClient from any stream
604    /// that implements AsyncRead + AsyncWrite + Unpin + Send
605    async fn from_stream<S>(stream: S) -> Result<Self>
606    where
607        S: EtherNetIpStream + 'static,
608    {
609        let mut client = Self {
610            stream: Arc::new(Mutex::new(Box::new(stream))),
611            session_handle: Arc::new(AtomicU32::new(0)),
612            stream_poisoned: Arc::new(AtomicBool::new(false)),
613            sender_context_counter: Arc::new(AtomicU64::new(1)),
614            diagnostic_counters: Arc::new(DiagnosticCounters::default()),
615            tag_manager: Arc::new(Mutex::new(TagManager::new())),
616            udt_manager: Arc::new(Mutex::new(UdtManager::new())),
617            schema_generation: Arc::new(AtomicU64::new(1)),
618            tag_manager_generation: Arc::new(AtomicU64::new(1)),
619            udt_manager_generation: Arc::new(AtomicU64::new(1)),
620            array_type_cache: Arc::new(StdMutex::new(HashMap::new())),
621            route_path: Arc::new(StdMutex::new(None)),
622            max_packet_size: Arc::new(AtomicU32::new(4000)),
623            last_activity: Arc::new(Mutex::new(Instant::now())),
624            batch_config: BatchConfig::default(),
625            subscriptions: Arc::new(Mutex::new(Vec::new())),
626            tag_groups: Arc::new(Mutex::new(HashMap::new())),
627        };
628        client.register_session().await?;
629        client.negotiate_packet_size().await?;
630        Ok(client)
631    }
632
633    /// Connects directly to a controller.
634    ///
635    /// This is an alias for [`Self::connect`].
636    pub async fn new(addr: &str) -> Result<Self> {
637        let addr = addr
638            .parse::<SocketAddr>()
639            .map_err(|e| EtherNetIpError::Protocol(format!("Invalid address format: {e}")))?;
640        let stream = TcpStream::connect(addr).await?;
641        Self::from_stream(stream).await
642    }
643
644    /// Public async connect function for `EipClient`
645    pub async fn connect(addr: &str) -> Result<Self> {
646        Self::new(addr).await
647    }
648
649    #[cfg(test)]
650    fn new_unconnected_for_testing() -> Self {
651        let (stream, _peer) = tokio::io::duplex(64);
652        Self {
653            stream: Arc::new(Mutex::new(Box::new(stream))),
654            session_handle: Arc::new(AtomicU32::new(0)),
655            stream_poisoned: Arc::new(AtomicBool::new(false)),
656            sender_context_counter: Arc::new(AtomicU64::new(1)),
657            diagnostic_counters: Arc::new(DiagnosticCounters::default()),
658            tag_manager: Arc::new(Mutex::new(TagManager::new())),
659            udt_manager: Arc::new(Mutex::new(UdtManager::new())),
660            schema_generation: Arc::new(AtomicU64::new(1)),
661            tag_manager_generation: Arc::new(AtomicU64::new(1)),
662            udt_manager_generation: Arc::new(AtomicU64::new(1)),
663            array_type_cache: Arc::new(StdMutex::new(HashMap::new())),
664            route_path: Arc::new(StdMutex::new(None)),
665            max_packet_size: Arc::new(AtomicU32::new(4000)),
666            last_activity: Arc::new(Mutex::new(Instant::now())),
667            batch_config: BatchConfig::default(),
668            subscriptions: Arc::new(Mutex::new(Vec::new())),
669            tag_groups: Arc::new(Mutex::new(HashMap::new())),
670        }
671    }
672
673    /// Registers an EtherNet/IP session with the PLC
674    ///
675    /// This is an internal function that implements the EtherNet/IP session
676    /// registration protocol. It sends a Register Session command and
677    /// processes the response to extract the session handle.
678    ///
679    /// # Protocol Details
680    ///
681    /// The Register Session command consists of:
682    /// - EtherNet/IP Encapsulation Header (24 bytes)
683    /// - Registration Data (4 bytes: protocol version + options)
684    ///
685    /// The PLC responds with:
686    /// - Same header format with assigned session handle
687    /// - Status code indicating success/failure
688    ///
689    /// # Errors
690    ///
691    /// - Network timeout or disconnection
692    /// - Invalid response format
693    /// - PLC rejection (status code non-zero)
694    async fn register_session(&mut self) -> crate::error::Result<()> {
695        self.ensure_stream_usable()?;
696        tracing::debug!("Starting session registration...");
697        let mut packet = BytesMut::with_capacity(28);
698        EncapsulationHeader::new(REGISTER_SESSION, 4, 0).encode(&mut packet);
699        packet.extend_from_slice(&[0x01, 0x00]); // Protocol Version: 1
700        packet.extend_from_slice(&[0x00, 0x00]); // Option Flags: 0
701
702        tracing::trace!("Sending Register Session packet: {:02X?}", packet);
703        let mut stream = self.stream.lock().await;
704        self.ensure_stream_usable()?;
705        // Relaxed is sufficient: this flag is a scalar fail-fast marker shared
706        // by clones; the stream mutex serializes actual I/O.
707        self.stream_poisoned.store(true, Ordering::Relaxed);
708        if let Err(e) = stream.write_all(&packet).await {
709            tracing::error!("Failed to send Register Session packet: {}", e);
710            return Err(EtherNetIpError::Io(e));
711        }
712
713        let mut header_buf = [0u8; 24];
714        tracing::debug!("Waiting for Register Session response...");
715        match timeout(Duration::from_secs(5), stream.read_exact(&mut header_buf)).await {
716            Ok(Ok(_)) => {
717                tracing::trace!("Received Register Session response header");
718            }
719            Ok(Err(e)) => {
720                tracing::error!("Error reading response: {}", e);
721                return Err(EtherNetIpError::Io(e));
722            }
723            Err(_) => {
724                tracing::warn!("Timeout waiting for response");
725                return Err(EtherNetIpError::Timeout(Duration::from_secs(5)));
726            }
727        };
728
729        let mut header_bytes = &header_buf[..];
730        let header = EncapsulationHeader::decode(&mut header_bytes)?;
731        let mut body = vec![0u8; header.length as usize];
732        if !body.is_empty() {
733            match timeout(Duration::from_secs(5), stream.read_exact(&mut body)).await {
734                Ok(Ok(_)) => {}
735                Ok(Err(e)) => {
736                    tracing::error!("Error reading response body: {}", e);
737                    return Err(EtherNetIpError::Io(e));
738                }
739                Err(_) => {
740                    tracing::warn!("Timeout waiting for response body");
741                    return Err(EtherNetIpError::Timeout(Duration::from_secs(5)));
742                }
743            }
744        }
745
746        self.stream_poisoned.store(false, Ordering::Relaxed);
747
748        // Extract session handle from response
749        self.set_session_handle(header.session_handle);
750        tracing::debug!("Session handle: 0x{:08X}", self.session_handle());
751
752        // Check status
753        let status = header.status;
754        tracing::trace!("Status code: 0x{:08X}", status);
755
756        if status != 0 {
757            tracing::error!("Session registration failed with status: 0x{:08X}", status);
758            return Err(EtherNetIpError::Protocol(format!(
759                "Session registration failed with status: 0x{status:08X}"
760            )));
761        }
762
763        tracing::info!("Session registration successful");
764        Ok(())
765    }
766
767    /// Sets the maximum packet size for communication
768    pub fn set_max_packet_size(&mut self, size: u32) {
769        self.max_packet_size
770            .store(size.min(4000), Ordering::Relaxed);
771    }
772
773    pub(crate) fn max_packet_size(&self) -> u32 {
774        self.max_packet_size.load(Ordering::Relaxed)
775    }
776
777    pub(crate) fn session_handle(&self) -> u32 {
778        self.session_handle.load(Ordering::Relaxed)
779    }
780
781    fn set_session_handle(&self, session_handle: u32) {
782        self.session_handle.store(session_handle, Ordering::Relaxed);
783    }
784
785    fn stream_poisoned(&self) -> bool {
786        self.stream_poisoned.load(Ordering::Relaxed)
787    }
788
789    fn next_sender_context(&self) -> [u8; 8] {
790        self.sender_context_counter
791            .fetch_add(1, Ordering::Relaxed)
792            .to_le_bytes()
793    }
794
795    fn diagnostic_operation_for(cip_request: &[u8]) -> Option<DiagnosticOperation> {
796        match cip_request.first().copied() {
797            Some(READ_TAG | READ_TAG_FRAGMENTED) => Some(DiagnosticOperation::Read),
798            Some(WRITE_TAG | WRITE_TAG_FRAGMENTED) => Some(DiagnosticOperation::Write),
799            Some(MULTIPLE_SERVICE_PACKET) => Some(DiagnosticOperation::Batch),
800            _ => None,
801        }
802    }
803
804    fn ensure_stream_usable(&self) -> crate::error::Result<()> {
805        if self.stream_poisoned() {
806            return Err(EtherNetIpError::ConnectionLost(
807                "connection stream is poisoned after an incomplete transaction; reconnect required"
808                    .to_string(),
809            ));
810        }
811        Ok(())
812    }
813
814    fn route_path_snapshot(&self) -> Option<RoutePath> {
815        self.route_path
816            .lock()
817            .unwrap_or_else(|poisoned| poisoned.into_inner())
818            .clone()
819    }
820
821    /// Discovers all tags in the PLC (including hierarchical UDT members)
822    pub async fn discover_tags(&mut self) -> crate::error::Result<()> {
823        let generation = self.schema_generation();
824        let response = self
825            .send_cip_request(&self.build_list_tags_request())
826            .await?;
827
828        // Extract CIP data from response and check for errors
829        let cip_data = self.extract_cip_from_response(&response)?;
830
831        // Check for CIP errors before parsing
832        if let Err(e) = self.check_cip_error(&cip_data) {
833            return Err(crate::error::EtherNetIpError::Protocol(format!(
834                "Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
835                e
836            )));
837        }
838
839        let tags = {
840            let tag_manager = self.tag_manager.lock().await;
841            tag_manager.parse_tag_list(&cip_data)?
842        };
843
844        tracing::debug!("Initial tag discovery found {} tags", tags.len());
845
846        // Perform recursive drill-down discovery (similar to TypeScript implementation)
847        let hierarchical_tags = {
848            let tag_manager = self.tag_manager.lock().await;
849            let hierarchical_tags = tag_manager.drill_down_tags(&tags).await?;
850            drop(tag_manager);
851            hierarchical_tags
852        };
853
854        tracing::debug!(
855            "After drill-down: {} total tags discovered",
856            hierarchical_tags.len()
857        );
858
859        {
860            let tag_manager = self.tag_manager.lock().await;
861            if self.schema_generation() != generation {
862                tracing::debug!(
863                    "discarding tag discovery cache fill from schema generation {generation}"
864                );
865                return Ok(());
866            }
867            if self.tag_manager_generation.load(Ordering::Acquire) != generation {
868                tag_manager.clear_cache().await?;
869                tag_manager.clear_udt_cache();
870            }
871            let mut cache = tag_manager.cache.write()?;
872            for (name, metadata) in hierarchical_tags {
873                cache.insert(name, metadata);
874            }
875            self.tag_manager_generation
876                .store(generation, Ordering::Release);
877        }
878        Ok(())
879    }
880
881    /// Discovers UDT members for a specific structure
882    pub async fn discover_udt_members(
883        &mut self,
884        udt_name: &str,
885    ) -> crate::error::Result<Vec<(String, TagMetadata)>> {
886        let generation = self.schema_generation();
887        let definition = self.get_udt_definition(udt_name).await?;
888
889        // Cache the definition
890        {
891            let tag_manager = self.tag_manager.lock().await;
892            if self.schema_generation() == generation {
893                if self.tag_manager_generation.load(Ordering::Acquire) != generation {
894                    tag_manager.clear_cache().await?;
895                    tag_manager.clear_udt_cache();
896                }
897                let mut definitions = tag_manager.udt_definitions.write()?;
898                definitions.insert(udt_name.to_string(), definition.clone());
899                self.tag_manager_generation
900                    .store(generation, Ordering::Release);
901            }
902        }
903
904        // Create member metadata
905        let mut members = Vec::new();
906        for member in &definition.members {
907            let member_name = member.name.clone();
908            let full_name = format!("{}.{}", udt_name, member_name);
909
910            let metadata = TagMetadata {
911                data_type: member.data_type,
912                scope: TagScope::Controller,
913                permissions: TagPermissions {
914                    readable: true,
915                    writable: true,
916                },
917                is_array: false,
918                dimensions: Vec::new(),
919                last_access: std::time::Instant::now(),
920                size: member.size,
921                array_info: None,
922                last_updated: std::time::Instant::now(),
923            };
924
925            members.push((full_name, metadata));
926        }
927
928        Ok(members)
929    }
930
931    /// Gets cached UDT definition
932    pub async fn get_udt_definition_cached(&self, udt_name: &str) -> Option<UdtDefinition> {
933        if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
934            return None;
935        }
936        let tag_manager = self.tag_manager.lock().await;
937        tag_manager.get_udt_definition_cached(udt_name)
938    }
939
940    /// Lists all cached UDT definitions
941    pub async fn list_udt_definitions(&self) -> Vec<String> {
942        if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
943            return Vec::new();
944        }
945        let tag_manager = self.tag_manager.lock().await;
946        tag_manager.list_udt_definitions()
947    }
948
949    /// Discovers all tags with full attributes
950    /// This method queries the PLC for all available tags and their detailed attributes
951    pub async fn discover_tags_detailed(&mut self) -> crate::error::Result<Vec<TagAttributes>> {
952        let (tags, _) = self.discover_tags_detailed_internal(false).await?;
953        Ok(tags)
954    }
955
956    async fn discover_tags_detailed_internal(
957        &mut self,
958        best_effort: bool,
959    ) -> crate::error::Result<(Vec<TagAttributes>, Vec<String>)> {
960        let mut start_instance = 0u32;
961        let mut tags = Vec::new();
962        let mut warnings = Vec::new();
963
964        loop {
965            let request = self.build_tag_list_request_from_instance(start_instance)?;
966            let response = match self.send_cip_request(&request).await {
967                Ok(response) => response,
968                Err(err) if best_effort && !tags.is_empty() => {
969                    warnings.push(format!(
970                        "Tag discovery stopped early at instance {} after transport/protocol failure: {}",
971                        start_instance, err
972                    ));
973                    break;
974                }
975                Err(err) => return Err(err),
976            };
977            let cip_data = match self.extract_cip_from_response(&response) {
978                Ok(cip_data) => cip_data,
979                Err(err) if best_effort && !tags.is_empty() => {
980                    warnings.push(format!(
981                        "Tag discovery stopped early at instance {} after response extraction failure: {}",
982                        start_instance, err
983                    ));
984                    break;
985                }
986                Err(err) => return Err(err),
987            };
988            let page = match self.parse_tag_list_response_page(&cip_data, udt::TagScope::Controller)
989            {
990                Ok(page) => page,
991                Err(err) if best_effort && !tags.is_empty() => {
992                    warnings.push(format!(
993                        "Tag discovery stopped early at instance {} after page-parse failure: {}",
994                        start_instance, err
995                    ));
996                    break;
997                }
998                Err(err) => return Err(err),
999            };
1000
1001            tags.extend(page.tags);
1002
1003            if !page.partial_transfer {
1004                break;
1005            }
1006
1007            let Some(last_instance_id) = page.last_instance_id else {
1008                return Err(crate::error::EtherNetIpError::Protocol(
1009                    "Tag discovery returned Partial transfer without a last instance ID"
1010                        .to_string(),
1011                ));
1012            };
1013
1014            if last_instance_id == u32::MAX || last_instance_id < start_instance {
1015                return Err(crate::error::EtherNetIpError::Protocol(format!(
1016                    "Tag discovery pagination stalled at instance {}",
1017                    last_instance_id
1018                )));
1019            }
1020
1021            start_instance = last_instance_id.saturating_add(1);
1022        }
1023
1024        Ok((tags, warnings))
1025    }
1026
1027    /// Discovers program-scoped tags
1028    /// This method discovers tags within a specific program scope
1029    ///
1030    /// The Symbol Object enumeration is PAGED: a program holding more tags than
1031    /// fit in one reply answers with general status `0x06` (partial transfer)
1032    /// and the caller must resume from the last instance id returned. This loop
1033    /// mirrors `discover_tags_detailed_internal`; without it a program
1034    /// with many tags surfaced only as `CIP Error 0x06: Partial transfer` and
1035    /// none of its tags were reachable.
1036    pub async fn discover_program_tags(
1037        &mut self,
1038        program_name: &str,
1039    ) -> crate::error::Result<Vec<TagAttributes>> {
1040        let mut start_instance = 0u32;
1041        let mut tags = Vec::new();
1042        // The scope is the caller's knowledge: the Symbol Object reply carries
1043        // no scope field, so it must be threaded in from the request. The
1044        // callers' two accepted spellings ("Dashboard" and "Program:Dashboard")
1045        // designate the same program and must yield the same scope.
1046        let scope = udt::TagScope::Program(program_scope_name(program_name).to_string());
1047
1048        loop {
1049            let request = self.build_program_tag_list_request(program_name, start_instance)?;
1050            let response = self.send_cip_request(&request).await?;
1051            let cip_data = self.extract_cip_from_response(&response)?;
1052
1053            let page = self
1054                .parse_tag_list_response_page(&cip_data, scope.clone())
1055                .map_err(|e| {
1056                    crate::error::EtherNetIpError::Protocol(format!(
1057                        "Program tag discovery failed for '{}': {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
1058                        program_name, e
1059                    ))
1060                })?;
1061
1062            tags.extend(page.tags);
1063
1064            if !page.partial_transfer {
1065                break;
1066            }
1067
1068            let Some(last_instance_id) = page.last_instance_id else {
1069                return Err(crate::error::EtherNetIpError::Protocol(format!(
1070                    "Program tag discovery for '{}' returned Partial transfer without a last instance ID",
1071                    program_name
1072                )));
1073            };
1074
1075            if last_instance_id == u32::MAX || last_instance_id < start_instance {
1076                return Err(crate::error::EtherNetIpError::Protocol(format!(
1077                    "Program tag discovery for '{}' pagination stalled at instance {}",
1078                    program_name, last_instance_id
1079                )));
1080            }
1081
1082            start_instance = last_instance_id.saturating_add(1);
1083        }
1084
1085        Ok(tags)
1086    }
1087
1088    /// Lists all cached tag attributes
1089    pub async fn list_cached_tag_attributes(&self) -> Vec<String> {
1090        if self.udt_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
1091            return Vec::new();
1092        }
1093        self.udt_manager.lock().await.list_tag_attributes()
1094    }
1095
1096    /// Returns the shared controller-schema generation observed by all clones.
1097    pub fn schema_generation(&self) -> u64 {
1098        self.schema_generation.load(Ordering::Acquire)
1099    }
1100
1101    fn advance_schema_generation(&self) -> u64 {
1102        let generation = self.schema_generation.fetch_add(1, Ordering::AcqRel) + 1;
1103        self.tag_manager_generation.store(0, Ordering::Release);
1104        self.udt_manager_generation.store(0, Ordering::Release);
1105        self.clear_array_type_cache();
1106        generation
1107    }
1108
1109    /// Advances the shared schema generation and clears every schema-derived cache.
1110    ///
1111    /// Call this after an online tag replacement or controller project download,
1112    /// before rediscovery and before writes resume. Cloned clients observe the same
1113    /// generation. Results from operations that began in an older generation are
1114    /// not allowed to repopulate the refreshed caches.
1115    pub async fn refresh_schema(&self) -> u64 {
1116        let generation = self.advance_schema_generation();
1117        self.diagnostic_counters
1118            .schema_refreshes
1119            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1120
1121        {
1122            let tag_manager = self.tag_manager.lock().await;
1123            if let Err(error) = tag_manager.clear_cache().await {
1124                tracing::warn!("failed to clear tag metadata cache: {error}");
1125            }
1126            tag_manager.clear_udt_cache();
1127            self.tag_manager_generation
1128                .store(generation, Ordering::Release);
1129        }
1130
1131        {
1132            self.udt_manager.lock().await.clear_cache();
1133            self.udt_manager_generation
1134                .store(generation, Ordering::Release);
1135        }
1136
1137        generation
1138    }
1139
1140    /// Backward-compatible alias for [`Self::refresh_schema`].
1141    pub async fn clear_caches(&mut self) {
1142        self.refresh_schema().await;
1143    }
1144
1145    /// Creates a new client with a specific route path
1146    pub async fn with_route_path(addr: &str, route: RoutePath) -> crate::error::Result<Self> {
1147        let mut client = Self::new(addr).await?;
1148        client.set_route_path(route);
1149        Ok(client)
1150    }
1151
1152    /// Connect to a PLC using a custom stream
1153    ///
1154    /// This method allows you to provide your own stream implementation, enabling:
1155    /// - Wrapping streams for metrics/observability (bytes in/out)
1156    /// - Applying custom socket options (keepalive, timeouts, bind local address)
1157    /// - Reusing pre-established tunnels/connections
1158    /// - Using in-memory streams for deterministic testing
1159    ///
1160    /// # Arguments
1161    ///
1162    /// * `stream` - Any stream that implements `AsyncRead + AsyncWrite + Unpin + Send`
1163    ///
1164    /// # Example
1165    ///
1166    /// ```no_run
1167    /// use rust_ethernet_ip::EipClient;
1168    /// use std::io::Cursor;
1169    ///
1170    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1171    /// // Any AsyncRead + AsyncWrite + Unpin + Send stream can be injected.
1172    /// let stream = Cursor::new(Vec::<u8>::new());
1173    ///
1174    /// // Connect using the custom stream
1175    /// let client = EipClient::connect_with_stream(stream, None).await?;
1176    /// # Ok(())
1177    /// # }
1178    /// ```
1179    pub async fn connect_with_stream<S>(stream: S, route: Option<RoutePath>) -> Result<Self>
1180    where
1181        S: EtherNetIpStream + 'static,
1182    {
1183        let mut client = Self::from_stream(stream).await?;
1184        if let Some(route) = route {
1185            client.set_route_path(route);
1186        }
1187        Ok(client)
1188    }
1189
1190    /// Sets the route path for the client
1191    pub fn set_route_path(&mut self, route: RoutePath) {
1192        self.advance_schema_generation();
1193        *self
1194            .route_path
1195            .lock()
1196            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(route);
1197    }
1198
1199    /// Gets the current route path
1200    pub fn get_route_path(&self) -> Option<RoutePath> {
1201        self.route_path_snapshot()
1202    }
1203
1204    /// Removes the route path (uses direct connection)
1205    pub fn clear_route_path(&mut self) {
1206        self.advance_schema_generation();
1207        *self
1208            .route_path
1209            .lock()
1210            .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
1211    }
1212
1213    fn cached_array_is_packed_bool(&self, array_path: &str) -> Option<bool> {
1214        let generation = self.schema_generation();
1215        let mut cache = self
1216            .array_type_cache
1217            .lock()
1218            .unwrap_or_else(|poisoned| poisoned.into_inner());
1219        match cache.get(array_path).copied() {
1220            Some(entry) if entry.generation == generation => {
1221                self.diagnostic_counters
1222                    .array_cache_hits
1223                    .fetch_add(1, Ordering::Relaxed);
1224                Some(entry.is_packed_bool)
1225            }
1226            Some(_) => {
1227                cache.remove(array_path);
1228                self.diagnostic_counters
1229                    .array_cache_evictions
1230                    .fetch_add(1, Ordering::Relaxed);
1231                self.diagnostic_counters
1232                    .array_cache_misses
1233                    .fetch_add(1, Ordering::Relaxed);
1234                None
1235            }
1236            None => {
1237                self.diagnostic_counters
1238                    .array_cache_misses
1239                    .fetch_add(1, Ordering::Relaxed);
1240                None
1241            }
1242        }
1243    }
1244
1245    #[cfg(test)]
1246    fn cache_array_is_packed_bool(&self, array_path: &str, is_packed_bool: bool) {
1247        let generation = self.schema_generation();
1248        self.cache_array_is_packed_bool_at_generation(array_path, is_packed_bool, generation);
1249    }
1250
1251    fn cache_array_is_packed_bool_at_generation(
1252        &self,
1253        array_path: &str,
1254        is_packed_bool: bool,
1255        generation: u64,
1256    ) -> bool {
1257        let mut cache = self
1258            .array_type_cache
1259            .lock()
1260            .unwrap_or_else(|poisoned| poisoned.into_inner());
1261        if self.schema_generation() != generation {
1262            return false;
1263        }
1264        cache.insert(
1265            array_path.to_string(),
1266            ArrayTypeCacheEntry {
1267                generation,
1268                is_packed_bool,
1269            },
1270        );
1271        true
1272    }
1273
1274    fn clear_array_type_cache(&self) {
1275        let mut cache = self
1276            .array_type_cache
1277            .lock()
1278            .unwrap_or_else(|poisoned| poisoned.into_inner());
1279        let evicted = cache.len() as u64;
1280        cache.clear();
1281        self.diagnostic_counters
1282            .array_cache_evictions
1283            .fetch_add(evicted, Ordering::Relaxed);
1284    }
1285
1286    fn evict_array_type_cache_entry(&self, array_path: &str) -> bool {
1287        let removed = self
1288            .array_type_cache
1289            .lock()
1290            .unwrap_or_else(|poisoned| poisoned.into_inner())
1291            .remove(array_path)
1292            .is_some();
1293        if removed {
1294            self.diagnostic_counters
1295                .array_cache_evictions
1296                .fetch_add(1, Ordering::Relaxed);
1297        }
1298        removed
1299    }
1300
1301    fn array_path_for_schema_recovery(&self, tag_name: &str) -> Option<String> {
1302        self.parse_array_element_access(tag_name)
1303            .map(|(base, _)| base)
1304            .or_else(|| {
1305                self.parse_final_array_element_access(tag_name)
1306                    .map(|(base, _)| base)
1307            })
1308    }
1309
1310    fn is_schema_drift_read_error(error: &EtherNetIpError) -> bool {
1311        if matches!(error, EtherNetIpError::DataTypeMismatch { .. }) {
1312            return true;
1313        }
1314        let message = error.to_string().to_ascii_lowercase();
1315        [
1316            "cip error 0x04",
1317            "cip error 0x05",
1318            "cip error 0x16",
1319            "0x2107",
1320            "expected bool array dword",
1321        ]
1322        .iter()
1323        .any(|marker| message.contains(marker))
1324    }
1325
1326    /// Gets metadata for a tag
1327    pub async fn get_tag_metadata(&self, tag_name: &str) -> Option<TagMetadata> {
1328        if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
1329            return None;
1330        }
1331        let tag_manager = self.tag_manager.lock().await;
1332        match tag_manager.cache.read() {
1333            Ok(cache) => cache.get(tag_name).cloned(),
1334            Err(_) => {
1335                tracing::warn!("failed to read tag metadata cache: lock poisoned");
1336                None
1337            }
1338        }
1339    }
1340
1341    /// Reads a tag value from the PLC
1342    ///
1343    /// This function performs a CIP read request for the specified tag.
1344    /// The tag's data type is automatically determined from the PLC's response.
1345    ///
1346    /// **v0.6.0**: For UDT tags, this returns `PlcValue::Udt(UdtData)` with `symbol_id`
1347    /// and raw bytes. Use `UdtData::parse()` with a UDT definition to access members.
1348    ///
1349    /// # Arguments
1350    ///
1351    /// * `tag_name` - The name of the tag to read
1352    ///
1353    /// # Returns
1354    ///
1355    /// The tag's value as a `PlcValue` enum. For UDTs, this is `PlcValue::Udt(UdtData)`.
1356    ///
1357    /// # Examples
1358    ///
1359    /// ```rust,no_run
1360    /// use rust_ethernet_ip::{EipClient, PlcValue};
1361    ///
1362    /// #[tokio::main]
1363    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1364    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
1365    ///
1366    ///     // Read different data types
1367    ///     let bool_val = client.read_tag("MotorRunning").await?;
1368    ///     let int_val = client.read_tag("Counter").await?;
1369    ///     let real_val = client.read_tag("Temperature").await?;
1370    ///
1371    ///     // Read a UDT (v0.6.0: returns UdtData)
1372    ///     let udt_val = client.read_tag("MyUDT").await?;
1373    ///     if let PlcValue::Udt(udt_data) = udt_val {
1374    ///         let udt_def = client.get_udt_definition("MyUDT").await?;
1375    ///         // Convert UdtDefinition to UserDefinedType
1376    ///         let mut user_def = rust_ethernet_ip::udt::UserDefinedType::new(udt_def.name.clone());
1377    ///         for member in &udt_def.members {
1378    ///             user_def.add_member(member.clone());
1379    ///         }
1380    ///         let members = udt_data.parse(&user_def)?;
1381    ///         println!("UDT has {} members", members.len());
1382    ///     }
1383    ///
1384    ///     // Handle the result
1385    ///     match bool_val {
1386    ///         PlcValue::Bool(true) => println!("Motor is running"),
1387    ///         PlcValue::Bool(false) => println!("Motor is stopped"),
1388    ///         _ => println!("Unexpected data type"),
1389    ///     }
1390    ///     Ok(())
1391    /// }
1392    /// ```
1393    ///
1394    /// # Performance
1395    ///
1396    /// - Latency: 1-5ms typical
1397    /// - Throughput: 1,500+ ops/sec
1398    /// - Network: 1 request/response cycle
1399    ///
1400    /// # Error Handling
1401    ///
1402    /// Common errors:
1403    /// - `Protocol`: Tag doesn't exist or invalid format
1404    /// - `Connection`: Lost connection to PLC
1405    /// - `Timeout`: Operation timed out
1406    pub async fn read_tag(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1407        self.validate_session().await?;
1408
1409        let result = self.read_tag_once(tag_name).await;
1410        let Err(error) = result else {
1411            return result;
1412        };
1413        let Some(array_path) = self.array_path_for_schema_recovery(tag_name) else {
1414            return Err(error);
1415        };
1416        if !Self::is_schema_drift_read_error(&error) {
1417            return Err(error);
1418        }
1419
1420        self.evict_array_type_cache_entry(&array_path);
1421        self.diagnostic_counters
1422            .schema_type_contradictions
1423            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1424        tracing::warn!(
1425            "schema drift suspected for '{tag_name}'; evicted '{array_path}' classification and retrying read once"
1426        );
1427
1428        match self.read_tag_once(tag_name).await {
1429            Ok(value) => {
1430                self.diagnostic_counters
1431                    .schema_read_recoveries_succeeded
1432                    .fetch_add(1, Ordering::Relaxed);
1433                Ok(value)
1434            }
1435            Err(retry_error) => {
1436                self.diagnostic_counters
1437                    .schema_read_recoveries_failed
1438                    .fetch_add(1, Ordering::Relaxed);
1439                Err(retry_error)
1440            }
1441        }
1442    }
1443
1444    async fn read_tag_once(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1445        if let Some((base_path, bit_index)) = self.parse_bit_access(tag_name) {
1446            return self
1447                .read_bit_base_direct(&base_path, bit_index)
1448                .await
1449                .map(PlcValue::Bool);
1450        }
1451
1452        // Check if this is a simple array element access (e.g., "ArrayName[0]")
1453        // BUT NOT if it has member access after (e.g., "ArrayName[0].Member")
1454        // Complex paths like "gTestUDT_Array[0].Member1_DINT" should use TagPath::parse()
1455        if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
1456            // Only use workaround if there's no member access after the array brackets
1457            // Find the FIRST [ and ] pair to check for member access after it
1458            if let Some(bracket_start) = tag_name.find('[')
1459                && let Some(bracket_end_rel) = tag_name[bracket_start..].find(']')
1460            {
1461                let bracket_end_abs = bracket_start + bracket_end_rel;
1462                let after_bracket = &tag_name[bracket_end_abs + 1..];
1463                tracing::debug!(
1464                    "Array element detected for '{}': base='{}', index={}, after_bracket='{}'",
1465                    tag_name,
1466                    base_name,
1467                    index,
1468                    after_bracket
1469                );
1470                // If there's a dot after the bracket, it's a member access - use TagPath::parse() instead
1471                if !after_bracket.starts_with('.') {
1472                    tracing::debug!(
1473                        "Detected simple array element access: {}[{}], using workaround",
1474                        base_name,
1475                        index
1476                    );
1477                    return self.read_array_element_workaround(&base_name, index).await;
1478                } else {
1479                    tracing::debug!(
1480                        "Array element '{}[{}]' has member access after bracket ('{}'), using TagPath::parse()",
1481                        base_name,
1482                        index,
1483                        after_bracket
1484                    );
1485                }
1486            }
1487        }
1488
1489        // For complex paths (with member access, nested arrays, etc.), use TagPath::parse()
1490        // This handles paths like "gTestUDT_Array[0].Member1_DINT" correctly
1491        // Standard tag reading uses build_read_request which uses TagPath::parse()
1492        if let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
1493            && self.detect_bool_array_path(&parent_path).await?
1494        {
1495            return self
1496                .read_bool_array_element_workaround(&parent_path, index)
1497                .await;
1498        }
1499
1500        self.read_tag_direct(tag_name).await
1501    }
1502
1503    async fn read_tag_direct(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1504        let response = self
1505            .send_cip_request(&self.build_read_request(tag_name)?)
1506            .await?;
1507        let cip_data = self.extract_cip_from_response(&response)?;
1508        if cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1509            return self.read_tag_fragmented(tag_name).await;
1510        }
1511        if let Some((array_path, _)) = self.parse_final_array_element_access(tag_name)
1512            && self.cached_array_is_packed_bool(&array_path) == Some(false)
1513            && cip_data.len() >= 6
1514        {
1515            let returned_type = u16::from_le_bytes([cip_data[4], cip_data[5]]);
1516            if returned_type == values::BOOL_ARRAY_DWORD {
1517                return Err(EtherNetIpError::DataTypeMismatch {
1518                    expected: "ordinary array element".to_string(),
1519                    actual: "packed BOOL array DWORD".to_string(),
1520                });
1521            }
1522        }
1523        self.parse_cip_response(&cip_data)
1524    }
1525
1526    async fn read_tag_fragmented(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1527        let mut offset = 0u32;
1528        let mut reassembled = Vec::new();
1529
1530        loop {
1531            let request = self.build_read_fragmented_request(tag_name, 1, offset)?;
1532            let response = self.send_cip_request(&request).await?;
1533            let cip_data = self.extract_unconnected_data_item(&response)?;
1534            let (status, fragment) = self.parse_read_fragmented_response(&cip_data)?;
1535
1536            if fragment.is_empty() && status == CIP_STATUS_PARTIAL_TRANSFER {
1537                return Err(EtherNetIpError::Protocol(format!(
1538                    "Read Tag Fragmented for '{tag_name}' returned an empty partial fragment at offset {offset}"
1539                )));
1540            }
1541
1542            offset = offset
1543                .checked_add(fragment.len() as u32)
1544                .ok_or_else(|| EtherNetIpError::Protocol("fragment offset overflow".to_string()))?;
1545            reassembled.extend_from_slice(fragment);
1546
1547            if status == CIP_STATUS_SUCCESS {
1548                break;
1549            }
1550        }
1551
1552        self.decode_type_prefixed_value(&reassembled)
1553    }
1554
1555    /// Reads a single bit from a tag (e.g. a DINT used as a status word).
1556    ///
1557    /// Equivalent to `read_tag(&format!("{}.{}", tag_base, bit_index))` for bit paths.
1558    /// `bit_index` must be in 0..32 (Allen-Bradley DINT bits).
1559    ///
1560    /// # Example
1561    ///
1562    /// ```ignore
1563    /// let bit_5 = client.read_bit("StatusWord", 5).await?;
1564    /// ```
1565    pub async fn read_bit(&mut self, tag_base: &str, bit_index: u8) -> crate::error::Result<bool> {
1566        self.validate_session().await?;
1567        self.read_bit_base_direct(tag_base, bit_index).await
1568    }
1569
1570    async fn read_bit_base_direct(
1571        &mut self,
1572        tag_base: &str,
1573        bit_index: u8,
1574    ) -> crate::error::Result<bool> {
1575        if bit_index >= 32 {
1576            return Err(crate::error::EtherNetIpError::Protocol(
1577                "bit_index must be 0..32 for DINT bit access".to_string(),
1578            ));
1579        }
1580        // Logix has no wire-level bit segment for atomic tags, so read the
1581        // parent word and extract the bit client-side.
1582        match self.read_tag_direct(tag_base).await? {
1583            PlcValue::Bool(b) => Ok(b),
1584            PlcValue::Dint(n) => Ok((n >> bit_index) & 1 != 0),
1585            other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
1586                expected: "BOOL or DINT".to_string(),
1587                actual: format!("{:?}", other),
1588            }),
1589        }
1590    }
1591
1592    /// Writes a single bit to a tag (e.g. a DINT used as a control word).
1593    ///
1594    /// Equivalent to `write_tag(&format!("{}.{}", tag_base, bit_index), PlcValue::Bool(value))`.
1595    /// `bit_index` must be in 0..32.
1596    ///
1597    /// # Example
1598    ///
1599    /// ```ignore
1600    /// client.write_bit("ControlWord", 3, true).await?;
1601    /// ```
1602    pub async fn write_bit(
1603        &mut self,
1604        tag_base: &str,
1605        bit_index: u8,
1606        value: bool,
1607    ) -> crate::error::Result<()> {
1608        self.validate_session().await?;
1609        self.write_bit_base_direct(tag_base, bit_index, value).await
1610    }
1611
1612    async fn write_bit_base_direct(
1613        &mut self,
1614        tag_base: &str,
1615        bit_index: u8,
1616        value: bool,
1617    ) -> crate::error::Result<()> {
1618        if bit_index >= 32 {
1619            return Err(crate::error::EtherNetIpError::Protocol(
1620                "bit_index must be 0..32 for DINT bit access".to_string(),
1621            ));
1622        }
1623        // Logix cannot write a single bit of an atomic tag over CIP, so emulate
1624        // it with a read-modify-write of the parent word. Note: this is not
1625        // atomic across concurrent writers to the same word.
1626        match self.read_tag_direct(tag_base).await? {
1627            PlcValue::Dint(current) => {
1628                let mask = 1i32 << bit_index;
1629                let updated = if value {
1630                    current | mask
1631                } else {
1632                    current & !mask
1633                };
1634                self.write_tag_direct(tag_base, &PlcValue::Dint(updated))
1635                    .await
1636            }
1637            PlcValue::Bool(_) if bit_index == 0 => {
1638                self.write_tag_direct(tag_base, &PlcValue::Bool(value))
1639                    .await
1640            }
1641            other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
1642                expected: "DINT".to_string(),
1643                actual: format!("{:?}", other),
1644            }),
1645        }
1646    }
1647
1648    /// Parses array element access syntax (e.g., "ArrayName[0]") and returns (base_name, index)
1649    fn parse_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
1650        // Look for array bracket notation
1651        if let Some(bracket_pos) = tag_name.rfind('[')
1652            && let Some(close_bracket_pos) = tag_name.rfind(']')
1653            && close_bracket_pos > bracket_pos
1654        {
1655            let base_name = tag_name[..bracket_pos].to_string();
1656            let index_str = &tag_name[bracket_pos + 1..close_bracket_pos];
1657            if let Ok(index) = index_str.parse::<u32>()
1658                && !tag_name[..bracket_pos].contains('[')
1659            {
1660                // Make sure there are no more brackets after this (multi-dimensional arrays not supported yet)
1661                return Some((base_name, index));
1662            }
1663        }
1664        None
1665    }
1666
1667    fn has_member_suffix_after_first_array_index(&self, tag_name: &str) -> bool {
1668        if let Some(bracket_start) = tag_name.find('[')
1669            && let Some(bracket_end_rel) = tag_name[bracket_start..].find(']')
1670        {
1671            let bracket_end_abs = bracket_start + bracket_end_rel;
1672            return tag_name[bracket_end_abs + 1..].starts_with('.');
1673        }
1674
1675        false
1676    }
1677
1678    fn parse_bit_access(&self, tag_name: &str) -> Option<(String, u8)> {
1679        match TagPath::parse(tag_name).ok()? {
1680            TagPath::Bit {
1681                base_path,
1682                bit_index,
1683            } => Some((base_path.as_string(), bit_index)),
1684            _ => None,
1685        }
1686    }
1687
1688    fn parse_final_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
1689        match TagPath::parse(tag_name).ok()? {
1690            TagPath::Array { base_path, indices } if indices.len() == 1 => {
1691                Some((base_path.as_string(), indices[0]))
1692            }
1693            _ => None,
1694        }
1695    }
1696
1697    async fn detect_bool_array_path(&mut self, array_path: &str) -> crate::error::Result<bool> {
1698        if let Some(is_packed_bool) = self.cached_array_is_packed_bool(array_path) {
1699            return Ok(is_packed_bool);
1700        }
1701
1702        let generation = self.schema_generation();
1703
1704        let test_response = self
1705            .send_cip_request(&self.build_read_request_with_count(array_path, 1)?)
1706            .await?;
1707        let test_cip_data = self.extract_cip_from_response(&test_response)?;
1708
1709        // A large structure can legitimately report Partial Transfer during the
1710        // one-element probe; it is not a packed BOOL array. Other CIP failures
1711        // (including Symbol Not Found) must remain visible to the caller so a
1712        // recovery attempt does not issue a second, misleading element read.
1713        if test_cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1714            return Ok(false);
1715        }
1716        self.check_cip_error(&test_cip_data)?;
1717        if test_cip_data.len() < 6 {
1718            return Err(EtherNetIpError::Protocol(format!(
1719                "array type probe for '{array_path}' returned fewer than 6 CIP bytes"
1720            )));
1721        }
1722
1723        let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
1724        let is_packed_bool = test_data_type == values::BOOL_ARRAY_DWORD;
1725        self.cache_array_is_packed_bool_at_generation(array_path, is_packed_bool, generation);
1726        Ok(is_packed_bool)
1727    }
1728
1729    fn parse_bool_array_dword_response(&self, cip_data: &[u8]) -> crate::error::Result<u32> {
1730        let mut response_bytes = cip_data;
1731        let response = CipResponse::decode(&mut response_bytes)?;
1732        if response.status != 0 {
1733            return Err(EtherNetIpError::Protocol(format!(
1734                "CIP Error {} when reading BOOL array DWORD: {}",
1735                response.status,
1736                self.get_cip_error_message(response.status)
1737            )));
1738        }
1739
1740        if response.service != 0xCC {
1741            return Err(EtherNetIpError::Protocol(format!(
1742                "Unexpected service reply: 0x{:02X}",
1743                response.service
1744            )));
1745        }
1746
1747        if response.data.len() < 6 {
1748            return Err(EtherNetIpError::Protocol(
1749                "BOOL array response too short for data type and DWORD".to_string(),
1750            ));
1751        }
1752
1753        let data_type = u16::from_le_bytes([response.data[0], response.data[1]]);
1754        if data_type != values::BOOL_ARRAY_DWORD {
1755            return Err(EtherNetIpError::Protocol(format!(
1756                "Expected BOOL array DWORD data type 0x00D3, got 0x{data_type:04X}"
1757            )));
1758        }
1759
1760        let value_data = &response.data[2..];
1761
1762        if value_data.len() < 4 {
1763            return Err(EtherNetIpError::Protocol(format!(
1764                "BOOL array data too short: need 4 bytes (DWORD), got {} bytes",
1765                value_data.len()
1766            )));
1767        }
1768
1769        Ok(u32::from_le_bytes([
1770            value_data[0],
1771            value_data[1],
1772            value_data[2],
1773            value_data[3],
1774        ]))
1775    }
1776
1777    /// Reads a single array element using proper CIP element addressing
1778    ///
1779    /// This method uses element addressing (0x28/0x29/0x2A segments) in the Request Path
1780    /// to read directly from the specified array index, eliminating the need to read
1781    /// the entire array.
1782    ///
1783    /// Reference: 1756-PM020, Pages 603-611, 815-837 (Array Element Access Examples)
1784    ///
1785    /// # Arguments
1786    ///
1787    /// * `base_array_name` - Base name of the array (e.g., "MyArray" for "MyArray[5]")
1788    /// * `index` - Element index to read (0-based)
1789    async fn read_array_element_workaround(
1790        &mut self,
1791        base_array_name: &str,
1792        index: u32,
1793    ) -> crate::error::Result<PlcValue> {
1794        tracing::debug!(
1795            "Reading array element '{}[{}]' using element addressing",
1796            base_array_name,
1797            index
1798        );
1799
1800        // Classification is generation-stamped. Reuse it on steady-state reads,
1801        // then let a contradictory response trigger the caller's one-time recovery.
1802        if self.detect_bool_array_path(base_array_name).await? {
1803            return self
1804                .read_bool_array_element_workaround(base_array_name, index)
1805                .await;
1806        }
1807
1808        // Use element addressing to read directly from the specified index
1809        // Reference: 1756-PM020, Pages 815-837 (Reading Array Element - Full Message)
1810        let request = self.build_read_array_request(base_array_name, index, 1);
1811
1812        let response = self.send_cip_request(&request).await?;
1813        let cip_data = self.extract_cip_from_response(&response)?;
1814
1815        // A whole array element that is a large structure (e.g. a UDT bigger than one CIP
1816        // packet) comes back as Partial Transfer; reassemble it via the fragmented read path,
1817        // mirroring read_tag_direct.
1818        if cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1819            return self
1820                .read_tag_fragmented(&format!("{base_array_name}[{index}]"))
1821                .await;
1822        }
1823
1824        // Check for errors (including extended errors)
1825        self.check_cip_error(&cip_data)?;
1826
1827        if cip_data.len() >= 6
1828            && u16::from_le_bytes([cip_data[4], cip_data[5]]) == values::BOOL_ARRAY_DWORD
1829        {
1830            return Err(EtherNetIpError::DataTypeMismatch {
1831                expected: "ordinary array element".to_string(),
1832                actual: "packed BOOL array DWORD".to_string(),
1833            });
1834        }
1835
1836        // Parse response - should be consistent format now
1837        // Reference: 1756-PM020, Page 828-837 (Response format)
1838        self.parse_cip_response(&cip_data)
1839    }
1840
1841    /// Special workaround for BOOL arrays: reads DWORD and extracts the specific bit
1842    ///
1843    /// Reference: 1756-PM020, Page 797-811 (BOOL Array Access)
1844    async fn read_bool_array_element_workaround(
1845        &mut self,
1846        base_array_name: &str,
1847        index: u32,
1848    ) -> crate::error::Result<PlcValue> {
1849        tracing::debug!(
1850            "BOOL array detected - reading DWORD and extracting bit [{}]",
1851            index
1852        );
1853
1854        let dword_index = index / 32;
1855
1856        // Read just 1 element (the DWORD containing 32 BOOLs)
1857        // Reference: 1756-PM020, Page 797-811
1858        let response = self
1859            .send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
1860            .await?;
1861        let cip_data = self.extract_cip_from_response(&response)?;
1862        let dword_value = self.parse_bool_array_dword_response(&cip_data)?;
1863
1864        // Extract the specific bit
1865        // Each DWORD contains 32 BOOLs (bits 0-31)
1866        let bit_index = (index % 32) as u8;
1867        let bool_value = (dword_value >> bit_index) & 1 != 0;
1868
1869        Ok(PlcValue::Bool(bool_value))
1870    }
1871
1872    /// Helper function to read large arrays in chunks to avoid PLC response size limits
1873    ///
1874    /// This method uses element addressing to read specific ranges of array elements,
1875    /// allowing efficient reading of large arrays without reading from element 0 each time.
1876    ///
1877    /// Reference: 1756-PM020, Pages 276-315 (Read Tag Fragmented Service), 840-851 (Reading Multiple Array Elements)
1878    async fn read_array_in_chunks(
1879        &mut self,
1880        base_array_name: &str,
1881        data_type: u16,
1882        start_index: u32,
1883        target_element_count: u32,
1884    ) -> crate::error::Result<Vec<u8>> {
1885        // Determine element size and safe chunk size
1886        let element_size = match data_type {
1887            0x00C1 => 1, // BOOL
1888            0x00C2 => 1, // SINT
1889            0x00C3 => 2, // INT
1890            0x00C4 => 4, // DINT
1891            0x00C5 => 8, // LINT
1892            0x00C6 => 1, // USINT
1893            0x00C7 => 2, // UINT
1894            0x00C8 => 4, // UDINT
1895            0x00C9 => 8, // ULINT
1896            0x00CA => 4, // REAL
1897            0x00CB => 8, // LREAL
1898            _ => {
1899                return Err(EtherNetIpError::Protocol(format!(
1900                    "Unsupported array data type for chunked reading: 0x{:04X}",
1901                    data_type
1902                )));
1903            }
1904        };
1905
1906        // Read in chunks - use 8 elements per chunk for 4-byte types to stay under 38-byte limit
1907        // For smaller types, we can read more elements per chunk
1908        let elements_per_chunk = match element_size {
1909            1 => 30, // 1-byte types: 30 elements = 30 bytes + 8 header = 38 bytes
1910            2 => 15, // 2-byte types: 15 elements = 30 bytes + 8 header = 38 bytes
1911            4 => 8, // 4-byte types: 8 elements = 32 bytes + 8 header = 40 bytes (may truncate to 38)
1912            8 => 4, // 8-byte types: 4 elements = 32 bytes + 8 header = 40 bytes
1913            _ => 8,
1914        };
1915
1916        let end_index = start_index
1917            .checked_add(target_element_count)
1918            .ok_or_else(|| EtherNetIpError::Protocol("Array range overflow".to_string()))?;
1919
1920        let mut all_data = Vec::new();
1921        let mut next_chunk_start = start_index;
1922
1923        tracing::debug!(
1924            "Reading array '{}' in chunks: {} elements per chunk, target: {} elements",
1925            base_array_name,
1926            elements_per_chunk,
1927            target_element_count
1928        );
1929
1930        while next_chunk_start < end_index {
1931            // Use element addressing to read specific range starting from next_chunk_start
1932            // Reference: 1756-PM020, Pages 840-851 (Reading Multiple Array Elements)
1933            let chunk_end = (next_chunk_start + elements_per_chunk as u32).min(end_index);
1934            let chunk_size = (chunk_end - next_chunk_start) as u16;
1935
1936            tracing::trace!(
1937                "Reading chunk: elements {} to {} ({} elements) using element addressing",
1938                next_chunk_start,
1939                chunk_end - 1,
1940                chunk_size
1941            );
1942
1943            // Use element addressing to read this specific range
1944            // Reference: 1756-PM020, Pages 840-851 (Reading Multiple Array Elements)
1945            let response = self
1946                .send_cip_request(&self.build_read_array_request(
1947                    base_array_name,
1948                    next_chunk_start,
1949                    chunk_size,
1950                ))
1951                .await?;
1952            let cip_data = self.extract_cip_from_response(&response)?;
1953
1954            let mut response_bytes = cip_data.as_slice();
1955            let response = CipResponse::decode(&mut response_bytes)?;
1956            if response.status != 0 {
1957                let error_msg = self.get_cip_error_message(response.status);
1958                return Err(EtherNetIpError::Protocol(format!(
1959                    "CIP Error {} when reading chunk (elements {} to {}): {}",
1960                    response.status,
1961                    next_chunk_start,
1962                    chunk_end - 1,
1963                    error_msg
1964                )));
1965            }
1966
1967            if response.service != 0xCC {
1968                return Err(EtherNetIpError::Protocol(format!(
1969                    "Unexpected service reply in chunk: 0x{:02X} (expected 0xCC)",
1970                    response.service
1971                )));
1972            }
1973
1974            if response.data.len() < 2 {
1975                return Err(EtherNetIpError::Protocol(format!(
1976                    "Chunk response too short for data type: got {} bytes, expected at least 6",
1977                    cip_data.len()
1978                )));
1979            }
1980
1981            let chunk_data_type = u16::from_le_bytes([response.data[0], response.data[1]]);
1982            if chunk_data_type != data_type {
1983                return Err(EtherNetIpError::Protocol(format!(
1984                    "Data type mismatch in chunk: expected 0x{:04X}, got 0x{:04X}",
1985                    data_type, chunk_data_type
1986                )));
1987            }
1988
1989            // Parse response data - with element addressing, response contains the requested range
1990            // Reference: 1756-PM020, Page 828-837 (Response format)
1991            // A Logix Read Tag reply data body is [data_type u16][data...]; it does
1992            // not include an element-count field.
1993            let chunk_value_data = &response.data[2..];
1994            let chunk_complete_bytes = (chunk_value_data.len() / element_size) * element_size;
1995            let chunk_data = &chunk_value_data[..chunk_complete_bytes];
1996
1997            // With element addressing, the response directly contains the requested range
1998            // No need to extract a portion - use all the data we received
1999            if !chunk_data.is_empty() {
2000                all_data.extend_from_slice(chunk_data);
2001                let elements_received = chunk_data.len() / element_size;
2002                next_chunk_start += elements_received as u32;
2003
2004                tracing::trace!(
2005                    "Chunk read: {} elements ({} bytes) starting at index {}, total so far: {} elements",
2006                    elements_received,
2007                    chunk_data.len(),
2008                    next_chunk_start - elements_received as u32,
2009                    all_data.len() / element_size
2010                );
2011
2012                // Continue reading if we haven't reached our target yet
2013                if next_chunk_start >= end_index {
2014                    tracing::trace!(
2015                        "Reached target element count ({}), stopping chunked read",
2016                        target_element_count
2017                    );
2018                    break;
2019                }
2020            } else {
2021                // No data received, we're done
2022                break;
2023            }
2024        }
2025
2026        let final_element_count = all_data.len() / element_size;
2027        tracing::debug!(
2028            "Chunked read complete: {} total elements ({} bytes), target was {} elements",
2029            final_element_count,
2030            all_data.len(),
2031            target_element_count
2032        );
2033
2034        if final_element_count < target_element_count as usize {
2035            return Err(EtherNetIpError::Protocol(format!(
2036                "Incomplete array read: requested {} elements, received {}",
2037                target_element_count, final_element_count
2038            )));
2039        }
2040
2041        Ok(all_data)
2042    }
2043
2044    fn array_element_size(data_type: u16) -> Option<usize> {
2045        match data_type {
2046            0x00C1 => Some(1), // BOOL
2047            0x00C2 => Some(1), // SINT
2048            0x00C3 => Some(2), // INT
2049            0x00C4 => Some(4), // DINT
2050            0x00C5 => Some(8), // LINT
2051            0x00C6 => Some(1), // USINT
2052            0x00C7 => Some(2), // UINT
2053            0x00C8 => Some(4), // UDINT
2054            0x00C9 => Some(8), // ULINT
2055            0x00CA => Some(4), // REAL
2056            0x00CB => Some(8), // LREAL
2057            _ => None,
2058        }
2059    }
2060
2061    fn decode_array_bytes(
2062        &self,
2063        data_type: u16,
2064        bytes: &[u8],
2065    ) -> crate::error::Result<Vec<PlcValue>> {
2066        let Some(element_size) = Self::array_element_size(data_type) else {
2067            return Err(EtherNetIpError::Protocol(format!(
2068                "Unsupported data type for array decoding: 0x{:04X}",
2069                data_type
2070            )));
2071        };
2072
2073        if !bytes.len().is_multiple_of(element_size) {
2074            return Err(EtherNetIpError::Protocol(format!(
2075                "Array payload length {} is not aligned to element size {}",
2076                bytes.len(),
2077                element_size
2078            )));
2079        }
2080
2081        let mut values = Vec::with_capacity(bytes.len() / element_size);
2082        for chunk in bytes.chunks_exact(element_size) {
2083            values.push(values::decode_array_element(data_type, chunk)?);
2084        }
2085
2086        Ok(values)
2087    }
2088
2089    /// Read a range of elements from a basic-type PLC array.
2090    ///
2091    /// This method reads arrays in chunks under the hood to avoid PLC packet-size limits.
2092    /// It supports basic CIP scalar types:
2093    /// BOOL, SINT, INT, DINT, LINT, USINT, UINT, UDINT, ULINT, REAL, LREAL.
2094    ///
2095    /// # Arguments
2096    ///
2097    /// * `base_array_name` - Base array tag name without index (e.g., `"MyDintArray"`)
2098    /// * `start_index` - Starting element index
2099    /// * `element_count` - Number of elements to read
2100    ///
2101    /// # Returns
2102    ///
2103    /// A `Vec<PlcValue>` with one element per requested array entry.
2104    pub async fn read_array_range(
2105        &mut self,
2106        base_array_name: &str,
2107        start_index: u32,
2108        element_count: u32,
2109    ) -> crate::error::Result<Vec<PlcValue>> {
2110        if element_count == 0 {
2111            return Ok(Vec::new());
2112        }
2113
2114        let probe_response = self
2115            .send_cip_request(&self.build_read_array_request(base_array_name, start_index, 1))
2116            .await?;
2117        let probe_cip = self.extract_cip_from_response(&probe_response)?;
2118        self.check_cip_error(&probe_cip)?;
2119
2120        if probe_cip.len() < 6 {
2121            return Err(EtherNetIpError::Protocol(
2122                "Array probe response too short".to_string(),
2123            ));
2124        }
2125
2126        let data_type = u16::from_le_bytes([probe_cip[4], probe_cip[5]]);
2127        let raw = self
2128            .read_array_in_chunks(base_array_name, data_type, start_index, element_count)
2129            .await?;
2130        let values = self.decode_array_bytes(data_type, &raw)?;
2131
2132        if values.len() != element_count as usize {
2133            return Err(EtherNetIpError::Protocol(format!(
2134                "Array read count mismatch: requested {}, got {}",
2135                element_count,
2136                values.len()
2137            )));
2138        }
2139
2140        Ok(values)
2141    }
2142
2143    /// Writes to a single array element using direct element addressing
2144    ///
2145    /// This method uses element addressing (0x28/0x29/0x2A segments) in the Request Path
2146    /// to write directly to the specified array index, eliminating the need to read
2147    /// the entire array.
2148    ///
2149    /// Reference: 1756-PM020, Pages 855-867 (Writing to Array Element)
2150    ///
2151    /// # Arguments
2152    ///
2153    /// * `base_array_name` - Base name of the array (e.g., `"MyArray"` for `"MyArray[10]"`)
2154    /// * `index` - Element index to write (0-based)
2155    /// * `value` - The value to write
2156    async fn write_array_element_workaround(
2157        &mut self,
2158        base_array_name: &str,
2159        index: u32,
2160        value: PlcValue,
2161    ) -> crate::error::Result<()> {
2162        tracing::debug!(
2163            "Writing to array element '{}[{}]' using element addressing",
2164            base_array_name,
2165            index
2166        );
2167
2168        // First, detect if it's a BOOL array by reading with count=1
2169        let test_response = self
2170            .send_cip_request(&self.build_read_request_with_count(base_array_name, 1)?)
2171            .await?;
2172        let test_cip_data = self.extract_cip_from_response(&test_response)?;
2173
2174        // Check for errors in the test read response
2175        if test_cip_data.len() < 3 {
2176            return Err(EtherNetIpError::Protocol(
2177                "Test read response too short".to_string(),
2178            ));
2179        }
2180
2181        // Check for errors in test read (including extended errors)
2182        if let Err(e) = self.check_cip_error(&test_cip_data) {
2183            return Err(EtherNetIpError::Protocol(format!(
2184                "Cannot write to array element: Test read failed: {}",
2185                e
2186            )));
2187        }
2188
2189        // Check if we have enough data to determine the data type
2190        if test_cip_data.len() < 6 {
2191            return Err(EtherNetIpError::Protocol(
2192                "Test read response too short to determine data type".to_string(),
2193            ));
2194        }
2195
2196        let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
2197
2198        // If it's a BOOL array (0x00D3 = DWORD), handle it specially
2199        if test_data_type == 0x00D3 {
2200            return self
2201                .write_bool_array_element_workaround(base_array_name, index, value)
2202                .await;
2203        }
2204
2205        // Get the data type and convert value to bytes
2206        let data_type = test_data_type;
2207        let value_bytes = value.to_bytes();
2208
2209        // Use element addressing to write directly to the specified index
2210        // Reference: 1756-PM020, Pages 855-867
2211        let request = self.build_write_array_request_with_index(
2212            base_array_name,
2213            index,
2214            1, // Write 1 element
2215            data_type,
2216            &value_bytes,
2217        )?;
2218
2219        let response = self.send_cip_request(&request).await?;
2220        let cip_data = self.extract_cip_from_response(&response)?;
2221
2222        // Check for errors (including extended errors)
2223        self.check_cip_error(&cip_data)?;
2224
2225        tracing::info!("Array element write completed successfully");
2226        Ok(())
2227    }
2228
2229    /// Special workaround for BOOL arrays: reads DWORD, modifies bit, writes back.
2230    ///
2231    /// Note: This is a read-modify-write operation. Callers must ensure exclusive
2232    /// access to the client for the entire duration (the `&mut self` requirement
2233    /// provides this guarantee in safe Rust; FFI callers are protected by the global mutex).
2234    ///
2235    /// Reference: 1756-PM020, Page 797-811 (BOOL Array Access)
2236    async fn write_bool_array_element_workaround(
2237        &mut self,
2238        base_array_name: &str,
2239        index: u32,
2240        value: PlcValue,
2241    ) -> crate::error::Result<()> {
2242        tracing::debug!(
2243            "BOOL array element write - reading DWORD, modifying bit [{}], writing back",
2244            index
2245        );
2246
2247        let bool_value = match value {
2248            PlcValue::Bool(b) => b,
2249            _ => {
2250                return Err(EtherNetIpError::Protocol(
2251                    "Expected BOOL value for BOOL array element".to_string(),
2252                ));
2253            }
2254        };
2255        let dword_index = index / 32;
2256
2257        // Read the DWORD
2258        let response = self
2259            .send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
2260            .await?;
2261        let cip_data = self.extract_cip_from_response(&response)?;
2262
2263        // Modify the DWORD
2264        let original_dword_value = match self.parse_bool_array_dword_response(&cip_data) {
2265            Ok(value) => value,
2266            Err(error) if Self::is_schema_drift_read_error(&error) => {
2267                self.evict_array_type_cache_entry(base_array_name);
2268                self.diagnostic_counters
2269                    .schema_type_contradictions
2270                    .fetch_add(1, Ordering::Relaxed);
2271                if !self.detect_bool_array_path(base_array_name).await? {
2272                    let tag_name = format!("{base_array_name}[{index}]");
2273                    return self
2274                        .write_tag_direct(&tag_name, &PlcValue::Bool(bool_value))
2275                        .await;
2276                }
2277                let retry_response = self
2278                    .send_cip_request(&self.build_read_array_request(
2279                        base_array_name,
2280                        dword_index,
2281                        1,
2282                    ))
2283                    .await?;
2284                let retry_cip_data = self.extract_cip_from_response(&retry_response)?;
2285                self.parse_bool_array_dword_response(&retry_cip_data)?
2286            }
2287            Err(error) => return Err(error),
2288        };
2289        let mut dword_value = original_dword_value;
2290
2291        let bit_index = (index % 32) as u8;
2292        if bool_value {
2293            dword_value |= 1u32 << bit_index;
2294        } else {
2295            dword_value &= !(1u32 << bit_index);
2296        }
2297
2298        tracing::trace!(
2299            "Modified BOOL[{}] in DWORD: 0x{:08X} -> 0x{:08X} (bit {} = {})",
2300            index,
2301            original_dword_value,
2302            dword_value,
2303            bit_index,
2304            bool_value
2305        );
2306
2307        // Write the DWORD back
2308        let write_request = self.build_write_array_request_with_index(
2309            base_array_name,
2310            dword_index,
2311            1,
2312            values::BOOL_ARRAY_DWORD,
2313            &dword_value.to_le_bytes(),
2314        )?;
2315        let write_response = self.send_cip_request(&write_request).await?;
2316        let write_cip_data = self.extract_cip_from_response(&write_response)?;
2317
2318        // Check for errors (including extended errors)
2319        self.check_cip_error(&write_cip_data)?;
2320
2321        tracing::info!("BOOL array element write completed successfully");
2322        Ok(())
2323    }
2324
2325    /// Builds a CIP Write Tag Service request for array elements with element addressing
2326    ///
2327    /// This method uses proper CIP element addressing (0x28/0x29/0x2A segments) in the
2328    /// Request Path to write to specific array elements or ranges.
2329    ///
2330    /// Reference: 1756-PM020, Pages 603-611, 855-867 (Writing to Array Element)
2331    ///
2332    /// # Arguments
2333    ///
2334    /// * `base_array_name` - Base name of the array (e.g., `"MyArray"` for `"MyArray[10]"`)
2335    /// * `start_index` - Starting element index (0-based)
2336    /// * `element_count` - Number of elements to write
2337    /// * `data_type` - CIP data type code (e.g., 0x00C4 for DINT)
2338    /// * `data` - Raw bytes of the data to write
2339    ///
2340    /// # Example
2341    ///
2342    /// Writing value 0x12345678 to element 10 of array "MyArray":
2343    /// ```
2344    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2345    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
2346    /// let data = 0x12345678u32.to_le_bytes();
2347    /// let request = client.build_write_array_request_with_index(
2348    ///     "MyArray", 10, 1, 0x00C4, &data
2349    /// )?;
2350    /// # Ok(())
2351    /// # }
2352    /// ```
2353    #[cfg_attr(not(test), allow(dead_code))]
2354    pub fn build_write_array_request_with_index(
2355        &self,
2356        base_array_name: &str,
2357        start_index: u32,
2358        element_count: u16,
2359        data_type: u16,
2360        data: &[u8],
2361    ) -> crate::error::Result<Vec<u8>> {
2362        let mut cip_request = Vec::new();
2363
2364        // Service: Write Tag Service (0x4D)
2365        // Reference: 1756-PM020, Page 318
2366        cip_request.push(0x4D);
2367
2368        // Build base tag path (symbolic segment)
2369        // Reference: 1756-PM020, Page 894-909
2370        let mut full_path = self.build_base_tag_path(base_array_name);
2371
2372        // Add element addressing segment
2373        // Reference: 1756-PM020, Pages 603-611, 870-890
2374        full_path.extend_from_slice(&self.build_element_id_segment(start_index));
2375
2376        // Ensure path is word-aligned
2377        if !full_path.len().is_multiple_of(2) {
2378            full_path.push(0x00);
2379        }
2380
2381        // Path size (in words)
2382        let path_size = (full_path.len() / 2) as u8;
2383        cip_request.push(path_size);
2384        cip_request.extend_from_slice(&full_path);
2385
2386        // Request Data: Data type, element count, and data
2387        // Reference: 1756-PM020, Page 855-867 (Writing to Array Element - Full Message)
2388        cip_request.extend_from_slice(&data_type.to_le_bytes());
2389        cip_request.extend_from_slice(&element_count.to_le_bytes());
2390        cip_request.extend_from_slice(data);
2391
2392        Ok(cip_request)
2393    }
2394
2395    /// Reads a UDT through the maintained normal tag-read path.
2396    ///
2397    /// **v0.6.0**: Returns `PlcValue::Udt(UdtData)` with `symbol_id` and raw bytes.
2398    /// Use `UdtData::parse()` with a UDT definition to access individual members.
2399    ///
2400    /// The historical "advanced chunked" strategy ladder was retired in 1.2.0
2401    /// because its alternate request builders were protocol-invalid and could
2402    /// convert failures into fabricated empty UDT payloads. This compatibility
2403    /// method now delegates to `read_tag` and returns an error if the target is
2404    /// not decoded as a UDT. Correct fragmented UDT reads belong to the
2405    /// capture-gated CODEX-AO wire-format work.
2406    ///
2407    /// # Arguments
2408    ///
2409    /// * `tag_name` - The name of the UDT tag to read
2410    ///
2411    /// # Returns
2412    ///
2413    /// `PlcValue::Udt(UdtData)` containing the symbol_id and raw data bytes
2414    ///
2415    /// # Example
2416    ///
2417    /// ```no_run
2418    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2419    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
2420    /// let udt_value = client.read_udt_chunked("Part_Data").await?;
2421    /// if let rust_ethernet_ip::PlcValue::Udt(udt_data) = udt_value {
2422    ///     println!("UDT symbol_id: {}, data size: {} bytes", udt_data.symbol_id, udt_data.data.len());
2423    ///     // Parse members if needed
2424    ///     let udt_def = client.get_udt_definition("Part_Data").await?;
2425    ///     // Convert UdtDefinition to UserDefinedType
2426    ///     let mut user_def = rust_ethernet_ip::udt::UserDefinedType::new(udt_def.name.clone());
2427    ///     for member in &udt_def.members {
2428    ///         user_def.add_member(member.clone());
2429    ///     }
2430    ///     let members = udt_data.parse(&user_def)?;
2431    /// }
2432    /// # Ok(())
2433    /// # }
2434    /// ```
2435    pub async fn read_udt_chunked(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
2436        self.validate_session().await?;
2437
2438        match self.read_tag(tag_name).await? {
2439            value @ PlcValue::Udt(_) => Ok(value),
2440            other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
2441                expected: "UDT".to_string(),
2442                actual: format!("{other:?}"),
2443            }),
2444        }
2445    }
2446
2447    /// Reads a specific UDT member by offset
2448    ///
2449    /// This method reads a specific member of a UDT by calculating its offset
2450    /// and reading only that portion of the UDT.
2451    ///
2452    /// # Arguments
2453    ///
2454    /// * `udt_name` - The name of the UDT tag
2455    /// * `member_offset` - The byte offset of the member in the UDT
2456    /// * `member_size` - The size of the member in bytes
2457    /// * `data_type` - The data type of the member (0x00C1 for BOOL, 0x00CA for REAL, etc.)
2458    ///
2459    /// # Example
2460    ///
2461    /// ```no_run
2462    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2463    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
2464    /// let member_value = client.read_udt_member_by_offset("MyUDT", 0, 1, 0x00C1).await?;
2465    /// println!("Member value: {:?}", member_value);
2466    /// # Ok(())
2467    /// # }
2468    /// ```
2469    #[deprecated(
2470        since = "1.2.0",
2471        note = "offset-based UDT member access indexed the CIP envelope, not the UDT payload; use read_udt_chunked + UdtData::parse or direct member tag reads; removal planned for 2.0"
2472    )]
2473    pub async fn read_udt_member_by_offset(
2474        &mut self,
2475        _udt_name: &str,
2476        _member_offset: usize,
2477        _member_size: usize,
2478        _data_type: u16,
2479    ) -> crate::error::Result<PlcValue> {
2480        Err(crate::error::EtherNetIpError::Unsupported {
2481            api: "read_udt_member_by_offset",
2482            reason: "this API indexed the full CIP reply envelope instead of the UDT payload; use read_udt_chunked + UdtData::parse or direct member tag reads instead; removal is planned for 2.0",
2483        })
2484    }
2485
2486    /// Writes a specific UDT member by offset
2487    ///
2488    /// This method writes a specific member of a UDT by calculating its offset
2489    /// and writing only that portion of the UDT.
2490    ///
2491    /// # Arguments
2492    ///
2493    /// * `udt_name` - The name of the UDT tag
2494    /// * `member_offset` - The byte offset of the member in the UDT
2495    /// * `member_size` - The size of the member in bytes
2496    /// * `data_type` - The data type of the member (0x00C1 for BOOL, 0x00CA for REAL, etc.)
2497    /// * `value` - The value to write
2498    ///
2499    /// # Example
2500    ///
2501    /// ```no_run
2502    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2503    /// # use rust_ethernet_ip::PlcValue;
2504    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
2505    /// client.write_udt_member_by_offset("MyUDT", 0, 1, 0x00C1, PlcValue::Bool(true)).await?;
2506    /// # Ok(())
2507    /// # }
2508    /// ```
2509    #[deprecated(
2510        since = "1.2.0",
2511        note = "offset-based UDT member writes round-tripped CIP envelope bytes as tag data; use write_udt_member/write_udt_array_member or direct member tag writes; removal planned for 2.0"
2512    )]
2513    pub async fn write_udt_member_by_offset(
2514        &mut self,
2515        _udt_name: &str,
2516        _member_offset: usize,
2517        _member_size: usize,
2518        _data_type: u16,
2519        _value: PlcValue,
2520    ) -> crate::error::Result<()> {
2521        Err(crate::error::EtherNetIpError::Unsupported {
2522            api: "write_udt_member_by_offset",
2523            reason: "this API read the full CIP reply envelope and wrote mutated envelope bytes back to the PLC; use write_udt_member/write_udt_array_member or direct member tag writes instead; removal is planned for 2.0",
2524        })
2525    }
2526
2527    /// Gets UDT definition from the PLC
2528    /// This method queries the PLC for the UDT structure and caches it for future use
2529    pub async fn get_udt_definition(
2530        &mut self,
2531        udt_name: &str,
2532    ) -> crate::error::Result<UdtDefinition> {
2533        // Check cache first
2534        if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2535            && let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name)
2536        {
2537            return Ok(cached.clone());
2538        }
2539
2540        // Get tag attributes to find template ID
2541        let attributes = self.get_tag_attributes(udt_name).await?;
2542
2543        // If this is not a UDT, return error
2544        if attributes.data_type != 0x00A0 {
2545            return Err(crate::error::EtherNetIpError::Protocol(format!(
2546                "Tag '{}' is not a UDT (type: {})",
2547                udt_name, attributes.data_type_name
2548            )));
2549        }
2550
2551        // Get template instance ID
2552        let template_id = attributes.template_instance_id.ok_or_else(|| {
2553            crate::error::EtherNetIpError::Protocol(
2554                "UDT template instance ID not found".to_string(),
2555            )
2556        })?;
2557
2558        let (definition, _structure_size_bytes) = self
2559            .load_udt_definition_from_template(template_id, udt_name)
2560            .await?;
2561
2562        Ok(definition)
2563    }
2564
2565    async fn get_udt_definition_by_template_id(
2566        &mut self,
2567        template_id: u32,
2568        udt_name: &str,
2569    ) -> crate::error::Result<(UdtDefinition, u32)> {
2570        if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2571            && let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name)
2572        {
2573            return Ok((cached.clone(), 0));
2574        }
2575
2576        self.load_udt_definition_from_template(template_id, udt_name)
2577            .await
2578    }
2579
2580    async fn load_udt_definition_from_template(
2581        &mut self,
2582        template_id: u32,
2583        udt_name: &str,
2584    ) -> crate::error::Result<(UdtDefinition, u32)> {
2585        let generation = self.schema_generation();
2586        let (template_attributes, template_data) = self.read_udt_template(template_id).await?;
2587        let template = self.udt_manager.lock().await.parse_udt_template(
2588            template_id,
2589            template_attributes.member_count,
2590            template_attributes.structure_size_bytes,
2591            &template_data,
2592        )?;
2593
2594        let definition = UdtDefinition {
2595            name: udt_name.to_string(),
2596            members: template.members,
2597        };
2598
2599        {
2600            let mut udt_manager = self.udt_manager.lock().await;
2601            if self.schema_generation() == generation {
2602                if self.udt_manager_generation.load(Ordering::Acquire) != generation {
2603                    udt_manager.clear_cache();
2604                }
2605                udt_manager.add_definition(definition.clone());
2606                self.udt_manager_generation
2607                    .store(generation, Ordering::Release);
2608            }
2609        }
2610
2611        Ok((definition, template_attributes.structure_size_bytes))
2612    }
2613
2614    /// Gets tag attributes (type, size, dimensions, scope) from the PLC.
2615    ///
2616    /// Use this to introspect a tag before reading or writing: discover data type,
2617    /// size in bytes, array dimensions, and scope (controller vs program). Results
2618    /// are cached per tag for the lifetime of the client.
2619    ///
2620    /// # Example
2621    ///
2622    /// ```ignore
2623    /// let attrs = client.get_tag_attributes("MyTag").await?;
2624    /// println!("Type: {}, size: {} bytes", attrs.data_type_name, attrs.size);
2625    /// if !attrs.dimensions.is_empty() {
2626    ///     println!("Array dimensions: {:?}", attrs.dimensions);
2627    /// }
2628    /// ```
2629    ///
2630    pub async fn get_tag_attributes(
2631        &mut self,
2632        tag_name: &str,
2633    ) -> crate::error::Result<TagAttributes> {
2634        // Check cache first
2635        if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2636            && let Some(cached) = self.udt_manager.lock().await.get_tag_attributes(tag_name)
2637        {
2638            return Ok(cached.clone());
2639        }
2640
2641        let generation = self.schema_generation();
2642
2643        let attributes = match self.get_tag_attributes_direct(tag_name).await {
2644            Ok(attributes) => attributes,
2645            Err(direct_error) => {
2646                // Some controllers reject the per-tag symbolic Get Attribute
2647                // List request outright with CIP 0x04 (Path segment error),
2648                // for every tag shape (bare, array element, program-scoped,
2649                // UDT and non-UDT alike) — observed on a real ControlLogix
2650                // 1756-L75 through a 1756-EN2T route; see CODEX-BJ. Fall
2651                // back to the bulk Symbol-Object discovery sweep, which uses
2652                // a different CIP service and is proven to work there.
2653                // Known gap: the discovery-sourced `TagAttributes.size` is
2654                // always 0 (discovery doesn't report it), so callers that
2655                // need an accurate byte size should not rely on this
2656                // fallback path for that field.
2657                tracing::warn!(
2658                    "get_tag_attributes('{tag_name}') direct request failed ({direct_error}); falling back to discovery"
2659                );
2660                self.get_tag_attributes_via_discovery(tag_name)
2661                    .await
2662                    .map_err(|discovery_error| {
2663                        crate::error::EtherNetIpError::Protocol(format!(
2664                            "{direct_error}; discovery fallback also failed: {discovery_error}"
2665                        ))
2666                    })?
2667            }
2668        };
2669
2670        // Cache the attributes
2671        {
2672            let mut udt_manager = self.udt_manager.lock().await;
2673            if self.schema_generation() == generation {
2674                if self.udt_manager_generation.load(Ordering::Acquire) != generation {
2675                    udt_manager.clear_cache();
2676                }
2677                udt_manager.add_tag_attributes(attributes.clone());
2678                self.udt_manager_generation
2679                    .store(generation, Ordering::Release);
2680            }
2681        }
2682
2683        Ok(attributes)
2684    }
2685
2686    /// Sends the per-tag Get Attribute List (Service 0x03) request and
2687    /// parses the reply, with no fallback. Split out of
2688    /// [`get_tag_attributes`](Self::get_tag_attributes) so the fallback
2689    /// path can retry through a different CIP mechanism on failure.
2690    async fn get_tag_attributes_direct(
2691        &mut self,
2692        tag_name: &str,
2693    ) -> crate::error::Result<TagAttributes> {
2694        let request = self.build_get_attributes_request(tag_name)?;
2695        let response = self.send_cip_request(&request).await?;
2696        let cip_data = self.extract_cip_from_response(&response)?;
2697        self.parse_attributes_response(tag_name, &cip_data)
2698    }
2699
2700    /// Recovers tag attributes via the bulk Symbol-Object discovery sweep
2701    /// instead of the per-tag Get Attribute List request. Used as a
2702    /// fallback when the direct request fails outright (see
2703    /// [`get_tag_attributes`](Self::get_tag_attributes)).
2704    async fn get_tag_attributes_via_discovery(
2705        &mut self,
2706        tag_name: &str,
2707    ) -> crate::error::Result<TagAttributes> {
2708        let path = TagPath::parse(tag_name).map_err(|error| {
2709            crate::error::EtherNetIpError::Protocol(error.message().to_string())
2710        })?;
2711        let base_name = path.base_tag_name();
2712
2713        let candidates = if let Some(program) = path.program_name() {
2714            self.discover_program_tags(&program).await?
2715        } else {
2716            self.discover_tags_detailed().await?
2717        };
2718
2719        candidates
2720            .into_iter()
2721            .find(|candidate| candidate.name == base_name)
2722            .ok_or_else(|| {
2723                crate::error::EtherNetIpError::Protocol(format!(
2724                    "'{base_name}' not found via discovery"
2725                ))
2726            })
2727    }
2728
2729    /// Reads UDT template data from the PLC
2730    async fn read_udt_template(
2731        &mut self,
2732        template_id: u32,
2733    ) -> crate::error::Result<(TemplateAttributes, Vec<u8>)> {
2734        let template_attributes = self.get_template_attributes(template_id).await?;
2735        let read_size = template_attributes
2736            .definition_size_words
2737            .checked_mul(4)
2738            .and_then(|bytes| bytes.checked_sub(23))
2739            .ok_or_else(|| {
2740                crate::error::EtherNetIpError::Protocol(format!(
2741                    "Template {} reported invalid definition size {} words",
2742                    template_id, template_attributes.definition_size_words
2743                ))
2744            })?;
2745
2746        let mut template_data = Vec::with_capacity(read_size as usize);
2747        let mut offset = 0u32;
2748
2749        while offset < read_size {
2750            let chunk_size = (read_size - offset).min(200);
2751            let request = self.build_read_template_request(template_id, offset, chunk_size)?;
2752            let response = self.send_cip_request(&request).await?;
2753            let cip_data = self.extract_cip_from_response(&response)?;
2754            let (chunk, partial_transfer) = self.parse_template_response_chunk(&cip_data)?;
2755
2756            if chunk.is_empty() {
2757                return Err(crate::error::EtherNetIpError::Protocol(format!(
2758                    "Template {} returned an empty chunk at offset {}",
2759                    template_id, offset
2760                )));
2761            }
2762
2763            offset = offset.saturating_add(chunk.len() as u32);
2764            template_data.extend_from_slice(&chunk);
2765
2766            if !partial_transfer && chunk.len() < chunk_size as usize {
2767                break;
2768            }
2769        }
2770
2771        Ok((template_attributes, template_data))
2772    }
2773
2774    async fn get_template_attributes(
2775        &mut self,
2776        template_id: u32,
2777    ) -> crate::error::Result<TemplateAttributes> {
2778        let request = self.build_get_template_attributes_request(template_id)?;
2779        let response = self.send_cip_request(&request).await?;
2780        let cip_data = self.extract_cip_from_response(&response)?;
2781        self.parse_template_attributes_response(template_id, &cip_data)
2782    }
2783
2784    /// Builds CIP request for Get Attribute List (Service 0x03)
2785    fn build_get_attributes_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
2786        let path = self.build_tag_path(tag_name);
2787        let request_data = vec![
2788            0x02, 0x00, // attribute count
2789            0x01, 0x00, // data type
2790            0x02, 0x00, // template/symbol instance id
2791        ];
2792        let request = CipRequest::new(0x03, path, request_data);
2793        let mut encoded = BytesMut::new();
2794        request.encode(&mut encoded)?;
2795        Ok(encoded.to_vec())
2796    }
2797
2798    fn build_get_template_attributes_request(
2799        &self,
2800        template_id: u32,
2801    ) -> crate::error::Result<Vec<u8>> {
2802        let mut request = Vec::new();
2803        let template_id = u16::try_from(template_id).map_err(|_| {
2804            crate::error::EtherNetIpError::Protocol(format!(
2805                "Template instance {} exceeds 16-bit path encoding",
2806                template_id
2807            ))
2808        })?;
2809
2810        request.push(0x03);
2811        request.push(0x03);
2812        request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
2813        request.extend_from_slice(&template_id.to_le_bytes());
2814        request.extend_from_slice(&[0x04, 0x00]);
2815        request.extend_from_slice(&[0x01, 0x00]);
2816        request.extend_from_slice(&[0x02, 0x00]);
2817        request.extend_from_slice(&[0x04, 0x00]);
2818        request.extend_from_slice(&[0x05, 0x00]);
2819
2820        Ok(request)
2821    }
2822
2823    /// Builds CIP request for Template Read (Service 0x4C)
2824    fn build_read_template_request(
2825        &self,
2826        template_id: u32,
2827        read_offset: u32,
2828        read_size: u32,
2829    ) -> crate::error::Result<Vec<u8>> {
2830        let mut request = Vec::new();
2831        let template_id = u16::try_from(template_id).map_err(|_| {
2832            crate::error::EtherNetIpError::Protocol(format!(
2833                "Template instance {} exceeds 16-bit path encoding",
2834                template_id
2835            ))
2836        })?;
2837        let read_size = u16::try_from(read_size).map_err(|_| {
2838            crate::error::EtherNetIpError::Protocol(format!(
2839                "Template read size {} exceeds 16-bit service limit",
2840                read_size
2841            ))
2842        })?;
2843
2844        request.push(0x4C);
2845        request.push(0x03);
2846        request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
2847        request.extend_from_slice(&template_id.to_le_bytes());
2848        request.extend_from_slice(&read_offset.to_le_bytes());
2849        request.extend_from_slice(&read_size.to_le_bytes());
2850
2851        Ok(request)
2852    }
2853
2854    /// Parses attributes response from CIP
2855    fn parse_attributes_response(
2856        &self,
2857        tag_name: &str,
2858        response: &[u8],
2859    ) -> crate::error::Result<TagAttributes> {
2860        let mut response_bytes = response;
2861        let response = CipResponse::decode(&mut response_bytes)?;
2862        if response.service != 0x83 {
2863            return Err(crate::error::EtherNetIpError::Protocol(format!(
2864                "Unexpected Get Attribute List reply service: 0x{:02X}",
2865                response.service
2866            )));
2867        }
2868
2869        if response.status != 0 {
2870            return Err(crate::error::EtherNetIpError::Protocol(format!(
2871                "Get Attribute List for '{}' failed: {}",
2872                tag_name,
2873                self.get_cip_error_message(response.status)
2874            )));
2875        }
2876
2877        if response.data.len() < 2 {
2878            return Err(crate::error::EtherNetIpError::Protocol(
2879                "Attributes response missing attribute count".to_string(),
2880            ));
2881        }
2882
2883        let attr_count = u16::from_le_bytes([response.data[0], response.data[1]]) as usize;
2884        let mut offset = 2;
2885        let mut data_type = None;
2886        let mut template_instance_id = None;
2887        let mut attr_errors = Vec::new();
2888
2889        for _ in 0..attr_count {
2890            if response.data.len() < offset + 4 {
2891                return Err(crate::error::EtherNetIpError::Protocol(
2892                    "Attributes response truncated before attribute record header".to_string(),
2893                ));
2894            }
2895
2896            let attr_id = u16::from_le_bytes([response.data[offset], response.data[offset + 1]]);
2897            let attr_status =
2898                u16::from_le_bytes([response.data[offset + 2], response.data[offset + 3]]);
2899            offset += 4;
2900
2901            if attr_status != 0 {
2902                attr_errors.push(format!("attr {attr_id} status 0x{attr_status:04X}"));
2903                continue;
2904            }
2905
2906            match attr_id {
2907                0x0001 => {
2908                    if response.data.len() < offset + 2 {
2909                        return Err(crate::error::EtherNetIpError::Protocol(
2910                            "Attributes response truncated in data type value".to_string(),
2911                        ));
2912                    }
2913                    data_type = Some(u16::from_le_bytes([
2914                        response.data[offset],
2915                        response.data[offset + 1],
2916                    ]));
2917                    offset += 2;
2918                }
2919                0x0002 => {
2920                    if response.data.len() < offset + 4 {
2921                        return Err(crate::error::EtherNetIpError::Protocol(
2922                            "Attributes response truncated in instance id value".to_string(),
2923                        ));
2924                    }
2925                    template_instance_id = Some(u32::from_le_bytes([
2926                        response.data[offset],
2927                        response.data[offset + 1],
2928                        response.data[offset + 2],
2929                        response.data[offset + 3],
2930                    ]));
2931                    offset += 4;
2932                }
2933                _ => {
2934                    return Err(crate::error::EtherNetIpError::Protocol(format!(
2935                        "Unexpected attribute id {attr_id} in Get Attribute List response"
2936                    )));
2937                }
2938            }
2939        }
2940
2941        let data_type = data_type.ok_or_else(|| {
2942            crate::error::EtherNetIpError::Protocol(format!(
2943                "Get Attribute List for '{}' did not return data type{}",
2944                tag_name,
2945                if attr_errors.is_empty() {
2946                    String::new()
2947                } else {
2948                    format!(" ({})", attr_errors.join(", "))
2949                }
2950            ))
2951        })?;
2952        let size = Self::data_type_size(data_type);
2953
2954        // Create attributes
2955        let attributes = TagAttributes {
2956            name: tag_name.to_string(),
2957            data_type,
2958            data_type_name: self.get_data_type_name(data_type),
2959            dimensions: Vec::new(), // Would need additional parsing
2960            permissions: udt::TagPermissions::ReadWrite, // Default assumption
2961            scope: if tag_name.contains(':') {
2962                let parts: Vec<&str> = tag_name.split(':').collect();
2963                if parts.len() >= 2 {
2964                    udt::TagScope::Program(parts[0].to_string())
2965                } else {
2966                    udt::TagScope::Controller
2967                }
2968            } else {
2969                udt::TagScope::Controller
2970            },
2971            template_instance_id,
2972            size,
2973        };
2974
2975        Ok(attributes)
2976    }
2977
2978    fn data_type_size(data_type: u16) -> u32 {
2979        match data_type {
2980            0x00C1 | 0x00C2 | 0x00C6 => 1,
2981            0x00C3 | 0x00C7 => 2,
2982            0x00C4 | 0x00C8 | 0x00CA => 4,
2983            0x00C5 | 0x00C9 | 0x00CB => 8,
2984            0x00CE => 88,
2985            _ => 4,
2986        }
2987    }
2988
2989    fn parse_template_attributes_response(
2990        &self,
2991        template_id: u32,
2992        response: &[u8],
2993    ) -> crate::error::Result<TemplateAttributes> {
2994        if response.len() < 4 {
2995            return Err(crate::error::EtherNetIpError::Protocol(
2996                "Template attribute response too short".to_string(),
2997            ));
2998        }
2999
3000        let general_status = response[2];
3001        if general_status != 0x00 {
3002            return Err(crate::error::EtherNetIpError::Protocol(format!(
3003                "Template {} attribute read failed: {}",
3004                template_id,
3005                self.get_cip_error_message(general_status)
3006            )));
3007        }
3008
3009        let additional_status_words = response[3] as usize;
3010        let mut offset = 4 + additional_status_words * 2;
3011        if response.len() < offset + 2 {
3012            return Err(crate::error::EtherNetIpError::Protocol(
3013                "Template attribute response missing attribute count".to_string(),
3014            ));
3015        }
3016
3017        let attr_count = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
3018        offset += 2;
3019
3020        let mut attributes = TemplateAttributes {
3021            structure_handle: 0,
3022            member_count: 0,
3023            definition_size_words: 0,
3024            structure_size_bytes: 0,
3025        };
3026
3027        for _ in 0..attr_count {
3028            if response.len() < offset + 4 {
3029                return Err(crate::error::EtherNetIpError::Protocol(
3030                    "Template attribute response truncated".to_string(),
3031                ));
3032            }
3033
3034            let attr_id = u16::from_le_bytes([response[offset], response[offset + 1]]);
3035            let attr_status = u16::from_le_bytes([response[offset + 2], response[offset + 3]]);
3036            offset += 4;
3037
3038            if attr_status != 0 {
3039                return Err(crate::error::EtherNetIpError::Protocol(format!(
3040                    "Template {} attribute {} read returned status 0x{:04X}",
3041                    template_id, attr_id, attr_status
3042                )));
3043            }
3044
3045            match attr_id {
3046                1 => {
3047                    if response.len() < offset + 2 {
3048                        return Err(crate::error::EtherNetIpError::Protocol(
3049                            "Template attribute 1 missing value".to_string(),
3050                        ));
3051                    }
3052                    attributes.structure_handle =
3053                        u16::from_le_bytes([response[offset], response[offset + 1]]);
3054                    offset += 2;
3055                }
3056                2 => {
3057                    if response.len() < offset + 2 {
3058                        return Err(crate::error::EtherNetIpError::Protocol(
3059                            "Template attribute 2 missing value".to_string(),
3060                        ));
3061                    }
3062                    attributes.member_count =
3063                        u16::from_le_bytes([response[offset], response[offset + 1]]);
3064                    offset += 2;
3065                }
3066                4 => {
3067                    if response.len() < offset + 4 {
3068                        return Err(crate::error::EtherNetIpError::Protocol(
3069                            "Template attribute 4 missing value".to_string(),
3070                        ));
3071                    }
3072                    attributes.definition_size_words = u32::from_le_bytes([
3073                        response[offset],
3074                        response[offset + 1],
3075                        response[offset + 2],
3076                        response[offset + 3],
3077                    ]);
3078                    offset += 4;
3079                }
3080                5 => {
3081                    if response.len() < offset + 4 {
3082                        return Err(crate::error::EtherNetIpError::Protocol(
3083                            "Template attribute 5 missing value".to_string(),
3084                        ));
3085                    }
3086                    attributes.structure_size_bytes = u32::from_le_bytes([
3087                        response[offset],
3088                        response[offset + 1],
3089                        response[offset + 2],
3090                        response[offset + 3],
3091                    ]);
3092                    offset += 4;
3093                }
3094                _ => {
3095                    return Err(crate::error::EtherNetIpError::Protocol(format!(
3096                        "Unexpected template attribute {} in response",
3097                        attr_id
3098                    )));
3099                }
3100            }
3101        }
3102
3103        if attributes.definition_size_words == 0 {
3104            return Err(crate::error::EtherNetIpError::Protocol(format!(
3105                "Template {} reported zero definition size",
3106                template_id
3107            )));
3108        }
3109
3110        Ok(attributes)
3111    }
3112
3113    fn parse_template_response_chunk(
3114        &self,
3115        response: &[u8],
3116    ) -> crate::error::Result<(Vec<u8>, bool)> {
3117        if response.len() < 4 {
3118            return Err(crate::error::EtherNetIpError::Protocol(
3119                "Template response too short".to_string(),
3120            ));
3121        }
3122
3123        let general_status = response[2];
3124        let partial_transfer = general_status == 0x06;
3125        if general_status != 0x00 && !partial_transfer {
3126            return Err(crate::error::EtherNetIpError::Protocol(format!(
3127                "Template read failed: {}",
3128                self.get_cip_error_message(general_status)
3129            )));
3130        }
3131
3132        let additional_status_words = response[3] as usize;
3133        let data_start = 4 + additional_status_words * 2;
3134        if data_start > response.len() {
3135            return Err(crate::error::EtherNetIpError::Protocol(
3136                "Template response missing payload".to_string(),
3137            ));
3138        }
3139
3140        Ok((response[data_start..].to_vec(), partial_transfer))
3141    }
3142
3143    /// Gets human-readable data type name
3144    fn get_data_type_name(&self, data_type: u16) -> String {
3145        match data_type {
3146            0x00C1 => "BOOL".to_string(),
3147            0x00C2 => "SINT".to_string(),
3148            0x00C3 => "INT".to_string(),
3149            0x00C4 => "DINT".to_string(),
3150            0x00C5 => "LINT".to_string(),
3151            0x00C6 => "USINT".to_string(),
3152            0x00C7 => "UINT".to_string(),
3153            0x00C8 => "UDINT".to_string(),
3154            0x00C9 => "ULINT".to_string(),
3155            0x00CA => "REAL".to_string(),
3156            0x00CB => "LREAL".to_string(),
3157            0x00CE => "STRING".to_string(),
3158            0x00A0 => "UDT".to_string(),
3159            _ => format!("UNKNOWN(0x{:04X})", data_type),
3160        }
3161    }
3162
3163    /// Builds CIP request for tag list discovery starting from a specific symbol instance.
3164    fn build_tag_list_request_from_instance(
3165        &self,
3166        start_instance: u32,
3167    ) -> crate::error::Result<Vec<u8>> {
3168        let start_instance = u16::try_from(start_instance).map_err(|_| {
3169            crate::error::EtherNetIpError::Protocol(format!(
3170                "Tag discovery start instance {} exceeds 16-bit Symbol Object range",
3171                start_instance
3172            ))
3173        })?;
3174        let mut request = vec![
3175            // Service: Get Instance Attribute List (0x55)
3176            0x55, // Path size: 3 words (6 bytes)
3177            0x03, // Path: Symbol Object (Class 0x6B), start instance
3178            0x20, 0x6B, 0x25, 0x00,
3179        ];
3180        request.extend_from_slice(&start_instance.to_le_bytes());
3181
3182        // Attribute count
3183        request.extend_from_slice(&[0x02, 0x00]);
3184
3185        // Attribute 1: Symbol Name (0x01)
3186        request.extend_from_slice(&[0x01, 0x00]);
3187
3188        // Attribute 2: Symbol Type (0x02)
3189        request.extend_from_slice(&[0x02, 0x00]);
3190
3191        Ok(request)
3192    }
3193
3194    /// Builds CIP request for program-scoped tag list discovery, resuming the
3195    /// Symbol Object enumeration at `start_instance`.
3196    ///
3197    /// A program with more tags than fit in one reply answers `0x06`
3198    /// (partial transfer); the caller resumes from `last_instance_id + 1`, the
3199    /// same paging contract as [`Self::build_tag_list_request_from_instance`].
3200    fn build_program_tag_list_request(
3201        &self,
3202        program_name: &str,
3203        start_instance: u32,
3204    ) -> crate::error::Result<Vec<u8>> {
3205        let start_instance = u16::try_from(start_instance).map_err(|_| {
3206            crate::error::EtherNetIpError::Protocol(format!(
3207                "Program tag discovery start instance {} exceeds 16-bit Symbol Object range",
3208                start_instance
3209            ))
3210        })?;
3211        let scoped_program = format!("Program:{}", program_scope_name(program_name));
3212
3213        let mut path = Vec::new();
3214        path.push(0x91);
3215        path.push(scoped_program.len() as u8);
3216        path.extend_from_slice(scoped_program.as_bytes());
3217        if !path.len().is_multiple_of(2) {
3218            path.push(0x00);
3219        }
3220        // Symbol Object (Class 0x6B), 16-bit start instance.
3221        path.extend_from_slice(&[0x20, 0x6B, 0x25, 0x00]);
3222        path.extend_from_slice(&start_instance.to_le_bytes());
3223
3224        let path_words = u8::try_from(path.len() / 2).map_err(|_| {
3225            crate::error::EtherNetIpError::Protocol(format!(
3226                "Program tag discovery path too long for '{}'",
3227                program_name
3228            ))
3229        })?;
3230
3231        let mut request = vec![
3232            // Service: Get Instance Attribute List (0x55)
3233            0x55, path_words,
3234        ];
3235        request.extend_from_slice(&path);
3236
3237        // Attribute count
3238        request.extend_from_slice(&[0x02, 0x00]); // 2 attributes
3239
3240        // Attribute 1: Symbol Name (0x01)
3241        request.extend_from_slice(&[0x01, 0x00]);
3242
3243        // Attribute 2: Data Type (0x02)
3244        request.extend_from_slice(&[0x02, 0x00]);
3245
3246        Ok(request)
3247    }
3248
3249    /// Parses one page of tag discovery results from a Get Instance Attribute List response.
3250    ///
3251    /// `scope` is the scope the REQUEST was addressed to, and it is stamped onto
3252    /// every tag of the page. The Symbol Object reply carries no scope field --
3253    /// a program-scoped enumeration and a controller-scoped one are
3254    /// byte-identical on the wire -- so the scope is knowledge the CALLER holds
3255    /// and the parser cannot recover. Hardcoding it here made every tag returned
3256    /// by [`Self::discover_program_tags`] claim `TagScope::Controller`.
3257    fn parse_tag_list_response_page(
3258        &self,
3259        response: &[u8],
3260        scope: udt::TagScope,
3261    ) -> crate::error::Result<TagListPage> {
3262        if response.len() < 4 {
3263            return Err(crate::error::EtherNetIpError::Protocol(
3264                "Tag list response too short".to_string(),
3265            ));
3266        }
3267
3268        let general_status = response[2];
3269        let partial_transfer = general_status == 0x06;
3270        if general_status != 0x00 && !partial_transfer {
3271            return Err(crate::error::EtherNetIpError::Protocol(format!(
3272                "Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
3273                self.get_cip_error_message(general_status)
3274            )));
3275        }
3276
3277        let additional_status_words = response[3] as usize;
3278        let mut offset = 4 + additional_status_words * 2;
3279        if response.len() == offset {
3280            return Ok(TagListPage {
3281                tags: Vec::new(),
3282                last_instance_id: None,
3283                partial_transfer: false,
3284            });
3285        }
3286        if response.len() < offset + 4 {
3287            return Err(crate::error::EtherNetIpError::Protocol(
3288                "Tag list response missing first entry".to_string(),
3289            ));
3290        }
3291        let mut tags = Vec::new();
3292        let mut last_instance_id = None;
3293
3294        while offset + 8 <= response.len() {
3295            let instance_id = u32::from_le_bytes([
3296                response[offset],
3297                response[offset + 1],
3298                response[offset + 2],
3299                response[offset + 3],
3300            ]);
3301            last_instance_id = Some(instance_id);
3302            offset += 4;
3303
3304            let name_length = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
3305            offset += 2;
3306
3307            if offset
3308                .checked_add(name_length)
3309                .is_none_or(|end| end > response.len())
3310            {
3311                break;
3312            }
3313
3314            let name_bytes = &response[offset..offset + name_length];
3315            let tag_name = String::from_utf8_lossy(name_bytes).to_string();
3316            offset += name_length;
3317
3318            if offset + 2 > response.len() {
3319                break;
3320            }
3321
3322            let raw_tag_type = u16::from_le_bytes([response[offset], response[offset + 1]]);
3323            offset += 2;
3324
3325            // Symbol list includes controller/program/system tags. Keep user-visible names only.
3326            if tag_name.starts_with("__") || tag_name.contains(':') {
3327                continue;
3328            }
3329
3330            let array_dims = ((raw_tag_type & 0x6000) >> 13) as usize;
3331            let is_structure = (raw_tag_type & 0x8000) != 0;
3332            let reserved = (raw_tag_type & 0x1000) != 0;
3333            let type_param = raw_tag_type & 0x0FFF;
3334            let is_user_atomic =
3335                !is_structure && !reserved && (0x0001..=0x00FF).contains(&type_param);
3336            let is_user_structure =
3337                is_structure && !reserved && (0x0100..=0x0EFF).contains(&type_param);
3338
3339            if !is_user_atomic && !is_user_structure {
3340                continue;
3341            }
3342
3343            let data_type = if is_structure {
3344                0x00A0
3345            } else if (raw_tag_type & 0x00FF) == 0x00C1 {
3346                0x00C1
3347            } else {
3348                type_param
3349            };
3350
3351            let template_instance_id = if is_structure && !reserved {
3352                Some(type_param as u32)
3353            } else {
3354                None
3355            };
3356
3357            tags.push(TagAttributes {
3358                name: tag_name,
3359                data_type,
3360                data_type_name: if is_structure {
3361                    "UDT".to_string()
3362                } else {
3363                    self.get_data_type_name(data_type)
3364                },
3365                dimensions: vec![0; array_dims],
3366                permissions: udt::TagPermissions::ReadWrite,
3367                scope: scope.clone(),
3368                template_instance_id,
3369                size: 0,
3370            });
3371        }
3372
3373        Ok(TagListPage {
3374            tags,
3375            last_instance_id,
3376            partial_transfer,
3377        })
3378    }
3379
3380    /// Negotiates packet size with the PLC
3381    /// This method queries the PLC for its maximum supported packet size
3382    /// and updates the client's configuration accordingly
3383    async fn negotiate_packet_size(&mut self) -> crate::error::Result<()> {
3384        // Build CIP request for Get Attribute List (Service 0x03)
3385        // Query the Message Router object (Class 0x02, Instance 1) for max packet size
3386        let mut request = vec![
3387            0x03, // Service: Get Attribute List
3388            0x02, // Path size: 2 words (4 bytes)
3389            0x20, 0x02, // 8-bit class segment: Class 0x02 (Message Router)
3390            0x24, 0x01, // 8-bit instance segment: Instance 1
3391        ];
3392        // Attribute count
3393        request.extend_from_slice(&[0x01, 0x00]); // 1 attribute
3394        // Attribute: Max Packet Size (attribute 4 on the Message Router)
3395        request.extend_from_slice(&[0x04, 0x00]);
3396
3397        // Send request and extract CIP from CPF response
3398        let response = self.send_cip_request(&request).await?;
3399        let cip_data = self.extract_cip_from_response(&response)?;
3400
3401        // CIP response format: [Service Reply][Reserved][Status][AddtlStatusSize][...data...]
3402        // For Get Attribute List reply: after the 4-byte CIP header, we get:
3403        // [AttrCount(2)] [AttrID(2)] [Status(2)] [Value(2)]
3404        // The attribute value for max packet size is a UINT (2 bytes)
3405        if cip_data.len() >= 12 && cip_data[2] == 0x00 {
3406            // Skip CIP header (4 bytes) + attr count (2) + attr id (2) + attr status (2) = 10
3407            let max_packet_size = u16::from_le_bytes([cip_data[10], cip_data[11]]) as u32;
3408
3409            // Update client's max packet size (with reasonable limits)
3410            self.max_packet_size
3411                .store(max_packet_size.clamp(504, 4000), Ordering::Relaxed);
3412            tracing::debug!("Negotiated packet size: {} bytes", self.max_packet_size());
3413        } else {
3414            // If negotiation fails, use default size
3415            self.max_packet_size.store(4000, Ordering::Relaxed);
3416            tracing::debug!(
3417                "Using default packet size: {} bytes",
3418                self.max_packet_size()
3419            );
3420        }
3421
3422        Ok(())
3423    }
3424
3425    /// Writes a value to a PLC tag
3426    ///
3427    /// This method automatically determines the best communication method based on the data type:
3428    /// - STRING values use unconnected explicit messaging with proper AB STRING format
3429    /// - Other data types use standard unconnected messaging
3430    ///
3431    /// **v0.6.0**: For UDT tags, pass `PlcValue::Udt(UdtData)`. The `symbol_id` must be set
3432    /// (typically obtained by reading the UDT first). If `symbol_id` is 0, the method will
3433    /// attempt to read tag attributes to get the symbol_id automatically.
3434    ///
3435    /// # Arguments
3436    ///
3437    /// * `tag_name` - The name of the tag to write to
3438    /// * `value` - The value to write. For UDTs, use `PlcValue::Udt(UdtData)`.
3439    ///
3440    /// # Example
3441    ///
3442    /// ```no_run
3443    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
3444    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
3445    /// use rust_ethernet_ip::{PlcValue, UdtData};
3446    ///
3447    /// // Write simple types
3448    /// client.write_tag("Counter", PlcValue::Dint(42)).await?;
3449    /// client.write_tag("Message", PlcValue::String("Hello PLC".to_string())).await?;
3450    ///
3451    /// // Write UDT (v0.6.0: read first to get symbol_id, then modify and write)
3452    /// let udt_value = client.read_tag("MyUDT").await?;
3453    /// if let PlcValue::Udt(mut udt_data) = udt_value {
3454    ///     let udt_def = client.get_udt_definition("MyUDT").await?;
3455    ///     // Convert UdtDefinition to UserDefinedType
3456    ///     let mut user_def = rust_ethernet_ip::udt::UserDefinedType::new(udt_def.name.clone());
3457    ///     for member in &udt_def.members {
3458    ///         user_def.add_member(member.clone());
3459    ///     }
3460    ///     let mut members = udt_data.parse(&user_def)?;
3461    ///     members.insert("Member1".to_string(), PlcValue::Dint(100));
3462    ///     let modified_udt = UdtData::from_hash_map(&members, &user_def, udt_data.symbol_id)?;
3463    ///     client.write_tag("MyUDT", PlcValue::Udt(modified_udt)).await?;
3464    /// }
3465    /// # Ok(())
3466    /// # }
3467    /// ```
3468    pub async fn write_tag(&mut self, tag_name: &str, value: PlcValue) -> crate::error::Result<()> {
3469        tracing::debug!(
3470            "Writing '{}' to tag '{}'",
3471            match &value {
3472                PlcValue::String(s) => format!("\"{s}\""),
3473                _ => format!("{value:?}"),
3474            },
3475            tag_name
3476        );
3477
3478        // For UDT writes, ensure we have a valid symbol_id
3479        // As noted by the contributor: "to write a UDT, you typically need to read it first to get the symbol_id"
3480        let value = if let PlcValue::Udt(udt_data) = &value {
3481            if udt_data.symbol_id == 0 {
3482                tracing::debug!("[UDT WRITE] symbol_id is 0, reading tag to get symbol_id");
3483                // Read tag attributes to get symbol_id
3484                let attributes = self.get_tag_attributes(tag_name).await?;
3485                let symbol_id = attributes.template_instance_id.ok_or_else(|| {
3486                    crate::error::EtherNetIpError::Protocol(
3487                        "UDT template instance ID not found. Cannot write UDT without symbol_id."
3488                            .to_string(),
3489                    )
3490                })? as i32;
3491
3492                // Create new UdtData with the correct symbol_id
3493                PlcValue::Udt(UdtData {
3494                    symbol_id,
3495                    data: udt_data.data.clone(),
3496                })
3497            } else {
3498                value
3499            }
3500        } else {
3501            value
3502        };
3503
3504        if let Some((base_path, bit_index)) = self.parse_bit_access(tag_name) {
3505            return match value {
3506                PlcValue::Bool(bit_value) => {
3507                    self.write_bit_base_direct(&base_path, bit_index, bit_value)
3508                        .await
3509                }
3510                other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
3511                    expected: "BOOL".to_string(),
3512                    actual: format!("{:?}", other),
3513                }),
3514            };
3515        }
3516
3517        // Check if this is simple array element access (e.g., "ArrayName[0]").
3518        // Member paths such as "ArrayName[0].Member" must use TagPath so the
3519        // suffix is preserved instead of writing the whole element/base path.
3520        if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
3521            if !self.has_member_suffix_after_first_array_index(tag_name) {
3522                tracing::debug!(
3523                    "Detected array element write: {}[{}], using workaround",
3524                    base_name,
3525                    index
3526                );
3527                return self
3528                    .write_array_element_workaround(&base_name, index, value)
3529                    .await;
3530            }
3531
3532            tracing::debug!(
3533                "Array element '{}[{}]' has member access, using TagPath::parse()",
3534                base_name,
3535                index
3536            );
3537        }
3538
3539        if let PlcValue::Bool(_) = value
3540            && let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
3541            && self.detect_bool_array_path(&parent_path).await?
3542        {
3543            return self
3544                .write_bool_array_element_workaround(&parent_path, index, value)
3545                .await;
3546        }
3547
3548        self.write_tag_direct(tag_name, &value).await
3549    }
3550
3551    async fn write_tag_direct(
3552        &mut self,
3553        tag_name: &str,
3554        value: &PlcValue,
3555    ) -> crate::error::Result<()> {
3556        // STRING writes must carry the *target tag's* real structure handle. The built-in Logix
3557        // `STRING` type (handle 0x0FCE) is the common case, but users routinely define their own
3558        // string types with a custom name/length (e.g. `Str82`, `Str400`); each has its own
3559        // structure handle, and the built-in handle is rejected with CIP 0x2107. Try the standard
3560        // encoding first — it is the fast path and is all the simulator models — and on a type
3561        // mismatch discover the target's real handle + structure size and retry. A value longer
3562        // than the built-in 82-byte capacity can only be a custom type, so skip straight to the
3563        // handle-aware path. See docs/agents/notes/ab-firmware-quirks.md (STRING Members).
3564        if let PlcValue::String(text) = value {
3565            if text.len() <= values::STANDARD_STRING_DATA_LEN {
3566                match self.write_tag_standard(tag_name, value).await {
3567                    Ok(()) => return Ok(()),
3568                    Err(error) if service_layer::is_2107_type_mismatch(&error) => {
3569                        return self.write_string_handle_aware(tag_name, text).await;
3570                    }
3571                    Err(error) => return Err(error),
3572                }
3573            }
3574            return self.write_string_handle_aware(tag_name, text).await;
3575        }
3576        self.write_tag_standard(tag_name, value).await
3577    }
3578
3579    /// Single-request write ceiling for unconnected messaging. A request above this is rejected
3580    /// by the controller with encapsulation status 0x03, so the string path uses CIP
3581    /// fragmentation. Measured on CompactLogix 5069-L330ERM fw38 (494 bytes OK, 498 rejected).
3582    const SINGLE_PACKET_WRITE_LIMIT: usize = 494;
3583
3584    /// Writes a Logix STRING using the target tag's real structure handle and structure size,
3585    /// discovered by reading the tag first. Handles built-in `STRING` and custom string types
3586    /// (own name/length) uniformly. Structures larger than one CIP packet use Write Tag
3587    /// Fragmented.
3588    async fn write_string_handle_aware(
3589        &mut self,
3590        tag_name: &str,
3591        value: &str,
3592    ) -> crate::error::Result<()> {
3593        // Discover the target's structure handle and total structure size (LEN + DATA + pad).
3594        let (handle, struct_size) = match self.read_tag(tag_name).await? {
3595            PlcValue::String(_) => (
3596                values::STANDARD_STRING_HANDLE,
3597                values::STANDARD_STRING_PAYLOAD_LEN,
3598            ),
3599            PlcValue::Udt(udt) if udt.data.len() >= 6 => (
3600                u16::from_le_bytes([udt.data[0], udt.data[1]]),
3601                udt.data.len() - 2,
3602            ),
3603            other => {
3604                return Err(EtherNetIpError::DataTypeMismatch {
3605                    expected: "STRING structure".to_string(),
3606                    actual: format!("{other:?}"),
3607                });
3608            }
3609        };
3610
3611        // The value occupies the DATA region after the 4-byte LEN field.
3612        let capacity = struct_size.saturating_sub(4);
3613        if value.len() > capacity {
3614            return Err(EtherNetIpError::StringTooLong {
3615                max_length: capacity,
3616                actual_length: value.len(),
3617            });
3618        }
3619
3620        // Structure payload: LEN (u32 LE) + value bytes + zero fill to the structure size.
3621        let mut payload = vec![0u8; struct_size];
3622        payload[0..4].copy_from_slice(&(value.len() as u32).to_le_bytes());
3623        payload[4..4 + value.len()].copy_from_slice(value.as_bytes());
3624
3625        // Request data: structure type marker (0x02A0) + real handle + element count + payload.
3626        let mut data = Vec::with_capacity(6 + payload.len());
3627        data.extend_from_slice(&values::AB_UDT.to_le_bytes());
3628        data.extend_from_slice(&handle.to_le_bytes());
3629        data.extend_from_slice(&[0x01, 0x00]);
3630        data.extend_from_slice(&payload);
3631
3632        let path = self.build_tag_path(tag_name);
3633        let request = CipRequest::new(WRITE_TAG, path, data);
3634        let mut cip_request = BytesMut::new();
3635        request.encode(&mut cip_request)?;
3636
3637        if cip_request.len() > Self::SINGLE_PACKET_WRITE_LIMIT {
3638            return self
3639                .write_string_fragmented(tag_name, handle, &payload)
3640                .await;
3641        }
3642
3643        let response = self.send_cip_request(&cip_request).await?;
3644        let cip_response = self.extract_cip_from_response(&response)?;
3645        self.check_cip_error(&cip_response)?;
3646        Ok(())
3647    }
3648
3649    async fn write_string_fragmented(
3650        &mut self,
3651        tag_name: &str,
3652        handle: u16,
3653        payload: &[u8],
3654    ) -> crate::error::Result<()> {
3655        let max_fragment = self.max_write_fragment_payload_len(tag_name, handle)?;
3656        let mut offset = 0usize;
3657
3658        while offset < payload.len() {
3659            let end = usize::min(offset + max_fragment, payload.len());
3660            let request = self.build_write_fragmented_request(
3661                tag_name,
3662                handle,
3663                offset as u32,
3664                &payload[offset..end],
3665            )?;
3666            let response = self.send_cip_request(&request).await?;
3667            let cip_response = self.extract_cip_from_response(&response)?;
3668            if cip_response.first().copied() != Some(WRITE_TAG_FRAGMENTED_REPLY) {
3669                return Err(EtherNetIpError::Protocol(format!(
3670                    "Unexpected Write Tag Fragmented reply service: 0x{:02X}",
3671                    cip_response.first().copied().unwrap_or(0)
3672                )));
3673            }
3674            self.check_cip_error(&cip_response)?;
3675            offset = end;
3676        }
3677
3678        Ok(())
3679    }
3680
3681    async fn write_tag_standard(
3682        &mut self,
3683        tag_name: &str,
3684        value: &PlcValue,
3685    ) -> crate::error::Result<()> {
3686        let cip_request = self.build_write_request(tag_name, value)?;
3687
3688        let response = self.send_cip_request(&cip_request).await?;
3689
3690        // Check write response for errors - need to extract CIP response first
3691        let cip_response = self.extract_cip_from_response(&response)?;
3692
3693        if cip_response.len() < 3 {
3694            return Err(EtherNetIpError::Protocol(
3695                "Write response too short".to_string(),
3696            ));
3697        }
3698
3699        let service_reply = cip_response[0]; // Should be 0xCD (0x4D + 0x80) for Write Tag reply
3700        let general_status = cip_response[2]; // CIP status code
3701
3702        tracing::trace!(
3703            "Write response - Service: 0x{:02X}, Status: 0x{:02X}",
3704            service_reply,
3705            general_status
3706        );
3707
3708        // Check for errors (including extended errors)
3709        if let Err(e) = self.check_cip_error(&cip_response) {
3710            tracing::error!("[WRITE] CIP Error: {}", e);
3711            return Err(e);
3712        }
3713
3714        tracing::info!("Write operation completed successfully");
3715        Ok(())
3716    }
3717
3718    /// Builds a CIP Write Tag Service request
3719    ///
3720    /// This creates the CIP packet for writing a value to a tag.
3721    /// The request includes the service code, tag path, data type, and value.
3722    ///
3723    /// For UDT writes, the data type must be Structure Tag Type (0x02A0 + Structure Handle).
3724    /// The Structure Handle is the template_instance_id (symbol_id) from Template Attribute 1.
3725    ///
3726    /// Reference: 1756-PM020, Page 1080 (UDT Data Layout Considerations)
3727    fn build_write_request(
3728        &self,
3729        tag_name: &str,
3730        value: &PlcValue,
3731    ) -> crate::error::Result<Vec<u8>> {
3732        tracing::debug!("Building write request for tag: '{}'", tag_name);
3733
3734        // Use the same path building logic as read operations
3735        let path = self.build_tag_path(tag_name);
3736
3737        if let PlcValue::String(string_value) = value
3738            && string_value.len() > values::STANDARD_STRING_DATA_LEN
3739        {
3740            return Err(EtherNetIpError::StringTooLong {
3741                max_length: values::STANDARD_STRING_DATA_LEN,
3742                actual_length: string_value.len(),
3743            });
3744        }
3745
3746        let mut data = BytesMut::new();
3747        data.extend_from_slice(&values::write_data_type_bytes(value));
3748        data.extend_from_slice(&[0x01, 0x00]); // Element count: 1
3749        values::encode_payload(value, &mut data);
3750
3751        let request = CipRequest::new(WRITE_TAG, path, data.to_vec());
3752        let mut cip_request = BytesMut::new();
3753        request.encode(&mut cip_request)?;
3754
3755        tracing::trace!(
3756            "Built CIP write request ({} bytes): {:02X?}",
3757            cip_request.len(),
3758            cip_request
3759        );
3760        Ok(cip_request.to_vec())
3761    }
3762
3763    fn build_read_fragmented_request(
3764        &self,
3765        tag_name: &str,
3766        element_count: u16,
3767        byte_offset: u32,
3768    ) -> crate::error::Result<Vec<u8>> {
3769        let mut data = Vec::with_capacity(6);
3770        data.extend_from_slice(&element_count.to_le_bytes());
3771        data.extend_from_slice(&byte_offset.to_le_bytes());
3772        let request = CipRequest::new(READ_TAG_FRAGMENTED, self.build_tag_path(tag_name), data);
3773        let mut cip_request = BytesMut::new();
3774        request.encode(&mut cip_request)?;
3775        Ok(cip_request.to_vec())
3776    }
3777
3778    fn build_write_fragmented_request(
3779        &self,
3780        tag_name: &str,
3781        handle: u16,
3782        byte_offset: u32,
3783        fragment: &[u8],
3784    ) -> crate::error::Result<Vec<u8>> {
3785        let mut data = Vec::with_capacity(10 + fragment.len());
3786        data.extend_from_slice(&values::AB_UDT.to_le_bytes());
3787        data.extend_from_slice(&handle.to_le_bytes());
3788        data.extend_from_slice(&1u16.to_le_bytes());
3789        data.extend_from_slice(&byte_offset.to_le_bytes());
3790        data.extend_from_slice(fragment);
3791        let request = CipRequest::new(WRITE_TAG_FRAGMENTED, self.build_tag_path(tag_name), data);
3792        let mut cip_request = BytesMut::new();
3793        request.encode(&mut cip_request)?;
3794        Ok(cip_request.to_vec())
3795    }
3796
3797    fn max_write_fragment_payload_len(
3798        &self,
3799        tag_name: &str,
3800        handle: u16,
3801    ) -> crate::error::Result<usize> {
3802        let empty_request = self.build_write_fragmented_request(tag_name, handle, 0, &[])?;
3803        if empty_request.len() >= Self::SINGLE_PACKET_WRITE_LIMIT {
3804            return Err(EtherNetIpError::Protocol(format!(
3805                "Write Tag Fragmented request for '{tag_name}' has {} bytes of overhead, exceeding the {}-byte single-packet limit before payload",
3806                empty_request.len(),
3807                Self::SINGLE_PACKET_WRITE_LIMIT
3808            )));
3809        }
3810
3811        Ok(Self::SINGLE_PACKET_WRITE_LIMIT - empty_request.len())
3812    }
3813
3814    /// Builds the initial controller-scoped Symbol Object enumeration request.
3815    pub fn build_list_tags_request(&self) -> Vec<u8> {
3816        tracing::debug!("Building list tags request");
3817
3818        // Build path array for Symbol Object Class (0x6B)
3819        let path_array = vec![
3820            // Class segment: Symbol Object Class (0x6B)
3821            0x20, // Class segment identifier
3822            0x6B, // Symbol Object Class
3823            // Instance segment: Start at Instance 0
3824            0x25, // Instance segment identifier with 0x00
3825            0x00, 0x00, 0x00,
3826        ];
3827
3828        // Request data: 2 Attributes - Attribute 1 and Attribute 2
3829        let request_data = vec![0x02, 0x00, 0x01, 0x00, 0x02, 0x00];
3830
3831        // Build CIP Message Router request
3832        let request = CipRequest::new(0x55, path_array, request_data);
3833        let mut cip_request = BytesMut::new();
3834        request
3835            .encode(&mut cip_request)
3836            .expect("list-tags request path is static and valid");
3837
3838        tracing::trace!(
3839            "Built CIP list tags request ({} bytes): {:02X?}",
3840            cip_request.len(),
3841            cip_request
3842        );
3843
3844        cip_request.to_vec()
3845    }
3846
3847    /// Gets a human-readable error message for a CIP status code
3848    ///
3849    /// # Arguments
3850    ///
3851    /// * `status` - The CIP status code to look up
3852    ///
3853    /// # Returns
3854    ///
3855    /// A string describing the error
3856    /// Parses extended CIP error codes from response data
3857    ///
3858    /// Additional status is signaled by the additional-status size field.
3859    /// Format: [0]=service, [1]=reserved, [2]=general_status, [3]=additional_status_size (words), [4-5]=first extended status word
3860    fn parse_extended_error(&self, cip_data: &[u8]) -> crate::error::Result<String> {
3861        if cip_data.len() < 4 {
3862            return Err(EtherNetIpError::Protocol(
3863                "CIP response too short for additional-status check".to_string(),
3864            ));
3865        }
3866
3867        let additional_status_size = cip_data[3] as usize; // Size in words
3868        if additional_status_size == 0 {
3869            return Ok("Extended error (no additional status)".to_string());
3870        }
3871
3872        let expected_len = 4 + (additional_status_size * 2);
3873        if cip_data.len() < expected_len {
3874            return Err(EtherNetIpError::Protocol(format!(
3875                "Additional-status response truncated: expected {expected_len} bytes, got {}",
3876                cip_data.len()
3877            )));
3878        }
3879
3880        let extended_error_code = u16::from_le_bytes([cip_data[4], cip_data[5]]);
3881        let error_msg = match extended_error_code {
3882            0x0001 => "Connection failure (extended)".to_string(),
3883            0x0002 => "Resource unavailable (extended)".to_string(),
3884            0x0003 => "Invalid parameter value (extended)".to_string(),
3885            0x0004 => "Path segment error (extended)".to_string(),
3886            0x0005 => "Path destination unknown (extended)".to_string(),
3887            0x0006 => "Partial transfer (extended)".to_string(),
3888            0x0007 => "Connection lost (extended)".to_string(),
3889            0x0008 => "Service not supported (extended)".to_string(),
3890            0x0009 => "Invalid attribute value (extended)".to_string(),
3891            0x000A => "Attribute list error (extended)".to_string(),
3892            0x000B => "Already in requested mode/state (extended)".to_string(),
3893            0x000C => "Object state conflict (extended)".to_string(),
3894            0x000D => "Object already exists (extended)".to_string(),
3895            0x000E => "Attribute not settable (extended)".to_string(),
3896            0x000F => "Privilege violation (extended)".to_string(),
3897            0x0010 => "Device state conflict (extended)".to_string(),
3898            0x0011 => "Reply data too large (extended)".to_string(),
3899            0x0012 => "Fragmentation of a primitive value (extended)".to_string(),
3900            0x0013 => "Not enough data (extended)".to_string(),
3901            0x0014 => "Attribute not supported (extended)".to_string(),
3902            0x0015 => "Too much data (extended)".to_string(),
3903            0x0016 => "Object does not exist (extended)".to_string(),
3904            0x0017 => "Service fragmentation sequence not in progress (extended)".to_string(),
3905            0x0018 => "No stored attribute data (extended)".to_string(),
3906            0x0019 => "Store operation failure (extended)".to_string(),
3907            0x001A => "Routing failure, request packet too large (extended)".to_string(),
3908            0x001B => "Routing failure, response packet too large (extended)".to_string(),
3909            0x001C => "Missing attribute list entry data (extended)".to_string(),
3910            0x001D => "Invalid attribute value list (extended)".to_string(),
3911            0x001E => "Embedded service error (extended)".to_string(),
3912            0x001F => "Vendor specific error (extended)".to_string(),
3913            0x0020 => "Invalid parameter (extended)".to_string(),
3914            0x0021 => "Write-once value or medium already written (extended)".to_string(),
3915            0x0022 => "Invalid reply received (extended)".to_string(),
3916            0x0023 => "Buffer overflow (extended)".to_string(),
3917            0x0024 => "Invalid message format (extended)".to_string(),
3918            0x0025 => "Key failure in path (extended)".to_string(),
3919            0x0026 => "Path size invalid (extended)".to_string(),
3920            0x0027 => "Unexpected attribute in list (extended)".to_string(),
3921            0x0028 => "Invalid member ID (extended)".to_string(),
3922            0x0029 => "Member not settable (extended)".to_string(),
3923            0x002A => "Group 2 only server general failure (extended)".to_string(),
3924            0x002B => "Unknown Modbus error (extended)".to_string(),
3925            0x002C => "Attribute not gettable (extended)".to_string(),
3926            0x2107 => format!(
3927                "Read/Write Tag data-type mismatch extended error: 0x{extended_error_code:04X}. Raw bytes: [0x{:02X}, 0x{:02X}]. Check that the request data type matches the target tag; STRING members inside UDTs can also surface this current-encoding rejection.",
3928                cip_data[4], cip_data[5]
3929            ),
3930            _ => format!(
3931                "Unknown extended CIP error code: 0x{extended_error_code:04X}. Raw bytes: [0x{:02X}, 0x{:02X}]",
3932                cip_data[4], cip_data[5]
3933            ),
3934        };
3935
3936        Ok(error_msg)
3937    }
3938
3939    /// Checks CIP response for errors, including extended error codes
3940    /// Returns Ok(()) if no error, Err with error message if error found
3941    fn check_cip_error(&self, cip_data: &[u8]) -> crate::error::Result<()> {
3942        if cip_data.len() < 3 {
3943            return Err(EtherNetIpError::Protocol(
3944                "CIP response too short for status check".to_string(),
3945            ));
3946        }
3947
3948        let general_status = cip_data[2];
3949
3950        if general_status == 0x00 {
3951            // Success
3952            return Ok(());
3953        }
3954
3955        // Additional-status words carry the extended status details.
3956        if cip_data.get(3).copied().unwrap_or(0) > 0 {
3957            let error_msg = self.parse_extended_error(cip_data)?;
3958            return Err(EtherNetIpError::Protocol(format!(
3959                "CIP Extended Error: {error_msg}"
3960            )));
3961        }
3962
3963        // Regular error code
3964        let error_msg = self.get_cip_error_message(general_status);
3965        if general_status == 0x01 {
3966            return Err(EtherNetIpError::Connection(format!(
3967                "CIP Error 0x{general_status:02X}: {error_msg}"
3968            )));
3969        }
3970        if general_status == 0x07 {
3971            return Err(EtherNetIpError::ConnectionLost(format!(
3972                "CIP Error 0x{general_status:02X}: {error_msg}"
3973            )));
3974        }
3975        Err(EtherNetIpError::Protocol(format!(
3976            "CIP Error 0x{general_status:02X}: {error_msg}"
3977        )))
3978    }
3979
3980    fn get_cip_error_message(&self, status: u8) -> String {
3981        match status {
3982            0x00 => "Success".to_string(),
3983            0x01 => "Connection failure".to_string(),
3984            0x02 => "Resource unavailable".to_string(),
3985            0x03 => "Invalid parameter value".to_string(),
3986            0x04 => "Path segment error".to_string(),
3987            0x05 => "Path destination unknown".to_string(),
3988            0x06 => "Partial transfer".to_string(),
3989            0x07 => "Connection lost".to_string(),
3990            0x08 => "Service not supported".to_string(),
3991            0x09 => "Invalid attribute value".to_string(),
3992            0x0A => "Attribute list error".to_string(),
3993            0x0B => "Already in requested mode/state".to_string(),
3994            0x0C => "Object state conflict".to_string(),
3995            0x0D => "Object already exists".to_string(),
3996            0x0E => "Attribute not settable".to_string(),
3997            0x0F => "Privilege violation".to_string(),
3998            0x10 => "Device state conflict".to_string(),
3999            0x11 => "Reply data too large".to_string(),
4000            0x12 => "Fragmentation of a primitive value".to_string(),
4001            0x13 => "Not enough data".to_string(),
4002            0x14 => "Attribute not supported".to_string(),
4003            0x15 => "Too much data".to_string(),
4004            0x16 => "Object does not exist".to_string(),
4005            0x17 => "Service fragmentation sequence not in progress".to_string(),
4006            0x18 => "No stored attribute data".to_string(),
4007            0x19 => "Store operation failure".to_string(),
4008            0x1A => "Routing failure, request packet too large".to_string(),
4009            0x1B => "Routing failure, response packet too large".to_string(),
4010            0x1C => "Missing attribute list entry data".to_string(),
4011            0x1D => "Invalid attribute value list".to_string(),
4012            0x1E => "Embedded service error".to_string(),
4013            0x1F => "Vendor specific error".to_string(),
4014            0x20 => "Invalid parameter".to_string(),
4015            0x21 => "Write-once value or medium already written".to_string(),
4016            0x22 => "Invalid reply received".to_string(),
4017            0x23 => "Buffer overflow".to_string(),
4018            0x24 => "Invalid message format".to_string(),
4019            0x25 => "Key failure in path".to_string(),
4020            0x26 => "Path size invalid".to_string(),
4021            0x27 => "Unexpected attribute in list".to_string(),
4022            0x28 => "Invalid member ID".to_string(),
4023            0x29 => "Member not settable".to_string(),
4024            0x2A => "Group 2 only server general failure".to_string(),
4025            0x2B => "Unknown Modbus error".to_string(),
4026            0x2C => "Attribute not gettable".to_string(),
4027            _ => format!("Unknown CIP error code: 0x{status:02X}"),
4028        }
4029    }
4030
4031    fn describe_multiple_service_error(
4032        &self,
4033        general_status: u8,
4034        operations: &[BatchOperation],
4035    ) -> String {
4036        if general_status == 0x1E
4037            && operations.iter().any(|op| {
4038                matches!(
4039                    op,
4040                    BatchOperation::Write {
4041                        value: PlcValue::String(_),
4042                        ..
4043                    }
4044                )
4045            })
4046        {
4047            return "Multiple Service Response error: 0x1E (Embedded service error). A batched STRING write failed inside the controller; inspect the embedded reply for the rejected service and data-type details.".to_string();
4048        }
4049
4050        format!("Multiple Service Response error: 0x{general_status:02X}")
4051    }
4052
4053    async fn validate_session(&mut self) -> crate::error::Result<()> {
4054        let time_since_activity = self.last_activity.lock().await.elapsed();
4055
4056        // Send keep-alive if it's been more than 30 seconds since last activity
4057        if time_since_activity > Duration::from_secs(30) {
4058            self.send_keep_alive().await?;
4059        }
4060
4061        Ok(())
4062    }
4063
4064    async fn send_keep_alive(&mut self) -> crate::error::Result<()> {
4065        self.ensure_stream_usable()?;
4066        // Send NOP command (0x0000) — a valid 24-byte EtherNet/IP packet
4067        // that keeps the TCP connection alive without affecting session state.
4068        // NOP requires no response, so we don't read one.
4069        let packet = vec![0u8; 24];
4070        // Command: NOP (0x0000) — already zero
4071        // Length: 0 — already zero
4072        // Session handle, status, context, options — all zero for NOP
4073
4074        let mut stream = self.stream.lock().await;
4075        stream.write_all(&packet).await?;
4076        *self.last_activity.lock().await = Instant::now();
4077        Ok(())
4078    }
4079
4080    /// Builds an Unconnected Send message wrapping a CIP request
4081    ///
4082    /// Reference: EtherNetIP_Connection_Paths_and_Routing.md
4083    /// The route path goes at the END of the Unconnected Send message, NOT in the CIP service request.
4084    ///
4085    /// Structure:
4086    /// - Service: 0x52 (Unconnected Send)
4087    /// - Request Path: Connection Manager (Class 0x06, Instance 1)
4088    /// - Priority/Time Tick: 0x0A
4089    /// - Timeout Ticks: 0xF0
4090    /// - Embedded Message Length
4091    /// - Embedded CIP Message (Read Tag, Write Tag, etc.) ← NO route path here
4092    /// - Pad byte (if message length is odd)
4093    /// - Route Path Size
4094    /// - Reserved byte
4095    /// - Route Path ← Route path goes HERE
4096    fn build_unconnected_send(&self, embedded_message: &[u8]) -> Vec<u8> {
4097        let mut ucmm = vec![
4098            // Service: Unconnected Send (0x52)
4099            0x52, // Request Path Size: 2 words (4 bytes) for Connection Manager
4100            0x02,
4101            // Request Path: Connection Manager (Class 0x06, Instance 1)
4102            0x20, // Logical Class segment
4103            0x06, // Class 0x06 (Connection Manager)
4104            0x24, // Logical Instance segment
4105            0x01, // Instance 1
4106            // Priority/Time Tick: 0x0A
4107            0x0A, // Timeout Ticks: 0xF0 (240 ticks)
4108            0xF0,
4109        ];
4110
4111        // Embedded message length (16-bit, little-endian)
4112        let msg_len = embedded_message.len() as u16;
4113        ucmm.extend_from_slice(&msg_len.to_le_bytes());
4114
4115        // The actual CIP message (Read Tag, Write Tag, etc.) - NO route path here!
4116        ucmm.extend_from_slice(embedded_message);
4117
4118        // Pad byte if message length is odd
4119        if embedded_message.len() % 2 == 1 {
4120            ucmm.push(0x00);
4121        }
4122
4123        // Route Path Size (in 16-bit words)
4124        // Get route path if configured
4125        let route_path_bytes = if let Some(route_path) = self.route_path_snapshot() {
4126            route_path.to_cip_bytes()
4127        } else {
4128            Vec::new()
4129        };
4130
4131        let route_path_words = if route_path_bytes.is_empty() {
4132            0
4133        } else {
4134            (route_path_bytes.len() / 2) as u8
4135        };
4136        ucmm.push(route_path_words);
4137
4138        // Reserved byte
4139        ucmm.push(0x00);
4140
4141        // Route Path - THIS IS WHERE [0x01, slot] GOES
4142        if !route_path_bytes.is_empty() {
4143            tracing::trace!(
4144                "Adding route path to Unconnected Send: {:02X?} ({} bytes, {} words)",
4145                route_path_bytes,
4146                route_path_bytes.len(),
4147                route_path_words
4148            );
4149            ucmm.extend_from_slice(&route_path_bytes);
4150        }
4151
4152        ucmm
4153    }
4154
4155    /// Sends a CIP request using EtherNet/IP SendRRData.
4156    ///
4157    /// Primary mode uses Unconnected Send (0x52) wrapping. For controllers that reject
4158    /// this pattern for specific services, a direct-CIP fallback is attempted when:
4159    /// - the Unconnected Send response is `0xD2` with non-zero general status, and
4160    /// - no route path is configured (direct mode cannot carry a route path).
4161    pub async fn send_cip_request(&self, cip_request: &[u8]) -> Result<Vec<u8>> {
4162        tracing::trace!(
4163            "Sending CIP request ({} bytes): {:02X?}",
4164            cip_request.len(),
4165            cip_request
4166        );
4167
4168        // Build Unconnected Send message wrapping the CIP request
4169        // Route path goes at the END of Unconnected Send, NOT in the CIP request
4170        let ucmm_message = self.build_unconnected_send(cip_request);
4171        let diagnostic_operation = Self::diagnostic_operation_for(cip_request);
4172
4173        tracing::trace!(
4174            "Unconnected Send message ({} bytes): {:02X?}",
4175            ucmm_message.len(),
4176            &ucmm_message[..std::cmp::min(64, ucmm_message.len())]
4177        );
4178
4179        let response_data = match self.send_rr_data_item(&ucmm_message).await {
4180            Ok(response_data) => response_data,
4181            Err(error) => {
4182                self.diagnostic_counters
4183                    .record_failure(diagnostic_operation, &error);
4184                return Err(error);
4185            }
4186        };
4187
4188        if let Ok(raw_cip_data) = self.extract_unconnected_data_item(&response_data) {
4189            let use_direct_fallback = raw_cip_data.len() >= 3
4190                && raw_cip_data[0] == 0xD2
4191                && raw_cip_data[2] != 0x00
4192                && cip_request.first().copied() != Some(READ_TAG_FRAGMENTED)
4193                && self.route_path_snapshot().is_none();
4194
4195            if use_direct_fallback {
4196                tracing::warn!(
4197                    "Unconnected Send returned 0xD2 status 0x{:02X}; retrying with direct CIP SendRRData fallback",
4198                    raw_cip_data[2]
4199                );
4200                return match self.send_rr_data_item(cip_request).await {
4201                    Ok(response_data) => {
4202                        self.diagnostic_counters
4203                            .record_success(diagnostic_operation);
4204                        Ok(response_data)
4205                    }
4206                    Err(error) => {
4207                        self.diagnostic_counters
4208                            .record_failure(diagnostic_operation, &error);
4209                        Err(error)
4210                    }
4211                };
4212            }
4213
4214            if raw_cip_data.len() >= 3 && raw_cip_data[2] != 0x00 {
4215                self.diagnostic_counters
4216                    .record_cip_failure(diagnostic_operation);
4217            } else {
4218                self.diagnostic_counters
4219                    .record_success(diagnostic_operation);
4220            }
4221        } else {
4222            self.diagnostic_counters
4223                .record_success(diagnostic_operation);
4224        }
4225
4226        Ok(response_data)
4227    }
4228
4229    async fn send_rr_data_item(&self, item_data: &[u8]) -> Result<Vec<u8>> {
4230        let send_data = SendDataRequest::unconnected(item_data);
4231        let mut packet = BytesMut::new();
4232        let mut cpf = BytesMut::new();
4233        send_data.encode(&mut cpf);
4234        let sender_context = self.next_sender_context();
4235        EncapsulationHeader::send_rr_data_with_context(
4236            cpf.len() as u16,
4237            self.session_handle(),
4238            sender_context,
4239        )
4240        .encode(&mut packet);
4241        packet.extend_from_slice(&cpf);
4242
4243        tracing::trace!(
4244            "Built packet ({} bytes): {:02X?}",
4245            packet.len(),
4246            &packet[..std::cmp::min(64, packet.len())]
4247        );
4248
4249        // Send packet with timeout
4250        self.ensure_stream_usable()?;
4251        let mut stream = self.stream.lock().await;
4252        self.ensure_stream_usable()?;
4253        self.stream_poisoned.store(true, Ordering::Relaxed);
4254        if let Err(e) = stream.write_all(&packet).await {
4255            return Err(EtherNetIpError::Io(e));
4256        }
4257
4258        // Read response header with timeout
4259        let mut header = [0u8; 24];
4260        match timeout(Duration::from_secs(10), stream.read_exact(&mut header)).await {
4261            Ok(Ok(_)) => {}
4262            Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4263            Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4264        }
4265
4266        // Check EtherNet/IP command status
4267        let mut header_bytes = &header[..];
4268        let response_header = EncapsulationHeader::decode(&mut header_bytes)?;
4269        if response_header.sender_context != sender_context {
4270            return Err(EtherNetIpError::Protocol(format!(
4271                "SendRRData sender_context mismatch: expected {:02X?}, got {:02X?}",
4272                sender_context, response_header.sender_context
4273            )));
4274        }
4275
4276        // Parse response length
4277        let response_length = response_header.length as usize;
4278        if response_header.status != 0 {
4279            if response_length > 0 {
4280                let mut response_data = vec![0u8; response_length];
4281                match timeout(
4282                    Duration::from_secs(10),
4283                    stream.read_exact(&mut response_data),
4284                )
4285                .await
4286                {
4287                    Ok(Ok(_)) => {}
4288                    Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4289                    Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4290                }
4291            }
4292            self.stream_poisoned.store(false, Ordering::Relaxed);
4293            return Err(EtherNetIpError::Protocol(format!(
4294                "EIP Command failed. Status: 0x{:08X}",
4295                response_header.status
4296            )));
4297        }
4298
4299        if response_length == 0 {
4300            self.stream_poisoned.store(false, Ordering::Relaxed);
4301            return Ok(Vec::new());
4302        }
4303
4304        // Read response data with timeout
4305        let mut response_data = vec![0u8; response_length];
4306        match timeout(
4307            Duration::from_secs(10),
4308            stream.read_exact(&mut response_data),
4309        )
4310        .await
4311        {
4312            Ok(Ok(_)) => {}
4313            Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4314            Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4315        }
4316
4317        self.stream_poisoned.store(false, Ordering::Relaxed);
4318
4319        // Update last activity time
4320        *self.last_activity.lock().await = Instant::now();
4321
4322        tracing::trace!(
4323            "Received response ({} bytes): {:02X?}",
4324            response_data.len(),
4325            &response_data[..std::cmp::min(32, response_data.len())]
4326        );
4327
4328        Ok(response_data)
4329    }
4330
4331    fn extract_unconnected_data_item(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
4332        let mut response = response;
4333        let send_data = SendDataRequest::decode(&mut response)?;
4334        if let Some(item) = send_data
4335            .items
4336            .into_iter()
4337            .find(|item| item.type_id == 0x00B2)
4338        {
4339            return Ok(item.data);
4340        }
4341
4342        Err(EtherNetIpError::Protocol(
4343            "No Unconnected Data Item (0x00B2) found in response".to_string(),
4344        ))
4345    }
4346
4347    fn unwrap_unconnected_send_reply(&self, cip_data: &[u8]) -> crate::error::Result<Vec<u8>> {
4348        if cip_data.is_empty() || cip_data[0] != 0xD2 {
4349            return Ok(cip_data.to_vec());
4350        }
4351
4352        if cip_data.len() < 4 {
4353            return Err(EtherNetIpError::Protocol(
4354                "Unconnected Send reply too short".to_string(),
4355            ));
4356        }
4357
4358        let general_status = cip_data[2];
4359        let additional_status_words = cip_data[3] as usize;
4360        let embedded_offset = 4 + (additional_status_words * 2);
4361
4362        if general_status != 0x00 {
4363            let error_msg = self.get_cip_error_message(general_status);
4364            return Err(EtherNetIpError::Protocol(format!(
4365                "Unconnected Send failed (0xD2): CIP Error 0x{general_status:02X}: {error_msg}"
4366            )));
4367        }
4368
4369        if embedded_offset >= cip_data.len() {
4370            return Err(EtherNetIpError::Protocol(
4371                "Unconnected Send succeeded but no embedded response payload was returned"
4372                    .to_string(),
4373            ));
4374        }
4375
4376        Ok(cip_data[embedded_offset..].to_vec())
4377    }
4378
4379    /// Extracts CIP data from EtherNet/IP response packet
4380    fn extract_cip_from_response(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
4381        tracing::trace!(
4382            "Extracting CIP from response ({} bytes): {:02X?}",
4383            response.len(),
4384            &response[..std::cmp::min(32, response.len())]
4385        );
4386        let cip_data = self.extract_unconnected_data_item(response)?;
4387        tracing::trace!(
4388            "Found Unconnected Data Item, extracted CIP data ({} bytes)",
4389            cip_data.len()
4390        );
4391        tracing::trace!(
4392            "CIP data bytes: {:02X?}",
4393            &cip_data[..std::cmp::min(16, cip_data.len())]
4394        );
4395        self.unwrap_unconnected_send_reply(&cip_data)
4396    }
4397
4398    /// Parses CIP response and converts to `PlcValue`
4399    fn parse_cip_response(&self, cip_response: &[u8]) -> crate::error::Result<PlcValue> {
4400        tracing::trace!(
4401            "Parsing CIP response ({} bytes): {:02X?}",
4402            cip_response.len(),
4403            cip_response
4404        );
4405
4406        if let Err(e) = self.check_cip_error(cip_response) {
4407            tracing::error!("CIP Error: {}", e);
4408            return Err(e);
4409        }
4410
4411        let mut response_bytes = cip_response;
4412        let response = CipResponse::decode(&mut response_bytes)?;
4413
4414        if response.service == 0xCC {
4415            self.decode_type_prefixed_value(&response.data)
4416        } else if response.service == 0xCD {
4417            tracing::debug!("Write operation successful");
4418            Ok(PlcValue::Bool(true))
4419        } else {
4420            Err(EtherNetIpError::Protocol(format!(
4421                "Unknown service reply: 0x{:02X}",
4422                response.service
4423            )))
4424        }
4425    }
4426
4427    fn parse_read_fragmented_response<'a>(
4428        &self,
4429        cip_response: &'a [u8],
4430    ) -> crate::error::Result<(u8, &'a [u8])> {
4431        if cip_response.len() < 4 {
4432            return Err(EtherNetIpError::Protocol(
4433                "Read Tag Fragmented response too short".to_string(),
4434            ));
4435        }
4436
4437        let service = cip_response[0];
4438        if service != READ_TAG_FRAGMENTED_REPLY {
4439            return Err(EtherNetIpError::Protocol(format!(
4440                "Unexpected Read Tag Fragmented reply service: 0x{service:02X}"
4441            )));
4442        }
4443
4444        let status = cip_response[2];
4445        if status != CIP_STATUS_SUCCESS && status != CIP_STATUS_PARTIAL_TRANSFER {
4446            self.check_cip_error(cip_response)?;
4447        }
4448
4449        Ok((status, &cip_response[4..]))
4450    }
4451
4452    fn decode_type_prefixed_value(&self, data: &[u8]) -> crate::error::Result<PlcValue> {
4453        if data.len() < 2 {
4454            return Err(EtherNetIpError::Protocol(
4455                "Read response too short for data".to_string(),
4456            ));
4457        }
4458
4459        let data_type = u16::from_le_bytes([data[0], data[1]]);
4460        let value_data = &data[2..];
4461        tracing::trace!(
4462            "Data type: 0x{:04X}, Value data ({} bytes): {:02X?}",
4463            data_type,
4464            value_data.len(),
4465            value_data
4466        );
4467        Ok(values::decode_payload(data_type, value_data)?)
4468    }
4469
4470    /// Unregisters the EtherNet/IP session with the PLC
4471    pub async fn unregister_session(&mut self) -> crate::error::Result<()> {
4472        tracing::info!("Unregistering session...");
4473
4474        let mut packet = BytesMut::with_capacity(24);
4475        EncapsulationHeader::new(UNREGISTER_SESSION, 0, self.session_handle()).encode(&mut packet);
4476
4477        self.stream
4478            .lock()
4479            .await
4480            .write_all(&packet)
4481            .await
4482            .map_err(EtherNetIpError::Io)?;
4483
4484        tracing::info!("Session unregistered");
4485        Ok(())
4486    }
4487
4488    /// Builds a CIP Read Tag Service request
4489    fn build_read_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
4490        self.build_read_request_with_count(tag_name, 1)
4491    }
4492
4493    /// Builds a CIP Read Tag Service request with specified element count
4494    ///
4495    /// Reference: 1756-PM020, Page 220-252 (Read Tag Service)
4496    fn build_read_request_with_count(
4497        &self,
4498        tag_name: &str,
4499        element_count: u16,
4500    ) -> crate::error::Result<Vec<u8>> {
4501        tracing::debug!(
4502            "Building read request for tag: '{}' with count: {}",
4503            tag_name,
4504            element_count
4505        );
4506
4507        // Build the path based on tag name format
4508        let path = self.build_tag_path(tag_name);
4509
4510        // Request Path Size (in words)
4511        let path_size_words = (path.len() / 2) as u8;
4512        tracing::debug!(
4513            "Path size calculation: {} bytes / 2 = {} words for tag '{}'",
4514            path.len(),
4515            path_size_words,
4516            tag_name
4517        );
4518        tracing::debug!(
4519            "Path bytes ({} bytes, {} words) for tag '{}': {:02X?}",
4520            path.len(),
4521            path_size_words,
4522            tag_name,
4523            path
4524        );
4525        let request = CipRequest::new(READ_TAG, path, element_count.to_le_bytes().to_vec());
4526        let mut cip_request = BytesMut::new();
4527        request.encode(&mut cip_request)?;
4528
4529        tracing::debug!(
4530            "Built CIP read request ({} bytes) for tag '{}': {:02X?}",
4531            cip_request.len(),
4532            tag_name,
4533            cip_request
4534        );
4535        Ok(cip_request.to_vec())
4536    }
4537
4538    /// Builds an Element ID segment for array element addressing
4539    ///
4540    /// Reference: 1756-PM020, Pages 603-611, 870-890 (Element ID Segment Size Selection)
4541    ///
4542    /// Element ID segments use different sizes based on index value:
4543    /// - 0-255: 8-bit Element ID (0x28 + 1 byte value)
4544    /// - 256-65535: 16-bit Element ID (0x29 0x00 + 2 bytes low, high)
4545    /// - 65536+: 32-bit Element ID (0x2A 0x00 + 4 bytes lowest to highest)
4546    #[cfg_attr(not(test), allow(dead_code))]
4547    pub fn build_element_id_segment(&self, index: u32) -> Vec<u8> {
4548        let mut segment = Vec::new();
4549
4550        if index <= 255 {
4551            // 8-bit Element ID: 0x28 + index (2 bytes total)
4552            // Reference: 1756-PM020, Page 607, Example 1
4553            segment.push(0x28);
4554            segment.push(index as u8);
4555        } else if index <= 65535 {
4556            // 16-bit Element ID: 0x29, 0x00, low_byte, high_byte (4 bytes total)
4557            // Reference: 1756-PM020, Page 666-684, Example 3
4558            segment.push(0x29);
4559            segment.push(0x00); // Padding byte
4560            segment.extend_from_slice(&(index as u16).to_le_bytes());
4561        } else {
4562            // 32-bit Element ID: 0x2A, 0x00, byte0, byte1, byte2, byte3 (6 bytes total)
4563            // Reference: 1756-PM020, Page 144-146 (Element ID Segments table)
4564            segment.push(0x2A);
4565            segment.push(0x00); // Padding byte
4566            segment.extend_from_slice(&index.to_le_bytes());
4567        }
4568
4569        segment
4570    }
4571
4572    /// Builds base tag path without array element addressing
4573    ///
4574    /// Extracts the base tag name from array notation (e.g., `"MyArray[5]" -> "MyArray"`)
4575    /// Reference: 1756-PM020, Page 894-909 (ANSI Extended Symbol Segment Construction)
4576    #[cfg_attr(not(test), allow(dead_code))]
4577    pub fn build_base_tag_path(&self, tag_name: &str) -> Vec<u8> {
4578        // Parse tag path but strip array indices
4579        match TagPath::parse(tag_name) {
4580            Ok(path) => {
4581                // If it's an array path, get just the base
4582                let base_path = match &path {
4583                    TagPath::Array { base_path, .. } => base_path.as_ref(),
4584                    _ => &path,
4585                };
4586                base_path.to_cip_path().unwrap_or_else(|_| {
4587                    // Fallback: simple symbol segment
4588                    // Reference: 1756-PM020, Page 894-909
4589                    let mut path = Vec::new();
4590                    path.push(0x91); // ANSI Extended Symbol Segment
4591                    let name_bytes = tag_name.as_bytes();
4592                    path.push(name_bytes.len() as u8);
4593                    path.extend_from_slice(name_bytes);
4594                    // Pad to word boundary if odd length
4595                    if path.len() % 2 != 0 {
4596                        path.push(0x00);
4597                    }
4598                    path
4599                })
4600            }
4601            Err(_) => {
4602                // Fallback: simple symbol segment
4603                let mut path = Vec::new();
4604                path.push(0x91); // ANSI Extended Symbol Segment
4605                let name_bytes = tag_name.as_bytes();
4606                path.push(name_bytes.len() as u8);
4607                path.extend_from_slice(name_bytes);
4608                // Pad to word boundary if odd length
4609                if path.len() % 2 != 0 {
4610                    path.push(0x00);
4611                }
4612                path
4613            }
4614        }
4615    }
4616
4617    /// Builds a CIP Read Tag Service request for array elements with element addressing
4618    ///
4619    /// This method uses proper CIP element addressing (0x28/0x29/0x2A segments) in the
4620    /// Request Path to read specific array elements or ranges.
4621    ///
4622    /// Reference: 1756-PM020, Pages 603-611, 815-851 (Array Element Addressing Examples)
4623    ///
4624    /// # Arguments
4625    ///
4626    /// * `base_array_name` - Base name of the array (e.g., `"MyArray"` for `"MyArray[10]"`)
4627    /// * `start_index` - Starting element index (0-based)
4628    /// * `element_count` - Number of elements to read
4629    ///
4630    /// # Example
4631    ///
4632    /// Reading elements 10-14 of array "MyArray" (5 elements):
4633    /// ```
4634    /// # async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
4635    /// # let mut client = rust_ethernet_ip::EipClient::connect("192.168.1.100:44818").await?;
4636    /// let request = client.build_read_array_request("MyArray", 10, 5);
4637    /// # Ok(())
4638    /// # }
4639    /// ```
4640    ///
4641    /// This generates:
4642    /// - Request Path: `0x91 "MyArray" 0x28 0x0A` (element 10)
4643    /// - Request Data: `0x05 0x00` (5 elements)
4644    #[cfg_attr(not(test), allow(dead_code))]
4645    pub fn build_read_array_request(
4646        &self,
4647        base_array_name: &str,
4648        start_index: u32,
4649        element_count: u16,
4650    ) -> Vec<u8> {
4651        let mut cip_request = Vec::new();
4652
4653        // Service: Read Tag Service (0x4C)
4654        // Reference: 1756-PM020, Page 220
4655        cip_request.push(0x4C);
4656
4657        // Build base tag path (symbolic segment)
4658        // Reference: 1756-PM020, Page 894-909
4659        // NOTE: Route path does NOT go here - it goes at the end of Unconnected Send message
4660        // Reference: EtherNetIP_Connection_Paths_and_Routing.md
4661        let mut full_path = self.build_base_tag_path(base_array_name);
4662
4663        tracing::trace!(
4664            "build_read_array_request: base_path for '{}' = {:02X?} ({} bytes)",
4665            base_array_name,
4666            full_path,
4667            full_path.len()
4668        );
4669
4670        // Add element addressing segment
4671        // Reference: 1756-PM020, Pages 603-611, 870-890
4672        let element_segment = self.build_element_id_segment(start_index);
4673        tracing::trace!(
4674            "build_read_array_request: element_segment for index {} = {:02X?} ({} bytes)",
4675            start_index,
4676            element_segment,
4677            element_segment.len()
4678        );
4679        full_path.extend_from_slice(&element_segment);
4680
4681        // Ensure path is word-aligned
4682        if !full_path.len().is_multiple_of(2) {
4683            full_path.push(0x00);
4684        }
4685
4686        // Path size (in words)
4687        let path_size = (full_path.len() / 2) as u8;
4688        cip_request.push(path_size);
4689        cip_request.extend_from_slice(&full_path);
4690
4691        // Request Data: Element count (NOT in path, but in Request Data)
4692        // Reference: 1756-PM020, Page 840-851 (Reading Multiple Array Elements)
4693        cip_request.extend_from_slice(&element_count.to_le_bytes());
4694
4695        tracing::trace!(
4696            "build_read_array_request: final request = {:02X?} ({} bytes), path_size = {} words ({} bytes)",
4697            cip_request,
4698            cip_request.len(),
4699            path_size,
4700            full_path.len()
4701        );
4702
4703        cip_request
4704    }
4705
4706    /// Builds the symbolic CIP path for a tag name.
4707    /// Uses [`TagPath`] parsing to handle arrays, bits, UDTs, and program scope.
4708    ///
4709    /// Route-path bytes are not added here; routed requests carry the route path
4710    /// in the outer Unconnected Send wrapper.
4711    fn build_tag_path(&self, tag_name: &str) -> Vec<u8> {
4712        // Build the application path (tag name)
4713        // NOTE: Route path does NOT go here - it goes at the end of Unconnected Send message
4714        // Reference: EtherNetIP_Connection_Paths_and_Routing.md
4715        match TagPath::parse(tag_name) {
4716            Ok(tag_path) => {
4717                tracing::debug!("Parsed tag path for '{}': {:?}", tag_name, tag_path);
4718                // Generate CIP path using the proper parser
4719                match tag_path.to_cip_path() {
4720                    Ok(path) => {
4721                        tracing::debug!(
4722                            "TagPath generated {} bytes ({} words) for '{}': {:02X?}",
4723                            path.len(),
4724                            path.len() / 2,
4725                            tag_name,
4726                            path
4727                        );
4728                        path
4729                    }
4730                    Err(e) => {
4731                        tracing::warn!("TagPath.to_cip_path() failed for '{}': {}", tag_name, e);
4732                        // Fallback to old method if parsing fails
4733                        self.build_simple_tag_path_legacy(tag_name)
4734                    }
4735                }
4736            }
4737            Err(e) => {
4738                tracing::warn!("TagPath::parse() failed for '{}': {}", tag_name, e);
4739                // Fallback to old method if parsing fails
4740                self.build_simple_tag_path_legacy(tag_name)
4741            }
4742        }
4743    }
4744
4745    /// Builds a simple tag path (no program prefix) - legacy method for fallback
4746    fn build_simple_tag_path_legacy(&self, tag_name: &str) -> Vec<u8> {
4747        let mut path = Vec::new();
4748        path.push(0x91); // ANSI Extended Symbol Segment
4749        path.push(tag_name.len() as u8);
4750        path.extend_from_slice(tag_name.as_bytes());
4751
4752        // Pad to even length if necessary
4753        if !tag_name.len().is_multiple_of(2) {
4754            path.push(0x00);
4755        }
4756
4757        path
4758    }
4759}
4760
4761#[cfg(test)]
4762mod array_type_cache_tests {
4763    use super::EipClient;
4764    use crate::RoutePath;
4765    use crate::tag_manager::{TagMetadata, TagPermissions, TagScope};
4766    use crate::udt::{
4767        TagAttributes, TagPermissions as UdtTagPermissions, TagScope as UdtTagScope, UdtDefinition,
4768    };
4769    use std::sync::atomic::Ordering;
4770    use std::time::Instant;
4771
4772    #[test]
4773    fn cache_preserves_positive_and_negative_array_classifications() {
4774        let client = EipClient::new_unconnected_for_testing();
4775
4776        client.cache_array_is_packed_bool("BoolArray", true);
4777        client.cache_array_is_packed_bool("DintArray", false);
4778
4779        assert_eq!(client.cached_array_is_packed_bool("BoolArray"), Some(true));
4780        assert_eq!(client.cached_array_is_packed_bool("DintArray"), Some(false));
4781        assert_eq!(client.cached_array_is_packed_bool("UnknownArray"), None);
4782    }
4783
4784    #[test]
4785    fn cache_is_shared_across_client_clones_used_by_ffi() {
4786        let client = EipClient::new_unconnected_for_testing();
4787        let cloned = client.clone();
4788
4789        client.cache_array_is_packed_bool("ControllerArray", false);
4790        cloned.cache_array_is_packed_bool("Program:Main.BoolArray", true);
4791
4792        assert_eq!(
4793            cloned.cached_array_is_packed_bool("ControllerArray"),
4794            Some(false)
4795        );
4796        assert_eq!(
4797            client.cached_array_is_packed_bool("Program:Main.BoolArray"),
4798            Some(true)
4799        );
4800    }
4801
4802    #[test]
4803    fn route_changes_clear_array_classifications() {
4804        let mut client = EipClient::new_unconnected_for_testing();
4805        client.cache_array_is_packed_bool("SharedName", false);
4806
4807        client.set_route_path(RoutePath::new().add_slot(1));
4808        assert_eq!(client.cached_array_is_packed_bool("SharedName"), None);
4809
4810        client.cache_array_is_packed_bool("SharedName", true);
4811        client.clear_route_path();
4812        assert_eq!(client.cached_array_is_packed_bool("SharedName"), None);
4813    }
4814
4815    #[tokio::test]
4816    async fn public_cache_clear_removes_array_classifications() {
4817        let mut client = EipClient::new_unconnected_for_testing();
4818        client.cache_array_is_packed_bool("DintArray", false);
4819
4820        client.clear_caches().await;
4821
4822        assert_eq!(client.cached_array_is_packed_bool("DintArray"), None);
4823    }
4824
4825    #[tokio::test]
4826    async fn refresh_schema_clears_every_schema_cache_and_is_clone_visible() {
4827        let client = EipClient::new_unconnected_for_testing();
4828        let cloned = client.clone();
4829        client.cache_array_is_packed_bool("BoolArray", true);
4830
4831        {
4832            let tag_manager = client.tag_manager.lock().await;
4833            tag_manager
4834                .cache
4835                .write()
4836                .expect("tag metadata cache lock")
4837                .insert(
4838                    "CachedTag".to_string(),
4839                    TagMetadata {
4840                        data_type: 0x00C4,
4841                        size: 4,
4842                        is_array: false,
4843                        dimensions: Vec::new(),
4844                        permissions: TagPermissions {
4845                            readable: true,
4846                            writable: true,
4847                        },
4848                        scope: TagScope::Controller,
4849                        last_access: Instant::now(),
4850                        array_info: None,
4851                        last_updated: Instant::now(),
4852                    },
4853                );
4854            tag_manager
4855                .udt_definitions
4856                .write()
4857                .expect("tag UDT cache lock")
4858                .insert(
4859                    "CachedUdt".to_string(),
4860                    UdtDefinition {
4861                        name: "CachedUdt".to_string(),
4862                        members: Vec::new(),
4863                    },
4864                );
4865        }
4866        {
4867            let mut udt_manager = client.udt_manager.lock().await;
4868            udt_manager.add_definition(UdtDefinition {
4869                name: "ManagerUdt".to_string(),
4870                members: Vec::new(),
4871            });
4872            udt_manager.add_tag_attributes(TagAttributes {
4873                name: "ManagerTag".to_string(),
4874                data_type: 0x00C4,
4875                data_type_name: "DINT".to_string(),
4876                dimensions: Vec::new(),
4877                permissions: UdtTagPermissions::ReadWrite,
4878                scope: UdtTagScope::Controller,
4879                template_instance_id: None,
4880                size: 4,
4881            });
4882        }
4883
4884        let before = client.schema_generation();
4885        let after = cloned.refresh_schema().await;
4886
4887        assert_eq!(after, before + 1);
4888        assert_eq!(client.schema_generation(), after);
4889        assert_eq!(client.cached_array_is_packed_bool("BoolArray"), None);
4890        assert!(client.get_tag_metadata("CachedTag").await.is_none());
4891        assert!(client.list_udt_definitions().await.is_empty());
4892        let udt_manager = client.udt_manager.lock().await;
4893        assert!(udt_manager.list_definitions().is_empty());
4894        assert!(udt_manager.list_templates().is_empty());
4895        assert!(udt_manager.list_tag_attributes().is_empty());
4896    }
4897
4898    #[tokio::test]
4899    async fn stale_generation_cannot_repopulate_array_classification() {
4900        let client = EipClient::new_unconnected_for_testing();
4901        let stale_generation = client.schema_generation();
4902
4903        client.refresh_schema().await;
4904
4905        assert!(!client.cache_array_is_packed_bool_at_generation(
4906            "ReplacedArray",
4907            true,
4908            stale_generation,
4909        ));
4910        assert_eq!(client.cached_array_is_packed_bool("ReplacedArray"), None);
4911    }
4912
4913    #[test]
4914    fn route_changes_advance_the_shared_schema_generation() {
4915        let mut client = EipClient::new_unconnected_for_testing();
4916        let cloned = client.clone();
4917        let initial = client.schema_generation();
4918
4919        client.set_route_path(RoutePath::new().add_slot(1));
4920        assert_eq!(cloned.schema_generation(), initial + 1);
4921
4922        client.clear_route_path();
4923        assert_eq!(cloned.schema_generation(), initial + 2);
4924        assert_eq!(
4925            client
4926                .diagnostic_counters
4927                .schema_refreshes
4928                .load(Ordering::Relaxed),
4929            0,
4930            "route invalidation advances generation but is not an explicit refresh"
4931        );
4932    }
4933}
4934
4935#[cfg(test)]
4936mod discovery_tests {
4937    use super::{EipClient, TemplateAttributes};
4938
4939    #[test]
4940    fn build_tag_list_request_rejects_instance_above_u16() {
4941        let client = EipClient::new_unconnected_for_testing();
4942        let request = client
4943            .build_tag_list_request_from_instance(0x12345678)
4944            .expect_err("instance should be rejected");
4945
4946        assert!(format!("{request}").contains("exceeds 16-bit"));
4947    }
4948
4949    #[test]
4950    fn build_tag_list_request_encodes_path_size_and_start_instance() {
4951        let client = EipClient::new_unconnected_for_testing();
4952        let request = client
4953            .build_tag_list_request_from_instance(0x5678)
4954            .expect("request should build");
4955
4956        assert_eq!(request[0], 0x55);
4957        assert_eq!(request[1], 0x03);
4958        assert_eq!(&request[2..8], &[0x20, 0x6B, 0x25, 0x00, 0x78, 0x56]);
4959    }
4960
4961    #[test]
4962    fn build_program_tag_list_request_includes_program_symbol_scope() {
4963        let client = EipClient::new_unconnected_for_testing();
4964        let request = client
4965            .build_program_tag_list_request("MainProgram", 0)
4966            .expect("request should build");
4967
4968        let mut expected = vec![0x55, 0x0E, 0x91, 0x13];
4969        expected.extend_from_slice(b"Program:MainProgram");
4970        expected.push(0x00);
4971        expected.extend_from_slice(&[
4972            0x20, 0x6B, 0x25, 0x00, 0x00, 0x00, // Symbol class, instance 0
4973            0x02, 0x00, // attribute count
4974            0x01, 0x00, // Symbol Name
4975            0x02, 0x00, // Symbol Type
4976        ]);
4977
4978        assert_eq!(request, expected);
4979    }
4980
4981    #[test]
4982    fn build_program_tag_list_request_resumes_at_start_instance() {
4983        let client = EipClient::new_unconnected_for_testing();
4984        let request = client
4985            .build_program_tag_list_request("MainProgram", 0x1235)
4986            .expect("request should build");
4987
4988        // Only the instance segment moves: the program symbolic path, the
4989        // service and the requested attributes are identical to instance 0.
4990        let mut expected = vec![0x55, 0x0E, 0x91, 0x13];
4991        expected.extend_from_slice(b"Program:MainProgram");
4992        expected.push(0x00);
4993        expected.extend_from_slice(&[
4994            0x20, 0x6B, 0x25, 0x00, 0x35, 0x12, // Symbol class, instance 0x1235 LE
4995            0x02, 0x00, // attribute count
4996            0x01, 0x00, // Symbol Name
4997            0x02, 0x00, // Symbol Type
4998        ]);
4999
5000        assert_eq!(request, expected);
5001    }
5002
5003    #[test]
5004    fn both_accepted_program_name_forms_yield_the_bare_scope() {
5005        // `discover_program_tags` accepts a program either bare or in its wire
5006        // form; both designate the same program, so both must stamp the same
5007        // `TagScope::Program("Dashboard")` -- never `Program("Program:Dashboard")`.
5008        // This is the exact expression `discover_program_tags` builds.
5009        let bare =
5010            super::udt::TagScope::Program(super::program_scope_name("Dashboard").to_string());
5011        let prefixed = super::udt::TagScope::Program(
5012            super::program_scope_name("Program:Dashboard").to_string(),
5013        );
5014
5015        assert_eq!(
5016            bare,
5017            super::udt::TagScope::Program("Dashboard".to_string()),
5018            "a bare program name is already the scope payload"
5019        );
5020        assert_eq!(
5021            prefixed,
5022            super::udt::TagScope::Program("Dashboard".to_string()),
5023            "the wire prefix belongs to the request path, not to the scope payload"
5024        );
5025    }
5026
5027    #[test]
5028    fn both_accepted_program_name_forms_build_the_same_request() {
5029        // The same normalization feeds the request path: prefixing an already
5030        // prefixed name would address `Program:Program:Dashboard`.
5031        let client = EipClient::new_unconnected_for_testing();
5032        let bare = client
5033            .build_program_tag_list_request("Dashboard", 0)
5034            .expect("request should build");
5035        let prefixed = client
5036            .build_program_tag_list_request("Program:Dashboard", 0)
5037            .expect("request should build");
5038
5039        assert_eq!(bare, prefixed);
5040
5041        let mut expected = vec![0x55, 0x0D, 0x91, 0x11];
5042        expected.extend_from_slice(b"Program:Dashboard");
5043        expected.push(0x00);
5044        expected.extend_from_slice(&[
5045            0x20, 0x6B, 0x25, 0x00, 0x00, 0x00, // Symbol class, instance 0
5046            0x02, 0x00, // attribute count
5047            0x01, 0x00, // Symbol Name
5048            0x02, 0x00, // Symbol Type
5049        ]);
5050        assert_eq!(bare, expected);
5051    }
5052
5053    #[test]
5054    fn build_program_tag_list_request_rejects_out_of_range_start_instance() {
5055        let client = EipClient::new_unconnected_for_testing();
5056        let error = client
5057            .build_program_tag_list_request("MainProgram", u32::from(u16::MAX) + 1)
5058            .expect_err("start instance beyond the 16-bit range must be refused");
5059
5060        assert!(
5061            error.to_string().contains("16-bit Symbol Object range"),
5062            "unexpected error: {error}"
5063        );
5064    }
5065
5066    #[test]
5067    fn parse_tag_list_response_page_handles_partial_transfer() {
5068        let client = EipClient::new_unconnected_for_testing();
5069        let response = [
5070            0xD5, 0x00, 0x06,
5071            0x00, // service, reserved, partial-transfer status, no addl status
5072            0x34, 0x12, 0x00, 0x00, // instance id = 0x1234
5073            0x04, 0x00, // name length = 4
5074            b'R', b'a', b't', b'e', // tag name
5075            0xC4, 0x00, // DINT
5076        ];
5077
5078        let page = client
5079            .parse_tag_list_response_page(&response, super::udt::TagScope::Controller)
5080            .expect("response should parse");
5081
5082        assert!(page.partial_transfer);
5083        assert_eq!(page.last_instance_id, Some(0x1234));
5084        assert_eq!(page.tags.len(), 1);
5085        assert_eq!(page.tags[0].name, "Rate");
5086        assert_eq!(page.tags[0].data_type, 0x00C4);
5087        assert_eq!(page.tags[0].data_type_name, "DINT");
5088    }
5089
5090    #[test]
5091    fn parse_tag_list_response_page_stamps_the_requested_scope() {
5092        // The Symbol Object reply carries NO scope field: these exact bytes are
5093        // what a program-scoped enumeration and a controller-scoped one both
5094        // return. The scope therefore has to come from the request the caller
5095        // issued -- hardcoding `Controller` in the parser made every tag from
5096        // `discover_program_tags` claim controller scope.
5097        let client = EipClient::new_unconnected_for_testing();
5098        let response = [
5099            0xD5, 0x00, 0x00, 0x00, // service, reserved, success, no addl status
5100            0x01, 0x00, 0x00, 0x00, // instance id = 1
5101            0x04, 0x00, // name length = 4
5102            b'P', b'u', b'm', b'p', // tag name
5103            0xC4, 0x00, // DINT
5104        ];
5105
5106        let program = client
5107            .parse_tag_list_response_page(
5108                &response,
5109                super::udt::TagScope::Program("Dashboard".to_string()),
5110            )
5111            .expect("response should parse");
5112        assert_eq!(
5113            program.tags[0].scope,
5114            super::udt::TagScope::Program("Dashboard".to_string()),
5115            "a program-scoped enumeration must report the program it was addressed to"
5116        );
5117
5118        let controller = client
5119            .parse_tag_list_response_page(&response, super::udt::TagScope::Controller)
5120            .expect("response should parse");
5121        assert_eq!(
5122            controller.tags[0].scope,
5123            super::udt::TagScope::Controller,
5124            "the SAME bytes stay controller-scoped when the request was"
5125        );
5126    }
5127
5128    #[test]
5129    fn build_get_template_attributes_request_encodes_template_object_path() {
5130        let client = EipClient::new_unconnected_for_testing();
5131        let request = client
5132            .build_get_template_attributes_request(0x0456)
5133            .expect("request should build");
5134
5135        assert_eq!(request[0], 0x03);
5136        assert_eq!(request[1], 0x03);
5137        assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
5138        assert_eq!(
5139            &request[8..],
5140            &[0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x05, 0x00]
5141        );
5142    }
5143
5144    #[test]
5145    fn build_get_attributes_request_encodes_path_words_and_odd_name_padding() {
5146        let client = EipClient::new_unconnected_for_testing();
5147        let request = client
5148            .build_get_attributes_request("Odd")
5149            .expect("request should build");
5150
5151        assert_eq!(
5152            request,
5153            vec![
5154                0x03, // Get Attribute List
5155                0x03, // path size: 6 bytes / 2
5156                0x91, 0x03, b'O', b'd', b'd', 0x00, // padded symbolic segment
5157                0x02, 0x00, // two attributes
5158                0x01, 0x00, // data type
5159                0x02, 0x00, // instance id
5160            ]
5161        );
5162    }
5163
5164    #[test]
5165    fn parse_attributes_response_walks_attribute_records() {
5166        let client = EipClient::new_unconnected_for_testing();
5167        let response = [
5168            0x83, 0x00, 0x00, 0x00, // reply, reserved, success, no addl status
5169            0x02, 0x00, // two attribute records
5170            0x01, 0x00, 0x00, 0x00, 0xC4, 0x00, // attr 1 = DINT
5171            0x02, 0x00, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00, // attr 2 = instance
5172        ];
5173
5174        let attributes = client
5175            .parse_attributes_response("DINT_TAG", &response)
5176            .expect("response should parse");
5177
5178        assert_eq!(attributes.name, "DINT_TAG");
5179        assert_eq!(attributes.data_type, 0x00C4);
5180        assert_eq!(attributes.data_type_name, "DINT");
5181        assert_eq!(attributes.template_instance_id, Some(0x1234));
5182        assert_eq!(attributes.size, 4);
5183    }
5184
5185    #[test]
5186    fn extended_status_parser_uses_little_endian_additional_status() {
5187        let client = EipClient::new_unconnected_for_testing();
5188        let response = [0xCD, 0x00, 0xFF, 0x01, 0x07, 0x21];
5189
5190        let err = client
5191            .check_cip_error(&response)
5192            .expect_err("extended status should be an error");
5193        let message = err.to_string();
5194
5195        assert!(message.contains("0x2107"));
5196        assert!(message.contains("data-type mismatch"));
5197        assert!(!message.contains("(BE)"));
5198        assert!(!message.contains("0x0721"));
5199    }
5200
5201    #[test]
5202    fn extended_status_parser_does_not_require_general_status_ff() {
5203        let client = EipClient::new_unconnected_for_testing();
5204        let response = [0xCC, 0x00, 0x01, 0x01, 0x05, 0x00];
5205
5206        let err = client
5207            .check_cip_error(&response)
5208            .expect_err("additional status should be decoded");
5209
5210        assert!(err.to_string().contains("Path destination unknown"));
5211    }
5212
5213    #[test]
5214    fn build_read_template_request_encodes_template_read_size() {
5215        let client = EipClient::new_unconnected_for_testing();
5216        let request = client
5217            .build_read_template_request(0x0456, 0x0010, 0x0032)
5218            .expect("request should build");
5219
5220        assert_eq!(request[0], 0x4C);
5221        assert_eq!(request[1], 0x03);
5222        assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
5223        assert_eq!(&request[8..12], &[0x10, 0x00, 0x00, 0x00]);
5224        assert_eq!(&request[12..14], &[0x32, 0x00]);
5225    }
5226
5227    #[test]
5228    fn parse_template_attributes_response_reads_mixed_width_values() {
5229        let client = EipClient::new_unconnected_for_testing();
5230        let response = [
5231            0x83, 0x00, 0x00, 0x00, // service reply, reserved, success, no addl status
5232            0x04, 0x00, // four attributes
5233            0x01, 0x00, 0x00, 0x00, 0x34, 0x12, // attr 1 = structure handle
5234            0x02, 0x00, 0x00, 0x00, 0x07, 0x00, // attr 2 = member count
5235            0x04, 0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, // attr 4 = definition words
5236            0x05, 0x00, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, // attr 5 = structure bytes
5237        ];
5238
5239        let attributes = client
5240            .parse_template_attributes_response(0x0456, &response)
5241            .expect("response should parse");
5242
5243        assert_eq!(
5244            attributes,
5245            TemplateAttributes {
5246                structure_handle: 0x1234,
5247                member_count: 7,
5248                definition_size_words: 25,
5249                structure_size_bytes: 88,
5250            }
5251        );
5252    }
5253}
5254
5255#[cfg(test)]
5256mod write_request_tests {
5257    use super::EipClient;
5258    use crate::PlcValue;
5259    use crate::protocol::values;
5260
5261    #[test]
5262    fn build_write_request_encodes_standard_string_structure() {
5263        let client = EipClient::new_unconnected_for_testing();
5264        let request = client
5265            .build_write_request("Tag1", &PlcValue::String("AB".to_string()))
5266            .expect("STRING request should build");
5267
5268        assert_eq!(
5269            &request[..8],
5270            &[0x4D, 0x03, 0x91, 0x04, b'T', b'a', b'g', b'1']
5271        );
5272        let data = &request[8..];
5273        assert_eq!(&data[..6], &[0xA0, 0x02, 0xCE, 0x0F, 0x01, 0x00]);
5274        assert_eq!(&data[6..12], &[2, 0, 0, 0, b'A', b'B']);
5275        assert_eq!(data.len(), 6 + values::STANDARD_STRING_PAYLOAD_LEN);
5276        assert!(data[12..].iter().all(|byte| *byte == 0));
5277    }
5278
5279    #[test]
5280    fn build_write_request_rejects_overlong_standard_string() {
5281        let client = EipClient::new_unconnected_for_testing();
5282        let value = PlcValue::String("x".repeat(values::STANDARD_STRING_DATA_LEN + 1));
5283        let err = client
5284            .build_write_request("Tag1", &value)
5285            .expect_err("overlong STRING should be rejected");
5286
5287        assert!(err.to_string().contains("String too long"));
5288    }
5289}
5290
5291#[cfg(test)]
5292mod transport_tests {
5293    use super::{DiagnosticOperation, EipClient};
5294    use crate::EtherNetIpStream;
5295    use crate::error::EtherNetIpError;
5296    use std::sync::Arc;
5297    use std::time::Duration;
5298    use tokio::io::{AsyncReadExt, AsyncWriteExt};
5299    use tokio::sync::Mutex;
5300
5301    fn register_response(session_handle: u32) -> Vec<u8> {
5302        let mut response = Vec::with_capacity(28);
5303        response.extend_from_slice(&0x0065u16.to_le_bytes());
5304        response.extend_from_slice(&4u16.to_le_bytes());
5305        response.extend_from_slice(&session_handle.to_le_bytes());
5306        response.extend_from_slice(&0u32.to_le_bytes());
5307        response.extend_from_slice(&[0u8; 8]);
5308        response.extend_from_slice(&0u32.to_le_bytes());
5309        response.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]);
5310        response
5311    }
5312
5313    async fn read_register_request(stream: &mut tokio::io::DuplexStream) {
5314        let mut header = [0u8; 24];
5315        stream
5316            .read_exact(&mut header)
5317            .await
5318            .expect("request header");
5319        let body_len = u16::from_le_bytes([header[2], header[3]]) as usize;
5320        let mut body = vec![0u8; body_len];
5321        stream.read_exact(&mut body).await.expect("request body");
5322    }
5323
5324    #[tokio::test]
5325    async fn register_session_accepts_fragmented_reply() {
5326        let (client_stream, mut server_stream) = tokio::io::duplex(128);
5327        let mut client = EipClient::new_unconnected_for_testing();
5328        client.stream = Arc::new(Mutex::new(
5329            Box::new(client_stream) as Box<dyn EtherNetIpStream>
5330        ));
5331
5332        let server = tokio::spawn(async move {
5333            read_register_request(&mut server_stream).await;
5334            let response = register_response(0x0102_0304);
5335            server_stream
5336                .write_all(&response[..10])
5337                .await
5338                .expect("first fragment");
5339            tokio::task::yield_now().await;
5340            server_stream
5341                .write_all(&response[10..])
5342                .await
5343                .expect("second fragment");
5344        });
5345
5346        client
5347            .register_session()
5348            .await
5349            .expect("fragmented register response should parse");
5350        server.await.expect("server task");
5351
5352        assert_eq!(client.session_handle(), 0x0102_0304);
5353    }
5354
5355    #[tokio::test]
5356    async fn reregistration_updates_session_handle_across_clones() {
5357        let (client_stream, mut server_stream) = tokio::io::duplex(256);
5358        let mut client = EipClient::new_unconnected_for_testing();
5359        client.stream = Arc::new(Mutex::new(
5360            Box::new(client_stream) as Box<dyn EtherNetIpStream>
5361        ));
5362        let mut clone = client.clone();
5363
5364        let server = tokio::spawn(async move {
5365            for handle in [0x1111_2222, 0x3333_4444] {
5366                read_register_request(&mut server_stream).await;
5367                server_stream
5368                    .write_all(&register_response(handle))
5369                    .await
5370                    .expect("register response");
5371            }
5372        });
5373
5374        client.register_session().await.expect("first register");
5375        assert_eq!(client.session_handle(), 0x1111_2222);
5376        assert_eq!(clone.session_handle(), 0x1111_2222);
5377
5378        clone.register_session().await.expect("clone re-register");
5379        server.await.expect("server task");
5380
5381        assert_eq!(client.session_handle(), 0x3333_4444);
5382        assert_eq!(clone.session_handle(), 0x3333_4444);
5383    }
5384
5385    #[tokio::test]
5386    async fn diagnostics_snapshot_reports_counted_operations_and_errors() {
5387        let client = EipClient::new_unconnected_for_testing();
5388
5389        client
5390            .diagnostic_counters
5391            .record_success(Some(DiagnosticOperation::Read));
5392        client.diagnostic_counters.record_failure(
5393            Some(DiagnosticOperation::Write),
5394            &EtherNetIpError::Timeout(Duration::from_secs(1)),
5395        );
5396        client
5397            .diagnostic_counters
5398            .record_cip_failure(Some(DiagnosticOperation::Batch));
5399        assert_eq!(client.cached_array_is_packed_bool("MISSING_ARRAY"), None);
5400        client.cache_array_is_packed_bool("DIAG_ARRAY", false);
5401        assert_eq!(
5402            client.cached_array_is_packed_bool("DIAG_ARRAY"),
5403            Some(false)
5404        );
5405        client
5406            .diagnostic_counters
5407            .schema_type_contradictions
5408            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5409        client
5410            .diagnostic_counters
5411            .schema_read_recoveries_succeeded
5412            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5413        client
5414            .diagnostic_counters
5415            .schema_read_recoveries_failed
5416            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5417        let generation = client.refresh_schema().await;
5418
5419        let snapshot = client.get_diagnostics_snapshot().await;
5420        let schema_cache = client.schema_cache_metrics();
5421
5422        assert_eq!(snapshot.operations.total_reads, 1);
5423        assert_eq!(snapshot.operations.successful_reads, 1);
5424        assert_eq!(snapshot.operations.total_writes, 1);
5425        assert_eq!(snapshot.operations.failed_writes, 1);
5426        assert_eq!(snapshot.operations.batch_operations, 1);
5427        assert_eq!(snapshot.operations.partial_batch_failures, 1);
5428        assert_eq!(snapshot.errors.timeout_errors, 1);
5429        assert_eq!(snapshot.errors.protocol_errors, 1);
5430        assert_eq!(snapshot.errors.retriable_errors, 1);
5431        assert_eq!(snapshot.errors.non_retriable_errors, 1);
5432        assert!(snapshot.operations.last_successful_read_time.is_some());
5433        assert!(snapshot.operations.last_failed_write_time.is_some());
5434        assert!(snapshot.errors.last_error_time.is_some());
5435        assert_eq!(schema_cache.generation, generation);
5436        assert_eq!(schema_cache.refreshes, 1);
5437        assert_eq!(schema_cache.array_classification_hits, 1);
5438        assert_eq!(schema_cache.array_classification_misses, 1);
5439        assert_eq!(schema_cache.array_classification_evictions, 1);
5440        assert_eq!(schema_cache.datatype_contradictions, 1);
5441        assert_eq!(schema_cache.successful_read_recoveries, 1);
5442        assert_eq!(schema_cache.failed_read_recoveries, 1);
5443        assert!(snapshot.system_metrics_are_placeholders);
5444    }
5445}
5446
5447/*
5448===============================================================================
5449END OF LIBRARY DOCUMENTATION
5450
5451This file provides a complete, production-ready EtherNet/IP communication
5452library for Allen-Bradley PLCs. The library includes:
5453
5454- Native Rust API with async support
5455- C FFI exports for cross-language integration
5456- Comprehensive error handling and validation
5457- Detailed documentation and examples
5458- Performance optimizations
5459- Memory safety guarantees
5460
5461For usage examples, see the main.rs file or the C# integration samples.
5462
5463For technical details about the EtherNet/IP protocol implementation,
5464refer to the inline documentation above.
5465
5466Version: 1.0.0
5467Compatible with: CompactLogix L1x-L5x series PLCs
5468License: As specified in Cargo.toml
5469===============================================================================_
5470*/