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    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    // =========================================================================
23    // BATCH OPERATIONS IMPLEMENTATION
24    // =========================================================================
25
26    /// Executes a batch of read and write operations
27    ///
28    /// This is the main entry point for batch operations. It takes a slice of
29    /// `BatchOperation` items and executes them efficiently by grouping them
30    /// into optimal CIP packets based on the current `BatchConfig`.
31    ///
32    /// # Arguments
33    ///
34    /// * `operations` - A slice of operations to execute
35    ///
36    /// # Returns
37    ///
38    /// A vector of [`BatchResult`] items, one per executed operation.
39    ///
40    /// When `optimize_packet_packing` is enabled, operations may be regrouped
41    /// by type for execution, so result order is not guaranteed to match the
42    /// original mixed-operation input order. Use [`BatchResult::operation`] to
43    /// correlate each result.
44    ///
45    /// # Performance
46    ///
47    /// Batch execution primarily reduces round trips by combining multiple
48    /// operations into fewer requests. Observed throughput varies significantly
49    /// between simulator and real hardware, and also depends on packet sizing,
50    /// controller model, route path, and tag mix.
51    ///
52    /// # Examples
53    ///
54    /// ```rust,no_run
55    /// use rust_ethernet_ip::{EipClient, BatchOperation, PlcValue};
56    ///
57    /// #[tokio::main]
58    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
59    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
60    ///
61    ///     let operations = vec![
62    ///         BatchOperation::Read { tag_name: "Motor1_Speed".to_string() },
63    ///         BatchOperation::Read { tag_name: "Motor2_Speed".to_string() },
64    ///         BatchOperation::Write {
65    ///             tag_name: "SetPoint".to_string(),
66    ///             value: PlcValue::Dint(1500)
67    ///         },
68    ///     ];
69    ///
70    ///     let results = client.execute_batch(&operations).await?;
71    ///
72    ///     for result in results {
73    ///         match result.result {
74    ///             Ok(Some(value)) => println!("Read value: {:?}", value),
75    ///             Ok(None) => println!("Write successful"),
76    ///             Err(e) => println!("Operation failed: {}", e),
77    ///         }
78    ///     }
79    ///
80    ///     Ok(())
81    /// }
82    /// ```
83    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        // Group operations based on configuration
98        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        // Execute each group
107        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                    // Create error results for this group
124                    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    /// Reads multiple tags in a single batch operation
147    ///
148    /// This is a convenience method for read-only batch operations.
149    /// It's optimized for reading many tags at once.
150    ///
151    /// # Arguments
152    ///
153    /// * `tag_names` - A slice of tag names to read
154    ///
155    /// # Returns
156    ///
157    /// A vector of tuples containing `(tag_name, result)` pairs
158    ///
159    /// # Examples
160    ///
161    /// ```rust,no_run
162    /// use rust_ethernet_ip::EipClient;
163    ///
164    /// #[tokio::main]
165    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
166    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
167    ///
168    ///     let tags = ["Motor1_Speed", "Motor2_Speed", "Temperature", "Pressure"];
169    ///     let results = client.read_tags_batch(&tags).await?;
170    ///
171    ///     for (tag_name, result) in results {
172    ///         match result {
173    ///             Ok(value) => println!("{}: {:?}", tag_name, value),
174    ///             Err(e) => println!("{}: Error - {}", tag_name, e),
175    ///         }
176    ///     }
177    ///
178    ///     Ok(())
179    /// }
180    /// ```
181    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    /// Writes multiple tag values in a single batch operation
224    ///
225    /// This is a convenience method for write-only batch operations.
226    /// It's optimized for writing many values at once.
227    ///
228    /// # Arguments
229    ///
230    /// * `tag_values` - A slice of `(tag_name, value)` tuples to write
231    ///
232    /// # Returns
233    ///
234    /// A vector of tuples containing `(tag_name, result)` pairs
235    ///
236    /// # Examples
237    ///
238    /// ```rust,no_run
239    /// use rust_ethernet_ip::{EipClient, PlcValue};
240    ///
241    /// #[tokio::main]
242    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
243    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
244    ///
245    ///     let writes = vec![
246    ///         ("SetPoint1", PlcValue::Bool(true)),
247    ///         ("SetPoint2", PlcValue::Dint(2000)),
248    ///         ("EnableFlag", PlcValue::Bool(true)),
249    ///     ];
250    ///
251    ///     let results = client.write_tags_batch(&writes).await?;
252    ///
253    ///     for (tag_name, result) in results {
254    ///         match result {
255    ///             Ok(_) => println!("{}: Write successful", tag_name),
256    ///             Err(e) => println!("{}: Write failed - {}", tag_name, e),
257    ///         }
258    ///     }
259    ///
260    ///     Ok(())
261    /// }
262    /// ```
263    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    /// Configures batch operation settings
307    ///
308    /// This method allows fine-tuning of batch operation behavior,
309    /// including performance optimizations and error handling.
310    ///
311    /// # Arguments
312    ///
313    /// * `config` - The new batch configuration to use
314    ///
315    /// # Examples
316    ///
317    /// ```rust,no_run
318    /// use rust_ethernet_ip::{EipClient, BatchConfig};
319    ///
320    /// #[tokio::main]
321    /// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
322    ///     let mut client = EipClient::connect("192.168.1.100:44818").await?;
323    ///
324    ///     let config = BatchConfig {
325    ///         max_operations_per_packet: 50,
326    ///         max_packet_size: 1500,
327    ///         packet_timeout_ms: 5000,
328    ///         continue_on_error: false,
329    ///         optimize_packet_packing: true,
330    ///     };
331    ///
332    ///     client.configure_batch_operations(config);
333    ///
334    ///     Ok(())
335    /// }
336    /// ```
337    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    /// Gets current batch operation configuration
348    pub fn get_batch_config(&self) -> &BatchConfig {
349        &self.batch_config
350    }
351
352    // =========================================================================
353    // INTERNAL BATCH OPERATION HELPERS
354    // =========================================================================
355
356    /// Groups operations optimally for batch processing
357    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        // Separate reads and writes
365        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    /// Groups operations sequentially (preserves order)
379    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(&current_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        // Unconnected Send request path/timeout/message-length fields (10 bytes)
466        // plus optional pad, route-size/reserved fields (2 bytes), and route path.
467        12 + embedded_len + pad_len + route_path_len
468    }
469
470    /// Executes a single group of operations as a CIP Multiple Service Packet
471    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        // Build Multiple Service Packet request
479        let cip_request = self.build_multiple_service_packet(operations)?;
480
481        // Send request and get response
482        let response = self.send_cip_request(&cip_request).await?;
483
484        // Parse response and create results
485        let parsed_results = self.parse_multiple_service_response(&response, operations)?;
486
487        let execution_time = start_time.elapsed();
488
489        // Create BatchResult objects
490        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    /// Builds a CIP Multiple Service Packet request
541    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        // Multiple Service Packet service code
548        packet.push(0x0A);
549
550        // Request path (2 bytes for class 0x02, instance 1)
551        packet.push(0x02); // Path size in words
552        packet.push(0x20); // Class segment
553        packet.push(0x02); // Class 0x02 (Message Router)
554        packet.push(0x24); // Instance segment
555        packet.push(0x01); // Instance 1
556
557        // Number of services
558        packet.extend_from_slice(&(operations.len() as u16).to_le_bytes());
559
560        // Calculate offset table
561        let mut service_requests = Vec::with_capacity(operations.len());
562        let mut current_offset = 2 + (operations.len() * 2); // Start after offset table
563
564        for operation in operations {
565            service_requests.push(operation.service_request.clone());
566        }
567
568        // Add offset table
569        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        // Add service requests
575        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    /// Parses a Multiple Service Packet response
693    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        // First, extract the CIP data from the EtherNet/IP response
713        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        // Parse Multiple Service Response header from CIP data:
734        // [0] = Service Code (0x8A)
735        // [1] = Reserved (0x00)
736        // [2] = General Status (0x00 for success)
737        // [3] = Additional Status Size (0x00)
738        // [4-5] = Number of replies (little endian)
739
740        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        // Read reply offsets (each is 2 bytes, little endian)
772        let mut reply_offsets = Vec::new();
773        let mut offset = 6; // Skip header
774
775        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        // The reply data starts after all the offsets
790        let reply_base_offset = 6 + (num_replies * 2);
791
792        tracing::trace!("Reply base offset: {}", reply_base_offset);
793
794        // Parse each reply
795        for (i, &reply_offset) in reply_offsets.iter().enumerate() {
796            // Reply offset is relative to position 4 (after service code, reserved, status, additional status size)
797            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            // Calculate reply end position
807            let reply_end = if i + 1 < reply_offsets.len() {
808                // Not the last reply - use next reply's offset as boundary
809                4 + reply_offsets[i + 1]
810            } else {
811                // Last reply - goes to end of CIP data
812                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    /// Parses an individual service reply within a Multiple Service Packet response
842    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        // Each individual reply in Multiple Service Response has the same format as standalone CIP response:
860        // [0] = Service Code (0xCC for read response, 0xCD for write response)
861        // [1] = Reserved (0x00)
862        // [2] = General Status (0x00 for success)
863        // [3] = Additional Status Size (0x00)
864        // [4..] = Response data (for reads) or empty (for writes)
865
866        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                // Write operations return no data on success
886                Ok(None)
887            }
888            BatchOperation::Read { .. } => {
889                // Read operations return data starting at offset 4
890                if reply_data.len() < 6 {
891                    return Err(BatchError::SerializationError(
892                        "Read reply too short for data".to_string(),
893                    ));
894                }
895
896                // Parse the data directly (skip the 4-byte header)
897                // Data format: [type_low, type_high, value_bytes...]
898                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}