Skip to main content

rust_ethernet_ip/client/
batch_exec.rs

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    // =========================================================================
15    // BATCH OPERATIONS IMPLEMENTATION
16    // =========================================================================
17
18    /// Executes a batch of read and write operations
19    ///
20    /// This is the main entry point for batch operations. It takes a slice of
21    /// `BatchOperation` items and executes them efficiently by grouping them
22    /// into optimal CIP packets based on the current `BatchConfig`.
23    ///
24    /// # Arguments
25    ///
26    /// * `operations` - A slice of operations to execute
27    ///
28    /// # Returns
29    ///
30    /// A vector of [`BatchResult`] items, one per executed operation.
31    ///
32    /// When `optimize_packet_packing` is enabled, operations may be regrouped
33    /// by type for execution, so result order is not guaranteed to match the
34    /// original mixed-operation input order. Use [`BatchResult::operation`] to
35    /// correlate each result.
36    ///
37    /// # Performance
38    ///
39    /// Batch execution primarily reduces round trips by combining multiple
40    /// operations into fewer requests. Observed throughput varies significantly
41    /// between simulator and real hardware, and also depends on packet sizing,
42    /// controller model, route path, and tag mix.
43    ///
44    /// # Examples
45    ///
46    /// ```rust,no_run
47    /// use rust_ethernet_ip::{EipClient, BatchOperation, PlcValue};
48    ///
49    /// #[tokio::main]
50    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
51    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
52    ///
53    ///     let operations = vec![
54    ///         BatchOperation::Read { tag_name: "Motor1_Speed".to_string() },
55    ///         BatchOperation::Read { tag_name: "Motor2_Speed".to_string() },
56    ///         BatchOperation::Write {
57    ///             tag_name: "SetPoint".to_string(),
58    ///             value: PlcValue::Dint(1500)
59    ///         },
60    ///     ];
61    ///
62    ///     let results = client.execute_batch(&operations).await?;
63    ///
64    ///     for result in results {
65    ///         match result.result {
66    ///             Ok(Some(value)) => println!("Read value: {:?}", value),
67    ///             Ok(None) => println!("Write successful"),
68    ///             Err(e) => println!("Operation failed: {}", e),
69    ///         }
70    ///     }
71    ///
72    ///     Ok(())
73    /// }
74    /// ```
75    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        // Group operations based on configuration
90        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        // Execute each group
99        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                    // Create error results for this group
116                    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    /// Reads multiple tags in a single batch operation
139    ///
140    /// This is a convenience method for read-only batch operations.
141    /// It's optimized for reading many tags at once.
142    ///
143    /// # Arguments
144    ///
145    /// * `tag_names` - A slice of tag names to read
146    ///
147    /// # Returns
148    ///
149    /// A vector of tuples containing `(tag_name, result)` pairs
150    ///
151    /// # Examples
152    ///
153    /// ```rust,no_run
154    /// use rust_ethernet_ip::EipClient;
155    ///
156    /// #[tokio::main]
157    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
158    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
159    ///
160    ///     let tags = ["Motor1_Speed", "Motor2_Speed", "Temperature", "Pressure"];
161    ///     let results = client.read_tags_batch(&tags).await?;
162    ///
163    ///     for (tag_name, result) in results {
164    ///         match result {
165    ///             Ok(value) => println!("{}: {:?}", tag_name, value),
166    ///             Err(e) => println!("{}: Error - {}", tag_name, e),
167    ///         }
168    ///     }
169    ///
170    ///     Ok(())
171    /// }
172    /// ```
173    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    /// Writes multiple tag values in a single batch operation
216    ///
217    /// This is a convenience method for write-only batch operations.
218    /// It's optimized for writing many values at once.
219    ///
220    /// # Arguments
221    ///
222    /// * `tag_values` - A slice of `(tag_name, value)` tuples to write
223    ///
224    /// # Returns
225    ///
226    /// A vector of tuples containing `(tag_name, result)` pairs
227    ///
228    /// # Examples
229    ///
230    /// ```rust,no_run
231    /// use rust_ethernet_ip::{EipClient, PlcValue};
232    ///
233    /// #[tokio::main]
234    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
235    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
236    ///
237    ///     let writes = vec![
238    ///         ("SetPoint1", PlcValue::Bool(true)),
239    ///         ("SetPoint2", PlcValue::Dint(2000)),
240    ///         ("EnableFlag", PlcValue::Bool(true)),
241    ///     ];
242    ///
243    ///     let results = client.write_tags_batch(&writes).await?;
244    ///
245    ///     for (tag_name, result) in results {
246    ///         match result {
247    ///             Ok(_) => println!("{}: Write successful", tag_name),
248    ///             Err(e) => println!("{}: Write failed - {}", tag_name, e),
249    ///         }
250    ///     }
251    ///
252    ///     Ok(())
253    /// }
254    /// ```
255    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    /// Configures batch operation settings
299    ///
300    /// This method allows fine-tuning of batch operation behavior,
301    /// including performance optimizations and error handling.
302    ///
303    /// # Arguments
304    ///
305    /// * `config` - The new batch configuration to use
306    ///
307    /// # Examples
308    ///
309    /// ```rust,no_run
310    /// use rust_ethernet_ip::{EipClient, BatchConfig};
311    ///
312    /// #[tokio::main]
313    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
314    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
315    ///
316    ///     let config = BatchConfig {
317    ///         max_operations_per_packet: 50,
318    ///         max_packet_size: 1500,
319    ///         packet_timeout_ms: 5000,
320    ///         continue_on_error: false,
321    ///         optimize_packet_packing: true,
322    ///     };
323    ///
324    ///     client.configure_batch_operations(config);
325    ///
326    ///     Ok(())
327    /// }
328    /// ```
329    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    /// Gets current batch operation configuration
340    pub fn get_batch_config(&self) -> &BatchConfig {
341        &self.batch_config
342    }
343
344    // =========================================================================
345    // INTERNAL BATCH OPERATION HELPERS
346    // =========================================================================
347
348    /// Groups operations optimally for batch processing
349    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        // Separate reads and writes
357        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    /// Groups operations sequentially (preserves order)
371    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(&current_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        // Unconnected Send request path/timeout/message-length fields (10 bytes)
455        // plus optional pad, route-size/reserved fields (2 bytes), and route path.
456        12 + embedded_len + pad_len + route_path_len
457    }
458
459    /// Executes a single group of operations as a CIP Multiple Service Packet
460    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        // Build Multiple Service Packet request
468        let cip_request = self.build_multiple_service_packet(operations)?;
469
470        // Send request and get response
471        let response = self.send_cip_request(&cip_request).await?;
472
473        // Parse response and create results
474        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        // Create BatchResult objects
484        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    /// Builds a CIP Multiple Service Packet request
509    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        // Multiple Service Packet service code
516        packet.push(0x0A);
517
518        // Request path (2 bytes for class 0x02, instance 1)
519        packet.push(0x02); // Path size in words
520        packet.push(0x20); // Class segment
521        packet.push(0x02); // Class 0x02 (Message Router)
522        packet.push(0x24); // Instance segment
523        packet.push(0x01); // Instance 1
524
525        // Number of services
526        packet.extend_from_slice(&(operations.len() as u16).to_le_bytes());
527
528        // Calculate offset table
529        let mut service_requests = Vec::with_capacity(operations.len());
530        let mut current_offset = 2 + (operations.len() * 2); // Start after offset table
531
532        for operation in operations {
533            service_requests.push(operation.service_request.clone());
534        }
535
536        // Add offset table
537        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        // Add service requests
543        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    /// Parses a Multiple Service Packet response
607    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        // First, extract the CIP data from the EtherNet/IP response
627        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        // Parse Multiple Service Response header from CIP data:
648        // [0] = Service Code (0x8A)
649        // [1] = Reserved (0x00)
650        // [2] = General Status (0x00 for success)
651        // [3] = Additional Status Size (0x00)
652        // [4-5] = Number of replies (little endian)
653
654        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        // Read reply offsets (each is 2 bytes, little endian)
680        let mut reply_offsets = Vec::new();
681        let mut offset = 6; // Skip header
682
683        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        // The reply data starts after all the offsets
698        let reply_base_offset = 6 + (num_replies * 2);
699
700        tracing::trace!("Reply base offset: {}", reply_base_offset);
701
702        // Parse each reply
703        for (i, &reply_offset) in reply_offsets.iter().enumerate() {
704            // Reply offset is relative to position 4 (after service code, reserved, status, additional status size)
705            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            // Calculate reply end position
715            let reply_end = if i + 1 < reply_offsets.len() {
716                // Not the last reply - use next reply's offset as boundary
717                4 + reply_offsets[i + 1]
718            } else {
719                // Last reply - goes to end of CIP data
720                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    /// Parses an individual service reply within a Multiple Service Packet response
750    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        // Each individual reply in Multiple Service Response has the same format as standalone CIP response:
768        // [0] = Service Code (0xCC for read response, 0xCD for write response)
769        // [1] = Reserved (0x00)
770        // [2] = General Status (0x00 for success)
771        // [3] = Additional Status Size (0x00)
772        // [4..] = Response data (for reads) or empty (for writes)
773
774        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                // Write operations return no data on success
794                Ok(None)
795            }
796            BatchOperation::Read { .. } => {
797                // Read operations return data starting at offset 4
798                if reply_data.len() < 6 {
799                    return Err(BatchError::SerializationError(
800                        "Read reply too short for data".to_string(),
801                    ));
802                }
803
804                // Parse the data directly (skip the 4-byte header)
805                // Data format: [type_low, type_high, value_bytes...]
806                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}