1use crate::EtherNetIpStream;
2use crate::batch::{BatchConfig, BatchOperation};
3use crate::error::{EtherNetIpError, Result};
4use crate::protocol::cip::{
5 CipRequest, CipResponse, MULTIPLE_SERVICE_PACKET, READ_TAG, SendDataRequest, WRITE_TAG,
6};
7use crate::protocol::encap::{EncapsulationHeader, REGISTER_SESSION, UNREGISTER_SESSION};
8use crate::protocol::values;
9use crate::protocol::{Decode, Encode};
10use crate::route::RoutePath;
11use crate::subscription::TagSubscription;
12use crate::tag_group::TagGroupConfig;
13use crate::tag_manager::{TagManager, TagMetadata, TagPermissions, TagScope};
14use crate::types::{PlcValue, UdtData};
15use crate::udt::{TagAttributes, UdtDefinition, UdtManager};
16use crate::{TagPath, udt};
17use bytes::BytesMut;
18use std::collections::HashMap;
19use std::net::SocketAddr;
20#[cfg(feature = "ffi")]
21use std::sync::LazyLock;
22use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
23use std::sync::{Arc, Mutex as StdMutex};
24use std::time::{SystemTime, UNIX_EPOCH};
25use tokio::io::{AsyncReadExt, AsyncWriteExt};
26use tokio::net::TcpStream;
27#[cfg(feature = "ffi")]
28use tokio::runtime::Runtime;
29use tokio::sync::Mutex;
30use tokio::time::{Duration, Instant, timeout};
31
32mod actor;
33mod batch_exec;
34mod diagnostics;
35mod schema_export;
36mod service_layer;
37mod string;
38mod subscriptions;
39
40pub use actor::{Backoff, Client, ConnectionEvent, RetryClient, RetryPolicy};
41
42const READ_TAG_FRAGMENTED: u8 = 0x52;
43const WRITE_TAG_FRAGMENTED: u8 = 0x53;
44const READ_TAG_FRAGMENTED_REPLY: u8 = 0xD2;
45const WRITE_TAG_FRAGMENTED_REPLY: u8 = 0xD3;
46const CIP_STATUS_SUCCESS: u8 = 0x00;
47const CIP_STATUS_PARTIAL_TRANSFER: u8 = 0x06;
48
49#[derive(Debug)]
50struct TagListPage {
51 tags: Vec<TagAttributes>,
52 last_instance_id: Option<u32>,
53 partial_transfer: bool,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57struct TemplateAttributes {
58 structure_handle: u16,
59 member_count: u16,
60 definition_size_words: u32,
61 structure_size_bytes: u32,
62}
63
64#[derive(Debug, Clone, Copy)]
65enum DiagnosticOperation {
66 Read,
67 Write,
68 Batch,
69}
70
71#[derive(Debug, Default)]
72struct DiagnosticCounters {
73 total_reads: AtomicU64,
74 total_writes: AtomicU64,
75 successful_reads: AtomicU64,
76 successful_writes: AtomicU64,
77 failed_reads: AtomicU64,
78 failed_writes: AtomicU64,
79 batch_operations: AtomicU64,
80 partial_batch_failures: AtomicU64,
81 network_errors: AtomicU64,
82 protocol_errors: AtomicU64,
83 timeout_errors: AtomicU64,
84 tag_not_found_errors: AtomicU64,
85 data_type_errors: AtomicU64,
86 session_errors: AtomicU64,
87 route_path_errors: AtomicU64,
88 embedded_service_errors: AtomicU64,
89 known_controller_limitation_errors: AtomicU64,
90 retriable_errors: AtomicU64,
91 non_retriable_errors: AtomicU64,
92 last_successful_read_time: AtomicU64,
93 last_failed_read_time: AtomicU64,
94 last_successful_write_time: AtomicU64,
95 last_failed_write_time: AtomicU64,
96 last_error_time: AtomicU64,
97 last_error_category: AtomicU8,
98 schema_refreshes: AtomicU64,
99 array_cache_hits: AtomicU64,
100 array_cache_misses: AtomicU64,
101 array_cache_evictions: AtomicU64,
102 schema_type_contradictions: AtomicU64,
103 schema_read_recoveries_succeeded: AtomicU64,
104 schema_read_recoveries_failed: AtomicU64,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108struct ArrayTypeCacheEntry {
109 generation: u64,
110 is_packed_bool: bool,
111}
112
113impl DiagnosticCounters {
114 fn record_success(&self, operation: Option<DiagnosticOperation>) {
115 let now = current_unix_seconds();
116 match operation {
117 Some(DiagnosticOperation::Read) => {
118 self.total_reads.fetch_add(1, Ordering::Relaxed);
119 self.successful_reads.fetch_add(1, Ordering::Relaxed);
120 self.last_successful_read_time.store(now, Ordering::Relaxed);
121 }
122 Some(DiagnosticOperation::Write) => {
123 self.total_writes.fetch_add(1, Ordering::Relaxed);
124 self.successful_writes.fetch_add(1, Ordering::Relaxed);
125 self.last_successful_write_time
126 .store(now, Ordering::Relaxed);
127 }
128 Some(DiagnosticOperation::Batch) => {
129 self.batch_operations.fetch_add(1, Ordering::Relaxed);
130 }
131 None => {}
132 }
133 }
134
135 fn record_cip_failure(&self, operation: Option<DiagnosticOperation>) {
136 let category = crate::ErrorCategory::CipProtocol;
137 self.record_operation_failure(operation, current_unix_seconds());
138 self.protocol_errors.fetch_add(1, Ordering::Relaxed);
139 self.non_retriable_errors.fetch_add(1, Ordering::Relaxed);
140 self.store_last_error(category);
141 }
142
143 fn record_failure(&self, operation: Option<DiagnosticOperation>, error: &EtherNetIpError) {
144 let now = current_unix_seconds();
145 let category = diagnostic_error_category(error);
146 self.record_operation_failure(operation, now);
147
148 match category {
149 crate::ErrorCategory::Network => self.network_errors.fetch_add(1, Ordering::Relaxed),
150 crate::ErrorCategory::Timeout => self.timeout_errors.fetch_add(1, Ordering::Relaxed),
151 crate::ErrorCategory::Session => self.session_errors.fetch_add(1, Ordering::Relaxed),
152 crate::ErrorCategory::RoutePath => {
153 self.route_path_errors.fetch_add(1, Ordering::Relaxed)
154 }
155 crate::ErrorCategory::CipProtocol => {
156 self.protocol_errors.fetch_add(1, Ordering::Relaxed)
157 }
158 crate::ErrorCategory::BatchEmbeddedService => {
159 self.embedded_service_errors.fetch_add(1, Ordering::Relaxed)
160 }
161 crate::ErrorCategory::KnownControllerLimitation => self
162 .known_controller_limitation_errors
163 .fetch_add(1, Ordering::Relaxed),
164 crate::ErrorCategory::DataType => self.data_type_errors.fetch_add(1, Ordering::Relaxed),
165 crate::ErrorCategory::NotFound => {
166 self.tag_not_found_errors.fetch_add(1, Ordering::Relaxed)
167 }
168 crate::ErrorCategory::Unknown => self.protocol_errors.fetch_add(1, Ordering::Relaxed),
169 };
170
171 if error.is_retriable() || category.is_retriable() {
172 self.retriable_errors.fetch_add(1, Ordering::Relaxed);
173 } else {
174 self.non_retriable_errors.fetch_add(1, Ordering::Relaxed);
175 }
176 self.store_last_error(category);
177 }
178
179 fn record_operation_failure(&self, operation: Option<DiagnosticOperation>, now: u64) {
180 match operation {
181 Some(DiagnosticOperation::Read) => {
182 self.total_reads.fetch_add(1, Ordering::Relaxed);
183 self.failed_reads.fetch_add(1, Ordering::Relaxed);
184 self.last_failed_read_time.store(now, Ordering::Relaxed);
185 }
186 Some(DiagnosticOperation::Write) => {
187 self.total_writes.fetch_add(1, Ordering::Relaxed);
188 self.failed_writes.fetch_add(1, Ordering::Relaxed);
189 self.last_failed_write_time.store(now, Ordering::Relaxed);
190 }
191 Some(DiagnosticOperation::Batch) => {
192 self.batch_operations.fetch_add(1, Ordering::Relaxed);
193 self.partial_batch_failures.fetch_add(1, Ordering::Relaxed);
194 }
195 None => {}
196 }
197 }
198
199 fn store_last_error(&self, category: crate::ErrorCategory) {
200 self.last_error_time
201 .store(current_unix_seconds(), Ordering::Relaxed);
202 self.last_error_category
203 .store(error_category_to_code(category), Ordering::Relaxed);
204 }
205
206 fn operation_metrics(&self) -> crate::OperationMetrics {
207 crate::OperationMetrics {
208 total_reads: self.total_reads.load(Ordering::Relaxed),
209 total_writes: self.total_writes.load(Ordering::Relaxed),
210 successful_reads: self.successful_reads.load(Ordering::Relaxed),
211 successful_writes: self.successful_writes.load(Ordering::Relaxed),
212 failed_reads: self.failed_reads.load(Ordering::Relaxed),
213 failed_writes: self.failed_writes.load(Ordering::Relaxed),
214 batch_operations: self.batch_operations.load(Ordering::Relaxed),
215 subscription_updates: 0,
216 partial_batch_failures: self.partial_batch_failures.load(Ordering::Relaxed),
217 last_successful_read_time: unix_seconds_to_system_time(
218 self.last_successful_read_time.load(Ordering::Relaxed),
219 ),
220 last_failed_read_time: unix_seconds_to_system_time(
221 self.last_failed_read_time.load(Ordering::Relaxed),
222 ),
223 last_successful_write_time: unix_seconds_to_system_time(
224 self.last_successful_write_time.load(Ordering::Relaxed),
225 ),
226 last_failed_write_time: unix_seconds_to_system_time(
227 self.last_failed_write_time.load(Ordering::Relaxed),
228 ),
229 }
230 }
231
232 fn error_metrics(&self) -> crate::ErrorMetrics {
233 let last_error_category =
234 error_category_from_code(self.last_error_category.load(Ordering::Relaxed));
235 crate::ErrorMetrics {
236 network_errors: self.network_errors.load(Ordering::Relaxed),
237 protocol_errors: self.protocol_errors.load(Ordering::Relaxed),
238 timeout_errors: self.timeout_errors.load(Ordering::Relaxed),
239 tag_not_found_errors: self.tag_not_found_errors.load(Ordering::Relaxed),
240 data_type_errors: self.data_type_errors.load(Ordering::Relaxed),
241 session_errors: self.session_errors.load(Ordering::Relaxed),
242 route_path_errors: self.route_path_errors.load(Ordering::Relaxed),
243 embedded_service_errors: self.embedded_service_errors.load(Ordering::Relaxed),
244 known_controller_limitation_errors: self
245 .known_controller_limitation_errors
246 .load(Ordering::Relaxed),
247 retriable_errors: self.retriable_errors.load(Ordering::Relaxed),
248 non_retriable_errors: self.non_retriable_errors.load(Ordering::Relaxed),
249 last_error_time: unix_seconds_to_system_time(
250 self.last_error_time.load(Ordering::Relaxed),
251 ),
252 last_error_message: last_error_category.map(|category| {
253 format!("Most recent counted client operation failed: {category:?}")
254 }),
255 last_error_category,
256 last_retriable_error_time: if last_error_category.is_some_and(|c| c.is_retriable()) {
257 unix_seconds_to_system_time(self.last_error_time.load(Ordering::Relaxed))
258 } else {
259 None
260 },
261 }
262 }
263}
264
265fn current_unix_seconds() -> u64 {
266 SystemTime::now()
267 .duration_since(UNIX_EPOCH)
268 .unwrap_or_default()
269 .as_secs()
270}
271
272fn unix_seconds_to_system_time(seconds: u64) -> Option<SystemTime> {
273 (seconds != 0).then(|| UNIX_EPOCH + Duration::from_secs(seconds))
274}
275
276fn error_category_to_code(category: crate::ErrorCategory) -> u8 {
277 match category {
278 crate::ErrorCategory::Network => 1,
279 crate::ErrorCategory::Timeout => 2,
280 crate::ErrorCategory::Session => 3,
281 crate::ErrorCategory::RoutePath => 4,
282 crate::ErrorCategory::CipProtocol => 5,
283 crate::ErrorCategory::BatchEmbeddedService => 6,
284 crate::ErrorCategory::KnownControllerLimitation => 7,
285 crate::ErrorCategory::DataType => 8,
286 crate::ErrorCategory::NotFound => 9,
287 crate::ErrorCategory::Unknown => 10,
288 }
289}
290
291fn error_category_from_code(code: u8) -> Option<crate::ErrorCategory> {
292 match code {
293 1 => Some(crate::ErrorCategory::Network),
294 2 => Some(crate::ErrorCategory::Timeout),
295 3 => Some(crate::ErrorCategory::Session),
296 4 => Some(crate::ErrorCategory::RoutePath),
297 5 => Some(crate::ErrorCategory::CipProtocol),
298 6 => Some(crate::ErrorCategory::BatchEmbeddedService),
299 7 => Some(crate::ErrorCategory::KnownControllerLimitation),
300 8 => Some(crate::ErrorCategory::DataType),
301 9 => Some(crate::ErrorCategory::NotFound),
302 10 => Some(crate::ErrorCategory::Unknown),
303 _ => None,
304 }
305}
306
307fn diagnostic_error_category(error: &EtherNetIpError) -> crate::ErrorCategory {
308 match error {
309 EtherNetIpError::Io(_) | EtherNetIpError::ConnectionLost(_) => {
310 crate::ErrorCategory::Network
311 }
312 EtherNetIpError::Timeout(_) => crate::ErrorCategory::Timeout,
313 EtherNetIpError::Connection(_) => crate::ErrorCategory::Session,
314 EtherNetIpError::TagNotFound(_) => crate::ErrorCategory::NotFound,
315 EtherNetIpError::DataTypeMismatch { .. }
316 | EtherNetIpError::StringTooLong { .. }
317 | EtherNetIpError::InvalidString { .. } => crate::ErrorCategory::DataType,
318 EtherNetIpError::ReadError { .. }
319 | EtherNetIpError::WriteError { .. }
320 | EtherNetIpError::CipError { .. } => crate::ErrorCategory::CipProtocol,
321 EtherNetIpError::Protocol(message)
322 if message.contains("route") || message.contains("Route") =>
323 {
324 crate::ErrorCategory::RoutePath
325 }
326 EtherNetIpError::Protocol(message)
327 if message.contains("Embedded service") || message.contains("Multiple Service") =>
328 {
329 crate::ErrorCategory::BatchEmbeddedService
330 }
331 EtherNetIpError::Protocol(_) | EtherNetIpError::InvalidResponse { .. } => {
332 crate::ErrorCategory::CipProtocol
333 }
334 EtherNetIpError::Udt(_)
335 | EtherNetIpError::Tag(_)
336 | EtherNetIpError::Permission(_)
337 | EtherNetIpError::Utf8(_)
338 | EtherNetIpError::Other(_)
339 | EtherNetIpError::Subscription(_)
340 | EtherNetIpError::Unsupported { .. } => crate::ErrorCategory::Unknown,
341 }
342}
343
344#[cfg(feature = "ffi")]
346pub(crate) static RUNTIME: LazyLock<std::io::Result<Runtime>> = LazyLock::new(Runtime::new);
347
348#[derive(Clone)]
525pub struct EipClient {
526 stream: Arc<Mutex<Box<dyn EtherNetIpStream>>>,
528 session_handle: Arc<AtomicU32>,
530 stream_poisoned: Arc<AtomicBool>,
532 sender_context_counter: Arc<AtomicU64>,
534 diagnostic_counters: Arc<DiagnosticCounters>,
536 tag_manager: Arc<Mutex<TagManager>>,
538 udt_manager: Arc<Mutex<UdtManager>>,
540 schema_generation: Arc<AtomicU64>,
542 tag_manager_generation: Arc<AtomicU64>,
544 udt_manager_generation: Arc<AtomicU64>,
546 array_type_cache: Arc<StdMutex<HashMap<String, ArrayTypeCacheEntry>>>,
548 route_path: Arc<StdMutex<Option<RoutePath>>>,
550 max_packet_size: Arc<AtomicU32>,
552 last_activity: Arc<Mutex<Instant>>,
554 batch_config: BatchConfig,
556 subscriptions: Arc<Mutex<Vec<TagSubscription>>>,
558 tag_groups: Arc<Mutex<HashMap<String, TagGroupConfig>>>,
560}
561
562#[cfg(test)]
563const _: fn() = || {
564 fn assert_send_sync_static<T: Send + Sync + 'static>() {}
565 assert_send_sync_static::<EipClient>();
566};
567
568impl std::fmt::Debug for EipClient {
569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570 f.debug_struct("EipClient")
571 .field("session_handle", &self.session_handle())
572 .field("stream_poisoned", &self.stream_poisoned())
573 .field("route_path", &self.route_path_snapshot())
574 .field("max_packet_size", &self.max_packet_size())
575 .field("batch_config", &self.batch_config)
576 .field("stream", &"<stream>")
577 .field("diagnostic_counters", &"<diagnostic_counters>")
578 .field("tag_manager", &"<tag_manager>")
579 .field("udt_manager", &"<udt_manager>")
580 .field("schema_generation", &self.schema_generation())
581 .field("array_type_cache", &"<array_type_cache>")
582 .field("subscriptions", &"<subscriptions>")
583 .field("tag_groups", &"<tag_groups>")
584 .finish()
585 }
586}
587
588fn program_scope_name(program_name: &str) -> &str {
597 program_name
598 .strip_prefix("Program:")
599 .unwrap_or(program_name)
600}
601
602impl EipClient {
603 async fn from_stream<S>(stream: S) -> Result<Self>
606 where
607 S: EtherNetIpStream + 'static,
608 {
609 let mut client = Self {
610 stream: Arc::new(Mutex::new(Box::new(stream))),
611 session_handle: Arc::new(AtomicU32::new(0)),
612 stream_poisoned: Arc::new(AtomicBool::new(false)),
613 sender_context_counter: Arc::new(AtomicU64::new(1)),
614 diagnostic_counters: Arc::new(DiagnosticCounters::default()),
615 tag_manager: Arc::new(Mutex::new(TagManager::new())),
616 udt_manager: Arc::new(Mutex::new(UdtManager::new())),
617 schema_generation: Arc::new(AtomicU64::new(1)),
618 tag_manager_generation: Arc::new(AtomicU64::new(1)),
619 udt_manager_generation: Arc::new(AtomicU64::new(1)),
620 array_type_cache: Arc::new(StdMutex::new(HashMap::new())),
621 route_path: Arc::new(StdMutex::new(None)),
622 max_packet_size: Arc::new(AtomicU32::new(4000)),
623 last_activity: Arc::new(Mutex::new(Instant::now())),
624 batch_config: BatchConfig::default(),
625 subscriptions: Arc::new(Mutex::new(Vec::new())),
626 tag_groups: Arc::new(Mutex::new(HashMap::new())),
627 };
628 client.register_session().await?;
629 client.negotiate_packet_size().await?;
630 Ok(client)
631 }
632
633 pub async fn new(addr: &str) -> Result<Self> {
637 let addr = addr
638 .parse::<SocketAddr>()
639 .map_err(|e| EtherNetIpError::Protocol(format!("Invalid address format: {e}")))?;
640 let stream = TcpStream::connect(addr).await?;
641 Self::from_stream(stream).await
642 }
643
644 pub async fn connect(addr: &str) -> Result<Self> {
646 Self::new(addr).await
647 }
648
649 #[cfg(test)]
650 fn new_unconnected_for_testing() -> Self {
651 let (stream, _peer) = tokio::io::duplex(64);
652 Self {
653 stream: Arc::new(Mutex::new(Box::new(stream))),
654 session_handle: Arc::new(AtomicU32::new(0)),
655 stream_poisoned: Arc::new(AtomicBool::new(false)),
656 sender_context_counter: Arc::new(AtomicU64::new(1)),
657 diagnostic_counters: Arc::new(DiagnosticCounters::default()),
658 tag_manager: Arc::new(Mutex::new(TagManager::new())),
659 udt_manager: Arc::new(Mutex::new(UdtManager::new())),
660 schema_generation: Arc::new(AtomicU64::new(1)),
661 tag_manager_generation: Arc::new(AtomicU64::new(1)),
662 udt_manager_generation: Arc::new(AtomicU64::new(1)),
663 array_type_cache: Arc::new(StdMutex::new(HashMap::new())),
664 route_path: Arc::new(StdMutex::new(None)),
665 max_packet_size: Arc::new(AtomicU32::new(4000)),
666 last_activity: Arc::new(Mutex::new(Instant::now())),
667 batch_config: BatchConfig::default(),
668 subscriptions: Arc::new(Mutex::new(Vec::new())),
669 tag_groups: Arc::new(Mutex::new(HashMap::new())),
670 }
671 }
672
673 async fn register_session(&mut self) -> crate::error::Result<()> {
695 self.ensure_stream_usable()?;
696 tracing::debug!("Starting session registration...");
697 let mut packet = BytesMut::with_capacity(28);
698 EncapsulationHeader::new(REGISTER_SESSION, 4, 0).encode(&mut packet);
699 packet.extend_from_slice(&[0x01, 0x00]); packet.extend_from_slice(&[0x00, 0x00]); tracing::trace!("Sending Register Session packet: {:02X?}", packet);
703 let mut stream = self.stream.lock().await;
704 self.ensure_stream_usable()?;
705 self.stream_poisoned.store(true, Ordering::Relaxed);
708 if let Err(e) = stream.write_all(&packet).await {
709 tracing::error!("Failed to send Register Session packet: {}", e);
710 return Err(EtherNetIpError::Io(e));
711 }
712
713 let mut header_buf = [0u8; 24];
714 tracing::debug!("Waiting for Register Session response...");
715 match timeout(Duration::from_secs(5), stream.read_exact(&mut header_buf)).await {
716 Ok(Ok(_)) => {
717 tracing::trace!("Received Register Session response header");
718 }
719 Ok(Err(e)) => {
720 tracing::error!("Error reading response: {}", e);
721 return Err(EtherNetIpError::Io(e));
722 }
723 Err(_) => {
724 tracing::warn!("Timeout waiting for response");
725 return Err(EtherNetIpError::Timeout(Duration::from_secs(5)));
726 }
727 };
728
729 let mut header_bytes = &header_buf[..];
730 let header = EncapsulationHeader::decode(&mut header_bytes)?;
731 let mut body = vec![0u8; header.length as usize];
732 if !body.is_empty() {
733 match timeout(Duration::from_secs(5), stream.read_exact(&mut body)).await {
734 Ok(Ok(_)) => {}
735 Ok(Err(e)) => {
736 tracing::error!("Error reading response body: {}", e);
737 return Err(EtherNetIpError::Io(e));
738 }
739 Err(_) => {
740 tracing::warn!("Timeout waiting for response body");
741 return Err(EtherNetIpError::Timeout(Duration::from_secs(5)));
742 }
743 }
744 }
745
746 self.stream_poisoned.store(false, Ordering::Relaxed);
747
748 self.set_session_handle(header.session_handle);
750 tracing::debug!("Session handle: 0x{:08X}", self.session_handle());
751
752 let status = header.status;
754 tracing::trace!("Status code: 0x{:08X}", status);
755
756 if status != 0 {
757 tracing::error!("Session registration failed with status: 0x{:08X}", status);
758 return Err(EtherNetIpError::Protocol(format!(
759 "Session registration failed with status: 0x{status:08X}"
760 )));
761 }
762
763 tracing::info!("Session registration successful");
764 Ok(())
765 }
766
767 pub fn set_max_packet_size(&mut self, size: u32) {
769 self.max_packet_size
770 .store(size.min(4000), Ordering::Relaxed);
771 }
772
773 pub(crate) fn max_packet_size(&self) -> u32 {
774 self.max_packet_size.load(Ordering::Relaxed)
775 }
776
777 pub(crate) fn session_handle(&self) -> u32 {
778 self.session_handle.load(Ordering::Relaxed)
779 }
780
781 fn set_session_handle(&self, session_handle: u32) {
782 self.session_handle.store(session_handle, Ordering::Relaxed);
783 }
784
785 fn stream_poisoned(&self) -> bool {
786 self.stream_poisoned.load(Ordering::Relaxed)
787 }
788
789 fn next_sender_context(&self) -> [u8; 8] {
790 self.sender_context_counter
791 .fetch_add(1, Ordering::Relaxed)
792 .to_le_bytes()
793 }
794
795 fn diagnostic_operation_for(cip_request: &[u8]) -> Option<DiagnosticOperation> {
796 match cip_request.first().copied() {
797 Some(READ_TAG | READ_TAG_FRAGMENTED) => Some(DiagnosticOperation::Read),
798 Some(WRITE_TAG | WRITE_TAG_FRAGMENTED) => Some(DiagnosticOperation::Write),
799 Some(MULTIPLE_SERVICE_PACKET) => Some(DiagnosticOperation::Batch),
800 _ => None,
801 }
802 }
803
804 fn ensure_stream_usable(&self) -> crate::error::Result<()> {
805 if self.stream_poisoned() {
806 return Err(EtherNetIpError::ConnectionLost(
807 "connection stream is poisoned after an incomplete transaction; reconnect required"
808 .to_string(),
809 ));
810 }
811 Ok(())
812 }
813
814 fn route_path_snapshot(&self) -> Option<RoutePath> {
815 self.route_path
816 .lock()
817 .unwrap_or_else(|poisoned| poisoned.into_inner())
818 .clone()
819 }
820
821 pub async fn discover_tags(&mut self) -> crate::error::Result<()> {
823 let generation = self.schema_generation();
824 let response = self
825 .send_cip_request(&self.build_list_tags_request())
826 .await?;
827
828 let cip_data = self.extract_cip_from_response(&response)?;
830
831 if let Err(e) = self.check_cip_error(&cip_data) {
833 return Err(crate::error::EtherNetIpError::Protocol(format!(
834 "Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
835 e
836 )));
837 }
838
839 let tags = {
840 let tag_manager = self.tag_manager.lock().await;
841 tag_manager.parse_tag_list(&cip_data)?
842 };
843
844 tracing::debug!("Initial tag discovery found {} tags", tags.len());
845
846 let hierarchical_tags = {
848 let tag_manager = self.tag_manager.lock().await;
849 let hierarchical_tags = tag_manager.drill_down_tags(&tags).await?;
850 drop(tag_manager);
851 hierarchical_tags
852 };
853
854 tracing::debug!(
855 "After drill-down: {} total tags discovered",
856 hierarchical_tags.len()
857 );
858
859 {
860 let tag_manager = self.tag_manager.lock().await;
861 if self.schema_generation() != generation {
862 tracing::debug!(
863 "discarding tag discovery cache fill from schema generation {generation}"
864 );
865 return Ok(());
866 }
867 if self.tag_manager_generation.load(Ordering::Acquire) != generation {
868 tag_manager.clear_cache().await?;
869 tag_manager.clear_udt_cache();
870 }
871 let mut cache = tag_manager.cache.write()?;
872 for (name, metadata) in hierarchical_tags {
873 cache.insert(name, metadata);
874 }
875 self.tag_manager_generation
876 .store(generation, Ordering::Release);
877 }
878 Ok(())
879 }
880
881 pub async fn discover_udt_members(
883 &mut self,
884 udt_name: &str,
885 ) -> crate::error::Result<Vec<(String, TagMetadata)>> {
886 let generation = self.schema_generation();
887 let definition = self.get_udt_definition(udt_name).await?;
888
889 {
891 let tag_manager = self.tag_manager.lock().await;
892 if self.schema_generation() == generation {
893 if self.tag_manager_generation.load(Ordering::Acquire) != generation {
894 tag_manager.clear_cache().await?;
895 tag_manager.clear_udt_cache();
896 }
897 let mut definitions = tag_manager.udt_definitions.write()?;
898 definitions.insert(udt_name.to_string(), definition.clone());
899 self.tag_manager_generation
900 .store(generation, Ordering::Release);
901 }
902 }
903
904 let mut members = Vec::new();
906 for member in &definition.members {
907 let member_name = member.name.clone();
908 let full_name = format!("{}.{}", udt_name, member_name);
909
910 let metadata = TagMetadata {
911 data_type: member.data_type,
912 scope: TagScope::Controller,
913 permissions: TagPermissions {
914 readable: true,
915 writable: true,
916 },
917 is_array: false,
918 dimensions: Vec::new(),
919 last_access: std::time::Instant::now(),
920 size: member.size,
921 array_info: None,
922 last_updated: std::time::Instant::now(),
923 };
924
925 members.push((full_name, metadata));
926 }
927
928 Ok(members)
929 }
930
931 pub async fn get_udt_definition_cached(&self, udt_name: &str) -> Option<UdtDefinition> {
933 if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
934 return None;
935 }
936 let tag_manager = self.tag_manager.lock().await;
937 tag_manager.get_udt_definition_cached(udt_name)
938 }
939
940 pub async fn list_udt_definitions(&self) -> Vec<String> {
942 if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
943 return Vec::new();
944 }
945 let tag_manager = self.tag_manager.lock().await;
946 tag_manager.list_udt_definitions()
947 }
948
949 pub async fn discover_tags_detailed(&mut self) -> crate::error::Result<Vec<TagAttributes>> {
952 let (tags, _) = self.discover_tags_detailed_internal(false).await?;
953 Ok(tags)
954 }
955
956 async fn discover_tags_detailed_internal(
957 &mut self,
958 best_effort: bool,
959 ) -> crate::error::Result<(Vec<TagAttributes>, Vec<String>)> {
960 let mut start_instance = 0u32;
961 let mut tags = Vec::new();
962 let mut warnings = Vec::new();
963
964 loop {
965 let request = self.build_tag_list_request_from_instance(start_instance)?;
966 let response = match self.send_cip_request(&request).await {
967 Ok(response) => response,
968 Err(err) if best_effort && !tags.is_empty() => {
969 warnings.push(format!(
970 "Tag discovery stopped early at instance {} after transport/protocol failure: {}",
971 start_instance, err
972 ));
973 break;
974 }
975 Err(err) => return Err(err),
976 };
977 let cip_data = match self.extract_cip_from_response(&response) {
978 Ok(cip_data) => cip_data,
979 Err(err) if best_effort && !tags.is_empty() => {
980 warnings.push(format!(
981 "Tag discovery stopped early at instance {} after response extraction failure: {}",
982 start_instance, err
983 ));
984 break;
985 }
986 Err(err) => return Err(err),
987 };
988 let page = match self.parse_tag_list_response_page(&cip_data, udt::TagScope::Controller)
989 {
990 Ok(page) => page,
991 Err(err) if best_effort && !tags.is_empty() => {
992 warnings.push(format!(
993 "Tag discovery stopped early at instance {} after page-parse failure: {}",
994 start_instance, err
995 ));
996 break;
997 }
998 Err(err) => return Err(err),
999 };
1000
1001 tags.extend(page.tags);
1002
1003 if !page.partial_transfer {
1004 break;
1005 }
1006
1007 let Some(last_instance_id) = page.last_instance_id else {
1008 return Err(crate::error::EtherNetIpError::Protocol(
1009 "Tag discovery returned Partial transfer without a last instance ID"
1010 .to_string(),
1011 ));
1012 };
1013
1014 if last_instance_id == u32::MAX || last_instance_id < start_instance {
1015 return Err(crate::error::EtherNetIpError::Protocol(format!(
1016 "Tag discovery pagination stalled at instance {}",
1017 last_instance_id
1018 )));
1019 }
1020
1021 start_instance = last_instance_id.saturating_add(1);
1022 }
1023
1024 Ok((tags, warnings))
1025 }
1026
1027 pub async fn discover_program_tags(
1037 &mut self,
1038 program_name: &str,
1039 ) -> crate::error::Result<Vec<TagAttributes>> {
1040 let mut start_instance = 0u32;
1041 let mut tags = Vec::new();
1042 let scope = udt::TagScope::Program(program_scope_name(program_name).to_string());
1047
1048 loop {
1049 let request = self.build_program_tag_list_request(program_name, start_instance)?;
1050 let response = self.send_cip_request(&request).await?;
1051 let cip_data = self.extract_cip_from_response(&response)?;
1052
1053 let page = self
1054 .parse_tag_list_response_page(&cip_data, scope.clone())
1055 .map_err(|e| {
1056 crate::error::EtherNetIpError::Protocol(format!(
1057 "Program tag discovery failed for '{}': {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
1058 program_name, e
1059 ))
1060 })?;
1061
1062 tags.extend(page.tags);
1063
1064 if !page.partial_transfer {
1065 break;
1066 }
1067
1068 let Some(last_instance_id) = page.last_instance_id else {
1069 return Err(crate::error::EtherNetIpError::Protocol(format!(
1070 "Program tag discovery for '{}' returned Partial transfer without a last instance ID",
1071 program_name
1072 )));
1073 };
1074
1075 if last_instance_id == u32::MAX || last_instance_id < start_instance {
1076 return Err(crate::error::EtherNetIpError::Protocol(format!(
1077 "Program tag discovery for '{}' pagination stalled at instance {}",
1078 program_name, last_instance_id
1079 )));
1080 }
1081
1082 start_instance = last_instance_id.saturating_add(1);
1083 }
1084
1085 Ok(tags)
1086 }
1087
1088 pub async fn list_cached_tag_attributes(&self) -> Vec<String> {
1090 if self.udt_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
1091 return Vec::new();
1092 }
1093 self.udt_manager.lock().await.list_tag_attributes()
1094 }
1095
1096 pub fn schema_generation(&self) -> u64 {
1098 self.schema_generation.load(Ordering::Acquire)
1099 }
1100
1101 fn advance_schema_generation(&self) -> u64 {
1102 let generation = self.schema_generation.fetch_add(1, Ordering::AcqRel) + 1;
1103 self.tag_manager_generation.store(0, Ordering::Release);
1104 self.udt_manager_generation.store(0, Ordering::Release);
1105 self.clear_array_type_cache();
1106 generation
1107 }
1108
1109 pub async fn refresh_schema(&self) -> u64 {
1116 let generation = self.advance_schema_generation();
1117 self.diagnostic_counters
1118 .schema_refreshes
1119 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1120
1121 {
1122 let tag_manager = self.tag_manager.lock().await;
1123 if let Err(error) = tag_manager.clear_cache().await {
1124 tracing::warn!("failed to clear tag metadata cache: {error}");
1125 }
1126 tag_manager.clear_udt_cache();
1127 self.tag_manager_generation
1128 .store(generation, Ordering::Release);
1129 }
1130
1131 {
1132 self.udt_manager.lock().await.clear_cache();
1133 self.udt_manager_generation
1134 .store(generation, Ordering::Release);
1135 }
1136
1137 generation
1138 }
1139
1140 pub async fn clear_caches(&mut self) {
1142 self.refresh_schema().await;
1143 }
1144
1145 pub async fn with_route_path(addr: &str, route: RoutePath) -> crate::error::Result<Self> {
1147 let mut client = Self::new(addr).await?;
1148 client.set_route_path(route);
1149 Ok(client)
1150 }
1151
1152 pub async fn connect_with_stream<S>(stream: S, route: Option<RoutePath>) -> Result<Self>
1180 where
1181 S: EtherNetIpStream + 'static,
1182 {
1183 let mut client = Self::from_stream(stream).await?;
1184 if let Some(route) = route {
1185 client.set_route_path(route);
1186 }
1187 Ok(client)
1188 }
1189
1190 pub fn set_route_path(&mut self, route: RoutePath) {
1192 self.advance_schema_generation();
1193 *self
1194 .route_path
1195 .lock()
1196 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(route);
1197 }
1198
1199 pub fn get_route_path(&self) -> Option<RoutePath> {
1201 self.route_path_snapshot()
1202 }
1203
1204 pub fn clear_route_path(&mut self) {
1206 self.advance_schema_generation();
1207 *self
1208 .route_path
1209 .lock()
1210 .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
1211 }
1212
1213 fn cached_array_is_packed_bool(&self, array_path: &str) -> Option<bool> {
1214 let generation = self.schema_generation();
1215 let mut cache = self
1216 .array_type_cache
1217 .lock()
1218 .unwrap_or_else(|poisoned| poisoned.into_inner());
1219 match cache.get(array_path).copied() {
1220 Some(entry) if entry.generation == generation => {
1221 self.diagnostic_counters
1222 .array_cache_hits
1223 .fetch_add(1, Ordering::Relaxed);
1224 Some(entry.is_packed_bool)
1225 }
1226 Some(_) => {
1227 cache.remove(array_path);
1228 self.diagnostic_counters
1229 .array_cache_evictions
1230 .fetch_add(1, Ordering::Relaxed);
1231 self.diagnostic_counters
1232 .array_cache_misses
1233 .fetch_add(1, Ordering::Relaxed);
1234 None
1235 }
1236 None => {
1237 self.diagnostic_counters
1238 .array_cache_misses
1239 .fetch_add(1, Ordering::Relaxed);
1240 None
1241 }
1242 }
1243 }
1244
1245 #[cfg(test)]
1246 fn cache_array_is_packed_bool(&self, array_path: &str, is_packed_bool: bool) {
1247 let generation = self.schema_generation();
1248 self.cache_array_is_packed_bool_at_generation(array_path, is_packed_bool, generation);
1249 }
1250
1251 fn cache_array_is_packed_bool_at_generation(
1252 &self,
1253 array_path: &str,
1254 is_packed_bool: bool,
1255 generation: u64,
1256 ) -> bool {
1257 let mut cache = self
1258 .array_type_cache
1259 .lock()
1260 .unwrap_or_else(|poisoned| poisoned.into_inner());
1261 if self.schema_generation() != generation {
1262 return false;
1263 }
1264 cache.insert(
1265 array_path.to_string(),
1266 ArrayTypeCacheEntry {
1267 generation,
1268 is_packed_bool,
1269 },
1270 );
1271 true
1272 }
1273
1274 fn clear_array_type_cache(&self) {
1275 let mut cache = self
1276 .array_type_cache
1277 .lock()
1278 .unwrap_or_else(|poisoned| poisoned.into_inner());
1279 let evicted = cache.len() as u64;
1280 cache.clear();
1281 self.diagnostic_counters
1282 .array_cache_evictions
1283 .fetch_add(evicted, Ordering::Relaxed);
1284 }
1285
1286 fn evict_array_type_cache_entry(&self, array_path: &str) -> bool {
1287 let removed = self
1288 .array_type_cache
1289 .lock()
1290 .unwrap_or_else(|poisoned| poisoned.into_inner())
1291 .remove(array_path)
1292 .is_some();
1293 if removed {
1294 self.diagnostic_counters
1295 .array_cache_evictions
1296 .fetch_add(1, Ordering::Relaxed);
1297 }
1298 removed
1299 }
1300
1301 fn array_path_for_schema_recovery(&self, tag_name: &str) -> Option<String> {
1302 self.parse_array_element_access(tag_name)
1303 .map(|(base, _)| base)
1304 .or_else(|| {
1305 self.parse_final_array_element_access(tag_name)
1306 .map(|(base, _)| base)
1307 })
1308 }
1309
1310 fn is_schema_drift_read_error(error: &EtherNetIpError) -> bool {
1311 if matches!(error, EtherNetIpError::DataTypeMismatch { .. }) {
1312 return true;
1313 }
1314 let message = error.to_string().to_ascii_lowercase();
1315 [
1316 "cip error 0x04",
1317 "cip error 0x05",
1318 "cip error 0x16",
1319 "0x2107",
1320 "expected bool array dword",
1321 ]
1322 .iter()
1323 .any(|marker| message.contains(marker))
1324 }
1325
1326 pub async fn get_tag_metadata(&self, tag_name: &str) -> Option<TagMetadata> {
1328 if self.tag_manager_generation.load(Ordering::Acquire) != self.schema_generation() {
1329 return None;
1330 }
1331 let tag_manager = self.tag_manager.lock().await;
1332 match tag_manager.cache.read() {
1333 Ok(cache) => cache.get(tag_name).cloned(),
1334 Err(_) => {
1335 tracing::warn!("failed to read tag metadata cache: lock poisoned");
1336 None
1337 }
1338 }
1339 }
1340
1341 pub async fn read_tag(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1407 self.validate_session().await?;
1408
1409 let result = self.read_tag_once(tag_name).await;
1410 let Err(error) = result else {
1411 return result;
1412 };
1413 let Some(array_path) = self.array_path_for_schema_recovery(tag_name) else {
1414 return Err(error);
1415 };
1416 if !Self::is_schema_drift_read_error(&error) {
1417 return Err(error);
1418 }
1419
1420 self.evict_array_type_cache_entry(&array_path);
1421 self.diagnostic_counters
1422 .schema_type_contradictions
1423 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1424 tracing::warn!(
1425 "schema drift suspected for '{tag_name}'; evicted '{array_path}' classification and retrying read once"
1426 );
1427
1428 match self.read_tag_once(tag_name).await {
1429 Ok(value) => {
1430 self.diagnostic_counters
1431 .schema_read_recoveries_succeeded
1432 .fetch_add(1, Ordering::Relaxed);
1433 Ok(value)
1434 }
1435 Err(retry_error) => {
1436 self.diagnostic_counters
1437 .schema_read_recoveries_failed
1438 .fetch_add(1, Ordering::Relaxed);
1439 Err(retry_error)
1440 }
1441 }
1442 }
1443
1444 async fn read_tag_once(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1445 if let Some((base_path, bit_index)) = self.parse_bit_access(tag_name) {
1446 return self
1447 .read_bit_base_direct(&base_path, bit_index)
1448 .await
1449 .map(PlcValue::Bool);
1450 }
1451
1452 if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
1456 if let Some(bracket_start) = tag_name.find('[')
1459 && let Some(bracket_end_rel) = tag_name[bracket_start..].find(']')
1460 {
1461 let bracket_end_abs = bracket_start + bracket_end_rel;
1462 let after_bracket = &tag_name[bracket_end_abs + 1..];
1463 tracing::debug!(
1464 "Array element detected for '{}': base='{}', index={}, after_bracket='{}'",
1465 tag_name,
1466 base_name,
1467 index,
1468 after_bracket
1469 );
1470 if !after_bracket.starts_with('.') {
1472 tracing::debug!(
1473 "Detected simple array element access: {}[{}], using workaround",
1474 base_name,
1475 index
1476 );
1477 return self.read_array_element_workaround(&base_name, index).await;
1478 } else {
1479 tracing::debug!(
1480 "Array element '{}[{}]' has member access after bracket ('{}'), using TagPath::parse()",
1481 base_name,
1482 index,
1483 after_bracket
1484 );
1485 }
1486 }
1487 }
1488
1489 if let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
1493 && self.detect_bool_array_path(&parent_path).await?
1494 {
1495 return self
1496 .read_bool_array_element_workaround(&parent_path, index)
1497 .await;
1498 }
1499
1500 self.read_tag_direct(tag_name).await
1501 }
1502
1503 async fn read_tag_direct(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1504 let response = self
1505 .send_cip_request(&self.build_read_request(tag_name)?)
1506 .await?;
1507 let cip_data = self.extract_cip_from_response(&response)?;
1508 if cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1509 return self.read_tag_fragmented(tag_name).await;
1510 }
1511 if let Some((array_path, _)) = self.parse_final_array_element_access(tag_name)
1512 && self.cached_array_is_packed_bool(&array_path) == Some(false)
1513 && cip_data.len() >= 6
1514 {
1515 let returned_type = u16::from_le_bytes([cip_data[4], cip_data[5]]);
1516 if returned_type == values::BOOL_ARRAY_DWORD {
1517 return Err(EtherNetIpError::DataTypeMismatch {
1518 expected: "ordinary array element".to_string(),
1519 actual: "packed BOOL array DWORD".to_string(),
1520 });
1521 }
1522 }
1523 self.parse_cip_response(&cip_data)
1524 }
1525
1526 async fn read_tag_fragmented(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
1527 let mut offset = 0u32;
1528 let mut reassembled = Vec::new();
1529
1530 loop {
1531 let request = self.build_read_fragmented_request(tag_name, 1, offset)?;
1532 let response = self.send_cip_request(&request).await?;
1533 let cip_data = self.extract_unconnected_data_item(&response)?;
1534 let (status, fragment) = self.parse_read_fragmented_response(&cip_data)?;
1535
1536 if fragment.is_empty() && status == CIP_STATUS_PARTIAL_TRANSFER {
1537 return Err(EtherNetIpError::Protocol(format!(
1538 "Read Tag Fragmented for '{tag_name}' returned an empty partial fragment at offset {offset}"
1539 )));
1540 }
1541
1542 offset = offset
1543 .checked_add(fragment.len() as u32)
1544 .ok_or_else(|| EtherNetIpError::Protocol("fragment offset overflow".to_string()))?;
1545 reassembled.extend_from_slice(fragment);
1546
1547 if status == CIP_STATUS_SUCCESS {
1548 break;
1549 }
1550 }
1551
1552 self.decode_type_prefixed_value(&reassembled)
1553 }
1554
1555 pub async fn read_bit(&mut self, tag_base: &str, bit_index: u8) -> crate::error::Result<bool> {
1566 self.validate_session().await?;
1567 self.read_bit_base_direct(tag_base, bit_index).await
1568 }
1569
1570 async fn read_bit_base_direct(
1571 &mut self,
1572 tag_base: &str,
1573 bit_index: u8,
1574 ) -> crate::error::Result<bool> {
1575 if bit_index >= 32 {
1576 return Err(crate::error::EtherNetIpError::Protocol(
1577 "bit_index must be 0..32 for DINT bit access".to_string(),
1578 ));
1579 }
1580 match self.read_tag_direct(tag_base).await? {
1583 PlcValue::Bool(b) => Ok(b),
1584 PlcValue::Dint(n) => Ok((n >> bit_index) & 1 != 0),
1585 other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
1586 expected: "BOOL or DINT".to_string(),
1587 actual: format!("{:?}", other),
1588 }),
1589 }
1590 }
1591
1592 pub async fn write_bit(
1603 &mut self,
1604 tag_base: &str,
1605 bit_index: u8,
1606 value: bool,
1607 ) -> crate::error::Result<()> {
1608 self.validate_session().await?;
1609 self.write_bit_base_direct(tag_base, bit_index, value).await
1610 }
1611
1612 async fn write_bit_base_direct(
1613 &mut self,
1614 tag_base: &str,
1615 bit_index: u8,
1616 value: bool,
1617 ) -> crate::error::Result<()> {
1618 if bit_index >= 32 {
1619 return Err(crate::error::EtherNetIpError::Protocol(
1620 "bit_index must be 0..32 for DINT bit access".to_string(),
1621 ));
1622 }
1623 match self.read_tag_direct(tag_base).await? {
1627 PlcValue::Dint(current) => {
1628 let mask = 1i32 << bit_index;
1629 let updated = if value {
1630 current | mask
1631 } else {
1632 current & !mask
1633 };
1634 self.write_tag_direct(tag_base, &PlcValue::Dint(updated))
1635 .await
1636 }
1637 PlcValue::Bool(_) if bit_index == 0 => {
1638 self.write_tag_direct(tag_base, &PlcValue::Bool(value))
1639 .await
1640 }
1641 other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
1642 expected: "DINT".to_string(),
1643 actual: format!("{:?}", other),
1644 }),
1645 }
1646 }
1647
1648 fn parse_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
1650 if let Some(bracket_pos) = tag_name.rfind('[')
1652 && let Some(close_bracket_pos) = tag_name.rfind(']')
1653 && close_bracket_pos > bracket_pos
1654 {
1655 let base_name = tag_name[..bracket_pos].to_string();
1656 let index_str = &tag_name[bracket_pos + 1..close_bracket_pos];
1657 if let Ok(index) = index_str.parse::<u32>()
1658 && !tag_name[..bracket_pos].contains('[')
1659 {
1660 return Some((base_name, index));
1662 }
1663 }
1664 None
1665 }
1666
1667 fn has_member_suffix_after_first_array_index(&self, tag_name: &str) -> bool {
1668 if let Some(bracket_start) = tag_name.find('[')
1669 && let Some(bracket_end_rel) = tag_name[bracket_start..].find(']')
1670 {
1671 let bracket_end_abs = bracket_start + bracket_end_rel;
1672 return tag_name[bracket_end_abs + 1..].starts_with('.');
1673 }
1674
1675 false
1676 }
1677
1678 fn parse_bit_access(&self, tag_name: &str) -> Option<(String, u8)> {
1679 match TagPath::parse(tag_name).ok()? {
1680 TagPath::Bit {
1681 base_path,
1682 bit_index,
1683 } => Some((base_path.as_string(), bit_index)),
1684 _ => None,
1685 }
1686 }
1687
1688 fn parse_final_array_element_access(&self, tag_name: &str) -> Option<(String, u32)> {
1689 match TagPath::parse(tag_name).ok()? {
1690 TagPath::Array { base_path, indices } if indices.len() == 1 => {
1691 Some((base_path.as_string(), indices[0]))
1692 }
1693 _ => None,
1694 }
1695 }
1696
1697 async fn detect_bool_array_path(&mut self, array_path: &str) -> crate::error::Result<bool> {
1698 if let Some(is_packed_bool) = self.cached_array_is_packed_bool(array_path) {
1699 return Ok(is_packed_bool);
1700 }
1701
1702 let generation = self.schema_generation();
1703
1704 let test_response = self
1705 .send_cip_request(&self.build_read_request_with_count(array_path, 1)?)
1706 .await?;
1707 let test_cip_data = self.extract_cip_from_response(&test_response)?;
1708
1709 if test_cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1714 return Ok(false);
1715 }
1716 self.check_cip_error(&test_cip_data)?;
1717 if test_cip_data.len() < 6 {
1718 return Err(EtherNetIpError::Protocol(format!(
1719 "array type probe for '{array_path}' returned fewer than 6 CIP bytes"
1720 )));
1721 }
1722
1723 let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
1724 let is_packed_bool = test_data_type == values::BOOL_ARRAY_DWORD;
1725 self.cache_array_is_packed_bool_at_generation(array_path, is_packed_bool, generation);
1726 Ok(is_packed_bool)
1727 }
1728
1729 fn parse_bool_array_dword_response(&self, cip_data: &[u8]) -> crate::error::Result<u32> {
1730 let mut response_bytes = cip_data;
1731 let response = CipResponse::decode(&mut response_bytes)?;
1732 if response.status != 0 {
1733 return Err(EtherNetIpError::Protocol(format!(
1734 "CIP Error {} when reading BOOL array DWORD: {}",
1735 response.status,
1736 self.get_cip_error_message(response.status)
1737 )));
1738 }
1739
1740 if response.service != 0xCC {
1741 return Err(EtherNetIpError::Protocol(format!(
1742 "Unexpected service reply: 0x{:02X}",
1743 response.service
1744 )));
1745 }
1746
1747 if response.data.len() < 6 {
1748 return Err(EtherNetIpError::Protocol(
1749 "BOOL array response too short for data type and DWORD".to_string(),
1750 ));
1751 }
1752
1753 let data_type = u16::from_le_bytes([response.data[0], response.data[1]]);
1754 if data_type != values::BOOL_ARRAY_DWORD {
1755 return Err(EtherNetIpError::Protocol(format!(
1756 "Expected BOOL array DWORD data type 0x00D3, got 0x{data_type:04X}"
1757 )));
1758 }
1759
1760 let value_data = &response.data[2..];
1761
1762 if value_data.len() < 4 {
1763 return Err(EtherNetIpError::Protocol(format!(
1764 "BOOL array data too short: need 4 bytes (DWORD), got {} bytes",
1765 value_data.len()
1766 )));
1767 }
1768
1769 Ok(u32::from_le_bytes([
1770 value_data[0],
1771 value_data[1],
1772 value_data[2],
1773 value_data[3],
1774 ]))
1775 }
1776
1777 async fn read_array_element_workaround(
1790 &mut self,
1791 base_array_name: &str,
1792 index: u32,
1793 ) -> crate::error::Result<PlcValue> {
1794 tracing::debug!(
1795 "Reading array element '{}[{}]' using element addressing",
1796 base_array_name,
1797 index
1798 );
1799
1800 if self.detect_bool_array_path(base_array_name).await? {
1803 return self
1804 .read_bool_array_element_workaround(base_array_name, index)
1805 .await;
1806 }
1807
1808 let request = self.build_read_array_request(base_array_name, index, 1);
1811
1812 let response = self.send_cip_request(&request).await?;
1813 let cip_data = self.extract_cip_from_response(&response)?;
1814
1815 if cip_data.get(2).copied() == Some(CIP_STATUS_PARTIAL_TRANSFER) {
1819 return self
1820 .read_tag_fragmented(&format!("{base_array_name}[{index}]"))
1821 .await;
1822 }
1823
1824 self.check_cip_error(&cip_data)?;
1826
1827 if cip_data.len() >= 6
1828 && u16::from_le_bytes([cip_data[4], cip_data[5]]) == values::BOOL_ARRAY_DWORD
1829 {
1830 return Err(EtherNetIpError::DataTypeMismatch {
1831 expected: "ordinary array element".to_string(),
1832 actual: "packed BOOL array DWORD".to_string(),
1833 });
1834 }
1835
1836 self.parse_cip_response(&cip_data)
1839 }
1840
1841 async fn read_bool_array_element_workaround(
1845 &mut self,
1846 base_array_name: &str,
1847 index: u32,
1848 ) -> crate::error::Result<PlcValue> {
1849 tracing::debug!(
1850 "BOOL array detected - reading DWORD and extracting bit [{}]",
1851 index
1852 );
1853
1854 let dword_index = index / 32;
1855
1856 let response = self
1859 .send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
1860 .await?;
1861 let cip_data = self.extract_cip_from_response(&response)?;
1862 let dword_value = self.parse_bool_array_dword_response(&cip_data)?;
1863
1864 let bit_index = (index % 32) as u8;
1867 let bool_value = (dword_value >> bit_index) & 1 != 0;
1868
1869 Ok(PlcValue::Bool(bool_value))
1870 }
1871
1872 async fn read_array_in_chunks(
1879 &mut self,
1880 base_array_name: &str,
1881 data_type: u16,
1882 start_index: u32,
1883 target_element_count: u32,
1884 ) -> crate::error::Result<Vec<u8>> {
1885 let element_size = match data_type {
1887 0x00C1 => 1, 0x00C2 => 1, 0x00C3 => 2, 0x00C4 => 4, 0x00C5 => 8, 0x00C6 => 1, 0x00C7 => 2, 0x00C8 => 4, 0x00C9 => 8, 0x00CA => 4, 0x00CB => 8, _ => {
1899 return Err(EtherNetIpError::Protocol(format!(
1900 "Unsupported array data type for chunked reading: 0x{:04X}",
1901 data_type
1902 )));
1903 }
1904 };
1905
1906 let elements_per_chunk = match element_size {
1909 1 => 30, 2 => 15, 4 => 8, 8 => 4, _ => 8,
1914 };
1915
1916 let end_index = start_index
1917 .checked_add(target_element_count)
1918 .ok_or_else(|| EtherNetIpError::Protocol("Array range overflow".to_string()))?;
1919
1920 let mut all_data = Vec::new();
1921 let mut next_chunk_start = start_index;
1922
1923 tracing::debug!(
1924 "Reading array '{}' in chunks: {} elements per chunk, target: {} elements",
1925 base_array_name,
1926 elements_per_chunk,
1927 target_element_count
1928 );
1929
1930 while next_chunk_start < end_index {
1931 let chunk_end = (next_chunk_start + elements_per_chunk as u32).min(end_index);
1934 let chunk_size = (chunk_end - next_chunk_start) as u16;
1935
1936 tracing::trace!(
1937 "Reading chunk: elements {} to {} ({} elements) using element addressing",
1938 next_chunk_start,
1939 chunk_end - 1,
1940 chunk_size
1941 );
1942
1943 let response = self
1946 .send_cip_request(&self.build_read_array_request(
1947 base_array_name,
1948 next_chunk_start,
1949 chunk_size,
1950 ))
1951 .await?;
1952 let cip_data = self.extract_cip_from_response(&response)?;
1953
1954 let mut response_bytes = cip_data.as_slice();
1955 let response = CipResponse::decode(&mut response_bytes)?;
1956 if response.status != 0 {
1957 let error_msg = self.get_cip_error_message(response.status);
1958 return Err(EtherNetIpError::Protocol(format!(
1959 "CIP Error {} when reading chunk (elements {} to {}): {}",
1960 response.status,
1961 next_chunk_start,
1962 chunk_end - 1,
1963 error_msg
1964 )));
1965 }
1966
1967 if response.service != 0xCC {
1968 return Err(EtherNetIpError::Protocol(format!(
1969 "Unexpected service reply in chunk: 0x{:02X} (expected 0xCC)",
1970 response.service
1971 )));
1972 }
1973
1974 if response.data.len() < 2 {
1975 return Err(EtherNetIpError::Protocol(format!(
1976 "Chunk response too short for data type: got {} bytes, expected at least 6",
1977 cip_data.len()
1978 )));
1979 }
1980
1981 let chunk_data_type = u16::from_le_bytes([response.data[0], response.data[1]]);
1982 if chunk_data_type != data_type {
1983 return Err(EtherNetIpError::Protocol(format!(
1984 "Data type mismatch in chunk: expected 0x{:04X}, got 0x{:04X}",
1985 data_type, chunk_data_type
1986 )));
1987 }
1988
1989 let chunk_value_data = &response.data[2..];
1994 let chunk_complete_bytes = (chunk_value_data.len() / element_size) * element_size;
1995 let chunk_data = &chunk_value_data[..chunk_complete_bytes];
1996
1997 if !chunk_data.is_empty() {
2000 all_data.extend_from_slice(chunk_data);
2001 let elements_received = chunk_data.len() / element_size;
2002 next_chunk_start += elements_received as u32;
2003
2004 tracing::trace!(
2005 "Chunk read: {} elements ({} bytes) starting at index {}, total so far: {} elements",
2006 elements_received,
2007 chunk_data.len(),
2008 next_chunk_start - elements_received as u32,
2009 all_data.len() / element_size
2010 );
2011
2012 if next_chunk_start >= end_index {
2014 tracing::trace!(
2015 "Reached target element count ({}), stopping chunked read",
2016 target_element_count
2017 );
2018 break;
2019 }
2020 } else {
2021 break;
2023 }
2024 }
2025
2026 let final_element_count = all_data.len() / element_size;
2027 tracing::debug!(
2028 "Chunked read complete: {} total elements ({} bytes), target was {} elements",
2029 final_element_count,
2030 all_data.len(),
2031 target_element_count
2032 );
2033
2034 if final_element_count < target_element_count as usize {
2035 return Err(EtherNetIpError::Protocol(format!(
2036 "Incomplete array read: requested {} elements, received {}",
2037 target_element_count, final_element_count
2038 )));
2039 }
2040
2041 Ok(all_data)
2042 }
2043
2044 fn array_element_size(data_type: u16) -> Option<usize> {
2045 match data_type {
2046 0x00C1 => Some(1), 0x00C2 => Some(1), 0x00C3 => Some(2), 0x00C4 => Some(4), 0x00C5 => Some(8), 0x00C6 => Some(1), 0x00C7 => Some(2), 0x00C8 => Some(4), 0x00C9 => Some(8), 0x00CA => Some(4), 0x00CB => Some(8), _ => None,
2058 }
2059 }
2060
2061 fn decode_array_bytes(
2062 &self,
2063 data_type: u16,
2064 bytes: &[u8],
2065 ) -> crate::error::Result<Vec<PlcValue>> {
2066 let Some(element_size) = Self::array_element_size(data_type) else {
2067 return Err(EtherNetIpError::Protocol(format!(
2068 "Unsupported data type for array decoding: 0x{:04X}",
2069 data_type
2070 )));
2071 };
2072
2073 if !bytes.len().is_multiple_of(element_size) {
2074 return Err(EtherNetIpError::Protocol(format!(
2075 "Array payload length {} is not aligned to element size {}",
2076 bytes.len(),
2077 element_size
2078 )));
2079 }
2080
2081 let mut values = Vec::with_capacity(bytes.len() / element_size);
2082 for chunk in bytes.chunks_exact(element_size) {
2083 values.push(values::decode_array_element(data_type, chunk)?);
2084 }
2085
2086 Ok(values)
2087 }
2088
2089 pub async fn read_array_range(
2105 &mut self,
2106 base_array_name: &str,
2107 start_index: u32,
2108 element_count: u32,
2109 ) -> crate::error::Result<Vec<PlcValue>> {
2110 if element_count == 0 {
2111 return Ok(Vec::new());
2112 }
2113
2114 let probe_response = self
2115 .send_cip_request(&self.build_read_array_request(base_array_name, start_index, 1))
2116 .await?;
2117 let probe_cip = self.extract_cip_from_response(&probe_response)?;
2118 self.check_cip_error(&probe_cip)?;
2119
2120 if probe_cip.len() < 6 {
2121 return Err(EtherNetIpError::Protocol(
2122 "Array probe response too short".to_string(),
2123 ));
2124 }
2125
2126 let data_type = u16::from_le_bytes([probe_cip[4], probe_cip[5]]);
2127 let raw = self
2128 .read_array_in_chunks(base_array_name, data_type, start_index, element_count)
2129 .await?;
2130 let values = self.decode_array_bytes(data_type, &raw)?;
2131
2132 if values.len() != element_count as usize {
2133 return Err(EtherNetIpError::Protocol(format!(
2134 "Array read count mismatch: requested {}, got {}",
2135 element_count,
2136 values.len()
2137 )));
2138 }
2139
2140 Ok(values)
2141 }
2142
2143 async fn write_array_element_workaround(
2157 &mut self,
2158 base_array_name: &str,
2159 index: u32,
2160 value: PlcValue,
2161 ) -> crate::error::Result<()> {
2162 tracing::debug!(
2163 "Writing to array element '{}[{}]' using element addressing",
2164 base_array_name,
2165 index
2166 );
2167
2168 let test_response = self
2170 .send_cip_request(&self.build_read_request_with_count(base_array_name, 1)?)
2171 .await?;
2172 let test_cip_data = self.extract_cip_from_response(&test_response)?;
2173
2174 if test_cip_data.len() < 3 {
2176 return Err(EtherNetIpError::Protocol(
2177 "Test read response too short".to_string(),
2178 ));
2179 }
2180
2181 if let Err(e) = self.check_cip_error(&test_cip_data) {
2183 return Err(EtherNetIpError::Protocol(format!(
2184 "Cannot write to array element: Test read failed: {}",
2185 e
2186 )));
2187 }
2188
2189 if test_cip_data.len() < 6 {
2191 return Err(EtherNetIpError::Protocol(
2192 "Test read response too short to determine data type".to_string(),
2193 ));
2194 }
2195
2196 let test_data_type = u16::from_le_bytes([test_cip_data[4], test_cip_data[5]]);
2197
2198 if test_data_type == 0x00D3 {
2200 return self
2201 .write_bool_array_element_workaround(base_array_name, index, value)
2202 .await;
2203 }
2204
2205 let data_type = test_data_type;
2207 let value_bytes = value.to_bytes();
2208
2209 let request = self.build_write_array_request_with_index(
2212 base_array_name,
2213 index,
2214 1, data_type,
2216 &value_bytes,
2217 )?;
2218
2219 let response = self.send_cip_request(&request).await?;
2220 let cip_data = self.extract_cip_from_response(&response)?;
2221
2222 self.check_cip_error(&cip_data)?;
2224
2225 tracing::info!("Array element write completed successfully");
2226 Ok(())
2227 }
2228
2229 async fn write_bool_array_element_workaround(
2237 &mut self,
2238 base_array_name: &str,
2239 index: u32,
2240 value: PlcValue,
2241 ) -> crate::error::Result<()> {
2242 tracing::debug!(
2243 "BOOL array element write - reading DWORD, modifying bit [{}], writing back",
2244 index
2245 );
2246
2247 let bool_value = match value {
2248 PlcValue::Bool(b) => b,
2249 _ => {
2250 return Err(EtherNetIpError::Protocol(
2251 "Expected BOOL value for BOOL array element".to_string(),
2252 ));
2253 }
2254 };
2255 let dword_index = index / 32;
2256
2257 let response = self
2259 .send_cip_request(&self.build_read_array_request(base_array_name, dword_index, 1))
2260 .await?;
2261 let cip_data = self.extract_cip_from_response(&response)?;
2262
2263 let original_dword_value = match self.parse_bool_array_dword_response(&cip_data) {
2265 Ok(value) => value,
2266 Err(error) if Self::is_schema_drift_read_error(&error) => {
2267 self.evict_array_type_cache_entry(base_array_name);
2268 self.diagnostic_counters
2269 .schema_type_contradictions
2270 .fetch_add(1, Ordering::Relaxed);
2271 if !self.detect_bool_array_path(base_array_name).await? {
2272 let tag_name = format!("{base_array_name}[{index}]");
2273 return self
2274 .write_tag_direct(&tag_name, &PlcValue::Bool(bool_value))
2275 .await;
2276 }
2277 let retry_response = self
2278 .send_cip_request(&self.build_read_array_request(
2279 base_array_name,
2280 dword_index,
2281 1,
2282 ))
2283 .await?;
2284 let retry_cip_data = self.extract_cip_from_response(&retry_response)?;
2285 self.parse_bool_array_dword_response(&retry_cip_data)?
2286 }
2287 Err(error) => return Err(error),
2288 };
2289 let mut dword_value = original_dword_value;
2290
2291 let bit_index = (index % 32) as u8;
2292 if bool_value {
2293 dword_value |= 1u32 << bit_index;
2294 } else {
2295 dword_value &= !(1u32 << bit_index);
2296 }
2297
2298 tracing::trace!(
2299 "Modified BOOL[{}] in DWORD: 0x{:08X} -> 0x{:08X} (bit {} = {})",
2300 index,
2301 original_dword_value,
2302 dword_value,
2303 bit_index,
2304 bool_value
2305 );
2306
2307 let write_request = self.build_write_array_request_with_index(
2309 base_array_name,
2310 dword_index,
2311 1,
2312 values::BOOL_ARRAY_DWORD,
2313 &dword_value.to_le_bytes(),
2314 )?;
2315 let write_response = self.send_cip_request(&write_request).await?;
2316 let write_cip_data = self.extract_cip_from_response(&write_response)?;
2317
2318 self.check_cip_error(&write_cip_data)?;
2320
2321 tracing::info!("BOOL array element write completed successfully");
2322 Ok(())
2323 }
2324
2325 #[cfg_attr(not(test), allow(dead_code))]
2354 pub fn build_write_array_request_with_index(
2355 &self,
2356 base_array_name: &str,
2357 start_index: u32,
2358 element_count: u16,
2359 data_type: u16,
2360 data: &[u8],
2361 ) -> crate::error::Result<Vec<u8>> {
2362 let mut cip_request = Vec::new();
2363
2364 cip_request.push(0x4D);
2367
2368 let mut full_path = self.build_base_tag_path(base_array_name);
2371
2372 full_path.extend_from_slice(&self.build_element_id_segment(start_index));
2375
2376 if !full_path.len().is_multiple_of(2) {
2378 full_path.push(0x00);
2379 }
2380
2381 let path_size = (full_path.len() / 2) as u8;
2383 cip_request.push(path_size);
2384 cip_request.extend_from_slice(&full_path);
2385
2386 cip_request.extend_from_slice(&data_type.to_le_bytes());
2389 cip_request.extend_from_slice(&element_count.to_le_bytes());
2390 cip_request.extend_from_slice(data);
2391
2392 Ok(cip_request)
2393 }
2394
2395 pub async fn read_udt_chunked(&mut self, tag_name: &str) -> crate::error::Result<PlcValue> {
2436 self.validate_session().await?;
2437
2438 match self.read_tag(tag_name).await? {
2439 value @ PlcValue::Udt(_) => Ok(value),
2440 other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
2441 expected: "UDT".to_string(),
2442 actual: format!("{other:?}"),
2443 }),
2444 }
2445 }
2446
2447 #[deprecated(
2470 since = "1.2.0",
2471 note = "offset-based UDT member access indexed the CIP envelope, not the UDT payload; use read_udt_chunked + UdtData::parse or direct member tag reads; removal planned for 2.0"
2472 )]
2473 pub async fn read_udt_member_by_offset(
2474 &mut self,
2475 _udt_name: &str,
2476 _member_offset: usize,
2477 _member_size: usize,
2478 _data_type: u16,
2479 ) -> crate::error::Result<PlcValue> {
2480 Err(crate::error::EtherNetIpError::Unsupported {
2481 api: "read_udt_member_by_offset",
2482 reason: "this API indexed the full CIP reply envelope instead of the UDT payload; use read_udt_chunked + UdtData::parse or direct member tag reads instead; removal is planned for 2.0",
2483 })
2484 }
2485
2486 #[deprecated(
2510 since = "1.2.0",
2511 note = "offset-based UDT member writes round-tripped CIP envelope bytes as tag data; use write_udt_member/write_udt_array_member or direct member tag writes; removal planned for 2.0"
2512 )]
2513 pub async fn write_udt_member_by_offset(
2514 &mut self,
2515 _udt_name: &str,
2516 _member_offset: usize,
2517 _member_size: usize,
2518 _data_type: u16,
2519 _value: PlcValue,
2520 ) -> crate::error::Result<()> {
2521 Err(crate::error::EtherNetIpError::Unsupported {
2522 api: "write_udt_member_by_offset",
2523 reason: "this API read the full CIP reply envelope and wrote mutated envelope bytes back to the PLC; use write_udt_member/write_udt_array_member or direct member tag writes instead; removal is planned for 2.0",
2524 })
2525 }
2526
2527 pub async fn get_udt_definition(
2530 &mut self,
2531 udt_name: &str,
2532 ) -> crate::error::Result<UdtDefinition> {
2533 if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2535 && let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name)
2536 {
2537 return Ok(cached.clone());
2538 }
2539
2540 let attributes = self.get_tag_attributes(udt_name).await?;
2542
2543 if attributes.data_type != 0x00A0 {
2545 return Err(crate::error::EtherNetIpError::Protocol(format!(
2546 "Tag '{}' is not a UDT (type: {})",
2547 udt_name, attributes.data_type_name
2548 )));
2549 }
2550
2551 let template_id = attributes.template_instance_id.ok_or_else(|| {
2553 crate::error::EtherNetIpError::Protocol(
2554 "UDT template instance ID not found".to_string(),
2555 )
2556 })?;
2557
2558 let (definition, _structure_size_bytes) = self
2559 .load_udt_definition_from_template(template_id, udt_name)
2560 .await?;
2561
2562 Ok(definition)
2563 }
2564
2565 async fn get_udt_definition_by_template_id(
2566 &mut self,
2567 template_id: u32,
2568 udt_name: &str,
2569 ) -> crate::error::Result<(UdtDefinition, u32)> {
2570 if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2571 && let Some(cached) = self.udt_manager.lock().await.get_definition(udt_name)
2572 {
2573 return Ok((cached.clone(), 0));
2574 }
2575
2576 self.load_udt_definition_from_template(template_id, udt_name)
2577 .await
2578 }
2579
2580 async fn load_udt_definition_from_template(
2581 &mut self,
2582 template_id: u32,
2583 udt_name: &str,
2584 ) -> crate::error::Result<(UdtDefinition, u32)> {
2585 let generation = self.schema_generation();
2586 let (template_attributes, template_data) = self.read_udt_template(template_id).await?;
2587 let template = self.udt_manager.lock().await.parse_udt_template(
2588 template_id,
2589 template_attributes.member_count,
2590 template_attributes.structure_size_bytes,
2591 &template_data,
2592 )?;
2593
2594 let definition = UdtDefinition {
2595 name: udt_name.to_string(),
2596 members: template.members,
2597 };
2598
2599 {
2600 let mut udt_manager = self.udt_manager.lock().await;
2601 if self.schema_generation() == generation {
2602 if self.udt_manager_generation.load(Ordering::Acquire) != generation {
2603 udt_manager.clear_cache();
2604 }
2605 udt_manager.add_definition(definition.clone());
2606 self.udt_manager_generation
2607 .store(generation, Ordering::Release);
2608 }
2609 }
2610
2611 Ok((definition, template_attributes.structure_size_bytes))
2612 }
2613
2614 pub async fn get_tag_attributes(
2631 &mut self,
2632 tag_name: &str,
2633 ) -> crate::error::Result<TagAttributes> {
2634 if self.udt_manager_generation.load(Ordering::Acquire) == self.schema_generation()
2636 && let Some(cached) = self.udt_manager.lock().await.get_tag_attributes(tag_name)
2637 {
2638 return Ok(cached.clone());
2639 }
2640
2641 let generation = self.schema_generation();
2642
2643 let attributes = match self.get_tag_attributes_direct(tag_name).await {
2644 Ok(attributes) => attributes,
2645 Err(direct_error) => {
2646 tracing::warn!(
2658 "get_tag_attributes('{tag_name}') direct request failed ({direct_error}); falling back to discovery"
2659 );
2660 self.get_tag_attributes_via_discovery(tag_name)
2661 .await
2662 .map_err(|discovery_error| {
2663 crate::error::EtherNetIpError::Protocol(format!(
2664 "{direct_error}; discovery fallback also failed: {discovery_error}"
2665 ))
2666 })?
2667 }
2668 };
2669
2670 {
2672 let mut udt_manager = self.udt_manager.lock().await;
2673 if self.schema_generation() == generation {
2674 if self.udt_manager_generation.load(Ordering::Acquire) != generation {
2675 udt_manager.clear_cache();
2676 }
2677 udt_manager.add_tag_attributes(attributes.clone());
2678 self.udt_manager_generation
2679 .store(generation, Ordering::Release);
2680 }
2681 }
2682
2683 Ok(attributes)
2684 }
2685
2686 async fn get_tag_attributes_direct(
2691 &mut self,
2692 tag_name: &str,
2693 ) -> crate::error::Result<TagAttributes> {
2694 let request = self.build_get_attributes_request(tag_name)?;
2695 let response = self.send_cip_request(&request).await?;
2696 let cip_data = self.extract_cip_from_response(&response)?;
2697 self.parse_attributes_response(tag_name, &cip_data)
2698 }
2699
2700 async fn get_tag_attributes_via_discovery(
2705 &mut self,
2706 tag_name: &str,
2707 ) -> crate::error::Result<TagAttributes> {
2708 let path = TagPath::parse(tag_name).map_err(|error| {
2709 crate::error::EtherNetIpError::Protocol(error.message().to_string())
2710 })?;
2711 let base_name = path.base_tag_name();
2712
2713 let candidates = if let Some(program) = path.program_name() {
2714 self.discover_program_tags(&program).await?
2715 } else {
2716 self.discover_tags_detailed().await?
2717 };
2718
2719 candidates
2720 .into_iter()
2721 .find(|candidate| candidate.name == base_name)
2722 .ok_or_else(|| {
2723 crate::error::EtherNetIpError::Protocol(format!(
2724 "'{base_name}' not found via discovery"
2725 ))
2726 })
2727 }
2728
2729 async fn read_udt_template(
2731 &mut self,
2732 template_id: u32,
2733 ) -> crate::error::Result<(TemplateAttributes, Vec<u8>)> {
2734 let template_attributes = self.get_template_attributes(template_id).await?;
2735 let read_size = template_attributes
2736 .definition_size_words
2737 .checked_mul(4)
2738 .and_then(|bytes| bytes.checked_sub(23))
2739 .ok_or_else(|| {
2740 crate::error::EtherNetIpError::Protocol(format!(
2741 "Template {} reported invalid definition size {} words",
2742 template_id, template_attributes.definition_size_words
2743 ))
2744 })?;
2745
2746 let mut template_data = Vec::with_capacity(read_size as usize);
2747 let mut offset = 0u32;
2748
2749 while offset < read_size {
2750 let chunk_size = (read_size - offset).min(200);
2751 let request = self.build_read_template_request(template_id, offset, chunk_size)?;
2752 let response = self.send_cip_request(&request).await?;
2753 let cip_data = self.extract_cip_from_response(&response)?;
2754 let (chunk, partial_transfer) = self.parse_template_response_chunk(&cip_data)?;
2755
2756 if chunk.is_empty() {
2757 return Err(crate::error::EtherNetIpError::Protocol(format!(
2758 "Template {} returned an empty chunk at offset {}",
2759 template_id, offset
2760 )));
2761 }
2762
2763 offset = offset.saturating_add(chunk.len() as u32);
2764 template_data.extend_from_slice(&chunk);
2765
2766 if !partial_transfer && chunk.len() < chunk_size as usize {
2767 break;
2768 }
2769 }
2770
2771 Ok((template_attributes, template_data))
2772 }
2773
2774 async fn get_template_attributes(
2775 &mut self,
2776 template_id: u32,
2777 ) -> crate::error::Result<TemplateAttributes> {
2778 let request = self.build_get_template_attributes_request(template_id)?;
2779 let response = self.send_cip_request(&request).await?;
2780 let cip_data = self.extract_cip_from_response(&response)?;
2781 self.parse_template_attributes_response(template_id, &cip_data)
2782 }
2783
2784 fn build_get_attributes_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
2786 let path = self.build_tag_path(tag_name);
2787 let request_data = vec![
2788 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, ];
2792 let request = CipRequest::new(0x03, path, request_data);
2793 let mut encoded = BytesMut::new();
2794 request.encode(&mut encoded)?;
2795 Ok(encoded.to_vec())
2796 }
2797
2798 fn build_get_template_attributes_request(
2799 &self,
2800 template_id: u32,
2801 ) -> crate::error::Result<Vec<u8>> {
2802 let mut request = Vec::new();
2803 let template_id = u16::try_from(template_id).map_err(|_| {
2804 crate::error::EtherNetIpError::Protocol(format!(
2805 "Template instance {} exceeds 16-bit path encoding",
2806 template_id
2807 ))
2808 })?;
2809
2810 request.push(0x03);
2811 request.push(0x03);
2812 request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
2813 request.extend_from_slice(&template_id.to_le_bytes());
2814 request.extend_from_slice(&[0x04, 0x00]);
2815 request.extend_from_slice(&[0x01, 0x00]);
2816 request.extend_from_slice(&[0x02, 0x00]);
2817 request.extend_from_slice(&[0x04, 0x00]);
2818 request.extend_from_slice(&[0x05, 0x00]);
2819
2820 Ok(request)
2821 }
2822
2823 fn build_read_template_request(
2825 &self,
2826 template_id: u32,
2827 read_offset: u32,
2828 read_size: u32,
2829 ) -> crate::error::Result<Vec<u8>> {
2830 let mut request = Vec::new();
2831 let template_id = u16::try_from(template_id).map_err(|_| {
2832 crate::error::EtherNetIpError::Protocol(format!(
2833 "Template instance {} exceeds 16-bit path encoding",
2834 template_id
2835 ))
2836 })?;
2837 let read_size = u16::try_from(read_size).map_err(|_| {
2838 crate::error::EtherNetIpError::Protocol(format!(
2839 "Template read size {} exceeds 16-bit service limit",
2840 read_size
2841 ))
2842 })?;
2843
2844 request.push(0x4C);
2845 request.push(0x03);
2846 request.extend_from_slice(&[0x20, 0x6C, 0x25, 0x00]);
2847 request.extend_from_slice(&template_id.to_le_bytes());
2848 request.extend_from_slice(&read_offset.to_le_bytes());
2849 request.extend_from_slice(&read_size.to_le_bytes());
2850
2851 Ok(request)
2852 }
2853
2854 fn parse_attributes_response(
2856 &self,
2857 tag_name: &str,
2858 response: &[u8],
2859 ) -> crate::error::Result<TagAttributes> {
2860 let mut response_bytes = response;
2861 let response = CipResponse::decode(&mut response_bytes)?;
2862 if response.service != 0x83 {
2863 return Err(crate::error::EtherNetIpError::Protocol(format!(
2864 "Unexpected Get Attribute List reply service: 0x{:02X}",
2865 response.service
2866 )));
2867 }
2868
2869 if response.status != 0 {
2870 return Err(crate::error::EtherNetIpError::Protocol(format!(
2871 "Get Attribute List for '{}' failed: {}",
2872 tag_name,
2873 self.get_cip_error_message(response.status)
2874 )));
2875 }
2876
2877 if response.data.len() < 2 {
2878 return Err(crate::error::EtherNetIpError::Protocol(
2879 "Attributes response missing attribute count".to_string(),
2880 ));
2881 }
2882
2883 let attr_count = u16::from_le_bytes([response.data[0], response.data[1]]) as usize;
2884 let mut offset = 2;
2885 let mut data_type = None;
2886 let mut template_instance_id = None;
2887 let mut attr_errors = Vec::new();
2888
2889 for _ in 0..attr_count {
2890 if response.data.len() < offset + 4 {
2891 return Err(crate::error::EtherNetIpError::Protocol(
2892 "Attributes response truncated before attribute record header".to_string(),
2893 ));
2894 }
2895
2896 let attr_id = u16::from_le_bytes([response.data[offset], response.data[offset + 1]]);
2897 let attr_status =
2898 u16::from_le_bytes([response.data[offset + 2], response.data[offset + 3]]);
2899 offset += 4;
2900
2901 if attr_status != 0 {
2902 attr_errors.push(format!("attr {attr_id} status 0x{attr_status:04X}"));
2903 continue;
2904 }
2905
2906 match attr_id {
2907 0x0001 => {
2908 if response.data.len() < offset + 2 {
2909 return Err(crate::error::EtherNetIpError::Protocol(
2910 "Attributes response truncated in data type value".to_string(),
2911 ));
2912 }
2913 data_type = Some(u16::from_le_bytes([
2914 response.data[offset],
2915 response.data[offset + 1],
2916 ]));
2917 offset += 2;
2918 }
2919 0x0002 => {
2920 if response.data.len() < offset + 4 {
2921 return Err(crate::error::EtherNetIpError::Protocol(
2922 "Attributes response truncated in instance id value".to_string(),
2923 ));
2924 }
2925 template_instance_id = Some(u32::from_le_bytes([
2926 response.data[offset],
2927 response.data[offset + 1],
2928 response.data[offset + 2],
2929 response.data[offset + 3],
2930 ]));
2931 offset += 4;
2932 }
2933 _ => {
2934 return Err(crate::error::EtherNetIpError::Protocol(format!(
2935 "Unexpected attribute id {attr_id} in Get Attribute List response"
2936 )));
2937 }
2938 }
2939 }
2940
2941 let data_type = data_type.ok_or_else(|| {
2942 crate::error::EtherNetIpError::Protocol(format!(
2943 "Get Attribute List for '{}' did not return data type{}",
2944 tag_name,
2945 if attr_errors.is_empty() {
2946 String::new()
2947 } else {
2948 format!(" ({})", attr_errors.join(", "))
2949 }
2950 ))
2951 })?;
2952 let size = Self::data_type_size(data_type);
2953
2954 let attributes = TagAttributes {
2956 name: tag_name.to_string(),
2957 data_type,
2958 data_type_name: self.get_data_type_name(data_type),
2959 dimensions: Vec::new(), permissions: udt::TagPermissions::ReadWrite, scope: if tag_name.contains(':') {
2962 let parts: Vec<&str> = tag_name.split(':').collect();
2963 if parts.len() >= 2 {
2964 udt::TagScope::Program(parts[0].to_string())
2965 } else {
2966 udt::TagScope::Controller
2967 }
2968 } else {
2969 udt::TagScope::Controller
2970 },
2971 template_instance_id,
2972 size,
2973 };
2974
2975 Ok(attributes)
2976 }
2977
2978 fn data_type_size(data_type: u16) -> u32 {
2979 match data_type {
2980 0x00C1 | 0x00C2 | 0x00C6 => 1,
2981 0x00C3 | 0x00C7 => 2,
2982 0x00C4 | 0x00C8 | 0x00CA => 4,
2983 0x00C5 | 0x00C9 | 0x00CB => 8,
2984 0x00CE => 88,
2985 _ => 4,
2986 }
2987 }
2988
2989 fn parse_template_attributes_response(
2990 &self,
2991 template_id: u32,
2992 response: &[u8],
2993 ) -> crate::error::Result<TemplateAttributes> {
2994 if response.len() < 4 {
2995 return Err(crate::error::EtherNetIpError::Protocol(
2996 "Template attribute response too short".to_string(),
2997 ));
2998 }
2999
3000 let general_status = response[2];
3001 if general_status != 0x00 {
3002 return Err(crate::error::EtherNetIpError::Protocol(format!(
3003 "Template {} attribute read failed: {}",
3004 template_id,
3005 self.get_cip_error_message(general_status)
3006 )));
3007 }
3008
3009 let additional_status_words = response[3] as usize;
3010 let mut offset = 4 + additional_status_words * 2;
3011 if response.len() < offset + 2 {
3012 return Err(crate::error::EtherNetIpError::Protocol(
3013 "Template attribute response missing attribute count".to_string(),
3014 ));
3015 }
3016
3017 let attr_count = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
3018 offset += 2;
3019
3020 let mut attributes = TemplateAttributes {
3021 structure_handle: 0,
3022 member_count: 0,
3023 definition_size_words: 0,
3024 structure_size_bytes: 0,
3025 };
3026
3027 for _ in 0..attr_count {
3028 if response.len() < offset + 4 {
3029 return Err(crate::error::EtherNetIpError::Protocol(
3030 "Template attribute response truncated".to_string(),
3031 ));
3032 }
3033
3034 let attr_id = u16::from_le_bytes([response[offset], response[offset + 1]]);
3035 let attr_status = u16::from_le_bytes([response[offset + 2], response[offset + 3]]);
3036 offset += 4;
3037
3038 if attr_status != 0 {
3039 return Err(crate::error::EtherNetIpError::Protocol(format!(
3040 "Template {} attribute {} read returned status 0x{:04X}",
3041 template_id, attr_id, attr_status
3042 )));
3043 }
3044
3045 match attr_id {
3046 1 => {
3047 if response.len() < offset + 2 {
3048 return Err(crate::error::EtherNetIpError::Protocol(
3049 "Template attribute 1 missing value".to_string(),
3050 ));
3051 }
3052 attributes.structure_handle =
3053 u16::from_le_bytes([response[offset], response[offset + 1]]);
3054 offset += 2;
3055 }
3056 2 => {
3057 if response.len() < offset + 2 {
3058 return Err(crate::error::EtherNetIpError::Protocol(
3059 "Template attribute 2 missing value".to_string(),
3060 ));
3061 }
3062 attributes.member_count =
3063 u16::from_le_bytes([response[offset], response[offset + 1]]);
3064 offset += 2;
3065 }
3066 4 => {
3067 if response.len() < offset + 4 {
3068 return Err(crate::error::EtherNetIpError::Protocol(
3069 "Template attribute 4 missing value".to_string(),
3070 ));
3071 }
3072 attributes.definition_size_words = u32::from_le_bytes([
3073 response[offset],
3074 response[offset + 1],
3075 response[offset + 2],
3076 response[offset + 3],
3077 ]);
3078 offset += 4;
3079 }
3080 5 => {
3081 if response.len() < offset + 4 {
3082 return Err(crate::error::EtherNetIpError::Protocol(
3083 "Template attribute 5 missing value".to_string(),
3084 ));
3085 }
3086 attributes.structure_size_bytes = u32::from_le_bytes([
3087 response[offset],
3088 response[offset + 1],
3089 response[offset + 2],
3090 response[offset + 3],
3091 ]);
3092 offset += 4;
3093 }
3094 _ => {
3095 return Err(crate::error::EtherNetIpError::Protocol(format!(
3096 "Unexpected template attribute {} in response",
3097 attr_id
3098 )));
3099 }
3100 }
3101 }
3102
3103 if attributes.definition_size_words == 0 {
3104 return Err(crate::error::EtherNetIpError::Protocol(format!(
3105 "Template {} reported zero definition size",
3106 template_id
3107 )));
3108 }
3109
3110 Ok(attributes)
3111 }
3112
3113 fn parse_template_response_chunk(
3114 &self,
3115 response: &[u8],
3116 ) -> crate::error::Result<(Vec<u8>, bool)> {
3117 if response.len() < 4 {
3118 return Err(crate::error::EtherNetIpError::Protocol(
3119 "Template response too short".to_string(),
3120 ));
3121 }
3122
3123 let general_status = response[2];
3124 let partial_transfer = general_status == 0x06;
3125 if general_status != 0x00 && !partial_transfer {
3126 return Err(crate::error::EtherNetIpError::Protocol(format!(
3127 "Template read failed: {}",
3128 self.get_cip_error_message(general_status)
3129 )));
3130 }
3131
3132 let additional_status_words = response[3] as usize;
3133 let data_start = 4 + additional_status_words * 2;
3134 if data_start > response.len() {
3135 return Err(crate::error::EtherNetIpError::Protocol(
3136 "Template response missing payload".to_string(),
3137 ));
3138 }
3139
3140 Ok((response[data_start..].to_vec(), partial_transfer))
3141 }
3142
3143 fn get_data_type_name(&self, data_type: u16) -> String {
3145 match data_type {
3146 0x00C1 => "BOOL".to_string(),
3147 0x00C2 => "SINT".to_string(),
3148 0x00C3 => "INT".to_string(),
3149 0x00C4 => "DINT".to_string(),
3150 0x00C5 => "LINT".to_string(),
3151 0x00C6 => "USINT".to_string(),
3152 0x00C7 => "UINT".to_string(),
3153 0x00C8 => "UDINT".to_string(),
3154 0x00C9 => "ULINT".to_string(),
3155 0x00CA => "REAL".to_string(),
3156 0x00CB => "LREAL".to_string(),
3157 0x00CE => "STRING".to_string(),
3158 0x00A0 => "UDT".to_string(),
3159 _ => format!("UNKNOWN(0x{:04X})", data_type),
3160 }
3161 }
3162
3163 fn build_tag_list_request_from_instance(
3165 &self,
3166 start_instance: u32,
3167 ) -> crate::error::Result<Vec<u8>> {
3168 let start_instance = u16::try_from(start_instance).map_err(|_| {
3169 crate::error::EtherNetIpError::Protocol(format!(
3170 "Tag discovery start instance {} exceeds 16-bit Symbol Object range",
3171 start_instance
3172 ))
3173 })?;
3174 let mut request = vec![
3175 0x55, 0x03, 0x20, 0x6B, 0x25, 0x00,
3179 ];
3180 request.extend_from_slice(&start_instance.to_le_bytes());
3181
3182 request.extend_from_slice(&[0x02, 0x00]);
3184
3185 request.extend_from_slice(&[0x01, 0x00]);
3187
3188 request.extend_from_slice(&[0x02, 0x00]);
3190
3191 Ok(request)
3192 }
3193
3194 fn build_program_tag_list_request(
3201 &self,
3202 program_name: &str,
3203 start_instance: u32,
3204 ) -> crate::error::Result<Vec<u8>> {
3205 let start_instance = u16::try_from(start_instance).map_err(|_| {
3206 crate::error::EtherNetIpError::Protocol(format!(
3207 "Program tag discovery start instance {} exceeds 16-bit Symbol Object range",
3208 start_instance
3209 ))
3210 })?;
3211 let scoped_program = format!("Program:{}", program_scope_name(program_name));
3212
3213 let mut path = Vec::new();
3214 path.push(0x91);
3215 path.push(scoped_program.len() as u8);
3216 path.extend_from_slice(scoped_program.as_bytes());
3217 if !path.len().is_multiple_of(2) {
3218 path.push(0x00);
3219 }
3220 path.extend_from_slice(&[0x20, 0x6B, 0x25, 0x00]);
3222 path.extend_from_slice(&start_instance.to_le_bytes());
3223
3224 let path_words = u8::try_from(path.len() / 2).map_err(|_| {
3225 crate::error::EtherNetIpError::Protocol(format!(
3226 "Program tag discovery path too long for '{}'",
3227 program_name
3228 ))
3229 })?;
3230
3231 let mut request = vec![
3232 0x55, path_words,
3234 ];
3235 request.extend_from_slice(&path);
3236
3237 request.extend_from_slice(&[0x02, 0x00]); request.extend_from_slice(&[0x01, 0x00]);
3242
3243 request.extend_from_slice(&[0x02, 0x00]);
3245
3246 Ok(request)
3247 }
3248
3249 fn parse_tag_list_response_page(
3258 &self,
3259 response: &[u8],
3260 scope: udt::TagScope,
3261 ) -> crate::error::Result<TagListPage> {
3262 if response.len() < 4 {
3263 return Err(crate::error::EtherNetIpError::Protocol(
3264 "Tag list response too short".to_string(),
3265 ));
3266 }
3267
3268 let general_status = response[2];
3269 let partial_transfer = general_status == 0x06;
3270 if general_status != 0x00 && !partial_transfer {
3271 return Err(crate::error::EtherNetIpError::Protocol(format!(
3272 "Tag discovery failed: {}. Some PLCs may not support tag discovery. Try reading tags directly by name.",
3273 self.get_cip_error_message(general_status)
3274 )));
3275 }
3276
3277 let additional_status_words = response[3] as usize;
3278 let mut offset = 4 + additional_status_words * 2;
3279 if response.len() == offset {
3280 return Ok(TagListPage {
3281 tags: Vec::new(),
3282 last_instance_id: None,
3283 partial_transfer: false,
3284 });
3285 }
3286 if response.len() < offset + 4 {
3287 return Err(crate::error::EtherNetIpError::Protocol(
3288 "Tag list response missing first entry".to_string(),
3289 ));
3290 }
3291 let mut tags = Vec::new();
3292 let mut last_instance_id = None;
3293
3294 while offset + 8 <= response.len() {
3295 let instance_id = u32::from_le_bytes([
3296 response[offset],
3297 response[offset + 1],
3298 response[offset + 2],
3299 response[offset + 3],
3300 ]);
3301 last_instance_id = Some(instance_id);
3302 offset += 4;
3303
3304 let name_length = u16::from_le_bytes([response[offset], response[offset + 1]]) as usize;
3305 offset += 2;
3306
3307 if offset
3308 .checked_add(name_length)
3309 .is_none_or(|end| end > response.len())
3310 {
3311 break;
3312 }
3313
3314 let name_bytes = &response[offset..offset + name_length];
3315 let tag_name = String::from_utf8_lossy(name_bytes).to_string();
3316 offset += name_length;
3317
3318 if offset + 2 > response.len() {
3319 break;
3320 }
3321
3322 let raw_tag_type = u16::from_le_bytes([response[offset], response[offset + 1]]);
3323 offset += 2;
3324
3325 if tag_name.starts_with("__") || tag_name.contains(':') {
3327 continue;
3328 }
3329
3330 let array_dims = ((raw_tag_type & 0x6000) >> 13) as usize;
3331 let is_structure = (raw_tag_type & 0x8000) != 0;
3332 let reserved = (raw_tag_type & 0x1000) != 0;
3333 let type_param = raw_tag_type & 0x0FFF;
3334 let is_user_atomic =
3335 !is_structure && !reserved && (0x0001..=0x00FF).contains(&type_param);
3336 let is_user_structure =
3337 is_structure && !reserved && (0x0100..=0x0EFF).contains(&type_param);
3338
3339 if !is_user_atomic && !is_user_structure {
3340 continue;
3341 }
3342
3343 let data_type = if is_structure {
3344 0x00A0
3345 } else if (raw_tag_type & 0x00FF) == 0x00C1 {
3346 0x00C1
3347 } else {
3348 type_param
3349 };
3350
3351 let template_instance_id = if is_structure && !reserved {
3352 Some(type_param as u32)
3353 } else {
3354 None
3355 };
3356
3357 tags.push(TagAttributes {
3358 name: tag_name,
3359 data_type,
3360 data_type_name: if is_structure {
3361 "UDT".to_string()
3362 } else {
3363 self.get_data_type_name(data_type)
3364 },
3365 dimensions: vec![0; array_dims],
3366 permissions: udt::TagPermissions::ReadWrite,
3367 scope: scope.clone(),
3368 template_instance_id,
3369 size: 0,
3370 });
3371 }
3372
3373 Ok(TagListPage {
3374 tags,
3375 last_instance_id,
3376 partial_transfer,
3377 })
3378 }
3379
3380 async fn negotiate_packet_size(&mut self) -> crate::error::Result<()> {
3384 let mut request = vec![
3387 0x03, 0x02, 0x20, 0x02, 0x24, 0x01, ];
3392 request.extend_from_slice(&[0x01, 0x00]); request.extend_from_slice(&[0x04, 0x00]);
3396
3397 let response = self.send_cip_request(&request).await?;
3399 let cip_data = self.extract_cip_from_response(&response)?;
3400
3401 if cip_data.len() >= 12 && cip_data[2] == 0x00 {
3406 let max_packet_size = u16::from_le_bytes([cip_data[10], cip_data[11]]) as u32;
3408
3409 self.max_packet_size
3411 .store(max_packet_size.clamp(504, 4000), Ordering::Relaxed);
3412 tracing::debug!("Negotiated packet size: {} bytes", self.max_packet_size());
3413 } else {
3414 self.max_packet_size.store(4000, Ordering::Relaxed);
3416 tracing::debug!(
3417 "Using default packet size: {} bytes",
3418 self.max_packet_size()
3419 );
3420 }
3421
3422 Ok(())
3423 }
3424
3425 pub async fn write_tag(&mut self, tag_name: &str, value: PlcValue) -> crate::error::Result<()> {
3469 tracing::debug!(
3470 "Writing '{}' to tag '{}'",
3471 match &value {
3472 PlcValue::String(s) => format!("\"{s}\""),
3473 _ => format!("{value:?}"),
3474 },
3475 tag_name
3476 );
3477
3478 let value = if let PlcValue::Udt(udt_data) = &value {
3481 if udt_data.symbol_id == 0 {
3482 tracing::debug!("[UDT WRITE] symbol_id is 0, reading tag to get symbol_id");
3483 let attributes = self.get_tag_attributes(tag_name).await?;
3485 let symbol_id = attributes.template_instance_id.ok_or_else(|| {
3486 crate::error::EtherNetIpError::Protocol(
3487 "UDT template instance ID not found. Cannot write UDT without symbol_id."
3488 .to_string(),
3489 )
3490 })? as i32;
3491
3492 PlcValue::Udt(UdtData {
3494 symbol_id,
3495 data: udt_data.data.clone(),
3496 })
3497 } else {
3498 value
3499 }
3500 } else {
3501 value
3502 };
3503
3504 if let Some((base_path, bit_index)) = self.parse_bit_access(tag_name) {
3505 return match value {
3506 PlcValue::Bool(bit_value) => {
3507 self.write_bit_base_direct(&base_path, bit_index, bit_value)
3508 .await
3509 }
3510 other => Err(crate::error::EtherNetIpError::DataTypeMismatch {
3511 expected: "BOOL".to_string(),
3512 actual: format!("{:?}", other),
3513 }),
3514 };
3515 }
3516
3517 if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
3521 if !self.has_member_suffix_after_first_array_index(tag_name) {
3522 tracing::debug!(
3523 "Detected array element write: {}[{}], using workaround",
3524 base_name,
3525 index
3526 );
3527 return self
3528 .write_array_element_workaround(&base_name, index, value)
3529 .await;
3530 }
3531
3532 tracing::debug!(
3533 "Array element '{}[{}]' has member access, using TagPath::parse()",
3534 base_name,
3535 index
3536 );
3537 }
3538
3539 if let PlcValue::Bool(_) = value
3540 && let Some((parent_path, index)) = self.parse_final_array_element_access(tag_name)
3541 && self.detect_bool_array_path(&parent_path).await?
3542 {
3543 return self
3544 .write_bool_array_element_workaround(&parent_path, index, value)
3545 .await;
3546 }
3547
3548 self.write_tag_direct(tag_name, &value).await
3549 }
3550
3551 async fn write_tag_direct(
3552 &mut self,
3553 tag_name: &str,
3554 value: &PlcValue,
3555 ) -> crate::error::Result<()> {
3556 if let PlcValue::String(text) = value {
3565 if text.len() <= values::STANDARD_STRING_DATA_LEN {
3566 match self.write_tag_standard(tag_name, value).await {
3567 Ok(()) => return Ok(()),
3568 Err(error) if service_layer::is_2107_type_mismatch(&error) => {
3569 return self.write_string_handle_aware(tag_name, text).await;
3570 }
3571 Err(error) => return Err(error),
3572 }
3573 }
3574 return self.write_string_handle_aware(tag_name, text).await;
3575 }
3576 self.write_tag_standard(tag_name, value).await
3577 }
3578
3579 const SINGLE_PACKET_WRITE_LIMIT: usize = 494;
3583
3584 async fn write_string_handle_aware(
3589 &mut self,
3590 tag_name: &str,
3591 value: &str,
3592 ) -> crate::error::Result<()> {
3593 let (handle, struct_size) = match self.read_tag(tag_name).await? {
3595 PlcValue::String(_) => (
3596 values::STANDARD_STRING_HANDLE,
3597 values::STANDARD_STRING_PAYLOAD_LEN,
3598 ),
3599 PlcValue::Udt(udt) if udt.data.len() >= 6 => (
3600 u16::from_le_bytes([udt.data[0], udt.data[1]]),
3601 udt.data.len() - 2,
3602 ),
3603 other => {
3604 return Err(EtherNetIpError::DataTypeMismatch {
3605 expected: "STRING structure".to_string(),
3606 actual: format!("{other:?}"),
3607 });
3608 }
3609 };
3610
3611 let capacity = struct_size.saturating_sub(4);
3613 if value.len() > capacity {
3614 return Err(EtherNetIpError::StringTooLong {
3615 max_length: capacity,
3616 actual_length: value.len(),
3617 });
3618 }
3619
3620 let mut payload = vec![0u8; struct_size];
3622 payload[0..4].copy_from_slice(&(value.len() as u32).to_le_bytes());
3623 payload[4..4 + value.len()].copy_from_slice(value.as_bytes());
3624
3625 let mut data = Vec::with_capacity(6 + payload.len());
3627 data.extend_from_slice(&values::AB_UDT.to_le_bytes());
3628 data.extend_from_slice(&handle.to_le_bytes());
3629 data.extend_from_slice(&[0x01, 0x00]);
3630 data.extend_from_slice(&payload);
3631
3632 let path = self.build_tag_path(tag_name);
3633 let request = CipRequest::new(WRITE_TAG, path, data);
3634 let mut cip_request = BytesMut::new();
3635 request.encode(&mut cip_request)?;
3636
3637 if cip_request.len() > Self::SINGLE_PACKET_WRITE_LIMIT {
3638 return self
3639 .write_string_fragmented(tag_name, handle, &payload)
3640 .await;
3641 }
3642
3643 let response = self.send_cip_request(&cip_request).await?;
3644 let cip_response = self.extract_cip_from_response(&response)?;
3645 self.check_cip_error(&cip_response)?;
3646 Ok(())
3647 }
3648
3649 async fn write_string_fragmented(
3650 &mut self,
3651 tag_name: &str,
3652 handle: u16,
3653 payload: &[u8],
3654 ) -> crate::error::Result<()> {
3655 let max_fragment = self.max_write_fragment_payload_len(tag_name, handle)?;
3656 let mut offset = 0usize;
3657
3658 while offset < payload.len() {
3659 let end = usize::min(offset + max_fragment, payload.len());
3660 let request = self.build_write_fragmented_request(
3661 tag_name,
3662 handle,
3663 offset as u32,
3664 &payload[offset..end],
3665 )?;
3666 let response = self.send_cip_request(&request).await?;
3667 let cip_response = self.extract_cip_from_response(&response)?;
3668 if cip_response.first().copied() != Some(WRITE_TAG_FRAGMENTED_REPLY) {
3669 return Err(EtherNetIpError::Protocol(format!(
3670 "Unexpected Write Tag Fragmented reply service: 0x{:02X}",
3671 cip_response.first().copied().unwrap_or(0)
3672 )));
3673 }
3674 self.check_cip_error(&cip_response)?;
3675 offset = end;
3676 }
3677
3678 Ok(())
3679 }
3680
3681 async fn write_tag_standard(
3682 &mut self,
3683 tag_name: &str,
3684 value: &PlcValue,
3685 ) -> crate::error::Result<()> {
3686 let cip_request = self.build_write_request(tag_name, value)?;
3687
3688 let response = self.send_cip_request(&cip_request).await?;
3689
3690 let cip_response = self.extract_cip_from_response(&response)?;
3692
3693 if cip_response.len() < 3 {
3694 return Err(EtherNetIpError::Protocol(
3695 "Write response too short".to_string(),
3696 ));
3697 }
3698
3699 let service_reply = cip_response[0]; let general_status = cip_response[2]; tracing::trace!(
3703 "Write response - Service: 0x{:02X}, Status: 0x{:02X}",
3704 service_reply,
3705 general_status
3706 );
3707
3708 if let Err(e) = self.check_cip_error(&cip_response) {
3710 tracing::error!("[WRITE] CIP Error: {}", e);
3711 return Err(e);
3712 }
3713
3714 tracing::info!("Write operation completed successfully");
3715 Ok(())
3716 }
3717
3718 fn build_write_request(
3728 &self,
3729 tag_name: &str,
3730 value: &PlcValue,
3731 ) -> crate::error::Result<Vec<u8>> {
3732 tracing::debug!("Building write request for tag: '{}'", tag_name);
3733
3734 let path = self.build_tag_path(tag_name);
3736
3737 if let PlcValue::String(string_value) = value
3738 && string_value.len() > values::STANDARD_STRING_DATA_LEN
3739 {
3740 return Err(EtherNetIpError::StringTooLong {
3741 max_length: values::STANDARD_STRING_DATA_LEN,
3742 actual_length: string_value.len(),
3743 });
3744 }
3745
3746 let mut data = BytesMut::new();
3747 data.extend_from_slice(&values::write_data_type_bytes(value));
3748 data.extend_from_slice(&[0x01, 0x00]); values::encode_payload(value, &mut data);
3750
3751 let request = CipRequest::new(WRITE_TAG, path, data.to_vec());
3752 let mut cip_request = BytesMut::new();
3753 request.encode(&mut cip_request)?;
3754
3755 tracing::trace!(
3756 "Built CIP write request ({} bytes): {:02X?}",
3757 cip_request.len(),
3758 cip_request
3759 );
3760 Ok(cip_request.to_vec())
3761 }
3762
3763 fn build_read_fragmented_request(
3764 &self,
3765 tag_name: &str,
3766 element_count: u16,
3767 byte_offset: u32,
3768 ) -> crate::error::Result<Vec<u8>> {
3769 let mut data = Vec::with_capacity(6);
3770 data.extend_from_slice(&element_count.to_le_bytes());
3771 data.extend_from_slice(&byte_offset.to_le_bytes());
3772 let request = CipRequest::new(READ_TAG_FRAGMENTED, self.build_tag_path(tag_name), data);
3773 let mut cip_request = BytesMut::new();
3774 request.encode(&mut cip_request)?;
3775 Ok(cip_request.to_vec())
3776 }
3777
3778 fn build_write_fragmented_request(
3779 &self,
3780 tag_name: &str,
3781 handle: u16,
3782 byte_offset: u32,
3783 fragment: &[u8],
3784 ) -> crate::error::Result<Vec<u8>> {
3785 let mut data = Vec::with_capacity(10 + fragment.len());
3786 data.extend_from_slice(&values::AB_UDT.to_le_bytes());
3787 data.extend_from_slice(&handle.to_le_bytes());
3788 data.extend_from_slice(&1u16.to_le_bytes());
3789 data.extend_from_slice(&byte_offset.to_le_bytes());
3790 data.extend_from_slice(fragment);
3791 let request = CipRequest::new(WRITE_TAG_FRAGMENTED, self.build_tag_path(tag_name), data);
3792 let mut cip_request = BytesMut::new();
3793 request.encode(&mut cip_request)?;
3794 Ok(cip_request.to_vec())
3795 }
3796
3797 fn max_write_fragment_payload_len(
3798 &self,
3799 tag_name: &str,
3800 handle: u16,
3801 ) -> crate::error::Result<usize> {
3802 let empty_request = self.build_write_fragmented_request(tag_name, handle, 0, &[])?;
3803 if empty_request.len() >= Self::SINGLE_PACKET_WRITE_LIMIT {
3804 return Err(EtherNetIpError::Protocol(format!(
3805 "Write Tag Fragmented request for '{tag_name}' has {} bytes of overhead, exceeding the {}-byte single-packet limit before payload",
3806 empty_request.len(),
3807 Self::SINGLE_PACKET_WRITE_LIMIT
3808 )));
3809 }
3810
3811 Ok(Self::SINGLE_PACKET_WRITE_LIMIT - empty_request.len())
3812 }
3813
3814 pub fn build_list_tags_request(&self) -> Vec<u8> {
3816 tracing::debug!("Building list tags request");
3817
3818 let path_array = vec![
3820 0x20, 0x6B, 0x25, 0x00, 0x00, 0x00,
3826 ];
3827
3828 let request_data = vec![0x02, 0x00, 0x01, 0x00, 0x02, 0x00];
3830
3831 let request = CipRequest::new(0x55, path_array, request_data);
3833 let mut cip_request = BytesMut::new();
3834 request
3835 .encode(&mut cip_request)
3836 .expect("list-tags request path is static and valid");
3837
3838 tracing::trace!(
3839 "Built CIP list tags request ({} bytes): {:02X?}",
3840 cip_request.len(),
3841 cip_request
3842 );
3843
3844 cip_request.to_vec()
3845 }
3846
3847 fn parse_extended_error(&self, cip_data: &[u8]) -> crate::error::Result<String> {
3861 if cip_data.len() < 4 {
3862 return Err(EtherNetIpError::Protocol(
3863 "CIP response too short for additional-status check".to_string(),
3864 ));
3865 }
3866
3867 let additional_status_size = cip_data[3] as usize; if additional_status_size == 0 {
3869 return Ok("Extended error (no additional status)".to_string());
3870 }
3871
3872 let expected_len = 4 + (additional_status_size * 2);
3873 if cip_data.len() < expected_len {
3874 return Err(EtherNetIpError::Protocol(format!(
3875 "Additional-status response truncated: expected {expected_len} bytes, got {}",
3876 cip_data.len()
3877 )));
3878 }
3879
3880 let extended_error_code = u16::from_le_bytes([cip_data[4], cip_data[5]]);
3881 let error_msg = match extended_error_code {
3882 0x0001 => "Connection failure (extended)".to_string(),
3883 0x0002 => "Resource unavailable (extended)".to_string(),
3884 0x0003 => "Invalid parameter value (extended)".to_string(),
3885 0x0004 => "Path segment error (extended)".to_string(),
3886 0x0005 => "Path destination unknown (extended)".to_string(),
3887 0x0006 => "Partial transfer (extended)".to_string(),
3888 0x0007 => "Connection lost (extended)".to_string(),
3889 0x0008 => "Service not supported (extended)".to_string(),
3890 0x0009 => "Invalid attribute value (extended)".to_string(),
3891 0x000A => "Attribute list error (extended)".to_string(),
3892 0x000B => "Already in requested mode/state (extended)".to_string(),
3893 0x000C => "Object state conflict (extended)".to_string(),
3894 0x000D => "Object already exists (extended)".to_string(),
3895 0x000E => "Attribute not settable (extended)".to_string(),
3896 0x000F => "Privilege violation (extended)".to_string(),
3897 0x0010 => "Device state conflict (extended)".to_string(),
3898 0x0011 => "Reply data too large (extended)".to_string(),
3899 0x0012 => "Fragmentation of a primitive value (extended)".to_string(),
3900 0x0013 => "Not enough data (extended)".to_string(),
3901 0x0014 => "Attribute not supported (extended)".to_string(),
3902 0x0015 => "Too much data (extended)".to_string(),
3903 0x0016 => "Object does not exist (extended)".to_string(),
3904 0x0017 => "Service fragmentation sequence not in progress (extended)".to_string(),
3905 0x0018 => "No stored attribute data (extended)".to_string(),
3906 0x0019 => "Store operation failure (extended)".to_string(),
3907 0x001A => "Routing failure, request packet too large (extended)".to_string(),
3908 0x001B => "Routing failure, response packet too large (extended)".to_string(),
3909 0x001C => "Missing attribute list entry data (extended)".to_string(),
3910 0x001D => "Invalid attribute value list (extended)".to_string(),
3911 0x001E => "Embedded service error (extended)".to_string(),
3912 0x001F => "Vendor specific error (extended)".to_string(),
3913 0x0020 => "Invalid parameter (extended)".to_string(),
3914 0x0021 => "Write-once value or medium already written (extended)".to_string(),
3915 0x0022 => "Invalid reply received (extended)".to_string(),
3916 0x0023 => "Buffer overflow (extended)".to_string(),
3917 0x0024 => "Invalid message format (extended)".to_string(),
3918 0x0025 => "Key failure in path (extended)".to_string(),
3919 0x0026 => "Path size invalid (extended)".to_string(),
3920 0x0027 => "Unexpected attribute in list (extended)".to_string(),
3921 0x0028 => "Invalid member ID (extended)".to_string(),
3922 0x0029 => "Member not settable (extended)".to_string(),
3923 0x002A => "Group 2 only server general failure (extended)".to_string(),
3924 0x002B => "Unknown Modbus error (extended)".to_string(),
3925 0x002C => "Attribute not gettable (extended)".to_string(),
3926 0x2107 => format!(
3927 "Read/Write Tag data-type mismatch extended error: 0x{extended_error_code:04X}. Raw bytes: [0x{:02X}, 0x{:02X}]. Check that the request data type matches the target tag; STRING members inside UDTs can also surface this current-encoding rejection.",
3928 cip_data[4], cip_data[5]
3929 ),
3930 _ => format!(
3931 "Unknown extended CIP error code: 0x{extended_error_code:04X}. Raw bytes: [0x{:02X}, 0x{:02X}]",
3932 cip_data[4], cip_data[5]
3933 ),
3934 };
3935
3936 Ok(error_msg)
3937 }
3938
3939 fn check_cip_error(&self, cip_data: &[u8]) -> crate::error::Result<()> {
3942 if cip_data.len() < 3 {
3943 return Err(EtherNetIpError::Protocol(
3944 "CIP response too short for status check".to_string(),
3945 ));
3946 }
3947
3948 let general_status = cip_data[2];
3949
3950 if general_status == 0x00 {
3951 return Ok(());
3953 }
3954
3955 if cip_data.get(3).copied().unwrap_or(0) > 0 {
3957 let error_msg = self.parse_extended_error(cip_data)?;
3958 return Err(EtherNetIpError::Protocol(format!(
3959 "CIP Extended Error: {error_msg}"
3960 )));
3961 }
3962
3963 let error_msg = self.get_cip_error_message(general_status);
3965 if general_status == 0x01 {
3966 return Err(EtherNetIpError::Connection(format!(
3967 "CIP Error 0x{general_status:02X}: {error_msg}"
3968 )));
3969 }
3970 if general_status == 0x07 {
3971 return Err(EtherNetIpError::ConnectionLost(format!(
3972 "CIP Error 0x{general_status:02X}: {error_msg}"
3973 )));
3974 }
3975 Err(EtherNetIpError::Protocol(format!(
3976 "CIP Error 0x{general_status:02X}: {error_msg}"
3977 )))
3978 }
3979
3980 fn get_cip_error_message(&self, status: u8) -> String {
3981 match status {
3982 0x00 => "Success".to_string(),
3983 0x01 => "Connection failure".to_string(),
3984 0x02 => "Resource unavailable".to_string(),
3985 0x03 => "Invalid parameter value".to_string(),
3986 0x04 => "Path segment error".to_string(),
3987 0x05 => "Path destination unknown".to_string(),
3988 0x06 => "Partial transfer".to_string(),
3989 0x07 => "Connection lost".to_string(),
3990 0x08 => "Service not supported".to_string(),
3991 0x09 => "Invalid attribute value".to_string(),
3992 0x0A => "Attribute list error".to_string(),
3993 0x0B => "Already in requested mode/state".to_string(),
3994 0x0C => "Object state conflict".to_string(),
3995 0x0D => "Object already exists".to_string(),
3996 0x0E => "Attribute not settable".to_string(),
3997 0x0F => "Privilege violation".to_string(),
3998 0x10 => "Device state conflict".to_string(),
3999 0x11 => "Reply data too large".to_string(),
4000 0x12 => "Fragmentation of a primitive value".to_string(),
4001 0x13 => "Not enough data".to_string(),
4002 0x14 => "Attribute not supported".to_string(),
4003 0x15 => "Too much data".to_string(),
4004 0x16 => "Object does not exist".to_string(),
4005 0x17 => "Service fragmentation sequence not in progress".to_string(),
4006 0x18 => "No stored attribute data".to_string(),
4007 0x19 => "Store operation failure".to_string(),
4008 0x1A => "Routing failure, request packet too large".to_string(),
4009 0x1B => "Routing failure, response packet too large".to_string(),
4010 0x1C => "Missing attribute list entry data".to_string(),
4011 0x1D => "Invalid attribute value list".to_string(),
4012 0x1E => "Embedded service error".to_string(),
4013 0x1F => "Vendor specific error".to_string(),
4014 0x20 => "Invalid parameter".to_string(),
4015 0x21 => "Write-once value or medium already written".to_string(),
4016 0x22 => "Invalid reply received".to_string(),
4017 0x23 => "Buffer overflow".to_string(),
4018 0x24 => "Invalid message format".to_string(),
4019 0x25 => "Key failure in path".to_string(),
4020 0x26 => "Path size invalid".to_string(),
4021 0x27 => "Unexpected attribute in list".to_string(),
4022 0x28 => "Invalid member ID".to_string(),
4023 0x29 => "Member not settable".to_string(),
4024 0x2A => "Group 2 only server general failure".to_string(),
4025 0x2B => "Unknown Modbus error".to_string(),
4026 0x2C => "Attribute not gettable".to_string(),
4027 _ => format!("Unknown CIP error code: 0x{status:02X}"),
4028 }
4029 }
4030
4031 fn describe_multiple_service_error(
4032 &self,
4033 general_status: u8,
4034 operations: &[BatchOperation],
4035 ) -> String {
4036 if general_status == 0x1E
4037 && operations.iter().any(|op| {
4038 matches!(
4039 op,
4040 BatchOperation::Write {
4041 value: PlcValue::String(_),
4042 ..
4043 }
4044 )
4045 })
4046 {
4047 return "Multiple Service Response error: 0x1E (Embedded service error). A batched STRING write failed inside the controller; inspect the embedded reply for the rejected service and data-type details.".to_string();
4048 }
4049
4050 format!("Multiple Service Response error: 0x{general_status:02X}")
4051 }
4052
4053 async fn validate_session(&mut self) -> crate::error::Result<()> {
4054 let time_since_activity = self.last_activity.lock().await.elapsed();
4055
4056 if time_since_activity > Duration::from_secs(30) {
4058 self.send_keep_alive().await?;
4059 }
4060
4061 Ok(())
4062 }
4063
4064 async fn send_keep_alive(&mut self) -> crate::error::Result<()> {
4065 self.ensure_stream_usable()?;
4066 let packet = vec![0u8; 24];
4070 let mut stream = self.stream.lock().await;
4075 stream.write_all(&packet).await?;
4076 *self.last_activity.lock().await = Instant::now();
4077 Ok(())
4078 }
4079
4080 fn build_unconnected_send(&self, embedded_message: &[u8]) -> Vec<u8> {
4097 let mut ucmm = vec![
4098 0x52, 0x02,
4101 0x20, 0x06, 0x24, 0x01, 0x0A, 0xF0,
4109 ];
4110
4111 let msg_len = embedded_message.len() as u16;
4113 ucmm.extend_from_slice(&msg_len.to_le_bytes());
4114
4115 ucmm.extend_from_slice(embedded_message);
4117
4118 if embedded_message.len() % 2 == 1 {
4120 ucmm.push(0x00);
4121 }
4122
4123 let route_path_bytes = if let Some(route_path) = self.route_path_snapshot() {
4126 route_path.to_cip_bytes()
4127 } else {
4128 Vec::new()
4129 };
4130
4131 let route_path_words = if route_path_bytes.is_empty() {
4132 0
4133 } else {
4134 (route_path_bytes.len() / 2) as u8
4135 };
4136 ucmm.push(route_path_words);
4137
4138 ucmm.push(0x00);
4140
4141 if !route_path_bytes.is_empty() {
4143 tracing::trace!(
4144 "Adding route path to Unconnected Send: {:02X?} ({} bytes, {} words)",
4145 route_path_bytes,
4146 route_path_bytes.len(),
4147 route_path_words
4148 );
4149 ucmm.extend_from_slice(&route_path_bytes);
4150 }
4151
4152 ucmm
4153 }
4154
4155 pub async fn send_cip_request(&self, cip_request: &[u8]) -> Result<Vec<u8>> {
4162 tracing::trace!(
4163 "Sending CIP request ({} bytes): {:02X?}",
4164 cip_request.len(),
4165 cip_request
4166 );
4167
4168 let ucmm_message = self.build_unconnected_send(cip_request);
4171 let diagnostic_operation = Self::diagnostic_operation_for(cip_request);
4172
4173 tracing::trace!(
4174 "Unconnected Send message ({} bytes): {:02X?}",
4175 ucmm_message.len(),
4176 &ucmm_message[..std::cmp::min(64, ucmm_message.len())]
4177 );
4178
4179 let response_data = match self.send_rr_data_item(&ucmm_message).await {
4180 Ok(response_data) => response_data,
4181 Err(error) => {
4182 self.diagnostic_counters
4183 .record_failure(diagnostic_operation, &error);
4184 return Err(error);
4185 }
4186 };
4187
4188 if let Ok(raw_cip_data) = self.extract_unconnected_data_item(&response_data) {
4189 let use_direct_fallback = raw_cip_data.len() >= 3
4190 && raw_cip_data[0] == 0xD2
4191 && raw_cip_data[2] != 0x00
4192 && cip_request.first().copied() != Some(READ_TAG_FRAGMENTED)
4193 && self.route_path_snapshot().is_none();
4194
4195 if use_direct_fallback {
4196 tracing::warn!(
4197 "Unconnected Send returned 0xD2 status 0x{:02X}; retrying with direct CIP SendRRData fallback",
4198 raw_cip_data[2]
4199 );
4200 return match self.send_rr_data_item(cip_request).await {
4201 Ok(response_data) => {
4202 self.diagnostic_counters
4203 .record_success(diagnostic_operation);
4204 Ok(response_data)
4205 }
4206 Err(error) => {
4207 self.diagnostic_counters
4208 .record_failure(diagnostic_operation, &error);
4209 Err(error)
4210 }
4211 };
4212 }
4213
4214 if raw_cip_data.len() >= 3 && raw_cip_data[2] != 0x00 {
4215 self.diagnostic_counters
4216 .record_cip_failure(diagnostic_operation);
4217 } else {
4218 self.diagnostic_counters
4219 .record_success(diagnostic_operation);
4220 }
4221 } else {
4222 self.diagnostic_counters
4223 .record_success(diagnostic_operation);
4224 }
4225
4226 Ok(response_data)
4227 }
4228
4229 async fn send_rr_data_item(&self, item_data: &[u8]) -> Result<Vec<u8>> {
4230 let send_data = SendDataRequest::unconnected(item_data);
4231 let mut packet = BytesMut::new();
4232 let mut cpf = BytesMut::new();
4233 send_data.encode(&mut cpf);
4234 let sender_context = self.next_sender_context();
4235 EncapsulationHeader::send_rr_data_with_context(
4236 cpf.len() as u16,
4237 self.session_handle(),
4238 sender_context,
4239 )
4240 .encode(&mut packet);
4241 packet.extend_from_slice(&cpf);
4242
4243 tracing::trace!(
4244 "Built packet ({} bytes): {:02X?}",
4245 packet.len(),
4246 &packet[..std::cmp::min(64, packet.len())]
4247 );
4248
4249 self.ensure_stream_usable()?;
4251 let mut stream = self.stream.lock().await;
4252 self.ensure_stream_usable()?;
4253 self.stream_poisoned.store(true, Ordering::Relaxed);
4254 if let Err(e) = stream.write_all(&packet).await {
4255 return Err(EtherNetIpError::Io(e));
4256 }
4257
4258 let mut header = [0u8; 24];
4260 match timeout(Duration::from_secs(10), stream.read_exact(&mut header)).await {
4261 Ok(Ok(_)) => {}
4262 Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4263 Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4264 }
4265
4266 let mut header_bytes = &header[..];
4268 let response_header = EncapsulationHeader::decode(&mut header_bytes)?;
4269 if response_header.sender_context != sender_context {
4270 return Err(EtherNetIpError::Protocol(format!(
4271 "SendRRData sender_context mismatch: expected {:02X?}, got {:02X?}",
4272 sender_context, response_header.sender_context
4273 )));
4274 }
4275
4276 let response_length = response_header.length as usize;
4278 if response_header.status != 0 {
4279 if response_length > 0 {
4280 let mut response_data = vec![0u8; response_length];
4281 match timeout(
4282 Duration::from_secs(10),
4283 stream.read_exact(&mut response_data),
4284 )
4285 .await
4286 {
4287 Ok(Ok(_)) => {}
4288 Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4289 Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4290 }
4291 }
4292 self.stream_poisoned.store(false, Ordering::Relaxed);
4293 return Err(EtherNetIpError::Protocol(format!(
4294 "EIP Command failed. Status: 0x{:08X}",
4295 response_header.status
4296 )));
4297 }
4298
4299 if response_length == 0 {
4300 self.stream_poisoned.store(false, Ordering::Relaxed);
4301 return Ok(Vec::new());
4302 }
4303
4304 let mut response_data = vec![0u8; response_length];
4306 match timeout(
4307 Duration::from_secs(10),
4308 stream.read_exact(&mut response_data),
4309 )
4310 .await
4311 {
4312 Ok(Ok(_)) => {}
4313 Ok(Err(e)) => return Err(EtherNetIpError::Io(e)),
4314 Err(_) => return Err(EtherNetIpError::Timeout(Duration::from_secs(10))),
4315 }
4316
4317 self.stream_poisoned.store(false, Ordering::Relaxed);
4318
4319 *self.last_activity.lock().await = Instant::now();
4321
4322 tracing::trace!(
4323 "Received response ({} bytes): {:02X?}",
4324 response_data.len(),
4325 &response_data[..std::cmp::min(32, response_data.len())]
4326 );
4327
4328 Ok(response_data)
4329 }
4330
4331 fn extract_unconnected_data_item(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
4332 let mut response = response;
4333 let send_data = SendDataRequest::decode(&mut response)?;
4334 if let Some(item) = send_data
4335 .items
4336 .into_iter()
4337 .find(|item| item.type_id == 0x00B2)
4338 {
4339 return Ok(item.data);
4340 }
4341
4342 Err(EtherNetIpError::Protocol(
4343 "No Unconnected Data Item (0x00B2) found in response".to_string(),
4344 ))
4345 }
4346
4347 fn unwrap_unconnected_send_reply(&self, cip_data: &[u8]) -> crate::error::Result<Vec<u8>> {
4348 if cip_data.is_empty() || cip_data[0] != 0xD2 {
4349 return Ok(cip_data.to_vec());
4350 }
4351
4352 if cip_data.len() < 4 {
4353 return Err(EtherNetIpError::Protocol(
4354 "Unconnected Send reply too short".to_string(),
4355 ));
4356 }
4357
4358 let general_status = cip_data[2];
4359 let additional_status_words = cip_data[3] as usize;
4360 let embedded_offset = 4 + (additional_status_words * 2);
4361
4362 if general_status != 0x00 {
4363 let error_msg = self.get_cip_error_message(general_status);
4364 return Err(EtherNetIpError::Protocol(format!(
4365 "Unconnected Send failed (0xD2): CIP Error 0x{general_status:02X}: {error_msg}"
4366 )));
4367 }
4368
4369 if embedded_offset >= cip_data.len() {
4370 return Err(EtherNetIpError::Protocol(
4371 "Unconnected Send succeeded but no embedded response payload was returned"
4372 .to_string(),
4373 ));
4374 }
4375
4376 Ok(cip_data[embedded_offset..].to_vec())
4377 }
4378
4379 fn extract_cip_from_response(&self, response: &[u8]) -> crate::error::Result<Vec<u8>> {
4381 tracing::trace!(
4382 "Extracting CIP from response ({} bytes): {:02X?}",
4383 response.len(),
4384 &response[..std::cmp::min(32, response.len())]
4385 );
4386 let cip_data = self.extract_unconnected_data_item(response)?;
4387 tracing::trace!(
4388 "Found Unconnected Data Item, extracted CIP data ({} bytes)",
4389 cip_data.len()
4390 );
4391 tracing::trace!(
4392 "CIP data bytes: {:02X?}",
4393 &cip_data[..std::cmp::min(16, cip_data.len())]
4394 );
4395 self.unwrap_unconnected_send_reply(&cip_data)
4396 }
4397
4398 fn parse_cip_response(&self, cip_response: &[u8]) -> crate::error::Result<PlcValue> {
4400 tracing::trace!(
4401 "Parsing CIP response ({} bytes): {:02X?}",
4402 cip_response.len(),
4403 cip_response
4404 );
4405
4406 if let Err(e) = self.check_cip_error(cip_response) {
4407 tracing::error!("CIP Error: {}", e);
4408 return Err(e);
4409 }
4410
4411 let mut response_bytes = cip_response;
4412 let response = CipResponse::decode(&mut response_bytes)?;
4413
4414 if response.service == 0xCC {
4415 self.decode_type_prefixed_value(&response.data)
4416 } else if response.service == 0xCD {
4417 tracing::debug!("Write operation successful");
4418 Ok(PlcValue::Bool(true))
4419 } else {
4420 Err(EtherNetIpError::Protocol(format!(
4421 "Unknown service reply: 0x{:02X}",
4422 response.service
4423 )))
4424 }
4425 }
4426
4427 fn parse_read_fragmented_response<'a>(
4428 &self,
4429 cip_response: &'a [u8],
4430 ) -> crate::error::Result<(u8, &'a [u8])> {
4431 if cip_response.len() < 4 {
4432 return Err(EtherNetIpError::Protocol(
4433 "Read Tag Fragmented response too short".to_string(),
4434 ));
4435 }
4436
4437 let service = cip_response[0];
4438 if service != READ_TAG_FRAGMENTED_REPLY {
4439 return Err(EtherNetIpError::Protocol(format!(
4440 "Unexpected Read Tag Fragmented reply service: 0x{service:02X}"
4441 )));
4442 }
4443
4444 let status = cip_response[2];
4445 if status != CIP_STATUS_SUCCESS && status != CIP_STATUS_PARTIAL_TRANSFER {
4446 self.check_cip_error(cip_response)?;
4447 }
4448
4449 Ok((status, &cip_response[4..]))
4450 }
4451
4452 fn decode_type_prefixed_value(&self, data: &[u8]) -> crate::error::Result<PlcValue> {
4453 if data.len() < 2 {
4454 return Err(EtherNetIpError::Protocol(
4455 "Read response too short for data".to_string(),
4456 ));
4457 }
4458
4459 let data_type = u16::from_le_bytes([data[0], data[1]]);
4460 let value_data = &data[2..];
4461 tracing::trace!(
4462 "Data type: 0x{:04X}, Value data ({} bytes): {:02X?}",
4463 data_type,
4464 value_data.len(),
4465 value_data
4466 );
4467 Ok(values::decode_payload(data_type, value_data)?)
4468 }
4469
4470 pub async fn unregister_session(&mut self) -> crate::error::Result<()> {
4472 tracing::info!("Unregistering session...");
4473
4474 let mut packet = BytesMut::with_capacity(24);
4475 EncapsulationHeader::new(UNREGISTER_SESSION, 0, self.session_handle()).encode(&mut packet);
4476
4477 self.stream
4478 .lock()
4479 .await
4480 .write_all(&packet)
4481 .await
4482 .map_err(EtherNetIpError::Io)?;
4483
4484 tracing::info!("Session unregistered");
4485 Ok(())
4486 }
4487
4488 fn build_read_request(&self, tag_name: &str) -> crate::error::Result<Vec<u8>> {
4490 self.build_read_request_with_count(tag_name, 1)
4491 }
4492
4493 fn build_read_request_with_count(
4497 &self,
4498 tag_name: &str,
4499 element_count: u16,
4500 ) -> crate::error::Result<Vec<u8>> {
4501 tracing::debug!(
4502 "Building read request for tag: '{}' with count: {}",
4503 tag_name,
4504 element_count
4505 );
4506
4507 let path = self.build_tag_path(tag_name);
4509
4510 let path_size_words = (path.len() / 2) as u8;
4512 tracing::debug!(
4513 "Path size calculation: {} bytes / 2 = {} words for tag '{}'",
4514 path.len(),
4515 path_size_words,
4516 tag_name
4517 );
4518 tracing::debug!(
4519 "Path bytes ({} bytes, {} words) for tag '{}': {:02X?}",
4520 path.len(),
4521 path_size_words,
4522 tag_name,
4523 path
4524 );
4525 let request = CipRequest::new(READ_TAG, path, element_count.to_le_bytes().to_vec());
4526 let mut cip_request = BytesMut::new();
4527 request.encode(&mut cip_request)?;
4528
4529 tracing::debug!(
4530 "Built CIP read request ({} bytes) for tag '{}': {:02X?}",
4531 cip_request.len(),
4532 tag_name,
4533 cip_request
4534 );
4535 Ok(cip_request.to_vec())
4536 }
4537
4538 #[cfg_attr(not(test), allow(dead_code))]
4547 pub fn build_element_id_segment(&self, index: u32) -> Vec<u8> {
4548 let mut segment = Vec::new();
4549
4550 if index <= 255 {
4551 segment.push(0x28);
4554 segment.push(index as u8);
4555 } else if index <= 65535 {
4556 segment.push(0x29);
4559 segment.push(0x00); segment.extend_from_slice(&(index as u16).to_le_bytes());
4561 } else {
4562 segment.push(0x2A);
4565 segment.push(0x00); segment.extend_from_slice(&index.to_le_bytes());
4567 }
4568
4569 segment
4570 }
4571
4572 #[cfg_attr(not(test), allow(dead_code))]
4577 pub fn build_base_tag_path(&self, tag_name: &str) -> Vec<u8> {
4578 match TagPath::parse(tag_name) {
4580 Ok(path) => {
4581 let base_path = match &path {
4583 TagPath::Array { base_path, .. } => base_path.as_ref(),
4584 _ => &path,
4585 };
4586 base_path.to_cip_path().unwrap_or_else(|_| {
4587 let mut path = Vec::new();
4590 path.push(0x91); let name_bytes = tag_name.as_bytes();
4592 path.push(name_bytes.len() as u8);
4593 path.extend_from_slice(name_bytes);
4594 if path.len() % 2 != 0 {
4596 path.push(0x00);
4597 }
4598 path
4599 })
4600 }
4601 Err(_) => {
4602 let mut path = Vec::new();
4604 path.push(0x91); let name_bytes = tag_name.as_bytes();
4606 path.push(name_bytes.len() as u8);
4607 path.extend_from_slice(name_bytes);
4608 if path.len() % 2 != 0 {
4610 path.push(0x00);
4611 }
4612 path
4613 }
4614 }
4615 }
4616
4617 #[cfg_attr(not(test), allow(dead_code))]
4645 pub fn build_read_array_request(
4646 &self,
4647 base_array_name: &str,
4648 start_index: u32,
4649 element_count: u16,
4650 ) -> Vec<u8> {
4651 let mut cip_request = Vec::new();
4652
4653 cip_request.push(0x4C);
4656
4657 let mut full_path = self.build_base_tag_path(base_array_name);
4662
4663 tracing::trace!(
4664 "build_read_array_request: base_path for '{}' = {:02X?} ({} bytes)",
4665 base_array_name,
4666 full_path,
4667 full_path.len()
4668 );
4669
4670 let element_segment = self.build_element_id_segment(start_index);
4673 tracing::trace!(
4674 "build_read_array_request: element_segment for index {} = {:02X?} ({} bytes)",
4675 start_index,
4676 element_segment,
4677 element_segment.len()
4678 );
4679 full_path.extend_from_slice(&element_segment);
4680
4681 if !full_path.len().is_multiple_of(2) {
4683 full_path.push(0x00);
4684 }
4685
4686 let path_size = (full_path.len() / 2) as u8;
4688 cip_request.push(path_size);
4689 cip_request.extend_from_slice(&full_path);
4690
4691 cip_request.extend_from_slice(&element_count.to_le_bytes());
4694
4695 tracing::trace!(
4696 "build_read_array_request: final request = {:02X?} ({} bytes), path_size = {} words ({} bytes)",
4697 cip_request,
4698 cip_request.len(),
4699 path_size,
4700 full_path.len()
4701 );
4702
4703 cip_request
4704 }
4705
4706 fn build_tag_path(&self, tag_name: &str) -> Vec<u8> {
4712 match TagPath::parse(tag_name) {
4716 Ok(tag_path) => {
4717 tracing::debug!("Parsed tag path for '{}': {:?}", tag_name, tag_path);
4718 match tag_path.to_cip_path() {
4720 Ok(path) => {
4721 tracing::debug!(
4722 "TagPath generated {} bytes ({} words) for '{}': {:02X?}",
4723 path.len(),
4724 path.len() / 2,
4725 tag_name,
4726 path
4727 );
4728 path
4729 }
4730 Err(e) => {
4731 tracing::warn!("TagPath.to_cip_path() failed for '{}': {}", tag_name, e);
4732 self.build_simple_tag_path_legacy(tag_name)
4734 }
4735 }
4736 }
4737 Err(e) => {
4738 tracing::warn!("TagPath::parse() failed for '{}': {}", tag_name, e);
4739 self.build_simple_tag_path_legacy(tag_name)
4741 }
4742 }
4743 }
4744
4745 fn build_simple_tag_path_legacy(&self, tag_name: &str) -> Vec<u8> {
4747 let mut path = Vec::new();
4748 path.push(0x91); path.push(tag_name.len() as u8);
4750 path.extend_from_slice(tag_name.as_bytes());
4751
4752 if !tag_name.len().is_multiple_of(2) {
4754 path.push(0x00);
4755 }
4756
4757 path
4758 }
4759}
4760
4761#[cfg(test)]
4762mod array_type_cache_tests {
4763 use super::EipClient;
4764 use crate::RoutePath;
4765 use crate::tag_manager::{TagMetadata, TagPermissions, TagScope};
4766 use crate::udt::{
4767 TagAttributes, TagPermissions as UdtTagPermissions, TagScope as UdtTagScope, UdtDefinition,
4768 };
4769 use std::sync::atomic::Ordering;
4770 use std::time::Instant;
4771
4772 #[test]
4773 fn cache_preserves_positive_and_negative_array_classifications() {
4774 let client = EipClient::new_unconnected_for_testing();
4775
4776 client.cache_array_is_packed_bool("BoolArray", true);
4777 client.cache_array_is_packed_bool("DintArray", false);
4778
4779 assert_eq!(client.cached_array_is_packed_bool("BoolArray"), Some(true));
4780 assert_eq!(client.cached_array_is_packed_bool("DintArray"), Some(false));
4781 assert_eq!(client.cached_array_is_packed_bool("UnknownArray"), None);
4782 }
4783
4784 #[test]
4785 fn cache_is_shared_across_client_clones_used_by_ffi() {
4786 let client = EipClient::new_unconnected_for_testing();
4787 let cloned = client.clone();
4788
4789 client.cache_array_is_packed_bool("ControllerArray", false);
4790 cloned.cache_array_is_packed_bool("Program:Main.BoolArray", true);
4791
4792 assert_eq!(
4793 cloned.cached_array_is_packed_bool("ControllerArray"),
4794 Some(false)
4795 );
4796 assert_eq!(
4797 client.cached_array_is_packed_bool("Program:Main.BoolArray"),
4798 Some(true)
4799 );
4800 }
4801
4802 #[test]
4803 fn route_changes_clear_array_classifications() {
4804 let mut client = EipClient::new_unconnected_for_testing();
4805 client.cache_array_is_packed_bool("SharedName", false);
4806
4807 client.set_route_path(RoutePath::new().add_slot(1));
4808 assert_eq!(client.cached_array_is_packed_bool("SharedName"), None);
4809
4810 client.cache_array_is_packed_bool("SharedName", true);
4811 client.clear_route_path();
4812 assert_eq!(client.cached_array_is_packed_bool("SharedName"), None);
4813 }
4814
4815 #[tokio::test]
4816 async fn public_cache_clear_removes_array_classifications() {
4817 let mut client = EipClient::new_unconnected_for_testing();
4818 client.cache_array_is_packed_bool("DintArray", false);
4819
4820 client.clear_caches().await;
4821
4822 assert_eq!(client.cached_array_is_packed_bool("DintArray"), None);
4823 }
4824
4825 #[tokio::test]
4826 async fn refresh_schema_clears_every_schema_cache_and_is_clone_visible() {
4827 let client = EipClient::new_unconnected_for_testing();
4828 let cloned = client.clone();
4829 client.cache_array_is_packed_bool("BoolArray", true);
4830
4831 {
4832 let tag_manager = client.tag_manager.lock().await;
4833 tag_manager
4834 .cache
4835 .write()
4836 .expect("tag metadata cache lock")
4837 .insert(
4838 "CachedTag".to_string(),
4839 TagMetadata {
4840 data_type: 0x00C4,
4841 size: 4,
4842 is_array: false,
4843 dimensions: Vec::new(),
4844 permissions: TagPermissions {
4845 readable: true,
4846 writable: true,
4847 },
4848 scope: TagScope::Controller,
4849 last_access: Instant::now(),
4850 array_info: None,
4851 last_updated: Instant::now(),
4852 },
4853 );
4854 tag_manager
4855 .udt_definitions
4856 .write()
4857 .expect("tag UDT cache lock")
4858 .insert(
4859 "CachedUdt".to_string(),
4860 UdtDefinition {
4861 name: "CachedUdt".to_string(),
4862 members: Vec::new(),
4863 },
4864 );
4865 }
4866 {
4867 let mut udt_manager = client.udt_manager.lock().await;
4868 udt_manager.add_definition(UdtDefinition {
4869 name: "ManagerUdt".to_string(),
4870 members: Vec::new(),
4871 });
4872 udt_manager.add_tag_attributes(TagAttributes {
4873 name: "ManagerTag".to_string(),
4874 data_type: 0x00C4,
4875 data_type_name: "DINT".to_string(),
4876 dimensions: Vec::new(),
4877 permissions: UdtTagPermissions::ReadWrite,
4878 scope: UdtTagScope::Controller,
4879 template_instance_id: None,
4880 size: 4,
4881 });
4882 }
4883
4884 let before = client.schema_generation();
4885 let after = cloned.refresh_schema().await;
4886
4887 assert_eq!(after, before + 1);
4888 assert_eq!(client.schema_generation(), after);
4889 assert_eq!(client.cached_array_is_packed_bool("BoolArray"), None);
4890 assert!(client.get_tag_metadata("CachedTag").await.is_none());
4891 assert!(client.list_udt_definitions().await.is_empty());
4892 let udt_manager = client.udt_manager.lock().await;
4893 assert!(udt_manager.list_definitions().is_empty());
4894 assert!(udt_manager.list_templates().is_empty());
4895 assert!(udt_manager.list_tag_attributes().is_empty());
4896 }
4897
4898 #[tokio::test]
4899 async fn stale_generation_cannot_repopulate_array_classification() {
4900 let client = EipClient::new_unconnected_for_testing();
4901 let stale_generation = client.schema_generation();
4902
4903 client.refresh_schema().await;
4904
4905 assert!(!client.cache_array_is_packed_bool_at_generation(
4906 "ReplacedArray",
4907 true,
4908 stale_generation,
4909 ));
4910 assert_eq!(client.cached_array_is_packed_bool("ReplacedArray"), None);
4911 }
4912
4913 #[test]
4914 fn route_changes_advance_the_shared_schema_generation() {
4915 let mut client = EipClient::new_unconnected_for_testing();
4916 let cloned = client.clone();
4917 let initial = client.schema_generation();
4918
4919 client.set_route_path(RoutePath::new().add_slot(1));
4920 assert_eq!(cloned.schema_generation(), initial + 1);
4921
4922 client.clear_route_path();
4923 assert_eq!(cloned.schema_generation(), initial + 2);
4924 assert_eq!(
4925 client
4926 .diagnostic_counters
4927 .schema_refreshes
4928 .load(Ordering::Relaxed),
4929 0,
4930 "route invalidation advances generation but is not an explicit refresh"
4931 );
4932 }
4933}
4934
4935#[cfg(test)]
4936mod discovery_tests {
4937 use super::{EipClient, TemplateAttributes};
4938
4939 #[test]
4940 fn build_tag_list_request_rejects_instance_above_u16() {
4941 let client = EipClient::new_unconnected_for_testing();
4942 let request = client
4943 .build_tag_list_request_from_instance(0x12345678)
4944 .expect_err("instance should be rejected");
4945
4946 assert!(format!("{request}").contains("exceeds 16-bit"));
4947 }
4948
4949 #[test]
4950 fn build_tag_list_request_encodes_path_size_and_start_instance() {
4951 let client = EipClient::new_unconnected_for_testing();
4952 let request = client
4953 .build_tag_list_request_from_instance(0x5678)
4954 .expect("request should build");
4955
4956 assert_eq!(request[0], 0x55);
4957 assert_eq!(request[1], 0x03);
4958 assert_eq!(&request[2..8], &[0x20, 0x6B, 0x25, 0x00, 0x78, 0x56]);
4959 }
4960
4961 #[test]
4962 fn build_program_tag_list_request_includes_program_symbol_scope() {
4963 let client = EipClient::new_unconnected_for_testing();
4964 let request = client
4965 .build_program_tag_list_request("MainProgram", 0)
4966 .expect("request should build");
4967
4968 let mut expected = vec![0x55, 0x0E, 0x91, 0x13];
4969 expected.extend_from_slice(b"Program:MainProgram");
4970 expected.push(0x00);
4971 expected.extend_from_slice(&[
4972 0x20, 0x6B, 0x25, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, ]);
4977
4978 assert_eq!(request, expected);
4979 }
4980
4981 #[test]
4982 fn build_program_tag_list_request_resumes_at_start_instance() {
4983 let client = EipClient::new_unconnected_for_testing();
4984 let request = client
4985 .build_program_tag_list_request("MainProgram", 0x1235)
4986 .expect("request should build");
4987
4988 let mut expected = vec![0x55, 0x0E, 0x91, 0x13];
4991 expected.extend_from_slice(b"Program:MainProgram");
4992 expected.push(0x00);
4993 expected.extend_from_slice(&[
4994 0x20, 0x6B, 0x25, 0x00, 0x35, 0x12, 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, ]);
4999
5000 assert_eq!(request, expected);
5001 }
5002
5003 #[test]
5004 fn both_accepted_program_name_forms_yield_the_bare_scope() {
5005 let bare =
5010 super::udt::TagScope::Program(super::program_scope_name("Dashboard").to_string());
5011 let prefixed = super::udt::TagScope::Program(
5012 super::program_scope_name("Program:Dashboard").to_string(),
5013 );
5014
5015 assert_eq!(
5016 bare,
5017 super::udt::TagScope::Program("Dashboard".to_string()),
5018 "a bare program name is already the scope payload"
5019 );
5020 assert_eq!(
5021 prefixed,
5022 super::udt::TagScope::Program("Dashboard".to_string()),
5023 "the wire prefix belongs to the request path, not to the scope payload"
5024 );
5025 }
5026
5027 #[test]
5028 fn both_accepted_program_name_forms_build_the_same_request() {
5029 let client = EipClient::new_unconnected_for_testing();
5032 let bare = client
5033 .build_program_tag_list_request("Dashboard", 0)
5034 .expect("request should build");
5035 let prefixed = client
5036 .build_program_tag_list_request("Program:Dashboard", 0)
5037 .expect("request should build");
5038
5039 assert_eq!(bare, prefixed);
5040
5041 let mut expected = vec![0x55, 0x0D, 0x91, 0x11];
5042 expected.extend_from_slice(b"Program:Dashboard");
5043 expected.push(0x00);
5044 expected.extend_from_slice(&[
5045 0x20, 0x6B, 0x25, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, ]);
5050 assert_eq!(bare, expected);
5051 }
5052
5053 #[test]
5054 fn build_program_tag_list_request_rejects_out_of_range_start_instance() {
5055 let client = EipClient::new_unconnected_for_testing();
5056 let error = client
5057 .build_program_tag_list_request("MainProgram", u32::from(u16::MAX) + 1)
5058 .expect_err("start instance beyond the 16-bit range must be refused");
5059
5060 assert!(
5061 error.to_string().contains("16-bit Symbol Object range"),
5062 "unexpected error: {error}"
5063 );
5064 }
5065
5066 #[test]
5067 fn parse_tag_list_response_page_handles_partial_transfer() {
5068 let client = EipClient::new_unconnected_for_testing();
5069 let response = [
5070 0xD5, 0x00, 0x06,
5071 0x00, 0x34, 0x12, 0x00, 0x00, 0x04, 0x00, b'R', b'a', b't', b'e', 0xC4, 0x00, ];
5077
5078 let page = client
5079 .parse_tag_list_response_page(&response, super::udt::TagScope::Controller)
5080 .expect("response should parse");
5081
5082 assert!(page.partial_transfer);
5083 assert_eq!(page.last_instance_id, Some(0x1234));
5084 assert_eq!(page.tags.len(), 1);
5085 assert_eq!(page.tags[0].name, "Rate");
5086 assert_eq!(page.tags[0].data_type, 0x00C4);
5087 assert_eq!(page.tags[0].data_type_name, "DINT");
5088 }
5089
5090 #[test]
5091 fn parse_tag_list_response_page_stamps_the_requested_scope() {
5092 let client = EipClient::new_unconnected_for_testing();
5098 let response = [
5099 0xD5, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, b'P', b'u', b'm', b'p', 0xC4, 0x00, ];
5105
5106 let program = client
5107 .parse_tag_list_response_page(
5108 &response,
5109 super::udt::TagScope::Program("Dashboard".to_string()),
5110 )
5111 .expect("response should parse");
5112 assert_eq!(
5113 program.tags[0].scope,
5114 super::udt::TagScope::Program("Dashboard".to_string()),
5115 "a program-scoped enumeration must report the program it was addressed to"
5116 );
5117
5118 let controller = client
5119 .parse_tag_list_response_page(&response, super::udt::TagScope::Controller)
5120 .expect("response should parse");
5121 assert_eq!(
5122 controller.tags[0].scope,
5123 super::udt::TagScope::Controller,
5124 "the SAME bytes stay controller-scoped when the request was"
5125 );
5126 }
5127
5128 #[test]
5129 fn build_get_template_attributes_request_encodes_template_object_path() {
5130 let client = EipClient::new_unconnected_for_testing();
5131 let request = client
5132 .build_get_template_attributes_request(0x0456)
5133 .expect("request should build");
5134
5135 assert_eq!(request[0], 0x03);
5136 assert_eq!(request[1], 0x03);
5137 assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
5138 assert_eq!(
5139 &request[8..],
5140 &[0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x04, 0x00, 0x05, 0x00]
5141 );
5142 }
5143
5144 #[test]
5145 fn build_get_attributes_request_encodes_path_words_and_odd_name_padding() {
5146 let client = EipClient::new_unconnected_for_testing();
5147 let request = client
5148 .build_get_attributes_request("Odd")
5149 .expect("request should build");
5150
5151 assert_eq!(
5152 request,
5153 vec![
5154 0x03, 0x03, 0x91, 0x03, b'O', b'd', b'd', 0x00, 0x02, 0x00, 0x01, 0x00, 0x02, 0x00, ]
5161 );
5162 }
5163
5164 #[test]
5165 fn parse_attributes_response_walks_attribute_records() {
5166 let client = EipClient::new_unconnected_for_testing();
5167 let response = [
5168 0x83, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x02, 0x00, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00, ];
5173
5174 let attributes = client
5175 .parse_attributes_response("DINT_TAG", &response)
5176 .expect("response should parse");
5177
5178 assert_eq!(attributes.name, "DINT_TAG");
5179 assert_eq!(attributes.data_type, 0x00C4);
5180 assert_eq!(attributes.data_type_name, "DINT");
5181 assert_eq!(attributes.template_instance_id, Some(0x1234));
5182 assert_eq!(attributes.size, 4);
5183 }
5184
5185 #[test]
5186 fn extended_status_parser_uses_little_endian_additional_status() {
5187 let client = EipClient::new_unconnected_for_testing();
5188 let response = [0xCD, 0x00, 0xFF, 0x01, 0x07, 0x21];
5189
5190 let err = client
5191 .check_cip_error(&response)
5192 .expect_err("extended status should be an error");
5193 let message = err.to_string();
5194
5195 assert!(message.contains("0x2107"));
5196 assert!(message.contains("data-type mismatch"));
5197 assert!(!message.contains("(BE)"));
5198 assert!(!message.contains("0x0721"));
5199 }
5200
5201 #[test]
5202 fn extended_status_parser_does_not_require_general_status_ff() {
5203 let client = EipClient::new_unconnected_for_testing();
5204 let response = [0xCC, 0x00, 0x01, 0x01, 0x05, 0x00];
5205
5206 let err = client
5207 .check_cip_error(&response)
5208 .expect_err("additional status should be decoded");
5209
5210 assert!(err.to_string().contains("Path destination unknown"));
5211 }
5212
5213 #[test]
5214 fn build_read_template_request_encodes_template_read_size() {
5215 let client = EipClient::new_unconnected_for_testing();
5216 let request = client
5217 .build_read_template_request(0x0456, 0x0010, 0x0032)
5218 .expect("request should build");
5219
5220 assert_eq!(request[0], 0x4C);
5221 assert_eq!(request[1], 0x03);
5222 assert_eq!(&request[2..8], &[0x20, 0x6C, 0x25, 0x00, 0x56, 0x04]);
5223 assert_eq!(&request[8..12], &[0x10, 0x00, 0x00, 0x00]);
5224 assert_eq!(&request[12..14], &[0x32, 0x00]);
5225 }
5226
5227 #[test]
5228 fn parse_template_attributes_response_reads_mixed_width_values() {
5229 let client = EipClient::new_unconnected_for_testing();
5230 let response = [
5231 0x83, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x34, 0x12, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x04, 0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, ];
5238
5239 let attributes = client
5240 .parse_template_attributes_response(0x0456, &response)
5241 .expect("response should parse");
5242
5243 assert_eq!(
5244 attributes,
5245 TemplateAttributes {
5246 structure_handle: 0x1234,
5247 member_count: 7,
5248 definition_size_words: 25,
5249 structure_size_bytes: 88,
5250 }
5251 );
5252 }
5253}
5254
5255#[cfg(test)]
5256mod write_request_tests {
5257 use super::EipClient;
5258 use crate::PlcValue;
5259 use crate::protocol::values;
5260
5261 #[test]
5262 fn build_write_request_encodes_standard_string_structure() {
5263 let client = EipClient::new_unconnected_for_testing();
5264 let request = client
5265 .build_write_request("Tag1", &PlcValue::String("AB".to_string()))
5266 .expect("STRING request should build");
5267
5268 assert_eq!(
5269 &request[..8],
5270 &[0x4D, 0x03, 0x91, 0x04, b'T', b'a', b'g', b'1']
5271 );
5272 let data = &request[8..];
5273 assert_eq!(&data[..6], &[0xA0, 0x02, 0xCE, 0x0F, 0x01, 0x00]);
5274 assert_eq!(&data[6..12], &[2, 0, 0, 0, b'A', b'B']);
5275 assert_eq!(data.len(), 6 + values::STANDARD_STRING_PAYLOAD_LEN);
5276 assert!(data[12..].iter().all(|byte| *byte == 0));
5277 }
5278
5279 #[test]
5280 fn build_write_request_rejects_overlong_standard_string() {
5281 let client = EipClient::new_unconnected_for_testing();
5282 let value = PlcValue::String("x".repeat(values::STANDARD_STRING_DATA_LEN + 1));
5283 let err = client
5284 .build_write_request("Tag1", &value)
5285 .expect_err("overlong STRING should be rejected");
5286
5287 assert!(err.to_string().contains("String too long"));
5288 }
5289}
5290
5291#[cfg(test)]
5292mod transport_tests {
5293 use super::{DiagnosticOperation, EipClient};
5294 use crate::EtherNetIpStream;
5295 use crate::error::EtherNetIpError;
5296 use std::sync::Arc;
5297 use std::time::Duration;
5298 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5299 use tokio::sync::Mutex;
5300
5301 fn register_response(session_handle: u32) -> Vec<u8> {
5302 let mut response = Vec::with_capacity(28);
5303 response.extend_from_slice(&0x0065u16.to_le_bytes());
5304 response.extend_from_slice(&4u16.to_le_bytes());
5305 response.extend_from_slice(&session_handle.to_le_bytes());
5306 response.extend_from_slice(&0u32.to_le_bytes());
5307 response.extend_from_slice(&[0u8; 8]);
5308 response.extend_from_slice(&0u32.to_le_bytes());
5309 response.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]);
5310 response
5311 }
5312
5313 async fn read_register_request(stream: &mut tokio::io::DuplexStream) {
5314 let mut header = [0u8; 24];
5315 stream
5316 .read_exact(&mut header)
5317 .await
5318 .expect("request header");
5319 let body_len = u16::from_le_bytes([header[2], header[3]]) as usize;
5320 let mut body = vec![0u8; body_len];
5321 stream.read_exact(&mut body).await.expect("request body");
5322 }
5323
5324 #[tokio::test]
5325 async fn register_session_accepts_fragmented_reply() {
5326 let (client_stream, mut server_stream) = tokio::io::duplex(128);
5327 let mut client = EipClient::new_unconnected_for_testing();
5328 client.stream = Arc::new(Mutex::new(
5329 Box::new(client_stream) as Box<dyn EtherNetIpStream>
5330 ));
5331
5332 let server = tokio::spawn(async move {
5333 read_register_request(&mut server_stream).await;
5334 let response = register_response(0x0102_0304);
5335 server_stream
5336 .write_all(&response[..10])
5337 .await
5338 .expect("first fragment");
5339 tokio::task::yield_now().await;
5340 server_stream
5341 .write_all(&response[10..])
5342 .await
5343 .expect("second fragment");
5344 });
5345
5346 client
5347 .register_session()
5348 .await
5349 .expect("fragmented register response should parse");
5350 server.await.expect("server task");
5351
5352 assert_eq!(client.session_handle(), 0x0102_0304);
5353 }
5354
5355 #[tokio::test]
5356 async fn reregistration_updates_session_handle_across_clones() {
5357 let (client_stream, mut server_stream) = tokio::io::duplex(256);
5358 let mut client = EipClient::new_unconnected_for_testing();
5359 client.stream = Arc::new(Mutex::new(
5360 Box::new(client_stream) as Box<dyn EtherNetIpStream>
5361 ));
5362 let mut clone = client.clone();
5363
5364 let server = tokio::spawn(async move {
5365 for handle in [0x1111_2222, 0x3333_4444] {
5366 read_register_request(&mut server_stream).await;
5367 server_stream
5368 .write_all(®ister_response(handle))
5369 .await
5370 .expect("register response");
5371 }
5372 });
5373
5374 client.register_session().await.expect("first register");
5375 assert_eq!(client.session_handle(), 0x1111_2222);
5376 assert_eq!(clone.session_handle(), 0x1111_2222);
5377
5378 clone.register_session().await.expect("clone re-register");
5379 server.await.expect("server task");
5380
5381 assert_eq!(client.session_handle(), 0x3333_4444);
5382 assert_eq!(clone.session_handle(), 0x3333_4444);
5383 }
5384
5385 #[tokio::test]
5386 async fn diagnostics_snapshot_reports_counted_operations_and_errors() {
5387 let client = EipClient::new_unconnected_for_testing();
5388
5389 client
5390 .diagnostic_counters
5391 .record_success(Some(DiagnosticOperation::Read));
5392 client.diagnostic_counters.record_failure(
5393 Some(DiagnosticOperation::Write),
5394 &EtherNetIpError::Timeout(Duration::from_secs(1)),
5395 );
5396 client
5397 .diagnostic_counters
5398 .record_cip_failure(Some(DiagnosticOperation::Batch));
5399 assert_eq!(client.cached_array_is_packed_bool("MISSING_ARRAY"), None);
5400 client.cache_array_is_packed_bool("DIAG_ARRAY", false);
5401 assert_eq!(
5402 client.cached_array_is_packed_bool("DIAG_ARRAY"),
5403 Some(false)
5404 );
5405 client
5406 .diagnostic_counters
5407 .schema_type_contradictions
5408 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5409 client
5410 .diagnostic_counters
5411 .schema_read_recoveries_succeeded
5412 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5413 client
5414 .diagnostic_counters
5415 .schema_read_recoveries_failed
5416 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5417 let generation = client.refresh_schema().await;
5418
5419 let snapshot = client.get_diagnostics_snapshot().await;
5420 let schema_cache = client.schema_cache_metrics();
5421
5422 assert_eq!(snapshot.operations.total_reads, 1);
5423 assert_eq!(snapshot.operations.successful_reads, 1);
5424 assert_eq!(snapshot.operations.total_writes, 1);
5425 assert_eq!(snapshot.operations.failed_writes, 1);
5426 assert_eq!(snapshot.operations.batch_operations, 1);
5427 assert_eq!(snapshot.operations.partial_batch_failures, 1);
5428 assert_eq!(snapshot.errors.timeout_errors, 1);
5429 assert_eq!(snapshot.errors.protocol_errors, 1);
5430 assert_eq!(snapshot.errors.retriable_errors, 1);
5431 assert_eq!(snapshot.errors.non_retriable_errors, 1);
5432 assert!(snapshot.operations.last_successful_read_time.is_some());
5433 assert!(snapshot.operations.last_failed_write_time.is_some());
5434 assert!(snapshot.errors.last_error_time.is_some());
5435 assert_eq!(schema_cache.generation, generation);
5436 assert_eq!(schema_cache.refreshes, 1);
5437 assert_eq!(schema_cache.array_classification_hits, 1);
5438 assert_eq!(schema_cache.array_classification_misses, 1);
5439 assert_eq!(schema_cache.array_classification_evictions, 1);
5440 assert_eq!(schema_cache.datatype_contradictions, 1);
5441 assert_eq!(schema_cache.successful_read_recoveries, 1);
5442 assert_eq!(schema_cache.failed_read_recoveries, 1);
5443 assert!(snapshot.system_metrics_are_placeholders);
5444 }
5445}
5446
5447