1use super::EipClient;
2use crate::batch::{BatchConfig, BatchError, BatchOperation, BatchResult};
3use crate::protocol::values;
4use crate::types::PlcValue;
5use tokio::time::Instant;
6
7#[derive(Clone)]
8struct PreparedBatchOperation {
9 operation: BatchOperation,
10 service_request: Vec<u8>,
11 array_classification: Option<PreparedArrayClassification>,
12}
13
14#[derive(Clone)]
15struct PreparedArrayClassification {
16 array_path: String,
17 is_packed_bool: bool,
18 generation: u64,
19}
20
21impl EipClient {
22 pub async fn execute_batch(
84 &mut self,
85 operations: &[BatchOperation],
86 ) -> crate::error::Result<Vec<BatchResult>> {
87 if operations.is_empty() {
88 return Ok(Vec::new());
89 }
90
91 let start_time = Instant::now();
92 tracing::debug!(
93 "[BATCH] Starting batch execution with {} operations",
94 operations.len()
95 );
96
97 let operation_groups = if self.batch_config.optimize_packet_packing {
99 self.optimize_operation_groups(operations).await?
100 } else {
101 self.sequential_operation_groups(operations).await?
102 };
103
104 let mut all_results = Vec::with_capacity(operations.len());
105
106 for (group_index, group) in operation_groups.iter().enumerate() {
108 tracing::debug!(
109 "[BATCH] Processing group {} with {} operations",
110 group_index + 1,
111 group.len()
112 );
113
114 match self.execute_operation_group(group).await {
115 Ok(mut group_results) => {
116 all_results.append(&mut group_results);
117 }
118 Err(e) => {
119 if !self.batch_config.continue_on_error {
120 return Err(e);
121 }
122
123 for op in group {
125 let error_result = BatchResult {
126 operation: op.operation.clone(),
127 result: Err(BatchError::NetworkError(e.to_string())),
128 execution_time_us: 0,
129 };
130 all_results.push(error_result);
131 }
132 }
133 }
134 }
135
136 let total_time = start_time.elapsed();
137 tracing::info!(
138 "[BATCH] Completed batch execution in {:?} - {} operations processed",
139 total_time,
140 all_results.len()
141 );
142
143 Ok(all_results)
144 }
145
146 pub async fn read_tags_batch(
182 &mut self,
183 tag_names: &[&str],
184 ) -> crate::error::Result<Vec<(String, std::result::Result<PlcValue, BatchError>)>> {
185 let operations: Vec<BatchOperation> = tag_names
186 .iter()
187 .map(|&name| BatchOperation::Read {
188 tag_name: name.to_string(),
189 })
190 .collect();
191
192 let results = self.execute_batch(&operations).await?;
193
194 Ok(results
195 .into_iter()
196 .map(|result| {
197 let tag_name = match &result.operation {
198 BatchOperation::Read { tag_name } => tag_name.clone(),
199 BatchOperation::Write { tag_name, .. } => {
200 return (
201 tag_name.clone(),
202 Err(BatchError::Other(
203 "Internal batch error: write result returned from read-only helper"
204 .to_string(),
205 )),
206 );
207 }
208 };
209
210 let value_result = match result.result {
211 Ok(Some(value)) => Ok(value),
212 Ok(None) => Err(BatchError::Other(
213 "Unexpected None result for read operation".to_string(),
214 )),
215 Err(e) => Err(e),
216 };
217
218 (tag_name, value_result)
219 })
220 .collect())
221 }
222
223 pub async fn write_tags_batch(
264 &mut self,
265 tag_values: &[(&str, PlcValue)],
266 ) -> crate::error::Result<Vec<(String, std::result::Result<(), BatchError>)>> {
267 let operations: Vec<BatchOperation> = tag_values
268 .iter()
269 .map(|(name, value)| BatchOperation::Write {
270 tag_name: name.to_string(),
271 value: value.clone(),
272 })
273 .collect();
274
275 let results = self.execute_batch(&operations).await?;
276
277 Ok(results
278 .into_iter()
279 .map(|result| {
280 let tag_name = match &result.operation {
281 BatchOperation::Write { tag_name, .. } => tag_name.clone(),
282 BatchOperation::Read { tag_name } => {
283 return (
284 tag_name.clone(),
285 Err(BatchError::Other(
286 "Internal batch error: read result returned from write-only helper"
287 .to_string(),
288 )),
289 );
290 }
291 };
292
293 let write_result = match result.result {
294 Ok(None) => Ok(()),
295 Ok(Some(_)) => Err(BatchError::Other(
296 "Unexpected value result for write operation".to_string(),
297 )),
298 Err(e) => Err(e),
299 };
300
301 (tag_name, write_result)
302 })
303 .collect())
304 }
305
306 pub fn configure_batch_operations(&mut self, config: BatchConfig) {
338 self.batch_config = config;
339 tracing::debug!(
340 "[BATCH] Updated batch configuration: max_ops={}, max_size={}, timeout={}ms",
341 self.batch_config.max_operations_per_packet,
342 self.batch_config.max_packet_size,
343 self.batch_config.packet_timeout_ms
344 );
345 }
346
347 pub fn get_batch_config(&self) -> &BatchConfig {
349 &self.batch_config
350 }
351
352 async fn optimize_operation_groups(
358 &mut self,
359 operations: &[BatchOperation],
360 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
361 let mut reads = Vec::new();
362 let mut writes = Vec::new();
363
364 for op in operations {
366 match op {
367 BatchOperation::Read { .. } => reads.push(op.clone()),
368 BatchOperation::Write { .. } => writes.push(op.clone()),
369 }
370 }
371
372 let mut groups = self.prepare_and_pack_operations(&reads).await?;
373 groups.extend(self.prepare_and_pack_operations(&writes).await?);
374
375 Ok(groups)
376 }
377
378 async fn sequential_operation_groups(
380 &mut self,
381 operations: &[BatchOperation],
382 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
383 self.prepare_and_pack_operations(operations).await
384 }
385
386 async fn prepare_and_pack_operations(
387 &mut self,
388 operations: &[BatchOperation],
389 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
390 let mut prepared = Vec::with_capacity(operations.len());
391 for operation in operations {
392 let (service_request, array_classification) =
393 self.build_batch_service_request(operation).await?;
394 prepared.push(PreparedBatchOperation {
395 operation: operation.clone(),
396 service_request,
397 array_classification,
398 });
399 }
400
401 Ok(self.pack_prepared_operations(prepared))
402 }
403
404 fn pack_prepared_operations(
405 &self,
406 operations: Vec<PreparedBatchOperation>,
407 ) -> Vec<Vec<PreparedBatchOperation>> {
408 let max_operations = self.batch_config.max_operations_per_packet.max(1);
409 let max_packet_size = self.batch_config.max_packet_size;
410 let mut groups = Vec::new();
411 let mut current_group = Vec::new();
412
413 for operation in operations {
414 let exceeds_operation_count = current_group.len() >= max_operations;
415 let exceeds_packet_size = !current_group.is_empty()
416 && max_packet_size > 0
417 && self.group_wire_len_with_candidate(¤t_group, &operation) > max_packet_size;
418
419 if exceeds_operation_count || exceeds_packet_size {
420 groups.push(std::mem::take(&mut current_group));
421 }
422
423 current_group.push(operation);
424 }
425
426 if !current_group.is_empty() {
427 groups.push(current_group);
428 }
429
430 groups
431 }
432
433 fn group_wire_len_with_candidate(
434 &self,
435 group: &[PreparedBatchOperation],
436 candidate: &PreparedBatchOperation,
437 ) -> usize {
438 let service_bytes = self.group_service_bytes(group) + candidate.service_request.len();
439 let operation_count = group.len() + 1;
440 let msp_len = 8 + (operation_count * 2) + service_bytes;
441
442 self.unconnected_send_len_for_embedded(msp_len)
443 }
444
445 #[cfg(test)]
446 fn group_wire_len(&self, group: &[PreparedBatchOperation]) -> usize {
447 let msp_len = 8 + (group.len() * 2) + self.group_service_bytes(group);
448 self.unconnected_send_len_for_embedded(msp_len)
449 }
450
451 fn group_service_bytes(&self, group: &[PreparedBatchOperation]) -> usize {
452 group
453 .iter()
454 .map(|operation| operation.service_request.len())
455 .sum()
456 }
457
458 fn unconnected_send_len_for_embedded(&self, embedded_len: usize) -> usize {
459 let route_path_len = self
460 .route_path_snapshot()
461 .map(|route_path| route_path.to_cip_bytes().len())
462 .unwrap_or(0);
463 let pad_len = embedded_len % 2;
464
465 12 + embedded_len + pad_len + route_path_len
468 }
469
470 async fn execute_operation_group(
472 &mut self,
473 operations: &[PreparedBatchOperation],
474 ) -> crate::error::Result<Vec<BatchResult>> {
475 let start_time = Instant::now();
476 let mut results = Vec::with_capacity(operations.len());
477
478 let cip_request = self.build_multiple_service_packet(operations)?;
480
481 let response = self.send_cip_request(&cip_request).await?;
483
484 let parsed_results = self.parse_multiple_service_response(&response, operations)?;
486
487 let execution_time = start_time.elapsed();
488
489 for (i, operation) in operations.iter().enumerate() {
491 let op_execution_time = execution_time.as_micros() as u64 / operations.len() as u64;
492
493 let mut result = if i < parsed_results.len() {
494 match &parsed_results[i] {
495 Ok(value) => Ok(value.clone()),
496 Err(e) => Err(e.clone()),
497 }
498 } else {
499 Err(BatchError::Other(
500 "Missing result from response".to_string(),
501 ))
502 };
503
504 if matches!(operation.operation, BatchOperation::Read { .. })
505 && self.prepared_read_needs_schema_recovery(operation, &result)
506 && let BatchOperation::Read { tag_name } = &operation.operation
507 {
508 if let Some(classification) = &operation.array_classification {
509 self.evict_array_type_cache_entry(&classification.array_path);
510 }
511 self.diagnostic_counters
512 .schema_type_contradictions
513 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
514 result = match self.read_tag_once(tag_name).await {
515 Ok(value) => {
516 self.diagnostic_counters
517 .schema_read_recoveries_succeeded
518 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
519 Ok(Some(value))
520 }
521 Err(error) => {
522 self.diagnostic_counters
523 .schema_read_recoveries_failed
524 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
525 Err(BatchError::Other(error.to_string()))
526 }
527 };
528 }
529
530 results.push(BatchResult {
531 operation: operation.operation.clone(),
532 result,
533 execution_time_us: op_execution_time,
534 });
535 }
536
537 Ok(results)
538 }
539
540 fn build_multiple_service_packet(
542 &self,
543 operations: &[PreparedBatchOperation],
544 ) -> crate::error::Result<Vec<u8>> {
545 let mut packet = Vec::with_capacity(8 + (operations.len() * 2));
546
547 packet.push(0x0A);
549
550 packet.push(0x02); packet.push(0x20); packet.push(0x02); packet.push(0x24); packet.push(0x01); packet.extend_from_slice(&(operations.len() as u16).to_le_bytes());
559
560 let mut service_requests = Vec::with_capacity(operations.len());
562 let mut current_offset = 2 + (operations.len() * 2); for operation in operations {
565 service_requests.push(operation.service_request.clone());
566 }
567
568 for service_request in &service_requests {
570 packet.extend_from_slice(&(current_offset as u16).to_le_bytes());
571 current_offset += service_request.len();
572 }
573
574 for service_request in service_requests {
576 packet.extend_from_slice(&service_request);
577 }
578
579 tracing::trace!(
580 "[BATCH] Built Multiple Service Packet ({} bytes, {} services)",
581 packet.len(),
582 operations.len()
583 );
584
585 Ok(packet)
586 }
587
588 async fn build_batch_service_request(
589 &mut self,
590 operation: &BatchOperation,
591 ) -> crate::error::Result<(Vec<u8>, Option<PreparedArrayClassification>)> {
592 match operation {
593 BatchOperation::Read { tag_name } => {
594 if let Some((base_name, index)) = self.parse_array_element_access(tag_name) {
595 let generation = self.schema_generation();
596 let is_packed_bool = self.detect_bool_array_path(&base_name).await?;
597 let classification = PreparedArrayClassification {
598 array_path: base_name.clone(),
599 is_packed_bool,
600 generation,
601 };
602 let request = if is_packed_bool {
603 self.build_read_array_request(&base_name, index / 32, 1)
604 } else {
605 self.build_read_request(tag_name)?
606 };
607 return Ok((request, Some(classification)));
608 }
609
610 Ok((self.build_read_request(tag_name)?, None))
611 }
612 BatchOperation::Write { tag_name, value } => {
613 if let PlcValue::Bool(bit_value) = value
614 && let Some((base_name, index)) = self.parse_array_element_access(tag_name)
615 {
616 let generation = self.schema_generation();
617 let is_packed_bool = self.detect_bool_array_path(&base_name).await?;
618 let mut classification = PreparedArrayClassification {
619 array_path: base_name.clone(),
620 is_packed_bool,
621 generation,
622 };
623 if !is_packed_bool {
624 return Ok((
625 self.build_write_request(tag_name, value)?,
626 Some(classification),
627 ));
628 }
629 let dword_index = index / 32;
630 let bit_index = index % 32;
631 let response = self
632 .send_cip_request(&self.build_read_array_request(
633 &base_name,
634 dword_index,
635 1,
636 ))
637 .await?;
638 let cip_data = self.extract_cip_from_response(&response)?;
639 let mut dword = match self.parse_bool_array_dword_response(&cip_data) {
640 Ok(dword) => dword,
641 Err(error) if Self::is_schema_drift_read_error(&error) => {
642 self.evict_array_type_cache_entry(&base_name);
643 self.diagnostic_counters
644 .schema_type_contradictions
645 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
646 let generation = self.schema_generation();
647 let is_packed_bool = self.detect_bool_array_path(&base_name).await?;
648 classification = PreparedArrayClassification {
649 array_path: base_name.clone(),
650 is_packed_bool,
651 generation,
652 };
653 if !is_packed_bool {
654 return Ok((
655 self.build_write_request(tag_name, value)?,
656 Some(classification),
657 ));
658 }
659 let retry_response = self
660 .send_cip_request(&self.build_read_array_request(
661 &base_name,
662 dword_index,
663 1,
664 ))
665 .await?;
666 let retry_cip = self.extract_cip_from_response(&retry_response)?;
667 self.parse_bool_array_dword_response(&retry_cip)?
668 }
669 Err(error) => return Err(error),
670 };
671 if *bit_value {
672 dword |= 1u32 << bit_index;
673 } else {
674 dword &= !(1u32 << bit_index);
675 }
676
677 let request = self.build_write_array_request_with_index(
678 &base_name,
679 dword_index,
680 1,
681 values::BOOL_ARRAY_DWORD,
682 &dword.to_le_bytes(),
683 )?;
684 return Ok((request, Some(classification)));
685 }
686
687 Ok((self.build_write_request(tag_name, value)?, None))
688 }
689 }
690 }
691
692 fn parse_multiple_service_response(
694 &self,
695 response: &[u8],
696 operations: &[PreparedBatchOperation],
697 ) -> crate::error::Result<Vec<std::result::Result<Option<PlcValue>, BatchError>>> {
698 if response.len() < 6 {
699 return Err(crate::error::EtherNetIpError::Protocol(
700 "Response too short for Multiple Service Packet".to_string(),
701 ));
702 }
703
704 let mut results = Vec::new();
705
706 tracing::trace!(
707 "Raw Multiple Service Response ({} bytes): {:02X?}",
708 response.len(),
709 response
710 );
711
712 let cip_data = match self.extract_cip_from_response(response) {
714 Ok(data) => data,
715 Err(e) => {
716 tracing::error!("Failed to extract CIP data: {}", e);
717 return Err(e);
718 }
719 };
720
721 tracing::trace!(
722 "Extracted CIP data ({} bytes): {:02X?}",
723 cip_data.len(),
724 cip_data
725 );
726
727 if cip_data.len() < 6 {
728 return Err(crate::error::EtherNetIpError::Protocol(
729 "CIP data too short for Multiple Service Response".to_string(),
730 ));
731 }
732
733 let service_code = cip_data[0];
741 let general_status = cip_data[2];
742 let num_replies = u16::from_le_bytes([cip_data[4], cip_data[5]]) as usize;
743
744 tracing::debug!(
745 "Multiple Service Response: service=0x{:02X}, status=0x{:02X}, replies={}",
746 service_code,
747 general_status,
748 num_replies
749 );
750
751 if general_status != 0x00 {
752 return Err(crate::error::EtherNetIpError::Protocol(
753 self.describe_multiple_service_error(
754 general_status,
755 &operations
756 .iter()
757 .map(|prepared| prepared.operation.clone())
758 .collect::<Vec<_>>(),
759 ),
760 ));
761 }
762
763 if num_replies != operations.len() {
764 return Err(crate::error::EtherNetIpError::Protocol(format!(
765 "Reply count mismatch: expected {}, got {}",
766 operations.len(),
767 num_replies
768 )));
769 }
770
771 let mut reply_offsets = Vec::new();
773 let mut offset = 6; for _i in 0..num_replies {
776 if offset + 2 > cip_data.len() {
777 return Err(crate::error::EtherNetIpError::Protocol(
778 "CIP data too short for reply offsets".to_string(),
779 ));
780 }
781 let reply_offset =
782 u16::from_le_bytes([cip_data[offset], cip_data[offset + 1]]) as usize;
783 reply_offsets.push(reply_offset);
784 offset += 2;
785 }
786
787 tracing::trace!("Reply offsets: {:?}", reply_offsets);
788
789 let reply_base_offset = 6 + (num_replies * 2);
791
792 tracing::trace!("Reply base offset: {}", reply_base_offset);
793
794 for (i, &reply_offset) in reply_offsets.iter().enumerate() {
796 let reply_start = 4 + reply_offset;
798
799 if reply_start >= cip_data.len() {
800 results.push(Err(BatchError::Other(
801 "Reply offset beyond CIP data".to_string(),
802 )));
803 continue;
804 }
805
806 let reply_end = if i + 1 < reply_offsets.len() {
808 4 + reply_offsets[i + 1]
810 } else {
811 cip_data.len()
813 };
814
815 if reply_end > cip_data.len() || reply_start >= reply_end {
816 results.push(Err(BatchError::Other(
817 "Invalid reply boundaries".to_string(),
818 )));
819 continue;
820 }
821
822 let reply_data = &cip_data[reply_start..reply_end];
823
824 tracing::trace!(
825 "Reply {} at offset {}: start={}, end={}, len={}",
826 i,
827 reply_offset,
828 reply_start,
829 reply_end,
830 reply_data.len()
831 );
832 tracing::trace!("Reply {} data: {:02X?}", i, reply_data);
833
834 let result = self.parse_individual_reply(reply_data, &operations[i]);
835 results.push(result);
836 }
837
838 Ok(results)
839 }
840
841 fn parse_individual_reply(
843 &self,
844 reply_data: &[u8],
845 prepared: &PreparedBatchOperation,
846 ) -> std::result::Result<Option<PlcValue>, BatchError> {
847 if reply_data.len() < 4 {
848 return Err(BatchError::SerializationError(
849 "Reply too short".to_string(),
850 ));
851 }
852
853 tracing::trace!(
854 "Parsing individual reply ({} bytes): {:02X?}",
855 reply_data.len(),
856 reply_data
857 );
858
859 let service_code = reply_data[0];
867 let general_status = reply_data[2];
868
869 tracing::trace!(
870 "Service code: 0x{:02X}, Status: 0x{:02X}",
871 service_code,
872 general_status
873 );
874
875 if general_status != 0x00 {
876 let error_msg = self.get_cip_error_message(general_status);
877 return Err(BatchError::CipError {
878 status: general_status,
879 message: error_msg,
880 });
881 }
882
883 match &prepared.operation {
884 BatchOperation::Write { .. } => {
885 Ok(None)
887 }
888 BatchOperation::Read { .. } => {
889 if reply_data.len() < 6 {
891 return Err(BatchError::SerializationError(
892 "Read reply too short for data".to_string(),
893 ));
894 }
895
896 let data = &reply_data[4..];
899 tracing::trace!("Parsing data ({} bytes): {:02X?}", data.len(), data);
900
901 if data.len() < 2 {
902 return Err(BatchError::SerializationError(
903 "Data too short for type".to_string(),
904 ));
905 }
906
907 let data_type = u16::from_le_bytes([data[0], data[1]]);
908 let value_data = &data[2..];
909
910 if let Some(classification) = &prepared.array_classification {
911 let returned_is_packed_bool = data_type == values::BOOL_ARRAY_DWORD;
912 if classification.generation != self.schema_generation()
913 || returned_is_packed_bool != classification.is_packed_bool
914 {
915 return Err(BatchError::DataTypeMismatch {
916 expected: if classification.is_packed_bool {
917 "packed BOOL array DWORD".to_string()
918 } else {
919 "ordinary array element".to_string()
920 },
921 actual: format!("CIP type 0x{data_type:04X}"),
922 });
923 }
924 }
925
926 tracing::trace!(
927 "Data type: 0x{:04X}, Value data ({} bytes): {:02X?}",
928 data_type,
929 value_data.len(),
930 value_data
931 );
932
933 if data_type == values::BOOL_ARRAY_DWORD {
934 if value_data.len() < 4 {
935 return Err(BatchError::SerializationError(
936 "Missing packed BOOL array DWORD value".to_string(),
937 ));
938 }
939
940 let packed_value = u32::from_le_bytes([
941 value_data[0],
942 value_data[1],
943 value_data[2],
944 value_data[3],
945 ]);
946
947 if let BatchOperation::Read { tag_name } = &prepared.operation
948 && let Some((_base_name, index)) = self.parse_array_element_access(tag_name)
949 {
950 let bit_index = index % 32;
951 let value = (packed_value >> bit_index) & 1 != 0;
952 tracing::trace!(
953 "Parsed packed BOOL array element '{}' from DWORD 0x{:08X} using bit {} -> {}",
954 tag_name,
955 packed_value,
956 bit_index,
957 value
958 );
959 return Ok(Some(PlcValue::Bool(value)));
960 }
961 }
962
963 values::decode_payload(data_type, value_data)
964 .map(Some)
965 .map_err(|e| BatchError::SerializationError(e.to_string()))
966 }
967 }
968 }
969
970 fn prepared_read_needs_schema_recovery(
971 &self,
972 prepared: &PreparedBatchOperation,
973 result: &std::result::Result<Option<PlcValue>, BatchError>,
974 ) -> bool {
975 if !matches!(prepared.operation, BatchOperation::Read { .. }) {
976 return false;
977 }
978 let Some(classification) = &prepared.array_classification else {
979 return false;
980 };
981 if classification.generation != self.schema_generation() {
982 return true;
983 }
984 match result {
985 Err(BatchError::DataTypeMismatch { .. }) => true,
986 Err(BatchError::CipError { status, .. }) => {
987 matches!(*status, 0x04 | 0x05 | 0x16 | 0xFF)
988 }
989 _ => false,
990 }
991 }
992}
993
994#[cfg(test)]
995mod tests {
996 use super::{EipClient, PreparedArrayClassification, PreparedBatchOperation};
997 use crate::batch::{BatchConfig, BatchError, BatchOperation};
998 use crate::types::PlcValue;
999
1000 fn prepared_read(name: &str, service_len: usize) -> PreparedBatchOperation {
1001 PreparedBatchOperation {
1002 operation: BatchOperation::Read {
1003 tag_name: name.to_string(),
1004 },
1005 service_request: vec![0x4C; service_len],
1006 array_classification: None,
1007 }
1008 }
1009
1010 fn classified_read(
1011 client: &EipClient,
1012 tag_name: &str,
1013 is_packed_bool: bool,
1014 ) -> PreparedBatchOperation {
1015 let array_path = client
1016 .parse_array_element_access(tag_name)
1017 .expect("test tag should be an array element")
1018 .0;
1019 PreparedBatchOperation {
1020 operation: BatchOperation::Read {
1021 tag_name: tag_name.to_string(),
1022 },
1023 service_request: Vec::new(),
1024 array_classification: Some(PreparedArrayClassification {
1025 array_path,
1026 is_packed_bool,
1027 generation: client.schema_generation(),
1028 }),
1029 }
1030 }
1031
1032 #[test]
1033 fn batch_packing_respects_packet_size_and_operation_count() {
1034 let mut client = EipClient::new_unconnected_for_testing();
1035 client.configure_batch_operations(BatchConfig {
1036 max_operations_per_packet: 4,
1037 max_packet_size: 80,
1038 ..BatchConfig::default()
1039 });
1040
1041 let operations: Vec<_> = (0..10)
1042 .map(|index| prepared_read(&format!("Tag{index}"), 20))
1043 .collect();
1044 let groups = client.pack_prepared_operations(operations);
1045
1046 assert!(
1047 groups.len() > 1,
1048 "expected packet-size budget to split the batch"
1049 );
1050 for group in &groups {
1051 assert!(
1052 group.len() <= 4,
1053 "group exceeds max_operations_per_packet: {}",
1054 group.len()
1055 );
1056 assert!(
1057 client.group_wire_len(group) <= 80,
1058 "group exceeds max_packet_size: {}",
1059 client.group_wire_len(group)
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn batch_packing_keeps_single_oversized_operation() {
1066 let mut client = EipClient::new_unconnected_for_testing();
1067 client.configure_batch_operations(BatchConfig {
1068 max_operations_per_packet: 20,
1069 max_packet_size: 32,
1070 ..BatchConfig::default()
1071 });
1072
1073 let groups = client.pack_prepared_operations(vec![
1074 prepared_read("TooLarge", 64),
1075 prepared_read("Small1", 4),
1076 prepared_read("Small2", 4),
1077 ]);
1078
1079 assert_eq!(groups.len(), 2);
1080 assert_eq!(groups[0].len(), 1);
1081 assert!(
1082 client.group_wire_len(&groups[0]) > 32,
1083 "single oversized operation should be sent alone, not dropped"
1084 );
1085 assert!(
1086 client.group_wire_len(&groups[1]) <= 32,
1087 "small trailing operations should share a packet"
1088 );
1089 }
1090
1091 #[test]
1092 fn batch_reply_detects_both_packed_bool_transition_directions() {
1093 let client = EipClient::new_unconnected_for_testing();
1094 let ordinary = classified_read(&client, "ControllerArray[40]", false);
1095 let packed = classified_read(&client, "Program:Main.BoolArray[63]", true);
1096
1097 let packed_reply = [0xCC, 0, 0, 0, 0xD3, 0, 0, 0, 0, 0];
1098 let ordinary_reply = [0xCC, 0, 0, 0, 0xC4, 0, 42, 0, 0, 0];
1099
1100 assert!(matches!(
1101 client.parse_individual_reply(&packed_reply, &ordinary),
1102 Err(BatchError::DataTypeMismatch { .. })
1103 ));
1104 assert!(matches!(
1105 client.parse_individual_reply(&ordinary_reply, &packed),
1106 Err(BatchError::DataTypeMismatch { .. })
1107 ));
1108 }
1109
1110 #[test]
1111 fn ordinary_array_type_change_dint_to_real_stays_addressing_compatible() {
1112 let client = EipClient::new_unconnected_for_testing();
1113 let ordinary = classified_read(&client, "Program:Main.Values[5]", false);
1114 let real_reply = [0xCC, 0, 0, 0, 0xCA, 0, 0, 0, 0x80, 0x3F];
1115
1116 assert!(matches!(
1117 client.parse_individual_reply(&real_reply, &ordinary),
1118 Ok(Some(PlcValue::Real(value))) if value == 1.0
1119 ));
1120 }
1121
1122 #[tokio::test]
1123 async fn generation_change_and_symbol_errors_are_recoverable_for_reads_only() {
1124 let client = EipClient::new_unconnected_for_testing();
1125 let read = classified_read(&client, "ControllerArray[33]", true);
1126 let write = PreparedBatchOperation {
1127 operation: BatchOperation::Write {
1128 tag_name: "ControllerArray[33]".to_string(),
1129 value: PlcValue::Bool(true),
1130 },
1131 service_request: Vec::new(),
1132 array_classification: read.array_classification.clone(),
1133 };
1134
1135 client.refresh_schema().await;
1136
1137 assert!(client.prepared_read_needs_schema_recovery(&read, &Ok(None)));
1138 assert!(client.prepared_read_needs_schema_recovery(
1139 &read,
1140 &Err(BatchError::CipError {
1141 status: 0x05,
1142 message: "Path destination unknown".to_string(),
1143 })
1144 ));
1145 assert!(!client.prepared_read_needs_schema_recovery(
1146 &write,
1147 &Err(BatchError::CipError {
1148 status: 0x05,
1149 message: "Path destination unknown".to_string(),
1150 })
1151 ));
1152 }
1153}