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}
12
13impl EipClient {
14 pub async fn execute_batch(
76 &mut self,
77 operations: &[BatchOperation],
78 ) -> crate::error::Result<Vec<BatchResult>> {
79 if operations.is_empty() {
80 return Ok(Vec::new());
81 }
82
83 let start_time = Instant::now();
84 tracing::debug!(
85 "[BATCH] Starting batch execution with {} operations",
86 operations.len()
87 );
88
89 let operation_groups = if self.batch_config.optimize_packet_packing {
91 self.optimize_operation_groups(operations).await?
92 } else {
93 self.sequential_operation_groups(operations).await?
94 };
95
96 let mut all_results = Vec::with_capacity(operations.len());
97
98 for (group_index, group) in operation_groups.iter().enumerate() {
100 tracing::debug!(
101 "[BATCH] Processing group {} with {} operations",
102 group_index + 1,
103 group.len()
104 );
105
106 match self.execute_operation_group(group).await {
107 Ok(mut group_results) => {
108 all_results.append(&mut group_results);
109 }
110 Err(e) => {
111 if !self.batch_config.continue_on_error {
112 return Err(e);
113 }
114
115 for op in group {
117 let error_result = BatchResult {
118 operation: op.operation.clone(),
119 result: Err(BatchError::NetworkError(e.to_string())),
120 execution_time_us: 0,
121 };
122 all_results.push(error_result);
123 }
124 }
125 }
126 }
127
128 let total_time = start_time.elapsed();
129 tracing::info!(
130 "[BATCH] Completed batch execution in {:?} - {} operations processed",
131 total_time,
132 all_results.len()
133 );
134
135 Ok(all_results)
136 }
137
138 pub async fn read_tags_batch(
174 &mut self,
175 tag_names: &[&str],
176 ) -> crate::error::Result<Vec<(String, std::result::Result<PlcValue, BatchError>)>> {
177 let operations: Vec<BatchOperation> = tag_names
178 .iter()
179 .map(|&name| BatchOperation::Read {
180 tag_name: name.to_string(),
181 })
182 .collect();
183
184 let results = self.execute_batch(&operations).await?;
185
186 Ok(results
187 .into_iter()
188 .map(|result| {
189 let tag_name = match &result.operation {
190 BatchOperation::Read { tag_name } => tag_name.clone(),
191 BatchOperation::Write { tag_name, .. } => {
192 return (
193 tag_name.clone(),
194 Err(BatchError::Other(
195 "Internal batch error: write result returned from read-only helper"
196 .to_string(),
197 )),
198 );
199 }
200 };
201
202 let value_result = match result.result {
203 Ok(Some(value)) => Ok(value),
204 Ok(None) => Err(BatchError::Other(
205 "Unexpected None result for read operation".to_string(),
206 )),
207 Err(e) => Err(e),
208 };
209
210 (tag_name, value_result)
211 })
212 .collect())
213 }
214
215 pub async fn write_tags_batch(
256 &mut self,
257 tag_values: &[(&str, PlcValue)],
258 ) -> crate::error::Result<Vec<(String, std::result::Result<(), BatchError>)>> {
259 let operations: Vec<BatchOperation> = tag_values
260 .iter()
261 .map(|(name, value)| BatchOperation::Write {
262 tag_name: name.to_string(),
263 value: value.clone(),
264 })
265 .collect();
266
267 let results = self.execute_batch(&operations).await?;
268
269 Ok(results
270 .into_iter()
271 .map(|result| {
272 let tag_name = match &result.operation {
273 BatchOperation::Write { tag_name, .. } => tag_name.clone(),
274 BatchOperation::Read { tag_name } => {
275 return (
276 tag_name.clone(),
277 Err(BatchError::Other(
278 "Internal batch error: read result returned from write-only helper"
279 .to_string(),
280 )),
281 );
282 }
283 };
284
285 let write_result = match result.result {
286 Ok(None) => Ok(()),
287 Ok(Some(_)) => Err(BatchError::Other(
288 "Unexpected value result for write operation".to_string(),
289 )),
290 Err(e) => Err(e),
291 };
292
293 (tag_name, write_result)
294 })
295 .collect())
296 }
297
298 pub fn configure_batch_operations(&mut self, config: BatchConfig) {
330 self.batch_config = config;
331 tracing::debug!(
332 "[BATCH] Updated batch configuration: max_ops={}, max_size={}, timeout={}ms",
333 self.batch_config.max_operations_per_packet,
334 self.batch_config.max_packet_size,
335 self.batch_config.packet_timeout_ms
336 );
337 }
338
339 pub fn get_batch_config(&self) -> &BatchConfig {
341 &self.batch_config
342 }
343
344 async fn optimize_operation_groups(
350 &mut self,
351 operations: &[BatchOperation],
352 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
353 let mut reads = Vec::new();
354 let mut writes = Vec::new();
355
356 for op in operations {
358 match op {
359 BatchOperation::Read { .. } => reads.push(op.clone()),
360 BatchOperation::Write { .. } => writes.push(op.clone()),
361 }
362 }
363
364 let mut groups = self.prepare_and_pack_operations(&reads).await?;
365 groups.extend(self.prepare_and_pack_operations(&writes).await?);
366
367 Ok(groups)
368 }
369
370 async fn sequential_operation_groups(
372 &mut self,
373 operations: &[BatchOperation],
374 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
375 self.prepare_and_pack_operations(operations).await
376 }
377
378 async fn prepare_and_pack_operations(
379 &mut self,
380 operations: &[BatchOperation],
381 ) -> crate::error::Result<Vec<Vec<PreparedBatchOperation>>> {
382 let mut prepared = Vec::with_capacity(operations.len());
383 for operation in operations {
384 prepared.push(PreparedBatchOperation {
385 operation: operation.clone(),
386 service_request: self.build_batch_service_request(operation).await?,
387 });
388 }
389
390 Ok(self.pack_prepared_operations(prepared))
391 }
392
393 fn pack_prepared_operations(
394 &self,
395 operations: Vec<PreparedBatchOperation>,
396 ) -> Vec<Vec<PreparedBatchOperation>> {
397 let max_operations = self.batch_config.max_operations_per_packet.max(1);
398 let max_packet_size = self.batch_config.max_packet_size;
399 let mut groups = Vec::new();
400 let mut current_group = Vec::new();
401
402 for operation in operations {
403 let exceeds_operation_count = current_group.len() >= max_operations;
404 let exceeds_packet_size = !current_group.is_empty()
405 && max_packet_size > 0
406 && self.group_wire_len_with_candidate(¤t_group, &operation) > max_packet_size;
407
408 if exceeds_operation_count || exceeds_packet_size {
409 groups.push(std::mem::take(&mut current_group));
410 }
411
412 current_group.push(operation);
413 }
414
415 if !current_group.is_empty() {
416 groups.push(current_group);
417 }
418
419 groups
420 }
421
422 fn group_wire_len_with_candidate(
423 &self,
424 group: &[PreparedBatchOperation],
425 candidate: &PreparedBatchOperation,
426 ) -> usize {
427 let service_bytes = self.group_service_bytes(group) + candidate.service_request.len();
428 let operation_count = group.len() + 1;
429 let msp_len = 8 + (operation_count * 2) + service_bytes;
430
431 self.unconnected_send_len_for_embedded(msp_len)
432 }
433
434 #[cfg(test)]
435 fn group_wire_len(&self, group: &[PreparedBatchOperation]) -> usize {
436 let msp_len = 8 + (group.len() * 2) + self.group_service_bytes(group);
437 self.unconnected_send_len_for_embedded(msp_len)
438 }
439
440 fn group_service_bytes(&self, group: &[PreparedBatchOperation]) -> usize {
441 group
442 .iter()
443 .map(|operation| operation.service_request.len())
444 .sum()
445 }
446
447 fn unconnected_send_len_for_embedded(&self, embedded_len: usize) -> usize {
448 let route_path_len = self
449 .route_path_snapshot()
450 .map(|route_path| route_path.to_cip_bytes().len())
451 .unwrap_or(0);
452 let pad_len = embedded_len % 2;
453
454 12 + embedded_len + pad_len + route_path_len
457 }
458
459 async fn execute_operation_group(
461 &mut self,
462 operations: &[PreparedBatchOperation],
463 ) -> crate::error::Result<Vec<BatchResult>> {
464 let start_time = Instant::now();
465 let mut results = Vec::with_capacity(operations.len());
466
467 let cip_request = self.build_multiple_service_packet(operations)?;
469
470 let response = self.send_cip_request(&cip_request).await?;
472
473 let original_operations: Vec<BatchOperation> = operations
475 .iter()
476 .map(|operation| operation.operation.clone())
477 .collect();
478 let parsed_results =
479 self.parse_multiple_service_response(&response, &original_operations)?;
480
481 let execution_time = start_time.elapsed();
482
483 for (i, operation) in operations.iter().enumerate() {
485 let op_execution_time = execution_time.as_micros() as u64 / operations.len() as u64;
486
487 let result = if i < parsed_results.len() {
488 match &parsed_results[i] {
489 Ok(value) => Ok(value.clone()),
490 Err(e) => Err(e.clone()),
491 }
492 } else {
493 Err(BatchError::Other(
494 "Missing result from response".to_string(),
495 ))
496 };
497
498 results.push(BatchResult {
499 operation: operation.operation.clone(),
500 result,
501 execution_time_us: op_execution_time,
502 });
503 }
504
505 Ok(results)
506 }
507
508 fn build_multiple_service_packet(
510 &self,
511 operations: &[PreparedBatchOperation],
512 ) -> crate::error::Result<Vec<u8>> {
513 let mut packet = Vec::with_capacity(8 + (operations.len() * 2));
514
515 packet.push(0x0A);
517
518 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());
527
528 let mut service_requests = Vec::with_capacity(operations.len());
530 let mut current_offset = 2 + (operations.len() * 2); for operation in operations {
533 service_requests.push(operation.service_request.clone());
534 }
535
536 for service_request in &service_requests {
538 packet.extend_from_slice(&(current_offset as u16).to_le_bytes());
539 current_offset += service_request.len();
540 }
541
542 for service_request in service_requests {
544 packet.extend_from_slice(&service_request);
545 }
546
547 tracing::trace!(
548 "[BATCH] Built Multiple Service Packet ({} bytes, {} services)",
549 packet.len(),
550 operations.len()
551 );
552
553 Ok(packet)
554 }
555
556 async fn build_batch_service_request(
557 &mut self,
558 operation: &BatchOperation,
559 ) -> crate::error::Result<Vec<u8>> {
560 match operation {
561 BatchOperation::Read { tag_name } => {
562 if let Some((base_name, index)) = self.parse_array_element_access(tag_name)
563 && self.detect_bool_array_path(&base_name).await?
564 {
565 return Ok(self.build_read_array_request(&base_name, index / 32, 1));
566 }
567
568 self.build_read_request(tag_name)
569 }
570 BatchOperation::Write { tag_name, value } => {
571 if let PlcValue::Bool(bit_value) = value
572 && let Some((base_name, index)) = self.parse_array_element_access(tag_name)
573 && self.detect_bool_array_path(&base_name).await?
574 {
575 let dword_index = index / 32;
576 let bit_index = index % 32;
577 let response = self
578 .send_cip_request(&self.build_read_array_request(
579 &base_name,
580 dword_index,
581 1,
582 ))
583 .await?;
584 let cip_data = self.extract_cip_from_response(&response)?;
585 let mut dword = self.parse_bool_array_dword_response(&cip_data)?;
586 if *bit_value {
587 dword |= 1u32 << bit_index;
588 } else {
589 dword &= !(1u32 << bit_index);
590 }
591
592 return self.build_write_array_request_with_index(
593 &base_name,
594 dword_index,
595 1,
596 values::BOOL_ARRAY_DWORD,
597 &dword.to_le_bytes(),
598 );
599 }
600
601 self.build_write_request(tag_name, value)
602 }
603 }
604 }
605
606 fn parse_multiple_service_response(
608 &self,
609 response: &[u8],
610 operations: &[BatchOperation],
611 ) -> crate::error::Result<Vec<std::result::Result<Option<PlcValue>, BatchError>>> {
612 if response.len() < 6 {
613 return Err(crate::error::EtherNetIpError::Protocol(
614 "Response too short for Multiple Service Packet".to_string(),
615 ));
616 }
617
618 let mut results = Vec::new();
619
620 tracing::trace!(
621 "Raw Multiple Service Response ({} bytes): {:02X?}",
622 response.len(),
623 response
624 );
625
626 let cip_data = match self.extract_cip_from_response(response) {
628 Ok(data) => data,
629 Err(e) => {
630 tracing::error!("Failed to extract CIP data: {}", e);
631 return Err(e);
632 }
633 };
634
635 tracing::trace!(
636 "Extracted CIP data ({} bytes): {:02X?}",
637 cip_data.len(),
638 cip_data
639 );
640
641 if cip_data.len() < 6 {
642 return Err(crate::error::EtherNetIpError::Protocol(
643 "CIP data too short for Multiple Service Response".to_string(),
644 ));
645 }
646
647 let service_code = cip_data[0];
655 let general_status = cip_data[2];
656 let num_replies = u16::from_le_bytes([cip_data[4], cip_data[5]]) as usize;
657
658 tracing::debug!(
659 "Multiple Service Response: service=0x{:02X}, status=0x{:02X}, replies={}",
660 service_code,
661 general_status,
662 num_replies
663 );
664
665 if general_status != 0x00 {
666 return Err(crate::error::EtherNetIpError::Protocol(
667 self.describe_multiple_service_error(general_status, operations),
668 ));
669 }
670
671 if num_replies != operations.len() {
672 return Err(crate::error::EtherNetIpError::Protocol(format!(
673 "Reply count mismatch: expected {}, got {}",
674 operations.len(),
675 num_replies
676 )));
677 }
678
679 let mut reply_offsets = Vec::new();
681 let mut offset = 6; for _i in 0..num_replies {
684 if offset + 2 > cip_data.len() {
685 return Err(crate::error::EtherNetIpError::Protocol(
686 "CIP data too short for reply offsets".to_string(),
687 ));
688 }
689 let reply_offset =
690 u16::from_le_bytes([cip_data[offset], cip_data[offset + 1]]) as usize;
691 reply_offsets.push(reply_offset);
692 offset += 2;
693 }
694
695 tracing::trace!("Reply offsets: {:?}", reply_offsets);
696
697 let reply_base_offset = 6 + (num_replies * 2);
699
700 tracing::trace!("Reply base offset: {}", reply_base_offset);
701
702 for (i, &reply_offset) in reply_offsets.iter().enumerate() {
704 let reply_start = 4 + reply_offset;
706
707 if reply_start >= cip_data.len() {
708 results.push(Err(BatchError::Other(
709 "Reply offset beyond CIP data".to_string(),
710 )));
711 continue;
712 }
713
714 let reply_end = if i + 1 < reply_offsets.len() {
716 4 + reply_offsets[i + 1]
718 } else {
719 cip_data.len()
721 };
722
723 if reply_end > cip_data.len() || reply_start >= reply_end {
724 results.push(Err(BatchError::Other(
725 "Invalid reply boundaries".to_string(),
726 )));
727 continue;
728 }
729
730 let reply_data = &cip_data[reply_start..reply_end];
731
732 tracing::trace!(
733 "Reply {} at offset {}: start={}, end={}, len={}",
734 i,
735 reply_offset,
736 reply_start,
737 reply_end,
738 reply_data.len()
739 );
740 tracing::trace!("Reply {} data: {:02X?}", i, reply_data);
741
742 let result = self.parse_individual_reply(reply_data, &operations[i]);
743 results.push(result);
744 }
745
746 Ok(results)
747 }
748
749 fn parse_individual_reply(
751 &self,
752 reply_data: &[u8],
753 operation: &BatchOperation,
754 ) -> std::result::Result<Option<PlcValue>, BatchError> {
755 if reply_data.len() < 4 {
756 return Err(BatchError::SerializationError(
757 "Reply too short".to_string(),
758 ));
759 }
760
761 tracing::trace!(
762 "Parsing individual reply ({} bytes): {:02X?}",
763 reply_data.len(),
764 reply_data
765 );
766
767 let service_code = reply_data[0];
775 let general_status = reply_data[2];
776
777 tracing::trace!(
778 "Service code: 0x{:02X}, Status: 0x{:02X}",
779 service_code,
780 general_status
781 );
782
783 if general_status != 0x00 {
784 let error_msg = self.get_cip_error_message(general_status);
785 return Err(BatchError::CipError {
786 status: general_status,
787 message: error_msg,
788 });
789 }
790
791 match operation {
792 BatchOperation::Write { .. } => {
793 Ok(None)
795 }
796 BatchOperation::Read { .. } => {
797 if reply_data.len() < 6 {
799 return Err(BatchError::SerializationError(
800 "Read reply too short for data".to_string(),
801 ));
802 }
803
804 let data = &reply_data[4..];
807 tracing::trace!("Parsing data ({} bytes): {:02X?}", data.len(), data);
808
809 if data.len() < 2 {
810 return Err(BatchError::SerializationError(
811 "Data too short for type".to_string(),
812 ));
813 }
814
815 let data_type = u16::from_le_bytes([data[0], data[1]]);
816 let value_data = &data[2..];
817
818 tracing::trace!(
819 "Data type: 0x{:04X}, Value data ({} bytes): {:02X?}",
820 data_type,
821 value_data.len(),
822 value_data
823 );
824
825 if data_type == values::BOOL_ARRAY_DWORD {
826 if value_data.len() < 4 {
827 return Err(BatchError::SerializationError(
828 "Missing packed BOOL array DWORD value".to_string(),
829 ));
830 }
831
832 let packed_value = u32::from_le_bytes([
833 value_data[0],
834 value_data[1],
835 value_data[2],
836 value_data[3],
837 ]);
838
839 if let BatchOperation::Read { tag_name } = operation
840 && let Some((_base_name, index)) = self.parse_array_element_access(tag_name)
841 {
842 let bit_index = index % 32;
843 let value = (packed_value >> bit_index) & 1 != 0;
844 tracing::trace!(
845 "Parsed packed BOOL array element '{}' from DWORD 0x{:08X} using bit {} -> {}",
846 tag_name,
847 packed_value,
848 bit_index,
849 value
850 );
851 return Ok(Some(PlcValue::Bool(value)));
852 }
853 }
854
855 values::decode_payload(data_type, value_data)
856 .map(Some)
857 .map_err(|e| BatchError::SerializationError(e.to_string()))
858 }
859 }
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::{EipClient, PreparedBatchOperation};
866 use crate::batch::{BatchConfig, BatchOperation};
867
868 fn prepared_read(name: &str, service_len: usize) -> PreparedBatchOperation {
869 PreparedBatchOperation {
870 operation: BatchOperation::Read {
871 tag_name: name.to_string(),
872 },
873 service_request: vec![0x4C; service_len],
874 }
875 }
876
877 #[test]
878 fn batch_packing_respects_packet_size_and_operation_count() {
879 let mut client = EipClient::new_unconnected_for_testing();
880 client.configure_batch_operations(BatchConfig {
881 max_operations_per_packet: 4,
882 max_packet_size: 80,
883 ..BatchConfig::default()
884 });
885
886 let operations: Vec<_> = (0..10)
887 .map(|index| prepared_read(&format!("Tag{index}"), 20))
888 .collect();
889 let groups = client.pack_prepared_operations(operations);
890
891 assert!(
892 groups.len() > 1,
893 "expected packet-size budget to split the batch"
894 );
895 for group in &groups {
896 assert!(
897 group.len() <= 4,
898 "group exceeds max_operations_per_packet: {}",
899 group.len()
900 );
901 assert!(
902 client.group_wire_len(group) <= 80,
903 "group exceeds max_packet_size: {}",
904 client.group_wire_len(group)
905 );
906 }
907 }
908
909 #[test]
910 fn batch_packing_keeps_single_oversized_operation() {
911 let mut client = EipClient::new_unconnected_for_testing();
912 client.configure_batch_operations(BatchConfig {
913 max_operations_per_packet: 20,
914 max_packet_size: 32,
915 ..BatchConfig::default()
916 });
917
918 let groups = client.pack_prepared_operations(vec![
919 prepared_read("TooLarge", 64),
920 prepared_read("Small1", 4),
921 prepared_read("Small2", 4),
922 ]);
923
924 assert_eq!(groups.len(), 2);
925 assert_eq!(groups[0].len(), 1);
926 assert!(
927 client.group_wire_len(&groups[0]) > 32,
928 "single oversized operation should be sent alone, not dropped"
929 );
930 assert!(
931 client.group_wire_len(&groups[1]) <= 32,
932 "small trailing operations should share a packet"
933 );
934 }
935}