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