Skip to main content

snap7_client/
client.rs

1use bytes::{Buf, BufMut, Bytes, BytesMut};
2use std::net::SocketAddr;
3use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
4use tokio::sync::Mutex;
5
6use crate::proto::{
7    cotp::CotpPdu,
8    s7::{
9        clock::PlcDateTime,
10        header::{Area, PduType, S7Header, TransportSize},
11        read_var::{AddressItem, ReadVarRequest, ReadVarResponse},
12        szl::{SzlRequest, SzlResponse},
13        write_var::{WriteItem, WriteVarRequest, WriteVarResponse},
14    },
15    tpkt::TpktFrame,
16};
17
18use crate::{
19    connection::{connect, Connection},
20    error::{Error, Result},
21    types::ConnectParams,
22};
23
24/// A single item in a `read_multi_vars` request.
25#[derive(Debug, Clone)]
26pub struct MultiReadItem {
27    pub area: Area,
28    pub db_number: u16,
29    pub start: u32,
30    pub length: u16,
31    pub transport: TransportSize,
32}
33
34impl MultiReadItem {
35    /// Convenience constructor for a DataBlock byte read.
36    pub fn db(db: u16, start: u32, length: u16) -> Self {
37        Self {
38            area: Area::DataBlock,
39            db_number: db,
40            start,
41            length,
42            transport: TransportSize::Byte,
43        }
44    }
45}
46
47/// A single item in a `write_multi_vars` request.
48#[derive(Debug, Clone)]
49pub struct MultiWriteItem {
50    pub area: Area,
51    pub db_number: u16,
52    pub start: u32,
53    pub data: Bytes,
54}
55
56impl MultiWriteItem {
57    /// Convenience constructor for a DataBlock byte write.
58    pub fn db(db: u16, start: u32, data: impl Into<Bytes>) -> Self {
59        Self {
60            area: Area::DataBlock,
61            db_number: db,
62            start,
63            data: data.into(),
64        }
65    }
66}
67
68struct Inner<T> {
69    transport: T,
70    connection: Connection,
71    pdu_ref: u16,
72    request_timeout: std::time::Duration,
73    connected: bool,
74    job_start: Option<std::time::Instant>,
75    last_exec_ms: u32,
76}
77
78pub struct S7Client<T: AsyncRead + AsyncWrite + Unpin + Send> {
79    inner: Mutex<Inner<T>>,
80    params: ConnectParams,
81    remote_addr: Option<SocketAddr>,
82}
83
84impl<T: AsyncRead + AsyncWrite + Unpin + Send> S7Client<T> {
85    pub async fn from_transport(transport: T, params: ConnectParams) -> Result<Self> {
86        let mut t = transport;
87        let connection = connect(&mut t, &params).await?;
88        let timeout = params.request_timeout;
89        Ok(S7Client {
90            inner: Mutex::new(Inner {
91                transport: t,
92                connection,
93                pdu_ref: 1,
94                request_timeout: timeout,
95                connected: true,
96                job_start: None,
97                last_exec_ms: 0,
98            }),
99            params,
100            remote_addr: None,
101        })
102    }
103
104    /// Return the current request timeout.
105    pub fn request_timeout(&self) -> std::time::Duration {
106        self.params.request_timeout
107    }
108
109    /// Returns the execution time of the last completed S7 operation in milliseconds.
110    ///
111    /// Measures full round-trip: from send to response received. Equivalent to C `Cli_GetExecTime`.
112    pub async fn get_exec_time(&self) -> u32 {
113        self.inner.lock().await.last_exec_ms
114    }
115
116    /// Returns whether the transport connection is alive.
117    ///
118    /// Set to `false` when any I/O error is encountered.
119    /// Equivalent to C `Cli_GetConnected`.
120    pub async fn is_connected(&self) -> bool {
121        self.inner.lock().await.connected
122    }
123
124    /// Update the request timeout at runtime.
125    ///
126    /// This affects subsequent `recv_s7` calls made by this client instance.
127    pub async fn set_request_timeout(&self, timeout: std::time::Duration) {
128        let mut inner = self.inner.lock().await;
129        inner.request_timeout = timeout;
130    }
131
132    /// Read a client parameter by name.
133    ///
134    /// Supported names: `"request_timeout"`, `"connect_timeout"`, `"pdu_size"`.
135    pub fn get_param(&self, name: &str) -> Result<std::time::Duration> {
136        match name {
137            "request_timeout" => Ok(self.params.request_timeout),
138            "connect_timeout" => Ok(self.params.connect_timeout),
139            "pdu_size" => Err(Error::PlcError {
140                code: 0,
141                message: "pdu_size is not a Duration; use .params.pdu_size directly".into(),
142            }),
143            _ => Err(Error::PlcError {
144                code: 0,
145                message: format!("unknown parameter: {name}"),
146            }),
147        }
148    }
149
150    /// Set a client parameter at runtime.
151    ///
152    /// Supported names: `"request_timeout"` (Duration).
153    pub fn set_param(&mut self, name: &str, value: std::time::Duration) -> Result<()> {
154        match name {
155            "request_timeout" => {
156                self.params.request_timeout = value;
157                Ok(())
158            }
159            _ => Err(Error::PlcError {
160                code: 0,
161                message: format!("unknown parameter: {name}"),
162            }),
163        }
164    }
165
166    fn next_pdu_ref(inner: &mut Inner<T>) -> u16 {
167        inner.pdu_ref = inner.pdu_ref.wrapping_add(1);
168        inner.pdu_ref
169    }
170
171    async fn send_s7(
172        inner: &mut Inner<T>,
173        param_buf: Bytes,
174        data_buf: Bytes,
175        pdu_ref: u16,
176        pdu_type: PduType,
177    ) -> Result<()> {
178        let header = S7Header {
179            pdu_type,
180            reserved: 0,
181            pdu_ref,
182            param_len: param_buf.len() as u16,
183            data_len: data_buf.len() as u16,
184            error_class: None,
185            error_code: None,
186        };
187        let mut s7b = BytesMut::new();
188        header.encode(&mut s7b);
189        s7b.extend_from_slice(&param_buf);
190        s7b.extend_from_slice(&data_buf);
191
192        let dt = CotpPdu::Data {
193            tpdu_nr: 0,
194            last: true,
195            payload: s7b.freeze(),
196        };
197        let mut cotpb = BytesMut::new();
198        dt.encode(&mut cotpb);
199        let tpkt = TpktFrame {
200            payload: cotpb.freeze(),
201        };
202        let mut tb = BytesMut::new();
203        tpkt.encode(&mut tb)?;
204        inner.job_start = Some(std::time::Instant::now());
205        inner.transport.write_all(&tb).await?;
206        Ok(())
207    }
208
209    async fn recv_s7(inner: &mut Inner<T>) -> Result<(S7Header, Bytes)> {
210        let timeout = inner.request_timeout;
211        let mut tpkt_hdr = [0u8; 4];
212        if let Err(e) = tokio::time::timeout(timeout, inner.transport.read_exact(&mut tpkt_hdr))
213            .await
214            .map_err(|_| Error::Timeout(timeout))
215            .and_then(|r| r.map_err(Error::Io))
216        {
217            inner.connected = false;
218            return Err(e);
219        }
220        let total = u16::from_be_bytes([tpkt_hdr[2], tpkt_hdr[3]]) as usize;
221        if total < 4 {
222            return Err(Error::UnexpectedResponse);
223        }
224        let mut payload = vec![0u8; total - 4];
225        if let Err(e) = tokio::time::timeout(timeout, inner.transport.read_exact(&mut payload))
226            .await
227            .map_err(|_| Error::Timeout(timeout))
228            .and_then(|r| r.map_err(Error::Io))
229        {
230            inner.connected = false;
231            return Err(e);
232        }
233        let mut b = Bytes::from(payload);
234
235        // COTP DT header: LI (1) + code (1) + tpdu_nr (1)
236        if b.remaining() < 3 {
237            return Err(Error::UnexpectedResponse);
238        }
239        let _li = b.get_u8();
240        let cotp_code = b.get_u8();
241        if cotp_code != 0xF0 {
242            return Err(Error::UnexpectedResponse);
243        }
244        b.advance(1); // tpdu_nr byte
245
246        let header = S7Header::decode(&mut b)?;
247        if let Some(t0) = inner.job_start.take() {
248            inner.last_exec_ms = t0.elapsed().as_millis() as u32;
249        }
250        Ok((header, b))
251    }
252
253    pub async fn db_read(&self, db: u16, start: u32, length: u16) -> Result<Bytes> {
254        let mut inner = self.inner.lock().await;
255        let pdu_ref = Self::next_pdu_ref(&mut inner);
256
257        let req = ReadVarRequest {
258            items: vec![AddressItem {
259                area: Area::DataBlock,
260                db_number: db,
261                start,
262                bit_offset: 0,
263                length,
264                transport: TransportSize::Byte,
265            }],
266        };
267        let mut param_buf = BytesMut::new();
268        req.encode(&mut param_buf);
269
270        Self::send_s7(
271            &mut inner,
272            param_buf.freeze(),
273            Bytes::new(),
274            pdu_ref,
275            PduType::Job,
276        )
277        .await?;
278
279        let (header, mut body) = Self::recv_s7(&mut inner).await?;
280        check_plc_error(&header, "db_read")?;
281        if body.remaining() >= 2 {
282            body.advance(2); // skip param echo: func + item count
283        }
284        let resp = ReadVarResponse::decode(&mut body, 1)?;
285        if resp.items.is_empty() {
286            return Err(Error::UnexpectedResponse);
287        }
288        if resp.items[0].return_code != 0xFF {
289            return Err(Error::PlcError {
290                code: resp.items[0].return_code as u32,
291                message: "item error".into(),
292            });
293        }
294        Ok(resp.items[0].data.clone())
295    }
296
297    /// Read from any PLC area with explicit transport size.
298    ///
299    /// For DB areas use `db_read`. For Marker/Timer/Counter use this method.
300    /// Timer (`area=Timer, transport=Timer`) and Counter (`area=Counter, transport=Counter`)
301    /// use element-index addressing (no ×8 shift) and return 2 bytes per element.
302    pub async fn read_area(
303        &self,
304        area: Area,
305        db_number: u16,
306        start: u32,
307        element_count: u16,
308        transport: TransportSize,
309    ) -> Result<Bytes> {
310        let mut inner = self.inner.lock().await;
311        let pdu_ref = Self::next_pdu_ref(&mut inner);
312
313        let req = ReadVarRequest {
314            items: vec![AddressItem {
315                area,
316                db_number,
317                start,
318                bit_offset: 0,
319                length: element_count,
320                transport,
321            }],
322        };
323        let mut param_buf = BytesMut::new();
324        req.encode(&mut param_buf);
325
326        Self::send_s7(
327            &mut inner,
328            param_buf.freeze(),
329            Bytes::new(),
330            pdu_ref,
331            PduType::Job,
332        )
333        .await?;
334
335        let (header, mut body) = Self::recv_s7(&mut inner).await?;
336        check_plc_error(&header, "read_area")?;
337        if body.remaining() >= 2 {
338            body.advance(2);
339        }
340        let resp = ReadVarResponse::decode(&mut body, 1)?;
341        if resp.items.is_empty() {
342            return Err(Error::UnexpectedResponse);
343        }
344        if resp.items[0].return_code != 0xFF {
345            return Err(Error::PlcError {
346                code: resp.items[0].return_code as u32,
347                message: "item error".into(),
348            });
349        }
350        Ok(resp.items[0].data.clone())
351    }
352
353    /// Read multiple PLC regions in one or more S7 PDU exchanges.
354    ///
355    /// Automatically batches items when the item count would exceed the Siemens hard
356    /// limit of 20 per PDU, or when the encoded request or response would exceed the
357    /// negotiated PDU size. Returns one `Bytes` per item in input order.
358    ///
359    /// Unlike `db_read`, this accepts any `Area` and `TransportSize`.
360    pub async fn read_multi_vars(&self, items: &[MultiReadItem]) -> Result<Vec<Bytes>> {
361        if items.is_empty() {
362            return Ok(Vec::new());
363        }
364
365        // PDU size constants (in bytes)
366        // S7 header: 10, func+count: 2, per-item address: 12
367        const S7_HEADER: usize = 10;
368        const PARAM_OVERHEAD: usize = 2; // func + item count
369        const ADDR_ITEM_SIZE: usize = 12;
370        // Response data item: 4 header + data + 0/1 pad
371        const DATA_ITEM_OVERHEAD: usize = 4;
372        const MAX_ITEMS_PER_PDU: usize = 20;
373
374        let mut inner = self.inner.lock().await;
375        let pdu_size = inner.connection.pdu_size as usize;
376        let max_req_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
377        let max_resp_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
378
379        let mut results = vec![Bytes::new(); items.len()];
380        let mut batch_start = 0;
381
382        while batch_start < items.len() {
383            // Build a batch that fits within PDU limits
384            let mut batch_end = batch_start;
385            let mut req_bytes_used = 0usize;
386            let mut resp_bytes_used = 0usize;
387
388            while batch_end < items.len() && (batch_end - batch_start) < MAX_ITEMS_PER_PDU {
389                let item = &items[batch_end];
390                let item_resp_size =
391                    DATA_ITEM_OVERHEAD + item.length as usize + (item.length as usize % 2);
392
393                if batch_end > batch_start
394                    && (req_bytes_used + ADDR_ITEM_SIZE > max_req_payload
395                        || resp_bytes_used + item_resp_size > max_resp_payload)
396                {
397                    break;
398                }
399                req_bytes_used += ADDR_ITEM_SIZE;
400                resp_bytes_used += item_resp_size;
401                batch_end += 1;
402            }
403
404            let batch = &items[batch_start..batch_end];
405            let pdu_ref = Self::next_pdu_ref(&mut inner);
406
407            let req = ReadVarRequest {
408                items: batch
409                    .iter()
410                    .map(|item| AddressItem {
411                        area: item.area,
412                        db_number: item.db_number,
413                        start: item.start,
414                        bit_offset: 0,
415                        // Siemens requires Byte transport + byte-count length in the request.
416                        // The item's declared transport is only used to decode the response.
417                        length: item.length,
418                        transport: TransportSize::Byte,
419                    })
420                    .collect(),
421            };
422            let mut param_buf = BytesMut::new();
423            req.encode(&mut param_buf);
424
425            Self::send_s7(
426                &mut inner,
427                param_buf.freeze(),
428                Bytes::new(),
429                pdu_ref,
430                PduType::Job,
431            )
432            .await?;
433
434            let (header, mut body) = Self::recv_s7(&mut inner).await?;
435            check_plc_error(&header, "read_multi_vars")?;
436            if body.remaining() >= 2 {
437                body.advance(2); // skip func + item_count echo
438            }
439            let resp = ReadVarResponse::decode(&mut body, batch.len())?;
440
441            for (i, item) in resp.items.into_iter().enumerate() {
442                if item.return_code != 0xFF {
443                    return Err(Error::PlcError {
444                        code: item.return_code as u32,
445                        message: format!("item {} error", batch_start + i),
446                    });
447                }
448                results[batch_start + i] = item.data;
449            }
450
451            batch_start = batch_end;
452        }
453
454        Ok(results)
455    }
456
457    /// Write multiple PLC regions in one or more S7 PDU exchanges.
458    ///
459    /// Automatically batches items when the count or encoded size would exceed the
460    /// negotiated PDU size or the Siemens hard limit of 20 items per PDU.
461    /// Returns `Ok(())` only when all items are acknowledged with return code 0xFF.
462    pub async fn write_multi_vars(&self, items: &[MultiWriteItem]) -> Result<()> {
463        if items.is_empty() {
464            return Ok(());
465        }
466
467        const S7_HEADER: usize = 10;
468        const PARAM_OVERHEAD: usize = 2; // func + item count
469        const ADDR_ITEM_SIZE: usize = 12;
470        const DATA_ITEM_OVERHEAD: usize = 4; // reserved + transport + bit_len (2)
471        const MAX_ITEMS_PER_PDU: usize = 20;
472
473        let mut inner = self.inner.lock().await;
474        let pdu_size = inner.connection.pdu_size as usize;
475        let max_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
476
477        let mut batch_start = 0;
478
479        while batch_start < items.len() {
480            let mut batch_end = batch_start;
481            let mut bytes_used = 0usize;
482
483            while batch_end < items.len() && (batch_end - batch_start) < MAX_ITEMS_PER_PDU {
484                let item = &items[batch_end];
485                let data_len = item.data.len();
486                let item_size = ADDR_ITEM_SIZE + DATA_ITEM_OVERHEAD + data_len + (data_len % 2);
487
488                if batch_end > batch_start && bytes_used + item_size > max_payload {
489                    break;
490                }
491                bytes_used += item_size;
492                batch_end += 1;
493            }
494
495            let batch = &items[batch_start..batch_end];
496            let pdu_ref = Self::next_pdu_ref(&mut inner);
497
498            let req = WriteVarRequest {
499                items: batch
500                    .iter()
501                    .map(|item| WriteItem {
502                        address: AddressItem {
503                            area: item.area,
504                            db_number: item.db_number,
505                            start: item.start,
506                            bit_offset: 0,
507                            length: item.data.len() as u16,
508                            transport: TransportSize::Byte,
509                        },
510                        data: item.data.clone(),
511                    })
512                    .collect(),
513            };
514            let mut param_buf = BytesMut::new();
515            req.encode(&mut param_buf);
516
517            Self::send_s7(
518                &mut inner,
519                param_buf.freeze(),
520                Bytes::new(),
521                pdu_ref,
522                PduType::Job,
523            )
524            .await?;
525
526            let (header, mut body) = Self::recv_s7(&mut inner).await?;
527            check_plc_error(&header, "write_multi_vars")?;
528            if body.remaining() >= 2 {
529                body.advance(2); // skip func + item_count echo
530            }
531            let resp = WriteVarResponse::decode(&mut body, batch.len())?;
532            for (i, &code) in resp.return_codes.iter().enumerate() {
533                if code != 0xFF {
534                    return Err(Error::PlcError {
535                        code: code as u32,
536                        message: format!("item {} write error", batch_start + i),
537                    });
538                }
539            }
540
541            batch_start = batch_end;
542        }
543
544        Ok(())
545    }
546
547    pub async fn db_write(&self, db: u16, start: u32, data: &[u8]) -> Result<()> {
548        let mut inner = self.inner.lock().await;
549        let pdu_ref = Self::next_pdu_ref(&mut inner);
550
551        let req = WriteVarRequest {
552            items: vec![WriteItem {
553                address: AddressItem {
554                    area: Area::DataBlock,
555                    db_number: db,
556                    start,
557                    bit_offset: 0,
558                    length: data.len() as u16,
559                    transport: TransportSize::Byte,
560                },
561                data: Bytes::copy_from_slice(data),
562            }],
563        };
564        let mut param_buf = BytesMut::new();
565        req.encode(&mut param_buf);
566
567        Self::send_s7(
568            &mut inner,
569            param_buf.freeze(),
570            Bytes::new(),
571            pdu_ref,
572            PduType::Job,
573        )
574        .await?;
575
576        let (header, mut body) = Self::recv_s7(&mut inner).await?;
577        check_plc_error(&header, "db_write")?;
578        if body.has_remaining() {
579            body.advance(2); // skip func + item count
580        }
581        let resp = WriteVarResponse::decode(&mut body, 1)?;
582        if resp.return_codes[0] != 0xFF {
583            return Err(Error::PlcError {
584                code: resp.return_codes[0] as u32,
585                message: "write error".into(),
586            });
587        }
588        Ok(())
589    }
590
591    /// Write to any PLC area with explicit transport size.
592    ///
593    /// For Timer/Counter areas the transport size byte in the request must match
594    /// the area (0x1D / 0x1C). For Marker use `TransportSize::Byte`.
595    pub async fn write_area(
596        &self,
597        area: Area,
598        db_number: u16,
599        start: u32,
600        transport: TransportSize,
601        data: &[u8],
602    ) -> Result<()> {
603        let mut inner = self.inner.lock().await;
604        let pdu_ref = Self::next_pdu_ref(&mut inner);
605
606        let req = WriteVarRequest {
607            items: vec![WriteItem {
608                address: AddressItem {
609                    area,
610                    db_number,
611                    start,
612                    bit_offset: 0,
613                    length: data.len() as u16,
614                    transport,
615                },
616                data: Bytes::copy_from_slice(data),
617            }],
618        };
619        let mut param_buf = BytesMut::new();
620        req.encode(&mut param_buf);
621
622        Self::send_s7(
623            &mut inner,
624            param_buf.freeze(),
625            Bytes::new(),
626            pdu_ref,
627            PduType::Job,
628        )
629        .await?;
630
631        let (header, mut body) = Self::recv_s7(&mut inner).await?;
632        check_plc_error(&header, "write_area")?;
633        if body.has_remaining() {
634            body.advance(2);
635        }
636        let resp = WriteVarResponse::decode(&mut body, 1)?;
637        if resp.return_codes[0] != 0xFF {
638            return Err(Error::PlcError {
639                code: resp.return_codes[0] as u32,
640                message: "write_area error".into(),
641            });
642        }
643        Ok(())
644    }
645
646    /// Read from any PLC area using absolute addressing.
647    ///
648    /// A convenience wrapper around [`read_multi_vars`](Self::read_multi_vars)
649    /// for a single area read.
650    pub async fn ab_read(
651        &self,
652        area: Area,
653        db_number: u16,
654        start: u32,
655        length: u16,
656    ) -> Result<Bytes> {
657        let items = [MultiReadItem {
658            area,
659            db_number,
660            start,
661            length,
662            transport: TransportSize::Byte,
663        }];
664        let mut results = self.read_multi_vars(&items).await?;
665        Ok(results.swap_remove(0))
666    }
667
668    /// Write to any PLC area using absolute addressing.
669    ///
670    /// A convenience wrapper around [`write_multi_vars`](Self::write_multi_vars)
671    /// for a single area write.
672    pub async fn ab_write(
673        &self,
674        area: Area,
675        db_number: u16,
676        start: u32,
677        data: &[u8],
678    ) -> Result<()> {
679        let items = [MultiWriteItem {
680            area,
681            db_number,
682            start,
683            data: Bytes::copy_from_slice(data),
684        }];
685        self.write_multi_vars(&items).await
686    }
687
688    /// Read Merker (flag) bytes starting at `start`, `length` bytes.
689    pub async fn mb_read(&self, start: u32, length: u16) -> Result<Bytes> {
690        self.ab_read(Area::Marker, 0, start, length).await
691    }
692
693    /// Write Merker (flag) bytes starting at `start`.
694    pub async fn mb_write(&self, start: u32, data: &[u8]) -> Result<()> {
695        self.ab_write(Area::Marker, 0, start, data).await
696    }
697
698    /// Read I/O input (EB) bytes starting at `start`, `length` bytes.
699    pub async fn eb_read(&self, start: u32, length: u16) -> Result<Bytes> {
700        self.ab_read(Area::ProcessInput, 0, start, length).await
701    }
702
703    /// Write I/O input (EB) bytes starting at `start`.
704    pub async fn eb_write(&self, start: u32, data: &[u8]) -> Result<()> {
705        self.ab_write(Area::ProcessInput, 0, start, data).await
706    }
707
708    /// Read I/O output (AB) bytes starting at `start`, `length` bytes.
709    pub async fn ib_read(&self, start: u32, length: u16) -> Result<Bytes> {
710        self.ab_read(Area::ProcessOutput, 0, start, length).await
711    }
712
713    /// Write I/O output (AB) bytes starting at `start`.
714    pub async fn ib_write(&self, start: u32, data: &[u8]) -> Result<()> {
715        self.ab_write(Area::ProcessOutput, 0, start, data).await
716    }
717
718    /// Read `amount` Timer words starting at timer index `start`.
719    pub async fn tm_read(&self, start: u32, amount: u16) -> Result<Bytes> {
720        let items = [MultiReadItem {
721            area: Area::Timer,
722            db_number: 0,
723            start,
724            length: amount,
725            transport: TransportSize::Timer,
726        }];
727        let mut results = self.read_multi_vars(&items).await?;
728        Ok(results.swap_remove(0))
729    }
730
731    /// Write Timer S5Time words. `data` must be `amount * 2` bytes (one word per timer).
732    pub async fn tm_write(&self, start: u32, data: &[u8]) -> Result<()> {
733        let amount = (data.len() / 2) as u16;
734        let items = [MultiWriteItem {
735            area: Area::Timer,
736            db_number: 0,
737            start,
738            data: Bytes::copy_from_slice(data),
739        }];
740        let _ = amount;
741        self.write_multi_vars(&items).await
742    }
743
744    /// Read `amount` Counter BCD words starting at counter index `start`.
745    pub async fn ct_read(&self, start: u32, amount: u16) -> Result<Bytes> {
746        let items = [MultiReadItem {
747            area: Area::Counter,
748            db_number: 0,
749            start,
750            length: amount,
751            transport: TransportSize::Counter,
752        }];
753        let mut results = self.read_multi_vars(&items).await?;
754        Ok(results.swap_remove(0))
755    }
756
757    /// Write Counter BCD words. `data` must be `amount * 2` bytes (one word per counter).
758    pub async fn ct_write(&self, start: u32, data: &[u8]) -> Result<()> {
759        let items = [MultiWriteItem {
760            area: Area::Counter,
761            db_number: 0,
762            start,
763            data: Bytes::copy_from_slice(data),
764        }];
765        self.write_multi_vars(&items).await
766    }
767
768    pub async fn read_szl(&self, szl_id: u16, szl_index: u16) -> Result<SzlResponse> {
769        let payload = self.read_szl_payload(szl_id, szl_index).await?;
770        let mut b = payload;
771        Ok(SzlResponse::decode(&mut b)?)
772    }
773
774    /// Send a UserData SZL query and return the raw SZL data block
775    /// (starting with block_len, szl_id, szl_index, then entry data).
776    async fn read_szl_payload(&self, szl_id: u16, szl_index: u16) -> Result<Bytes> {
777        let mut inner = self.inner.lock().await;
778        let pdu_ref = Self::next_pdu_ref(&mut inner);
779
780        let req = SzlRequest { szl_id, szl_index };
781        let mut param_buf = BytesMut::new();
782        req.encode_params(&mut param_buf);
783        let mut data_buf = BytesMut::new();
784        req.encode_data(&mut data_buf);
785
786        Self::send_s7(
787            &mut inner,
788            param_buf.freeze(),
789            data_buf.freeze(),
790            pdu_ref,
791            PduType::UserData,
792        )
793        .await?;
794
795        let (header, mut body) = Self::recv_s7(&mut inner).await?;
796
797        // Skip the echoed param section
798        if body.remaining() < header.param_len as usize {
799            return Err(Error::UnexpectedResponse);
800        }
801        body.advance(header.param_len as usize);
802
803        // body is now the data section.
804        // Data envelope: return_code(1) + transport(1) + data_len(2)
805        // If shorter than 4, the PLC returned an error with no data.
806        if body.remaining() < 4 {
807            return Ok(Bytes::new());
808        }
809        let return_code = body.get_u8();
810        let _transport = body.get_u8();
811        let _data_len = body.get_u16();
812
813        // return_code 0xFF = success; anything else = PLC error (function not available etc.)
814        // Return empty payload so callers can handle gracefully.
815        if return_code != 0xFF {
816            return Ok(Bytes::new());
817        }
818
819        // Remaining is the SZL data block.
820        Ok(body.copy_to_bytes(body.remaining()))
821    }
822
823    pub async fn read_clock(&self) -> Result<PlcDateTime> {
824        let mut inner = self.inner.lock().await;
825        let pdu_ref = Self::next_pdu_ref(&mut inner);
826        // UserData params: head[3] + plen=4 + method=0x11(req) + Tg=0x47(clock) + subfn=0x01(read) + seq=0x00
827        let mut param_buf = BytesMut::new();
828        param_buf.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x47, 0x01, 0x00]);
829        Self::send_s7(
830            &mut inner,
831            param_buf.freeze(),
832            Bytes::new(),
833            pdu_ref,
834            PduType::UserData,
835        )
836        .await?;
837        let (header, mut body) = Self::recv_s7(&mut inner).await?;
838        // The clock response layout varies by PLC firmware:
839        //   - Standard: param_len=8 (echo), data_len=12 (4-byte envelope + 8-byte datetime)
840        //   - Variant A: param_len=12 (echo+envelope), data_len=8 (datetime only)
841        //   - Variant B: param_len=20 (echo+envelope+datetime), data_len=0
842        // Strategy: take the last 8 bytes of the combined param+data region.
843        let total = header.param_len as usize + header.data_len as usize;
844        if body.remaining() < total || total < 8 {
845            return Err(Error::UnexpectedResponse);
846        }
847        body.advance(total - 8);
848        Ok(PlcDateTime::decode(&mut body)?)
849    }
850
851    /// Set the PLC clock (UserData function group 0x47 = clock, subfunction 0x02 = write).
852    pub async fn set_clock(&self, dt: &PlcDateTime) -> Result<()> {
853        let mut inner = self.inner.lock().await;
854        let pdu_ref = Self::next_pdu_ref(&mut inner);
855        // UserData params: head[3] + plen=4 + method=0x11(req) + Tg=0x47(clock) + subfn=0x02(write) + seq=0x00
856        let mut param_buf = BytesMut::new();
857        param_buf.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x47, 0x02, 0x00]);
858        // Data envelope: return_code=0xFF, transport=0x09 (OCTET_STRING), length=8
859        let mut data_buf = BytesMut::new();
860        data_buf.extend_from_slice(&[0xFF, 0x09, 0x00, 0x08]);
861        dt.encode(&mut data_buf);
862        Self::send_s7(
863            &mut inner,
864            param_buf.freeze(),
865            data_buf.freeze(),
866            pdu_ref,
867            PduType::UserData,
868        )
869        .await?;
870        let (header, _body) = Self::recv_s7(&mut inner).await?;
871        check_plc_error(&header, "set_clock")?;
872        Ok(())
873    }
874
875    /// Set the PLC clock to the host system time.
876    ///
877    /// Uses [`std::time::SystemTime`] converted to a [`PlcDateTime`].  The
878    /// weekday field is set to 0 (unknown) since `SystemTime` does not carry it.
879    pub async fn set_clock_to_now(&self) -> Result<()> {
880        use std::time::{SystemTime, UNIX_EPOCH};
881        let secs = SystemTime::now()
882            .duration_since(UNIX_EPOCH)
883            .unwrap_or_default()
884            .as_secs();
885        // Simple UTC decomposition (no leap-second handling)
886        let s = secs % 60;
887        let m = (secs / 60) % 60;
888        let h = (secs / 3600) % 24;
889        // Days since epoch
890        let days = secs / 86400;
891        // Rough Gregorian year calculation
892        let mut year = 1970u16;
893        let mut d = days;
894        loop {
895            let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
896            let days_in_year: u64 = if leap { 366 } else { 365 };
897            if d < days_in_year {
898                break;
899            }
900            d -= days_in_year;
901            year += 1;
902        }
903        let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
904        let days_per_month: [u64; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
905        let mut month = 1u8;
906        for &dpm in &days_per_month {
907            if d < dpm {
908                break;
909            }
910            d -= dpm;
911            month += 1;
912        }
913        let dt = PlcDateTime {
914            year,
915            month,
916            day: (d + 1) as u8,
917            hour: h as u8,
918            minute: m as u8,
919            second: s as u8,
920            millisecond: 0,
921            weekday: 0,
922        };
923        self.set_clock(&dt).await
924    }
925
926    // -- Force operations ------------------------------------------------------
927
928    /// Force a bit in the process image output (Q) or process image input (I).
929    ///
930    /// `area` must be `Area::ProcessOutput` (Q) or `Area::ProcessInput` (I).
931    /// `byte_addr` is the byte address, `bit` is the bit number (0–7).
932    /// `value` is `true` to force to 1, `false` to force to 0.
933    ///
934    /// For outputs (Q): writes directly to the process image output — effective
935    /// on the next CPU scan cycle.  For inputs (I): writes to the process image
936    /// input — note that many CPUs will overwrite this on the next scan cycle
937    /// unless the CPU is in STOP mode or the input is truly "forced" via the
938    /// CPU's force table (STEP7 "Force Variables" function).
939    pub async fn force_bit(
940        &self,
941        area: Area,
942        byte_addr: u32,
943        bit: u8,
944        value: bool,
945    ) -> Result<()> {
946        let bit = bit & 0x07;
947        // Read the current byte, flip the target bit, write back.
948        let current = self.read_area(area, 0, byte_addr * 8, 1, TransportSize::Byte).await?;
949        let mut byte_val = if current.is_empty() { 0u8 } else { current[0] };
950        if value {
951            byte_val |= 1 << bit;
952        } else {
953            byte_val &= !(1 << bit);
954        }
955        self.write_area(area, 0, byte_addr * 8, TransportSize::Byte, &[byte_val]).await
956    }
957
958    /// Force a whole byte in the process image output (Q) or input (I).
959    pub async fn force_byte(
960        &self,
961        area: Area,
962        byte_addr: u32,
963        value: u8,
964    ) -> Result<()> {
965        self.write_area(area, 0, byte_addr * 8, TransportSize::Byte, &[value]).await
966    }
967
968    /// Cancel force on a byte by writing 0x00 to it.
969    pub async fn force_cancel_byte(&self, area: Area, byte_addr: u32) -> Result<()> {
970        self.write_area(area, 0, byte_addr * 8, TransportSize::Byte, &[0x00]).await
971    }
972
973    /// Read the SZL force table (SZL ID 0x0025) — returns raw SZL payload.
974    ///
975    /// Returns an empty `Bytes` when the CPU reports no forced variables.
976    pub async fn read_force_list(&self) -> Result<bytes::Bytes> {
977        self.read_szl_payload(0x0025, 0x0000).await
978    }
979
980    /// Read the list of all available SZL IDs from the PLC (SZL ID 0x0000).
981    ///
982    /// Returns a `Vec<u16>` where each entry is a supported SZL ID.
983    pub async fn read_szl_list(&self) -> Result<Vec<u16>> {
984        let payload = self.read_szl_payload(0x0000, 0x0000).await?;
985        if payload.is_empty() {
986            return Ok(Vec::new());
987        }
988        let mut b = payload;
989        // SZL block: [szl_id:2][szl_index:2][entry_len:2][entry_count:2][entries...]
990        if b.remaining() < 8 {
991            return Err(Error::UnexpectedResponse);
992        }
993        let _szl_id = b.get_u16();
994        let _szl_index = b.get_u16();
995        let entry_len = b.get_u16() as usize;
996        let entry_count = b.get_u16() as usize;
997        if entry_len < 2 {
998            return Err(Error::UnexpectedResponse);
999        }
1000        let mut ids = Vec::with_capacity(entry_count);
1001        for _ in 0..entry_count {
1002            if b.remaining() < entry_len {
1003                break;
1004            }
1005            ids.push(b.get_u16());
1006            b.advance(entry_len - 2);
1007        }
1008        Ok(ids)
1009    }
1010
1011    /// Copy RAM data to ROM (function 0x43).
1012    ///
1013    /// Copies the CPU's work memory to its load memory (retain on power-off).
1014    pub async fn copy_ram_to_rom(&self) -> Result<()> {
1015        let mut inner = self.inner.lock().await;
1016        let pdu_ref = Self::next_pdu_ref(&mut inner);
1017        let param = Bytes::copy_from_slice(&[
1018            0x00, 0x01, 0x12, 0x04, 0x43, 0x44, 0x01, 0x00,
1019        ]);
1020        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::UserData).await?;
1021        let (header, _body) = Self::recv_s7(&mut inner).await?;
1022        check_plc_error(&header, "copy_ram_to_rom")?;
1023        Ok(())
1024    }
1025
1026    /// Compress the PLC work memory (function 0x42).
1027    ///
1028    /// Reorganises memory to eliminate fragmentation.  The PLC must be in STOP
1029    /// mode before calling this.
1030    pub async fn compress(&self) -> Result<()> {
1031        let mut inner = self.inner.lock().await;
1032        let pdu_ref = Self::next_pdu_ref(&mut inner);
1033        let param = Bytes::copy_from_slice(&[
1034            0x00, 0x01, 0x12, 0x04, 0x42, 0x44, 0x01, 0x00,
1035        ]);
1036        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::UserData).await?;
1037        let (header, _body) = Self::recv_s7(&mut inner).await?;
1038        check_plc_error(&header, "compress")?;
1039        Ok(())
1040    }
1041
1042    // -- PI (Program Invocation) services -------------------------------------
1043
1044    /// Send an S7 PI (Program Invocation) Job — function 0x28.
1045    ///
1046    /// `service` is the PI service name (e.g. `"_MRES"`, `"_OVERALL_RESET"`).
1047    /// The parameter block layout is: [0x28, 0x00, 0x00, len_hi, len_lo, <name bytes>].
1048    async fn pi_service(inner: &mut Inner<T>, pdu_ref: u16, service: &str) -> Result<()> {
1049        let name = service.as_bytes();
1050        let mut param = BytesMut::with_capacity(5 + name.len());
1051        param.put_u8(0x28); // function = PI service
1052        param.put_u8(0x00);
1053        param.put_u8(0x00);
1054        param.put_u16(name.len() as u16);
1055        param.extend_from_slice(name);
1056        Self::send_s7(inner, param.freeze(), Bytes::new(), pdu_ref, PduType::Job).await?;
1057        let (header, _body) = Self::recv_s7(inner).await?;
1058        check_plc_error(&header, service)?;
1059        Ok(())
1060    }
1061
1062    /// Memory Reset — clears all work memory blocks.
1063    ///
1064    /// The PLC **must be in STOP mode** before calling this.  After memory reset
1065    /// the CPU will no longer have any OBs, FBs, FCs or DBs; it must be
1066    /// re-programmed before it can be restarted.
1067    pub async fn memory_reset(&self) -> Result<()> {
1068        let mut inner = self.inner.lock().await;
1069        let pdu_ref = Self::next_pdu_ref(&mut inner);
1070        Self::pi_service(&mut inner, pdu_ref, "_MRES").await
1071    }
1072
1073    /// Overall Reset — formats the entire PLC memory (load + work + retain).
1074    ///
1075    /// More destructive than [`memory_reset`](Self::memory_reset): also wipes
1076    /// the load memory (Flash/RAM card).  PLC must be in STOP mode.
1077    pub async fn overall_reset(&self) -> Result<()> {
1078        let mut inner = self.inner.lock().await;
1079        let pdu_ref = Self::next_pdu_ref(&mut inner);
1080        Self::pi_service(&mut inner, pdu_ref, "_OVERALL_RESET").await
1081    }
1082
1083    // -- Batch block operations -----------------------------------------------
1084
1085    /// Upload all blocks of given types from the PLC.
1086    ///
1087    /// Returns a `Vec` of `(block_type, block_number, data)` tuples.
1088    /// Pass `block_types` as a slice of raw type bytes, e.g.
1089    /// `&[0x38, 0x41, 0x43, 0x45]` for OB, DB, FC, FB.
1090    pub async fn upload_all_blocks(
1091        &self,
1092        block_types: &[u8],
1093    ) -> Result<Vec<(u8, u16, Vec<u8>)>> {
1094        let mut results = Vec::new();
1095        for &bt in block_types {
1096            let numbers = self.list_blocks_of_type(bt).await?;
1097            for num in numbers {
1098                match self.full_upload(bt, num).await {
1099                    Ok(data) => results.push((bt, num, data)),
1100                    Err(e) => return Err(e),
1101                }
1102            }
1103        }
1104        Ok(results)
1105    }
1106
1107    // -- PLC control & status -------------------------------------------------
1108
1109    /// Send a simple Job with a 2-byte parameter (func + 0x00) and no data.
1110    async fn simple_control(inner: &mut Inner<T>, pdu_ref: u16, func: u8) -> Result<()> {
1111        let param = Bytes::copy_from_slice(&[func, 0x00]);
1112        Self::send_s7(inner, param, Bytes::new(), pdu_ref, PduType::Job).await?;
1113        let (header, _body) = Self::recv_s7(inner).await?;
1114        check_plc_error(&header, "plc_control")?;
1115        Ok(())
1116    }
1117
1118    /// Stop the PLC (S7 function code 0x29).
1119    ///
1120    /// Sends a Job request with no additional data. Returns `Ok(())` when the
1121    /// PLC acknowledges the command, or an error if the PLC rejects it
1122    /// (e.g., password-protected or CPU in a non-stoppable state).
1123    pub async fn plc_stop(&self) -> Result<()> {
1124        let mut inner = self.inner.lock().await;
1125        let pdu_ref = Self::next_pdu_ref(&mut inner);
1126        Self::simple_control(&mut inner, pdu_ref, 0x29).await
1127    }
1128
1129    /// Hot-start (warm restart) the PLC (S7 function code 0x28).
1130    ///
1131    /// A warm restart retains the DB content and retentive memory.
1132    pub async fn plc_hot_start(&self) -> Result<()> {
1133        let mut inner = self.inner.lock().await;
1134        let pdu_ref = Self::next_pdu_ref(&mut inner);
1135        Self::simple_control(&mut inner, pdu_ref, 0x28).await
1136    }
1137
1138    /// Cold-start (full restart) the PLC (S7 function code 0x2A).
1139    ///
1140    /// A cold start clears all DBs and non-retentive memory.
1141    pub async fn plc_cold_start(&self) -> Result<()> {
1142        let mut inner = self.inner.lock().await;
1143        let pdu_ref = Self::next_pdu_ref(&mut inner);
1144        Self::simple_control(&mut inner, pdu_ref, 0x2A).await
1145    }
1146
1147    /// Read the current PLC status via SZL 0x0424.
1148    ///
1149    /// Returns one of [`PlcStatus::Run`], [`PlcStatus::Stop`], or
1150    /// [`PlcStatus::Unknown`].
1151    pub async fn get_plc_status(&self) -> Result<crate::types::PlcStatus> {
1152        let payload = self.read_szl_payload(0x0424, 0x0000).await?;
1153        // SZL 0x0424 response layout (after stripping 4-byte data envelope):
1154        //   [0..1]  SZL_ID  (0x0424)
1155        //   [2..3]  SZL_INDEX (0x0000)
1156        //   [4..5]  LENTHDR (entry length in bytes, big-endian)
1157        //   [6..7]  N_DR (entry count, big-endian)
1158        //   [8..]   first entry data
1159        // C snap7 strips SZL_ID+SZL_INDEX (4 bytes), so its opData[7] = payload[11].
1160        // Status byte = 4th byte of first entry = payload[11].
1161        if payload.len() < 12 {
1162            return Ok(crate::types::PlcStatus::Unknown);
1163        }
1164        let status_byte = payload[11];
1165        match status_byte {
1166            0x00 => Ok(crate::types::PlcStatus::Unknown),
1167            0x04 => Ok(crate::types::PlcStatus::Stop),
1168            0x08 => Ok(crate::types::PlcStatus::Run),
1169            // Old CPUs sometimes encode STOP as 0x03
1170            0x03 => Ok(crate::types::PlcStatus::Stop),
1171            _ => Ok(crate::types::PlcStatus::Stop),
1172        }
1173    }
1174
1175    // -- PLC information queries (via SZL UserData) ---------------------------
1176
1177    /// Read the PLC order code (SZL ID 0x0011).
1178    ///
1179    /// The order code is a 20-character ASCII string (e.g. `"6ES7 317-2EK14-0AB0"`).
1180    pub async fn get_order_code(&self) -> Result<crate::types::OrderCode> {
1181        let payload = self.read_szl_payload(0x0011, 0x0000).await?;
1182        if payload.len() < 8 {
1183            return Err(Error::UnexpectedResponse);
1184        }
1185
1186        // SZL 0x0011 payload: [szl_id:2][szl_index:2][entry_len:2][entry_count:2][entries...]
1187        // Each entry: [index:2][data: entry_len-2 bytes, null-padded]
1188        // Entry 0x0001 = order code string; version bytes = last 3 bytes of entire payload.
1189        let n = payload.len();
1190        let (v1, v2, v3) = if n >= 3 {
1191            (payload[n - 3], payload[n - 2], payload[n - 1])
1192        } else {
1193            (0, 0, 0)
1194        };
1195
1196        let mut b = payload.clone();
1197        let szl_id = b.get_u16();
1198        let _szl_idx = b.get_u16();
1199        let entry_len = b.get_u16() as usize;
1200        let entry_count = b.get_u16() as usize;
1201
1202        if (szl_id == 0x0011 || szl_id == 0x001C) && entry_len >= 4 && entry_count > 0 {
1203            for _ in 0..entry_count {
1204                if b.remaining() < entry_len { break; }
1205                let entry_idx = b.get_u16();
1206                let string_len = entry_len - 2;
1207                let raw = b.copy_to_bytes(string_len);
1208                if entry_idx == 0x0001 {
1209                    let null_end = raw.iter().position(|&x| x == 0).unwrap_or(string_len);
1210                    let code = String::from_utf8_lossy(&raw[..null_end]).trim().to_string();
1211                    if !code.is_empty() {
1212                        return Ok(crate::types::OrderCode { code, v1, v2, v3 });
1213                    }
1214                }
1215            }
1216        }
1217
1218        // Fallback: scan for "6ES"/"6AV"/"6GK" pattern anywhere in payload.
1219        let code = scan_ascii_fields(&payload, 10, 4).into_iter().find(|s| {
1220            let su = s.to_uppercase();
1221            (su.starts_with("6ES") || su.starts_with("6AV") || su.starts_with("6GK"))
1222                && s.len() >= 10
1223                && s.bytes().all(|c| c.is_ascii_graphic() || c == b' ')
1224        }).unwrap_or_default();
1225        Ok(crate::types::OrderCode { code, v1, v2, v3 })
1226    }
1227
1228    /// Read detailed CPU information (SZL ID 0x001C).
1229    ///
1230    /// Returns module type, serial number, plant identification, copyright
1231    /// and module name fields pre-parsed from the SZL response.
1232    /// Handles both classic S7-300/400 and S7-1200/1500 response formats.
1233    pub async fn get_cpu_info(&self) -> Result<crate::types::CpuInfo> {
1234        let payload = self.read_szl_payload(0x001C, 0x0000).await?;
1235        if payload.len() < 8 {
1236            return Err(Error::UnexpectedResponse);
1237        }
1238
1239        // SZL 0x001C payload layout (after correct request framing):
1240        //   [szl_id:2=0x001C][szl_index:2][entry_len:2][entry_count:2]
1241        //   followed by entry_count entries, each entry_len bytes:
1242        //     [entry_index:2][string_data: entry_len-2 bytes, null-padded]
1243        //
1244        // Entry indices observed on S7-300/400:
1245        //   0x0001 = plant identification (AS name)
1246        //   0x0002 = module type name (e.g. "CPU 319-3 PN/DP")
1247        //   0x0003 = module name (OB1 program name)
1248        //   0x0004 = copyright
1249        //   0x0005 = serial number
1250        //   0x0007 = module type name (duplicate in some firmware)
1251        //   0x0008 = module name (duplicate in some firmware)
1252        let mut b = payload.clone();
1253        let szl_id = b.get_u16();
1254        let _szl_idx = b.get_u16();
1255        let entry_len = b.get_u16() as usize;
1256        let entry_count = b.get_u16() as usize;
1257
1258        if szl_id == 0x001C && entry_len >= 4 && entry_count > 0 {
1259            let mut module_type = String::new();
1260            let mut module_type_canonical = String::new(); // index 0x0007 — always authoritative
1261            let mut serial_number = String::new();
1262            let mut as_name = String::new();
1263            let mut copyright = String::new();
1264            let mut module_name = String::new();
1265
1266            for _ in 0..entry_count {
1267                if b.remaining() < entry_len { break; }
1268                let entry_idx = b.get_u16();
1269                let string_len = entry_len - 2;
1270                let raw = b.copy_to_bytes(string_len);
1271                let null_end = raw.iter().position(|&x| x == 0).unwrap_or(string_len);
1272                let val = String::from_utf8_lossy(&raw[..null_end]).trim().to_string();
1273                match entry_idx {
1274                    0x0001 => { if as_name.is_empty() { as_name = val; } }
1275                    // 0x0002 is module type on S7-300, AS name on S7-1500 — only use if
1276                    // 0x0007 is absent (module_type_canonical will override below).
1277                    0x0002 => { if module_type.is_empty() { module_type = val; } }
1278                    0x0003 => { if module_name.is_empty() { module_name = val; } }
1279                    0x0004 => { if copyright.is_empty() { copyright = val; } }
1280                    0x0005 => { if serial_number.is_empty() { serial_number = val; } }
1281                    // 0x0007 is always the true module type name (both S7-300 and S7-1500)
1282                    0x0007 => { if module_type_canonical.is_empty() { module_type_canonical = val; } }
1283                    // 0x0008 is SMC memory card on S7-1500 — do not use for module_name
1284                    _ => {}
1285                }
1286            }
1287
1288            // 0x0007 wins over 0x0002 for module_type
1289            if !module_type_canonical.is_empty() {
1290                module_type = module_type_canonical;
1291            }
1292
1293            if module_name.is_empty() && !as_name.is_empty() {
1294                module_name = as_name.clone();
1295            }
1296
1297            if !module_type.is_empty() || !serial_number.is_empty() || !as_name.is_empty() {
1298                let protocol = detect_protocol(&payload, &module_type);
1299                return Ok(crate::types::CpuInfo {
1300                    module_type,
1301                    serial_number,
1302                    as_name,
1303                    copyright,
1304                    module_name,
1305                    protocol,
1306                });
1307            }
1308        }
1309
1310        // S7-1500 and some firmware variants use a tagged sub-record format.
1311        // Fall back to scanning the raw payload for tagged string fields.
1312        let data = payload.as_ref();
1313        let (module_type, serial_number, as_name, copyright, module_name) =
1314            parse_sub_record_fields(data);
1315
1316        if !module_type.is_empty() || !serial_number.is_empty() {
1317            let protocol = detect_protocol(&payload, &module_type);
1318            return Ok(crate::types::CpuInfo {
1319                module_type,
1320                serial_number,
1321                as_name,
1322                copyright,
1323                module_name,
1324                protocol,
1325            });
1326        }
1327
1328        // Last-resort scan: extract printable strings and apply heuristics.
1329        let mut module_type = String::new();
1330        let mut serial_number = String::new();
1331        let mut as_name = String::new();
1332        let mut copyright = String::new();
1333        let mut module_name = String::new();
1334
1335        let mut scan = 0;
1336        while scan < data.len() {
1337            if data[scan].is_ascii_graphic() || data[scan] == b' ' {
1338                let start = scan;
1339                while scan < data.len() && (data[scan].is_ascii_graphic() || data[scan] == b' ') {
1340                    scan += 1;
1341                }
1342                let val = String::from_utf8_lossy(&data[start..scan]).trim().to_string();
1343                if val.len() >= 3 {
1344                    let tag = if start >= 2 && data[start - 2] == 0x00 {
1345                        Some(data[start - 1])
1346                    } else {
1347                        None
1348                    };
1349                    let su = val.to_uppercase();
1350                    if su.contains("BOOT") || su.starts_with("P B") || su.starts_with("HBOOT") {
1351                        // skip firmware label
1352                    } else if tag == Some(0x07) && module_type.is_empty() {
1353                        module_type = val;
1354                    } else if tag == Some(0x08) && module_name.is_empty() {
1355                        module_name = val;
1356                    } else if tag == Some(0x05) && as_name.is_empty() {
1357                        as_name = val;
1358                    } else if tag == Some(0x06) && copyright.is_empty() {
1359                        copyright = val;
1360                    } else if tag == Some(0x04) && serial_number.is_empty() {
1361                        serial_number = val;
1362                    } else if val.contains('-')
1363                        && val.chars().filter(|c| c.is_ascii_digit()).count() >= 4
1364                        && !val.starts_with("6ES7")
1365                        && serial_number.is_empty()
1366                    {
1367                        serial_number = val;
1368                    } else if su.contains("CPU") && su.contains("PN") && module_type.is_empty() {
1369                        module_type = val;
1370                    } else if module_type.is_empty() && val.len() >= 8 && !su.contains("MC_") {
1371                        module_type = val;
1372                    }
1373                }
1374            } else {
1375                scan += 1;
1376            }
1377        }
1378
1379        let protocol = detect_protocol(&payload, &module_type);
1380        Ok(crate::types::CpuInfo {
1381            module_type,
1382            serial_number,
1383            as_name,
1384            copyright,
1385            module_name,
1386            protocol,
1387        })
1388    }
1389    
1390    /// Read communication processor information (SZL ID 0x0131, index 0x0001).
1391    ///
1392    /// Returns maximum PDU length, connection count, and baud rates.
1393    pub async fn get_cp_info(&self) -> Result<crate::types::CpInfo> {
1394        // Index 0x0001 = communication module info entry (used by C snap7).
1395        let payload = self.read_szl_payload(0x0131, 0x0001).await?;
1396
1397        // SZL 0x0131 response wire format (after stripping the 4-byte data envelope):
1398        //   [szl_id:2][szl_index:2][entry_len:2][entry_count:2][entries...]
1399        // Each entry for index 0x0001 (S7-300/400/1200/1500):
1400        //   [index:2][max_pdu_len:2][max_connections:2][max_mpi_rate:4][max_bus_rate:4] = 14 bytes
1401
1402        let mut b = payload.clone();
1403        if b.remaining() < 8 {
1404            return Ok(crate::types::CpInfo {
1405                max_pdu_len: 0, max_connections: 0, max_mpi_rate: 0, max_bus_rate: 0,
1406            });
1407        }
1408
1409        let szl_id = b.get_u16();
1410        let _szl_idx = b.get_u16();
1411        let entry_len = b.get_u16() as usize;
1412        let entry_count = b.get_u16() as usize;
1413
1414        // Classic format (S7-300/400/1200): szl_id=0x0131, entries with 14-byte records
1415        if szl_id == 0x0131 && entry_len >= 12 && entry_count >= 1 && b.remaining() >= entry_len {
1416            let _entry_idx = b.get_u16();
1417            let max_pdu_len = b.get_u16() as u32;
1418            let max_connections = b.get_u16() as u32;
1419            let max_mpi_rate = b.get_u32();
1420            let max_bus_rate = b.get_u32();
1421            return Ok(crate::types::CpInfo {
1422                max_pdu_len,
1423                max_connections,
1424                max_mpi_rate,
1425                max_bus_rate,
1426            });
1427        }
1428
1429        // Fallback: scan for any parseable numeric data
1430        Ok(crate::types::CpInfo {
1431            max_pdu_len: 0,
1432            max_connections: 0,
1433            max_mpi_rate: 0,
1434            max_bus_rate: 0,
1435        })
1436    }
1437
1438    /// Read the rack module list (SZL ID 0x00A0).
1439    ///
1440    /// Each entry is a 2-byte module type identifier.
1441    pub async fn read_module_list(&self) -> Result<Vec<crate::types::ModuleEntry>> {
1442        let payload = self.read_szl_payload(0x00A0, 0x0000).await?;
1443        if payload.len() < 6 {
1444            return Ok(Vec::new());
1445        }
1446        let mut b = payload;
1447        let _block_len = b.get_u16();
1448        let _szl_id = b.get_u16();
1449        let _szl_ix = b.get_u16();
1450        // Skip the optional SZL entry_length prefix (2 bytes).
1451        skip_szl_entry_header(&mut b);
1452        let mut modules = Vec::new();
1453        while b.remaining() >= 2 {
1454            modules.push(crate::types::ModuleEntry {
1455                module_type: b.get_u16(),
1456            });
1457        }
1458        Ok(modules)
1459    }
1460
1461    // -- Block list & block info (via UserData grBlocksInfo) ------------------
1462
1463    /// List all blocks in the PLC grouped by type.
1464    ///
1465    /// Uses UserData function group 0x43 (grBlocksInfo), SubFun 0x01 (ListAll).
1466    /// Response: 7 entries of [Zero(1) BType(1) BCount(2)] = 28 bytes data.
1467    pub async fn list_blocks(&self) -> Result<crate::types::BlockList> {
1468        let mut inner = self.inner.lock().await;
1469        let pdu_ref = Self::next_pdu_ref(&mut inner);
1470
1471        // Params: Head[00 01 12 04] + Uk=0x11 + Tg=0x43(grBlocksInfo) + SubFun=0x01(ListAll) + Seq=0x00
1472        let param = Bytes::from_static(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x01, 0x00]);
1473        // Data: 4 bytes constant
1474        let data = Bytes::from_static(&[0x0A, 0x00, 0x00, 0x00]);
1475
1476        Self::send_s7(&mut inner, param, data, pdu_ref, PduType::UserData).await?;
1477        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1478
1479        // Skip echoed param section
1480        if body.remaining() < header.param_len as usize {
1481            return Err(Error::UnexpectedResponse);
1482        }
1483        body.advance(header.param_len as usize);
1484
1485        // Data envelope: RetVal(1) + TRSize(1) + Length(2)
1486        if body.remaining() < 4 {
1487            return Ok(crate::types::BlockList { total_count: 0, entries: Vec::new() });
1488        }
1489        let _ret_val = body.get_u8();
1490        let _tr_size = body.get_u8();
1491        let data_len = body.get_u16() as usize;
1492
1493        // 7 entries × 4 bytes = 28 bytes
1494        if data_len < 28 || body.remaining() < 28 {
1495            return Ok(crate::types::BlockList { total_count: 0, entries: Vec::new() });
1496        }
1497
1498        let mut entries = Vec::new();
1499        let mut total_count: u32 = 0;
1500        for _ in 0..7 {
1501            let _zero = body.get_u8();
1502            let block_type = body.get_u8() as u16;
1503            let count = body.get_u16();
1504            total_count += count as u32;
1505            entries.push(crate::types::BlockListEntry { block_type, count });
1506        }
1507
1508        Ok(crate::types::BlockList { total_count, entries })
1509    }
1510
1511    /// List all block numbers of a given type (grBlocksInfo / SFun_ListBoT = 0x02).
1512    ///
1513    /// `block_type` is the raw byte: 0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC,
1514    /// 0x44=SFC, 0x45=FB, 0x46=SFB.
1515    /// Returns a sorted vec of block numbers.
1516    pub async fn list_blocks_of_type(&self, block_type: u8) -> Result<Vec<u16>> {
1517        let mut numbers: Vec<u16> = Vec::new();
1518        let mut first = true;
1519        let mut seq: u8 = 0x00;
1520
1521        loop {
1522            let mut inner = self.inner.lock().await;
1523            let pdu_ref = Self::next_pdu_ref(&mut inner);
1524
1525            let (param, data) = if first {
1526                // First request: 8-byte params + 6-byte data
1527                // Params: Head[00 01 12 04] Uk=0x11 Tg=0x43 SubFun=0x02 Seq=0x00
1528                // Data:   RetVal=0xFF TSize=0x09 Length=0x0002 Zero=0x30 BlkType
1529                let mut p = BytesMut::with_capacity(8);
1530                p.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x02, 0x00]);
1531                let mut d = BytesMut::with_capacity(6);
1532                d.extend_from_slice(&[0xFF, 0x09, 0x00, 0x02, 0x30, block_type]);
1533                (p.freeze(), d.freeze())
1534            } else {
1535                // Continuation: 12-byte params + 4-byte data
1536                // Params: Head[00 01 12 08] Uk=0x12 Tg=0x43 SubFun=0x02 Seq=<seq> + 4 zero pad
1537                // Data:   0x0A 0x00 0x00 0x00
1538                let mut p = BytesMut::with_capacity(12);
1539                p.extend_from_slice(&[0x00, 0x01, 0x12, 0x08, 0x12, 0x43, 0x02, seq, 0x00, 0x00, 0x00, 0x00]);
1540                let d = Bytes::from_static(&[0x0A, 0x00, 0x00, 0x00]);
1541                (p.freeze(), d)
1542            };
1543
1544            Self::send_s7(&mut inner, param, data, pdu_ref, PduType::UserData).await?;
1545            let (header, mut body) = Self::recv_s7(&mut inner).await?;
1546
1547            // Skip echoed params
1548            if body.remaining() < header.param_len as usize {
1549                return Err(Error::UnexpectedResponse);
1550            }
1551            // Grab seq + done flag from params before advancing
1552            // ResParams layout (after S7 header): Head[3] Plen Uk Tg SubFun Seq [Rsvd(2) ErrNo(2)]
1553            // Seq is at param offset 7, Rsvd high byte at offset 8 indicates done (0x00 = done)
1554            let param_bytes = body.slice(..header.param_len as usize);
1555            let done = param_bytes.len() >= 10 && param_bytes[8] == 0x00;
1556            seq = if param_bytes.len() >= 8 { param_bytes[7] } else { 0 };
1557            body.advance(header.param_len as usize);
1558            drop(inner);
1559
1560            // Data envelope: RetVal(1) TSize(1) DataLen(2)
1561            if body.remaining() < 4 { break; }
1562            let ret_val = body.get_u8();
1563            let _tr_size = body.get_u8();
1564            let data_len = body.get_u16() as usize;
1565
1566            if ret_val != 0xFF || data_len < 4 || body.remaining() < data_len { break; }
1567
1568            // Items: each 4 bytes [BlockNum(2) Unknown(1) BlockLang(1)]
1569            // Count = (data_len - 4) / 4 + 1  (from C snap7 source)
1570            let item_count = ((data_len - 4) / 4) + 1;
1571            for _ in 0..item_count {
1572                if body.remaining() < 4 { break; }
1573                let block_num = body.get_u16();
1574                let _unknown = body.get_u8();
1575                let _lang = body.get_u8();
1576                numbers.push(block_num);
1577            }
1578
1579            first = false;
1580            if done { break; }
1581        }
1582
1583        numbers.sort_unstable();
1584        Ok(numbers)
1585    }
1586
1587    /// Internal: send a UserData block-info request (grBlocksInfo / SFun_BlkInfo=0x03).
1588    ///
1589    /// Params (8 bytes): Head[00 01 12 04] Uk=0x11 Tg=0x43 SubFun=0x03 Seq=0x00
1590    /// Data  (12 bytes): FF 09 00 08 30 <blktype> <ascii5> 41
1591    async fn block_info_query(
1592        &self,
1593        _func: u8,
1594        block_type: u8,
1595        block_number: u16,
1596    ) -> Result<Bytes> {
1597        let mut inner = self.inner.lock().await;
1598        let pdu_ref = Self::next_pdu_ref(&mut inner);
1599
1600        // Params: Head[00 01 12 04] Uk=0x11 Tg=0x43(grBlocksInfo) SubFun=0x03(BlkInfo) Seq=0x00
1601        let param = Bytes::from_static(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x03, 0x00]);
1602
1603        // Data: RetVal=0xFF TSize=0x09 DataLen=0x0008 BlkPrfx=0x30 BlkType AsciiBlk[5] A=0x41
1604        let mut data_buf = BytesMut::with_capacity(12);
1605        data_buf.extend_from_slice(&[0xFF, 0x09, 0x00, 0x08, 0x30, block_type]);
1606        // block_number as 5-digit ASCII
1607        let n = block_number as u32;
1608        data_buf.put_u8((n / 10000) as u8 + 0x30);
1609        data_buf.put_u8(((n % 10000) / 1000) as u8 + 0x30);
1610        data_buf.put_u8(((n % 1000) / 100) as u8 + 0x30);
1611        data_buf.put_u8(((n % 100) / 10) as u8 + 0x30);
1612        data_buf.put_u8((n % 10) as u8 + 0x30);
1613        data_buf.put_u8(0x41); // 'A'
1614
1615        Self::send_s7(&mut inner, param, data_buf.freeze(), pdu_ref, PduType::UserData).await?;
1616
1617        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1618
1619        // Response params: TResFunGetBlockInfo (12 bytes)
1620        // Head[3] Plen Uk Tg SubFun Seq Rsvd[2] ErrNo[2]
1621        let param_len = header.param_len as usize;
1622        if body.remaining() < param_len {
1623            return Err(Error::UnexpectedResponse);
1624        }
1625        let params = body.slice(..param_len);
1626        body.advance(param_len);
1627
1628        // Check ErrNo (bytes 10-11 of params)
1629        if params.len() >= 12 {
1630            let err_no = u16::from_be_bytes([params[10], params[11]]);
1631            if err_no != 0 {
1632                return Err(Error::PlcError {
1633                    code: err_no as u32,
1634                    message: format!("block info error: ErrNo=0x{err_no:04X}"),
1635                });
1636            }
1637        }
1638
1639        // Data envelope: RetVal(1) TSize(1) DataLen(2)
1640        if body.remaining() < 4 {
1641            return Err(Error::UnexpectedResponse);
1642        }
1643        let ret_val = body.get_u8();
1644        let _tr_size = body.get_u8();
1645        let _data_len = body.get_u16();
1646
1647        if ret_val != 0xFF {
1648            return Err(Error::PlcError {
1649                code: ret_val as u32,
1650                message: format!("block info RetVal=0x{ret_val:02X}"),
1651            });
1652        }
1653
1654        Ok(body.copy_to_bytes(body.remaining()))
1655    }
1656
1657    /// Get detailed information about a block stored on the PLC.
1658    ///
1659    /// `block_type` should be one of the [`BlockType`](crate::types::BlockType)
1660    /// discriminant values (e.g. `0x41` for DB, `0x38` for OB).
1661    pub async fn get_ag_block_info(
1662        &self,
1663        block_type: u8,
1664        block_number: u16,
1665    ) -> Result<crate::types::BlockInfo> {
1666        self.get_block_info(0x13, block_type, block_number).await
1667    }
1668
1669    /// Get detailed block information from the PG perspective.
1670    ///
1671    /// Same fields as [`get_ag_block_info`](Self::get_ag_block_info) but the
1672    /// information is from the programming-device viewpoint.
1673    pub async fn get_pg_block_info(
1674        &self,
1675        block_type: u8,
1676        block_number: u16,
1677    ) -> Result<crate::types::BlockInfo> {
1678        self.get_block_info(0x14, block_type, block_number).await
1679    }
1680
1681    /// Shared implementation for AG and PG block info.
1682    async fn get_block_info(
1683        &self,
1684        func: u8,
1685        block_type: u8,
1686        block_number: u16,
1687    ) -> Result<crate::types::BlockInfo> {
1688        let payload = self
1689            .block_info_query(func, block_type, block_number)
1690            .await?;
1691
1692        // Payload = TResDataBlockInfo fields after the 4-byte envelope (RetVal/TSize/DataLen
1693        // already consumed in block_info_query). Struct layout:
1694        //   Cst_b(1) BlkType(1) Cst_w1(2) Cst_w2(2) Cst_pp(2)
1695        //   Unknown_1(1) BlkFlags(1) BlkLang(1) SubBlkType(1) BlkNumber(2)
1696        //   LenLoadMem(4) BlkSec(4) CodeTime_ms(4) CodeTime_dy(2)
1697        //   IntfTime_ms(4) IntfTime_dy(2) SbbLen(2) AddLen(2)
1698        //   LocDataLen(2) MC7Len(2)
1699        //   Author(8) Family(8) Header(8)
1700        //   Version(1) Unknown_2(1) BlkChksum(2) Resvd1(4) Resvd2(4)
1701        // Minimum meaningful size: 40 bytes
1702        if payload.len() < 40 {
1703            return Err(Error::UnexpectedResponse);
1704        }
1705        let mut b = payload;
1706
1707        let _cst_b       = b.get_u8();
1708        let blk_type: u16 = b.get_u8().into();
1709        let _cst_w1      = b.get_u16();
1710        let _cst_w2      = b.get_u16();
1711        let _cst_pp      = b.get_u16();
1712        let _unknown_1   = b.get_u8();
1713        let flags        = b.get_u8() as u16;
1714        let language     = b.get_u8() as u16;
1715        let _sub_blk     = b.get_u8();
1716        let _blk_number  = b.get_u16(); // echoes block_number from request
1717        let len_load_mem = b.get_u32();
1718        let _blk_sec     = b.get_u32();
1719        let _code_ms     = b.get_u32();
1720        let _code_dy     = b.get_u16();
1721        let _intf_ms     = b.get_u32();
1722        let _intf_dy     = b.get_u16();
1723        let sbb_len      = b.get_u16();
1724        let _add_len     = b.get_u16();
1725        let local_data   = b.get_u16();
1726        let mc7_size     = b.get_u16();
1727
1728        fn read_str(b: &mut Bytes, n: usize) -> String {
1729            let s = b.slice(..n.min(b.remaining()));
1730            b.advance(n.min(b.remaining()));
1731            let end = s.iter().position(|&x| x == 0).unwrap_or(s.len());
1732            String::from_utf8_lossy(&s[..end]).trim().to_string()
1733        }
1734
1735        let author   = read_str(&mut b, 8);
1736        let family   = read_str(&mut b, 8);
1737        let header   = read_str(&mut b, 8);
1738        let version  = if b.remaining() >= 1 { b.get_u8() as u16 } else { 0 };
1739        let _unk2    = if b.remaining() >= 1 { b.get_u8() } else { 0 };
1740        let checksum = if b.remaining() >= 2 { b.get_u16() } else { 0 };
1741
1742        Ok(crate::types::BlockInfo {
1743            block_type: blk_type,
1744            block_number,
1745            language,
1746            flags,
1747            size: (len_load_mem.min(0xFFFF)) as u16,
1748            size_ram: sbb_len,
1749            mc7_size,
1750            local_data,
1751            checksum,
1752            version,
1753            author,
1754            family,
1755            header,
1756            date: String::new(),
1757        })
1758    }
1759
1760    /// Parse block info from raw block bytes obtained via [`upload`](Self::upload)
1761    /// or [`full_upload`](Self::full_upload). No PLC connection required.
1762    ///
1763    /// Equivalent to C `Cli_GetPgBlockInfo` offline parsing mode.
1764    pub fn parse_block_info(data: &[u8]) -> Result<crate::types::BlockInfo> {
1765        const HDR: usize = 36;
1766        const FOOTER: usize = 48;
1767        if data.len() < HDR + FOOTER {
1768            return Err(Error::UnexpectedResponse);
1769        }
1770        let load_size = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1771        if load_size != data.len() {
1772            return Err(Error::UnexpectedResponse);
1773        }
1774        let mc7_size = u16::from_be_bytes([data[34], data[35]]) as usize;
1775        if mc7_size + HDR >= load_size {
1776            return Err(Error::UnexpectedResponse);
1777        }
1778
1779        let flags        = data[3] as u16;
1780        let language     = data[4] as u16;
1781        let block_type   = data[5] as u16;
1782        let block_number = u16::from_be_bytes([data[6], data[7]]);
1783        let sbb_len      = u16::from_be_bytes([data[28], data[29]]);
1784        let local_data   = u16::from_be_bytes([data[32], data[33]]);
1785
1786        fn read_str(s: &[u8]) -> String {
1787            let end = s.iter().position(|&x| x == 0).unwrap_or(s.len());
1788            String::from_utf8_lossy(&s[..end]).trim().to_string()
1789        }
1790
1791        let footer   = &data[load_size - FOOTER..];
1792        let author   = read_str(&footer[20..28]);
1793        let family   = read_str(&footer[28..36]);
1794        let header   = read_str(&footer[36..44]);
1795        let checksum = u16::from_be_bytes([footer[44], footer[45]]);
1796
1797        Ok(crate::types::BlockInfo {
1798            block_type,
1799            block_number,
1800            language,
1801            flags,
1802            size: load_size.min(0xFFFF) as u16,
1803            size_ram: sbb_len,
1804            mc7_size: mc7_size as u16,
1805            local_data,
1806            checksum,
1807            version: 0,
1808            author,
1809            family,
1810            header,
1811            date: String::new(),
1812        })
1813    }
1814
1815    // -- Security / protection (set/clear password + get protection) ----------
1816
1817    /// Set a session password for protected PLC access.
1818    ///
1819    /// The password is obfuscated using the S7 nibble-swap + XOR-0x55 algorithm
1820    /// and sent as a Job PDU with function code 0x12.  Passwords longer than
1821    /// 8 bytes are truncated.
1822    pub async fn set_session_password(&self, password: &str) -> Result<()> {
1823        let encrypted = crate::types::encrypt_password(password);
1824        let mut inner = self.inner.lock().await;
1825        let pdu_ref = Self::next_pdu_ref(&mut inner);
1826        let param = Bytes::copy_from_slice(&[0x12, 0x00]);
1827        let data = Bytes::copy_from_slice(&encrypted);
1828        Self::send_s7(&mut inner, param, data, pdu_ref, PduType::Job).await?;
1829        let (header, _body) = Self::recv_s7(&mut inner).await?;
1830        check_plc_error(&header, "set_session_password")?;
1831        Ok(())
1832    }
1833
1834    /// Clear the session password on the PLC (function code 0x11).
1835    pub async fn clear_session_password(&self) -> Result<()> {
1836        let mut inner = self.inner.lock().await;
1837        let pdu_ref = Self::next_pdu_ref(&mut inner);
1838        let param = Bytes::copy_from_slice(&[0x11, 0x00]);
1839        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::Job).await?;
1840        let (header, _body) = Self::recv_s7(&mut inner).await?;
1841        check_plc_error(&header, "clear_session_password")?;
1842        Ok(())
1843    }
1844
1845    /// Read the current protection level (SZL ID 0x0032, index 0x0004).
1846    ///
1847    /// Returns the protection scheme identifiers and level;
1848    /// `password_set` is `true` when the PLC reports a non-empty password.
1849    pub async fn get_protection(&self) -> Result<crate::types::Protection> {
1850        let payload = self.read_szl_payload(0x0032, 0x0004).await?;
1851        if payload.len() < 14 {
1852            return Err(Error::UnexpectedResponse);
1853        }
1854        let mut b = payload;
1855        let _block_len = b.get_u16();
1856        let _szl_id = b.get_u16();
1857        let _szl_ix = b.get_u16();
1858        // Skip the optional SZL entry_length prefix (2 bytes).
1859        skip_szl_entry_header(&mut b);
1860        let scheme_szl = b.get_u16();
1861        let scheme_module = b.get_u16();
1862        let scheme_bus = b.get_u16();
1863        let level = b.get_u16();
1864        // Next 8 bytes = pass_word field ("PASSWORD" if set, spaces otherwise)
1865        let pass_wort = if b.remaining() >= 8 {
1866            String::from_utf8_lossy(&b[..8]).trim().to_string()
1867        } else {
1868            String::new()
1869        };
1870        let password_set = pass_wort.eq_ignore_ascii_case("PASSWORD");
1871        Ok(crate::types::Protection {
1872            scheme_szl,
1873            scheme_module,
1874            scheme_bus,
1875            level,
1876            password_set,
1877        })
1878    }
1879
1880    // -- Block upload / download / delete ------------------------------------
1881    //
1882    // S7 function 0x1D = Upload  (sub-fn: 0=start, 1=data, 2=end)
1883    // S7 function 0x1E = Download (sub-fn: 0=start, 1=data, 2=end)
1884    // S7 function 0x1F = Delete
1885
1886    /// Delete a block from the PLC (S7 function code 0x1F).
1887    pub async fn delete_block(&self, block_type: u8, block_number: u16) -> Result<()> {
1888        let mut inner = self.inner.lock().await;
1889        let pdu_ref = Self::next_pdu_ref(&mut inner);
1890        // param: [0x1F, 0x00, block_type, 0x00, block_number(2)]
1891        let mut param = BytesMut::with_capacity(6);
1892        param.extend_from_slice(&[0x1F, 0x00, block_type, 0x00]);
1893        param.put_u16(block_number);
1894        Self::send_s7(
1895            &mut inner,
1896            param.freeze(),
1897            Bytes::new(),
1898            pdu_ref,
1899            PduType::Job,
1900        )
1901        .await?;
1902        let (header, _body) = Self::recv_s7(&mut inner).await?;
1903        check_plc_error(&header, "delete_block")?;
1904        Ok(())
1905    }
1906
1907    /// Upload a PLC block via S7 PI-Upload (function 0x1D).
1908    ///
1909    /// Returns the raw block bytes in Diagra format (20-byte header + payload).
1910    /// Use [`BlockData::from_bytes`] to parse the result.
1911    pub async fn upload(&self, block_type: u8, block_number: u16) -> Result<Vec<u8>> {
1912        let mut inner = self.inner.lock().await;
1913        let pdu_ref = Self::next_pdu_ref(&mut inner);
1914
1915        // --- Step 1: Start upload (sub-fn=0x00) ---
1916        // param: [0x1D, 0x00, block_type, 0x00, block_number(2)]
1917        let mut param = BytesMut::with_capacity(6);
1918        param.extend_from_slice(&[0x1D, 0x00, block_type, 0x00]);
1919        param.put_u16(block_number);
1920        Self::send_s7(
1921            &mut inner,
1922            param.freeze(),
1923            Bytes::new(),
1924            pdu_ref,
1925            PduType::Job,
1926        )
1927        .await?;
1928        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1929        check_plc_error(&header, "upload_start")?;
1930        // Response data: [upload_id(4)][total_len(4)]
1931        if body.remaining() < 8 {
1932            return Err(Error::UnexpectedResponse);
1933        }
1934        if body.remaining() >= 2 {
1935            body.advance(2); // skip param echo
1936        }
1937        let upload_id = body.get_u32();
1938        let _total_len = body.get_u32();
1939
1940        // --- Step 2: Loop data chunks (sub-fn=0x01) ---
1941        let mut block_data = Vec::new();
1942        loop {
1943            let chunk_pdu_ref = Self::next_pdu_ref(&mut inner);
1944            let mut dparam = BytesMut::with_capacity(6);
1945            dparam.extend_from_slice(&[0x1D, 0x01]);
1946            dparam.put_u32(upload_id);
1947            Self::send_s7(
1948                &mut inner,
1949                dparam.freeze(),
1950                Bytes::new(),
1951                chunk_pdu_ref,
1952                PduType::Job,
1953            )
1954            .await?;
1955            let (dheader, mut dbody) = Self::recv_s7(&mut inner).await?;
1956            check_plc_error(&dheader, "upload_data")?;
1957            // Skip param echo
1958            if dbody.remaining() >= 2 {
1959                dbody.advance(2);
1960            }
1961            if dbody.is_empty() {
1962                break; // no more data
1963            }
1964            // The first data PDU may have a 4-byte "data header" before the actual block data
1965            // (return_code + transport + bit_len).  Skip it.
1966            if block_data.is_empty() && dbody.remaining() >= 4 {
1967                // Peek at the first byte — if it looks like a return_code (0xFF), skip 4
1968                if dbody[0] == 0xFF || dbody[0] == 0x00 {
1969                    dbody.advance(4);
1970                }
1971            }
1972            let chunk = dbody.copy_to_bytes(dbody.remaining());
1973            block_data.extend_from_slice(&chunk);
1974
1975            // If this chunk was smaller than PDU size, it's the last one
1976            if chunk.len() < inner.connection.pdu_size as usize - 50 {
1977                break;
1978            }
1979            // Safety: prevent infinite loop on broken PLC
1980            if block_data.len() > 1024 * 1024 * 4 {
1981                // 4 MB
1982                return Err(Error::UnexpectedResponse);
1983            }
1984        }
1985
1986        // --- Step 3: End upload (sub-fn=0x02) ---
1987        let end_pdu_ref = Self::next_pdu_ref(&mut inner);
1988        let mut eparam = BytesMut::with_capacity(6);
1989        eparam.extend_from_slice(&[0x1D, 0x02]);
1990        eparam.put_u32(upload_id);
1991        Self::send_s7(
1992            &mut inner,
1993            eparam.freeze(),
1994            Bytes::new(),
1995            end_pdu_ref,
1996            PduType::Job,
1997        )
1998        .await?;
1999        let (eheader, _ebody) = Self::recv_s7(&mut inner).await?;
2000        check_plc_error(&eheader, "upload_end")?;
2001
2002        Ok(block_data)
2003    }
2004
2005    /// Full-upload a PLC block including MC7 (executable) code (S7 function 0x1F).
2006    ///
2007    /// Unlike [`upload`](Self::upload) which returns only the header/interface,
2008    /// `full_upload` returns the complete block including executable code.
2009    pub async fn full_upload(&self, block_type: u8, block_number: u16) -> Result<Vec<u8>> {
2010        let mut inner = self.inner.lock().await;
2011        let pdu_ref = Self::next_pdu_ref(&mut inner);
2012
2013        // Step 1: Start full-upload (func=0x1F, sub-fn=0x00)
2014        let mut param = BytesMut::with_capacity(6);
2015        param.extend_from_slice(&[0x1F, 0x00, block_type, 0x00]);
2016        param.put_u16(block_number);
2017        Self::send_s7(&mut inner, param.freeze(), Bytes::new(), pdu_ref, PduType::Job).await?;
2018        let (header, mut body) = Self::recv_s7(&mut inner).await?;
2019        check_plc_error(&header, "full_upload_start")?;
2020        if body.remaining() < 8 {
2021            return Err(Error::UnexpectedResponse);
2022        }
2023        if body.remaining() >= 2 {
2024            body.advance(2);
2025        }
2026        let upload_id = body.get_u32();
2027        let _total_len = body.get_u32();
2028
2029        // Step 2: Loop data chunks (func=0x1F, sub-fn=0x01)
2030        let mut block_data = Vec::new();
2031        loop {
2032            let chunk_ref = Self::next_pdu_ref(&mut inner);
2033            let mut dparam = BytesMut::with_capacity(6);
2034            dparam.extend_from_slice(&[0x1F, 0x01]);
2035            dparam.put_u32(upload_id);
2036            Self::send_s7(&mut inner, dparam.freeze(), Bytes::new(), chunk_ref, PduType::Job).await?;
2037            let (dheader, mut dbody) = Self::recv_s7(&mut inner).await?;
2038            check_plc_error(&dheader, "full_upload_data")?;
2039            if dbody.remaining() >= 2 {
2040                dbody.advance(2);
2041            }
2042            if dbody.is_empty() {
2043                break;
2044            }
2045            if block_data.is_empty() && dbody.remaining() >= 4 {
2046                if dbody[0] == 0xFF || dbody[0] == 0x00 {
2047                    dbody.advance(4);
2048                }
2049            }
2050            let chunk = dbody.copy_to_bytes(dbody.remaining());
2051            block_data.extend_from_slice(&chunk);
2052            if chunk.len() < inner.connection.pdu_size as usize - 50 {
2053                break;
2054            }
2055            if block_data.len() > 1024 * 1024 * 4 {
2056                return Err(Error::UnexpectedResponse);
2057            }
2058        }
2059
2060        // Step 3: End full-upload (func=0x1F, sub-fn=0x02)
2061        let end_ref = Self::next_pdu_ref(&mut inner);
2062        let mut eparam = BytesMut::with_capacity(6);
2063        eparam.extend_from_slice(&[0x1F, 0x02]);
2064        eparam.put_u32(upload_id);
2065        Self::send_s7(&mut inner, eparam.freeze(), Bytes::new(), end_ref, PduType::Job).await?;
2066        let (eheader, _) = Self::recv_s7(&mut inner).await?;
2067        check_plc_error(&eheader, "full_upload_end")?;
2068
2069        Ok(block_data)
2070    }
2071
2072    /// Return the negotiated PDU length in bytes.
2073    pub async fn get_pdu_length(&self) -> u16 {
2074        self.inner.lock().await.connection.pdu_size
2075    }
2076
2077    /// Upload a DB block (convenience wrapper around [`upload`](Self::upload)).
2078    pub async fn db_get(&self, db_number: u16) -> Result<Vec<u8>> {
2079        self.upload(0x41, db_number).await // Block_DB = 0x41
2080    }
2081
2082    /// Download a block to the PLC (S7 function 0x1E).
2083    ///
2084    /// `data` should be in Diagra format (20-byte header + payload, as returned by
2085    /// [`upload`](Self::upload) or built via [`BlockData::to_bytes`]).
2086    pub async fn download(&self, block_type: u8, block_number: u16, data: &[u8]) -> Result<()> {
2087        let total_len = data.len() as u32;
2088        let mut inner = self.inner.lock().await;
2089        let pdu_avail = (inner.connection.pdu_size as usize).saturating_sub(50);
2090
2091        // --- Step 1: Start download (sub-fn=0x00) ---
2092        let start_ref = Self::next_pdu_ref(&mut inner);
2093        // param: [0x1E, 0x00, block_type, 0x00, block_number(2), total_len(4)]
2094        let mut sparam = BytesMut::with_capacity(10);
2095        sparam.extend_from_slice(&[0x1E, 0x00, block_type, 0x00]);
2096        sparam.put_u16(block_number);
2097        sparam.put_u32(total_len);
2098
2099        // First data chunk
2100        let chunk_len = pdu_avail.min(data.len());
2101        let first_chunk = Bytes::copy_from_slice(&data[..chunk_len]);
2102        Self::send_s7(
2103            &mut inner,
2104            sparam.freeze(),
2105            first_chunk,
2106            start_ref,
2107            PduType::Job,
2108        )
2109        .await?;
2110
2111        let (sheader, mut sbody) = Self::recv_s7(&mut inner).await?;
2112        check_plc_error(&sheader, "download_start")?;
2113        // Response: [download_id(4)]
2114        if sbody.remaining() >= 2 {
2115            sbody.advance(2); // skip param echo
2116        }
2117        if sbody.remaining() < 4 {
2118            return Err(Error::UnexpectedResponse);
2119        }
2120        let download_id = sbody.get_u32();
2121
2122        let mut offset = chunk_len;
2123
2124        // --- Step 2: Send remaining data chunks (sub-fn=0x01) ---
2125        while offset < data.len() {
2126            let chunk_ref = Self::next_pdu_ref(&mut inner);
2127            let end = (offset + pdu_avail).min(data.len());
2128            let chunk = Bytes::copy_from_slice(&data[offset..end]);
2129
2130            let mut dparam = BytesMut::with_capacity(6);
2131            dparam.extend_from_slice(&[0x1E, 0x01]);
2132            dparam.put_u32(download_id);
2133
2134            Self::send_s7(
2135                &mut inner,
2136                dparam.freeze(),
2137                chunk,
2138                chunk_ref,
2139                PduType::Job,
2140            )
2141            .await?;
2142
2143            let (dheader, _dbody) = Self::recv_s7(&mut inner).await?;
2144            check_plc_error(&dheader, "download_data")?;
2145            offset = end;
2146        }
2147
2148        // --- Step 3: End download (sub-fn=0x02) ---
2149        let end_ref = Self::next_pdu_ref(&mut inner);
2150        let mut eparam = BytesMut::with_capacity(6);
2151        eparam.extend_from_slice(&[0x1E, 0x02]);
2152        eparam.put_u32(download_id);
2153        Self::send_s7(
2154            &mut inner,
2155            eparam.freeze(),
2156            Bytes::new(),
2157            end_ref,
2158            PduType::Job,
2159        )
2160        .await?;
2161        let (eheader, _ebody) = Self::recv_s7(&mut inner).await?;
2162        check_plc_error(&eheader, "download_end")?;
2163
2164        Ok(())
2165    }
2166
2167    /// Create a new empty DB on the PLC.
2168    ///
2169    /// Downloads a minimal zero-filled DB block.  The PLC must be in STOP mode
2170    /// or support online DB creation (S7-400 / S7-1500).  If `attrs` is
2171    /// `Some`, the block attributes are applied before download.
2172    pub async fn create_db(
2173        &self,
2174        db_number: u16,
2175        size_bytes: u16,
2176        attrs: Option<&crate::types::BlockAttributes>,
2177    ) -> Result<()> {
2178        let mut block = crate::types::BlockData::new_db(db_number, size_bytes);
2179        if let Some(a) = attrs {
2180            block.set_attributes(a);
2181        }
2182        let bytes = block.to_bytes();
2183        self.download(crate::types::BlockType::DB as u8, db_number, &bytes).await
2184    }
2185
2186    /// Compare local block data against blocks currently on the PLC.
2187    ///
2188    /// For each `(block_type, block_number, local_bytes)` entry in `local`,
2189    /// uploads the corresponding PLC block and compares CRC-32 checksums.
2190    /// Also reports blocks that exist only on the PLC (missing locally) when
2191    /// `report_plc_only` is `true`.
2192    pub async fn compare_blocks(
2193        &self,
2194        local: &[(u8, u16, Vec<u8>)],
2195        report_plc_only: bool,
2196    ) -> Result<Vec<(u8, u16, crate::types::BlockCmpResult)>> {
2197        use std::collections::HashMap;
2198        use crate::types::{BlockCmpResult, BlockData};
2199
2200        // Build local lookup: (type, num) → crc32
2201        let local_map: HashMap<(u8, u16), u32> = local
2202            .iter()
2203            .filter_map(|(bt, bn, bytes)| {
2204                BlockData::from_bytes(bytes).map(|bd| ((*bt, *bn), bd.crc32()))
2205            })
2206            .collect();
2207
2208        let mut out = Vec::new();
2209
2210        // For each local block, upload PLC version and compare
2211        for (bt, bn, local_bytes) in local {
2212            let local_crc = match BlockData::from_bytes(local_bytes) {
2213                Some(bd) => bd.crc32(),
2214                None => continue,
2215            };
2216            match self.full_upload(*bt, *bn).await {
2217                Ok(plc_bytes) => {
2218                    let plc_crc = BlockData::from_bytes(&plc_bytes)
2219                        .map(|bd| bd.crc32())
2220                        .unwrap_or(0);
2221                    let result = if local_crc == plc_crc {
2222                        BlockCmpResult::Match
2223                    } else {
2224                        BlockCmpResult::Mismatch { local_crc, plc_crc }
2225                    };
2226                    out.push((*bt, *bn, result));
2227                }
2228                Err(_) => {
2229                    out.push((*bt, *bn, BlockCmpResult::OnlyLocal));
2230                }
2231            }
2232        }
2233
2234        // Report PLC-only blocks
2235        if report_plc_only {
2236            for bt in &[0x38u8, 0x41, 0x43, 0x45] {
2237                let numbers = self.list_blocks_of_type(*bt).await?;
2238                for num in numbers {
2239                    if !local_map.contains_key(&(*bt, num)) {
2240                        out.push((*bt, num, BlockCmpResult::OnlyPlc));
2241                    }
2242                }
2243            }
2244        }
2245
2246        Ok(out)
2247    }
2248
2249    /// Fill a DB with a constant byte value.
2250    ///
2251    /// Uses [`get_ag_block_info`](Self::get_ag_block_info) to determine the DB
2252    /// size, then writes every byte to `value`.
2253    pub async fn db_fill(&self, db_number: u16, value: u8) -> Result<()> {
2254        let info = self.get_ag_block_info(0x41, db_number).await?; // Block_DB = 0x41
2255        let size = info.size as usize;
2256        if size == 0 {
2257            return Err(Error::PlcError {
2258                code: 0,
2259                message: format!("DB{db_number} has zero size"),
2260            });
2261        }
2262        let data = vec![value; size];
2263        // Write in chunks to respect PDU limits
2264        let chunk_size = 240usize; // conservative
2265        for offset in (0..size).step_by(chunk_size) {
2266            let end = (offset + chunk_size).min(size);
2267            self.db_write(db_number, offset as u32, &data[offset..end])
2268                .await?;
2269        }
2270        Ok(())
2271    }
2272}
2273
2274/// If the leading bytes look like an SZL entry_length header (2-byte big-endian u16
2275/// length value where the high byte is zero), skip them.  Real Siemens PLCs include
2276/// this header; our test server omits it.
2277fn skip_szl_entry_header(data: &mut Bytes) {
2278    if data.len() >= 2 && data[0] == 0x00 && data[1] > 0 && data[1] <= 200 {
2279        data.advance(2);
2280    }
2281}
2282
2283/// Scan byte data for sequences of visible ASCII characters and return them
2284/// as a vector of trimmed strings.  Skips non-ASCII and control bytes between
2285/// sequences.  Useful for extracting CPU info fields from SZL responses across
2286/// different PLC models and firmware versions.
2287fn scan_ascii_fields(data: &[u8], max_count: usize, min_len: usize) -> Vec<String> {
2288    let mut fields = Vec::new();
2289    let mut i = 0;
2290    while i < data.len() && fields.len() < max_count {
2291        // Skip bytes that are not visible ASCII (0x20-0x7E)
2292        if !data[i].is_ascii_graphic() && data[i] != b' ' {
2293            i += 1;
2294            continue;
2295        }
2296        // Collect a run of visible ASCII
2297        let start = i;
2298        while i < data.len() && (data[i].is_ascii_graphic() || data[i] == b' ') {
2299            i += 1;
2300        }
2301        let s = String::from_utf8_lossy(&data[start..i]).trim().to_string();
2302        if s.len() >= min_len {
2303            fields.push(s);
2304        }
2305    }
2306    fields
2307}
2308
2309/// Parse the S7-300 sub-record format used in SZL 0x001C responses.
2310///
2311/// This format uses tagged records: `[00 <tag> <string>] ...` where
2312/// known tags are:
2313/// - 0x01: order code / module identification
2314/// - 0x05: plant identification (AS name)
2315/// - 0x06: serial number
2316/// - 0x07: module type name
2317/// - 0x08: module name
2318fn parse_sub_record_fields(b: &[u8]) -> (String, String, String, String, String) {
2319    let mut module_type = String::new();
2320    let mut serial_number = String::new();
2321    let mut as_name = String::new();
2322    let mut copyright = String::new();
2323    let mut module_name = String::new();
2324
2325    let mut i = 0;
2326    while i + 2 < b.len() {
2327        // Look for 00 <tag> pattern with a known sub-record tag (1..=8)
2328        if b[i] == 0x00 && (1..=8).contains(&b[i + 1]) {
2329            let tag = b[i + 1];
2330            let start = i + 2;
2331
2332            // Find end of string: next 0x00 byte (including 00 C0)
2333            let mut end = start;
2334            while end < b.len() && b[end] != 0x00 {
2335                end += 1;
2336            }
2337
2338            let raw = &b[start..end];
2339            let val = String::from_utf8_lossy(raw).trim().to_string();
2340
2341            // Skip empty and firmware-label values
2342            let su = val.to_uppercase();
2343            if !val.is_empty() && !su.contains("BOOT") && !su.starts_with("P B") {
2344                match tag {
2345                    0x01 => {
2346                        // Tag 0x01 may be order code (starts with "6ES") or module type.
2347                        if !val.starts_with("6ES") && module_type.is_empty() {
2348                            module_type = val;
2349                        }
2350                    }
2351                    0x05 => { if as_name.is_empty() { as_name = val; } }
2352                    0x06 => { if serial_number.is_empty() { serial_number = val; } }
2353                    0x07 => { if module_type.is_empty() { module_type = val; } }
2354                    0x08 => { if module_name.is_empty() { module_name = val; } }
2355                    _ => {}
2356                }
2357            }
2358
2359            i = end;
2360        } else {
2361            i += 1;
2362        }
2363    }
2364
2365    // Also scan for free-standing printable strings that look like copyright
2366    // (e.g. "Boot Loader" appearing after the tagged records).
2367    if copyright.is_empty() {
2368        let mut scan = 0;
2369        while scan < b.len() {
2370            if b[scan].is_ascii_graphic() || b[scan] == b' ' {
2371                let s = scan;
2372                while scan < b.len() && (b[scan].is_ascii_graphic() || b[scan] == b' ') {
2373                    scan += 1;
2374                }
2375                let val = String::from_utf8_lossy(&b[s..scan]).trim().to_string();
2376                let su = val.to_uppercase();
2377                if val.len() >= 3 {
2378                    if su.contains("BOOT") || su.starts_with("P B") {
2379                        copyright = val;
2380                        break;
2381                    }
2382                }
2383            } else {
2384                scan += 1;
2385            }
2386        }
2387    }
2388
2389    (module_type, serial_number, as_name, copyright, module_name)
2390}
2391
2392/// Determine the S7 protocol variant from the raw SZL payload and extracted module type.
2393///
2394/// - S7-1200/1500/ET200SP uses S7+ protocol: detected from the 0x00 0x01 record marker in the
2395///   payload, or from a module_type containing `"15"` in its model number.
2396/// - Everything else (S7-300, S7-400, S7-1200) uses classic S7 protocol.
2397fn detect_protocol(_payload: &[u8], module_type: &str) -> crate::types::Protocol {
2398    // S7+ protocol: S7-1200, S7-1500, ET 200SP CPU
2399    // Classic S7: S7-300, S7-400
2400    let upper = module_type.to_uppercase();
2401    let is_s7plus = upper.contains("1500")
2402        || upper.contains("1200")
2403        || upper.contains("ET 200SP")
2404        || upper.contains("ET200SP")
2405        // "CPU 15xx" catches 1511, 1513, 1515, 1516, 1517, 1518
2406        || (upper.contains("CPU") && {
2407            let after_cpu = upper.find("CPU").map(|i| &upper[i+3..]).unwrap_or("");
2408            let num: String = after_cpu.chars().skip_while(|c| !c.is_ascii_digit()).take_while(|c| c.is_ascii_digit()).collect();
2409            matches!(num.get(..2), Some("12") | Some("15"))
2410        });
2411
2412    if is_s7plus {
2413        crate::types::Protocol::S7Plus
2414    } else {
2415        crate::types::Protocol::S7
2416    }
2417}
2418
2419
2420/// Decode common S7 protocol error class/code pairs into human-readable descriptions.
2421fn s7_error_description(ec: u8, ecd: u8) -> &'static str {
2422    match (ec, ecd) {
2423        (0x81, 0x04) => "function not supported or access denied by PLC",
2424        (0x81, 0x01) => "reserved by HW or SW function not available",
2425        (0x82, 0x04) => "PLC is in STOP mode, function not possible",
2426        (0x05, 0x01) => "invalid block type number",
2427        (0xD2, 0x01) => "object already exists, download rejected",
2428        (0xD2, 0x02) => "object does not exist, upload failed",
2429        (0xD6, 0x01) => "password protection violation",
2430        (0xD6, 0x05) => "insufficient privilege for this operation",
2431        _ => "unknown error",
2432    }
2433}
2434
2435fn check_plc_error(header: &S7Header, context: &str) -> Result<()> {
2436    if let (Some(ec), Some(ecd)) = (header.error_class, header.error_code) {
2437        if ec != 0 || ecd != 0 {
2438            let detail = s7_error_description(ec, ecd);
2439            return Err(Error::PlcError {
2440                code: ((ec as u32) << 8) | ecd as u32,
2441                message: format!("{}: {} (error_class=0x{ec:02X}, error_code=0x{ecd:02X})", context, detail),
2442            });
2443        }
2444    }
2445    Ok(())
2446}
2447
2448impl S7Client<crate::transport::TcpTransport> {
2449    pub async fn connect(addr: SocketAddr, params: ConnectParams) -> Result<Self> {
2450        let transport =
2451            crate::transport::TcpTransport::connect(addr, params.connect_timeout).await?;
2452        let mut client = Self::from_transport(transport, params).await?;
2453        client.remote_addr = Some(addr);
2454        Ok(client)
2455    }
2456
2457    /// Re-establish the TCP connection and S7 negotiate handshake after a disconnect.
2458    ///
2459    /// On success the client resumes normal operation. Returns an error if the
2460    /// reconnection attempt fails (caller may retry with back-off).
2461    pub async fn reconnect(&self) -> Result<()> {
2462        let addr = self.remote_addr.ok_or(Error::ConnectionRefused)?;
2463        let transport =
2464            crate::transport::TcpTransport::connect(addr, self.params.connect_timeout).await?;
2465        let mut t = transport;
2466        let connection = connect(&mut t, &self.params).await?;
2467        let mut inner = self.inner.lock().await;
2468        inner.transport = t;
2469        inner.connection = connection;
2470        inner.pdu_ref = 1;
2471        inner.connected = true;
2472        inner.job_start = None;
2473        inner.last_exec_ms = 0;
2474        Ok(())
2475    }
2476}
2477
2478impl S7Client<crate::UdpTransport> {
2479    /// Connect to a PLC using UDP transport.
2480    pub async fn connect_udp(addr: SocketAddr, params: ConnectParams) -> Result<Self> {
2481        let transport = crate::UdpTransport::connect(addr)
2482            .await
2483            .map_err(Error::Io)?;
2484        Self::from_transport(transport, params).await
2485    }
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490    use super::*;
2491    use bytes::BufMut;
2492    use crate::proto::{
2493        cotp::CotpPdu,
2494        s7::{
2495            header::{PduType, S7Header},
2496            negotiate::NegotiateResponse,
2497        },
2498        tpkt::TpktFrame,
2499    };
2500    use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
2501
2502    async fn mock_plc_db_read(mut server_io: tokio::io::DuplexStream, response_data: Vec<u8>) {
2503        let mut buf = vec![0u8; 4096];
2504
2505        // respond to COTP CR
2506        let _ = server_io.read(&mut buf).await;
2507        let cc = CotpPdu::ConnectConfirm {
2508            dst_ref: 1,
2509            src_ref: 1,
2510        };
2511        let mut cb = BytesMut::new();
2512        cc.encode(&mut cb);
2513        let mut tb = BytesMut::new();
2514        TpktFrame {
2515            payload: cb.freeze(),
2516        }
2517        .encode(&mut tb)
2518        .unwrap();
2519        server_io.write_all(&tb).await.unwrap();
2520
2521        // respond to S7 negotiate
2522        let _ = server_io.read(&mut buf).await;
2523        let neg = NegotiateResponse {
2524            max_amq_calling: 1,
2525            max_amq_called: 1,
2526            pdu_length: 480,
2527        };
2528        let mut s7b = BytesMut::new();
2529        S7Header {
2530            pdu_type: PduType::AckData,
2531            reserved: 0,
2532            pdu_ref: 1,
2533            param_len: 8,
2534            data_len: 0,
2535            error_class: Some(0),
2536            error_code: Some(0),
2537        }
2538        .encode(&mut s7b);
2539        neg.encode(&mut s7b);
2540        let dt = CotpPdu::Data {
2541            tpdu_nr: 0,
2542            last: true,
2543            payload: s7b.freeze(),
2544        };
2545        let mut cb = BytesMut::new();
2546        dt.encode(&mut cb);
2547        let mut tb = BytesMut::new();
2548        TpktFrame {
2549            payload: cb.freeze(),
2550        }
2551        .encode(&mut tb)
2552        .unwrap();
2553        server_io.write_all(&tb).await.unwrap();
2554
2555        // respond to db_read
2556        let _ = server_io.read(&mut buf).await;
2557        let mut s7b = BytesMut::new();
2558        S7Header {
2559            pdu_type: PduType::AckData,
2560            reserved: 0,
2561            pdu_ref: 2,
2562            param_len: 2,
2563            data_len: (4 + response_data.len()) as u16,
2564            error_class: Some(0),
2565            error_code: Some(0),
2566        }
2567        .encode(&mut s7b);
2568        s7b.extend_from_slice(&[0x04, 0x01]); // ReadVar func + 1 item
2569        s7b.put_u8(0xFF); // return_code = success
2570        s7b.put_u8(0x04); // transport = word
2571        s7b.put_u16((response_data.len() * 8) as u16);
2572        s7b.extend_from_slice(&response_data);
2573        let dt = CotpPdu::Data {
2574            tpdu_nr: 0,
2575            last: true,
2576            payload: s7b.freeze(),
2577        };
2578        let mut cb = BytesMut::new();
2579        dt.encode(&mut cb);
2580        let mut tb = BytesMut::new();
2581        TpktFrame {
2582            payload: cb.freeze(),
2583        }
2584        .encode(&mut tb)
2585        .unwrap();
2586        server_io.write_all(&tb).await.unwrap();
2587    }
2588
2589    #[tokio::test]
2590    async fn db_read_returns_data() {
2591        let (client_io, server_io) = duplex(4096);
2592        let params = ConnectParams::default();
2593        let expected = vec![0xDE, 0xAD, 0xBE, 0xEF];
2594        tokio::spawn(mock_plc_db_read(server_io, expected.clone()));
2595        let client = S7Client::from_transport(client_io, params).await.unwrap();
2596        let data = client.db_read(1, 0, 4).await.unwrap();
2597        assert_eq!(&data[..], &expected[..]);
2598    }
2599
2600    /// Mock that handles COTP+Negotiate handshake then serves one multi-read response.
2601    async fn mock_plc_multi_read(
2602        mut server_io: tokio::io::DuplexStream,
2603        items: Vec<Vec<u8>>, // one byte vec per item
2604    ) {
2605        let mut buf = vec![0u8; 4096];
2606
2607        // COTP CR
2608        let _ = server_io.read(&mut buf).await;
2609        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2610        let mut cb = BytesMut::new();
2611        cc.encode(&mut cb);
2612        let mut tb = BytesMut::new();
2613        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2614        server_io.write_all(&tb).await.unwrap();
2615
2616        // S7 Negotiate
2617        let _ = server_io.read(&mut buf).await;
2618        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: 480 };
2619        let mut s7b = BytesMut::new();
2620        S7Header {
2621            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2622            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2623        }.encode(&mut s7b);
2624        neg.encode(&mut s7b);
2625        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2626        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2627        let mut tb = BytesMut::new();
2628        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2629        server_io.write_all(&tb).await.unwrap();
2630
2631        // ReadMultiVar request
2632        let _ = server_io.read(&mut buf).await;
2633
2634        // Build response data: one DataItem per input item
2635        let item_count = items.len() as u8;
2636        let mut data_bytes = BytesMut::new();
2637        for item_data in &items {
2638            data_bytes.put_u8(0xFF); // return_code OK
2639            data_bytes.put_u8(0x04); // transport byte
2640            data_bytes.put_u16((item_data.len() * 8) as u16);
2641            data_bytes.extend_from_slice(item_data);
2642            if item_data.len() % 2 != 0 {
2643                data_bytes.put_u8(0x00); // pad
2644            }
2645        }
2646        let data_len = data_bytes.len() as u16;
2647        let mut s7b = BytesMut::new();
2648        S7Header {
2649            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
2650            param_len: 2, data_len, error_class: Some(0), error_code: Some(0),
2651        }.encode(&mut s7b);
2652        s7b.extend_from_slice(&[0x04, item_count]); // func + item_count
2653        s7b.extend_from_slice(&data_bytes);
2654
2655        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2656        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2657        let mut tb = BytesMut::new();
2658        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2659        server_io.write_all(&tb).await.unwrap();
2660    }
2661
2662    #[tokio::test]
2663    async fn read_multi_vars_returns_all_items() {
2664        let (client_io, server_io) = duplex(4096);
2665        let params = ConnectParams::default();
2666        let item1 = vec![0xDE, 0xAD, 0xBE, 0xEF];
2667        let item2 = vec![0x01, 0x02];
2668        tokio::spawn(mock_plc_multi_read(server_io, vec![item1.clone(), item2.clone()]));
2669        let client = S7Client::from_transport(client_io, params).await.unwrap();
2670        let items = [MultiReadItem::db(1, 0, 4), MultiReadItem::db(2, 10, 2)];
2671        let results = client.read_multi_vars(&items).await.unwrap();
2672        assert_eq!(results.len(), 2);
2673        assert_eq!(&results[0][..], &item1[..]);
2674        assert_eq!(&results[1][..], &item2[..]);
2675    }
2676
2677    #[tokio::test]
2678    async fn read_multi_vars_empty_returns_empty() {
2679        let (client_io, server_io) = duplex(4096);
2680        let params = ConnectParams::default();
2681        tokio::spawn(mock_plc_multi_read(server_io, vec![]));
2682        let client = S7Client::from_transport(client_io, params).await.unwrap();
2683        let results = client.read_multi_vars(&[]).await.unwrap();
2684        assert!(results.is_empty());
2685    }
2686
2687    /// Mock that handles COTP+Negotiate then serves N write-response round-trips.
2688    /// `batches` is a list of item counts per round-trip; the mock sends 0xFF for each.
2689    async fn mock_plc_multi_write(
2690        mut server_io: tokio::io::DuplexStream,
2691        pdu_size: u16,
2692        batches: Vec<usize>,
2693    ) {
2694        let mut buf = vec![0u8; 65536];
2695
2696        // COTP CR
2697        let _ = server_io.read(&mut buf).await;
2698        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2699        let mut cb = BytesMut::new(); cc.encode(&mut cb);
2700        let mut tb = BytesMut::new();
2701        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2702        server_io.write_all(&tb).await.unwrap();
2703
2704        // S7 Negotiate
2705        let _ = server_io.read(&mut buf).await;
2706        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: pdu_size };
2707        let mut s7b = BytesMut::new();
2708        S7Header {
2709            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2710            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2711        }.encode(&mut s7b);
2712        neg.encode(&mut s7b);
2713        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2714        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2715        let mut tb = BytesMut::new();
2716        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2717        server_io.write_all(&tb).await.unwrap();
2718
2719        // One round-trip per batch
2720        for (i, item_count) in batches.iter().enumerate() {
2721            let _ = server_io.read(&mut buf).await;
2722            // WriteVar response: param = func(0x05) + count; data = return_code per item
2723            let mut s7b = BytesMut::new();
2724            S7Header {
2725                pdu_type: PduType::AckData, reserved: 0, pdu_ref: (i + 2) as u16,
2726                param_len: 2, data_len: *item_count as u16,
2727                error_class: Some(0), error_code: Some(0),
2728            }.encode(&mut s7b);
2729            s7b.extend_from_slice(&[0x05, *item_count as u8]); // func + count
2730            for _ in 0..*item_count {
2731                s7b.put_u8(0xFF); // success
2732            }
2733            let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2734            let mut cb = BytesMut::new(); dt.encode(&mut cb);
2735            let mut tb = BytesMut::new();
2736            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2737            server_io.write_all(&tb).await.unwrap();
2738        }
2739    }
2740
2741    #[tokio::test]
2742    async fn write_multi_vars_returns_ok() {
2743        let (client_io, server_io) = duplex(65536);
2744        let params = ConnectParams::default();
2745        tokio::spawn(mock_plc_multi_write(server_io, 480, vec![2]));
2746        let client = S7Client::from_transport(client_io, params).await.unwrap();
2747        let items = [
2748            MultiWriteItem::db(1, 0, vec![0xAA, 0xBB, 0xCC, 0xDD]),
2749            MultiWriteItem::db(2, 10, vec![0x01, 0x02]),
2750        ];
2751        client.write_multi_vars(&items).await.unwrap();
2752    }
2753
2754    #[tokio::test]
2755    async fn write_multi_vars_empty_returns_ok() {
2756        let (client_io, server_io) = duplex(4096);
2757        let params = ConnectParams::default();
2758        // No messages exchanged after handshake — the mock just needs to satisfy connect.
2759        tokio::spawn(mock_plc_multi_write(server_io, 480, vec![]));
2760        let client = S7Client::from_transport(client_io, params).await.unwrap();
2761        client.write_multi_vars(&[]).await.unwrap();
2762    }
2763
2764    /// Items split into two round-trips when PDU budget is exhausted.
2765    ///
2766    /// PDU = 64. max_payload = 64 - 10(hdr) - 2(overhead) = 52.
2767    /// Each item: 12(addr) + 4(data hdr) + 20(data) = 36.
2768    /// Two items = 72 > 52 → must split into two 1-item batches.
2769    #[tokio::test]
2770    async fn write_multi_vars_batches_when_pdu_limit_exceeded() {
2771        let (client_io, server_io) = duplex(65536);
2772        let params = ConnectParams::default();
2773        tokio::spawn(mock_plc_multi_write(server_io, 64, vec![1, 1]));
2774        let client = S7Client::from_transport(client_io, params).await.unwrap();
2775        let items = [
2776            MultiWriteItem::db(1, 0, vec![0x11u8; 20]),
2777            MultiWriteItem::db(2, 0, vec![0x22u8; 20]),
2778        ];
2779        client.write_multi_vars(&items).await.unwrap();
2780    }
2781
2782    /// Items are split into two round trips when response would exceed the negotiated PDU size.
2783    ///
2784    /// PDU = 64 bytes. max_resp_payload = 64 - 10(hdr) - 2(func+count) = 52 bytes.
2785    /// Each item with 30 bytes of data costs 4+30 = 34 bytes in the response.
2786    /// Two such items = 68 bytes → exceeds 52 → must split into 2 round trips.
2787    #[tokio::test]
2788    async fn read_multi_vars_batches_when_pdu_limit_exceeded() {
2789        use crate::proto::s7::negotiate::NegotiateResponse;
2790
2791        async fn mock_split_pdu(mut server_io: tokio::io::DuplexStream) {
2792            let mut buf = vec![0u8; 4096];
2793
2794            // COTP CR
2795            let _ = server_io.read(&mut buf).await;
2796            let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2797            let mut cb = BytesMut::new(); cc.encode(&mut cb);
2798            let mut tb = BytesMut::new();
2799            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2800            server_io.write_all(&tb).await.unwrap();
2801
2802            // Negotiate — PDU size 64
2803            let _ = server_io.read(&mut buf).await;
2804            let neg = NegotiateResponse {
2805                max_amq_calling: 1, max_amq_called: 1, pdu_length: 64,
2806            };
2807            let mut s7b = BytesMut::new();
2808            S7Header {
2809                pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2810                param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2811            }.encode(&mut s7b);
2812            neg.encode(&mut s7b);
2813            let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2814            let mut cb = BytesMut::new(); dt.encode(&mut cb);
2815            let mut tb = BytesMut::new();
2816            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2817            server_io.write_all(&tb).await.unwrap();
2818
2819            // Two separate round-trips, one item each
2820            let payloads: &[&[u8]] = &[&[0x11u8; 30], &[0x22u8; 30]];
2821            for (i, payload) in payloads.iter().enumerate() {
2822                let _ = server_io.read(&mut buf).await;
2823                let bit_len = (payload.len() * 8) as u16;
2824                let mut data_bytes = BytesMut::new();
2825                data_bytes.put_u8(0xFF);
2826                data_bytes.put_u8(0x04);
2827                data_bytes.put_u16(bit_len);
2828                data_bytes.extend_from_slice(payload);
2829                if payload.len() % 2 != 0 { data_bytes.put_u8(0x00); }
2830                let data_len = data_bytes.len() as u16;
2831                let mut s7b = BytesMut::new();
2832                S7Header {
2833                    pdu_type: PduType::AckData, reserved: 0, pdu_ref: (i + 2) as u16,
2834                    param_len: 2, data_len, error_class: Some(0), error_code: Some(0),
2835                }.encode(&mut s7b);
2836                s7b.extend_from_slice(&[0x04, 0x01]);
2837                s7b.extend_from_slice(&data_bytes);
2838                let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2839                let mut cb = BytesMut::new(); dt.encode(&mut cb);
2840                let mut tb = BytesMut::new();
2841                TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2842                server_io.write_all(&tb).await.unwrap();
2843            }
2844        }
2845
2846        let (client_io, server_io) = duplex(4096);
2847        let params = ConnectParams::default();
2848        tokio::spawn(mock_split_pdu(server_io));
2849        let client = S7Client::from_transport(client_io, params).await.unwrap();
2850
2851        let items = [MultiReadItem::db(1, 0, 30), MultiReadItem::db(2, 0, 30)];
2852        let results = client.read_multi_vars(&items).await.unwrap();
2853        assert_eq!(results.len(), 2);
2854        assert_eq!(&results[0][..], &[0x11u8; 30][..]);
2855        assert_eq!(&results[1][..], &[0x22u8; 30][..]);
2856    }
2857
2858    // -- PLC control & status mocks & tests -----------------------------------
2859
2860    /// Common handshake for control tests: COTP CR → CC, S7 Negotiate.
2861    async fn mock_handshake(server_io: &mut (impl AsyncRead + AsyncWrite + Unpin)) {
2862        let mut buf = vec![0u8; 4096];
2863
2864        // COTP CR
2865        let _ = server_io.read(&mut buf).await;
2866        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2867        let mut cb = BytesMut::new(); cc.encode(&mut cb);
2868        let mut tb = BytesMut::new();
2869        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2870        server_io.write_all(&tb).await.unwrap();
2871
2872        // S7 Negotiate
2873        let _ = server_io.read(&mut buf).await;
2874        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: 480 };
2875        let mut s7b = BytesMut::new();
2876        S7Header {
2877            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2878            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2879        }.encode(&mut s7b);
2880        neg.encode(&mut s7b);
2881        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2882        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2883        let mut tb = BytesMut::new();
2884        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2885        server_io.write_all(&tb).await.unwrap();
2886    }
2887
2888    /// Mock for simple control commands (plc_stop / plc_hot_start / plc_cold_start).
2889    /// `ok` controls whether the mock sends success (error_class=0, error_code=0) or failure.
2890    async fn mock_plc_control(
2891        mut server_io: tokio::io::DuplexStream,
2892        ok: bool,
2893    ) {
2894        let mut buf = vec![0u8; 4096];
2895        mock_handshake(&mut server_io).await;
2896
2897        // Control request — consume
2898        let _ = server_io.read(&mut buf).await;
2899
2900        // AckData response
2901        let (ec, ecd) = if ok { (0u8, 0u8) } else { (0x81u8, 0x04u8) };
2902        let mut s7b = BytesMut::new();
2903        S7Header {
2904            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
2905            param_len: 0, data_len: 0,
2906            error_class: Some(ec), error_code: Some(ecd),
2907        }.encode(&mut s7b);
2908        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2909        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2910        let mut tb = BytesMut::new();
2911        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2912        server_io.write_all(&tb).await.unwrap();
2913    }
2914
2915    #[tokio::test]
2916    async fn plc_stop_succeeds() {
2917        let (client_io, server_io) = duplex(4096);
2918        let params = ConnectParams::default();
2919        tokio::spawn(mock_plc_control(server_io, true));
2920        let client = S7Client::from_transport(client_io, params).await.unwrap();
2921        client.plc_stop().await.unwrap();
2922    }
2923
2924    #[tokio::test]
2925    async fn plc_hot_start_succeeds() {
2926        let (client_io, server_io) = duplex(4096);
2927        let params = ConnectParams::default();
2928        tokio::spawn(mock_plc_control(server_io, true));
2929        let client = S7Client::from_transport(client_io, params).await.unwrap();
2930        client.plc_hot_start().await.unwrap();
2931    }
2932
2933    #[tokio::test]
2934    async fn plc_cold_start_succeeds() {
2935        let (client_io, server_io) = duplex(4096);
2936        let params = ConnectParams::default();
2937        tokio::spawn(mock_plc_control(server_io, true));
2938        let client = S7Client::from_transport(client_io, params).await.unwrap();
2939        client.plc_cold_start().await.unwrap();
2940    }
2941
2942    #[tokio::test]
2943    async fn plc_stop_rejected_returns_error() {
2944        let (client_io, server_io) = duplex(4096);
2945        let params = ConnectParams::default();
2946        tokio::spawn(mock_plc_control(server_io, false));
2947        let client = S7Client::from_transport(client_io, params).await.unwrap();
2948        let result = client.plc_stop().await;
2949        assert!(result.is_err());
2950    }
2951
2952    /// Mock for get_plc_status: sends back `status_byte` in the data section.
2953    async fn mock_plc_status(
2954        mut server_io: tokio::io::DuplexStream,
2955        status_byte: u8,
2956    ) {
2957        let mut buf = vec![0u8; 4096];
2958        mock_handshake(&mut server_io).await;
2959
2960        // GetPlcStatus request (UserData SZL) — consume
2961        let _ = server_io.read(&mut buf).await;
2962
2963        // SZL 0x0424 response payload layout (after data envelope):
2964        //   [0..1]  SZL_ID = 0x0424
2965        //   [2..3]  SZL_INDEX = 0x0000
2966        //   [4..5]  LENTHDR (entry length)
2967        //   [6..7]  N_DR = 0x0001
2968        //   [8..10] first 3 bytes of entry
2969        //   [11]    status byte (payload[11])
2970        let mut szl_payload = [0u8; 12];
2971        szl_payload[0..2].copy_from_slice(&0x0424u16.to_be_bytes());
2972        szl_payload[6..8].copy_from_slice(&0x0001u16.to_be_bytes()); // N_DR = 1
2973        szl_payload[11] = status_byte;
2974
2975        // UserData response body:
2976        //   params (8 bytes, echoed): [0x00,0x01,0x12,0x08,0x12,0x84,0x01,0x00]
2977        //   data envelope (4 bytes): return_code=0xFF, transport=0x09, len=12
2978        //   szl_payload (12 bytes)
2979        let params: [u8; 8] = [0x00, 0x01, 0x12, 0x08, 0x12, 0x84, 0x01, 0x00];
2980        let data_envelope: [u8; 4] = [0xFF, 0x09, 0x00, 0x0C];
2981        let param_len = params.len() as u16;
2982        let data_len = (data_envelope.len() + szl_payload.len()) as u16;
2983
2984        let mut s7b = BytesMut::new();
2985        S7Header {
2986            pdu_type: PduType::UserData, reserved: 0, pdu_ref: 2,
2987            param_len, data_len,
2988            error_class: None, error_code: None,
2989        }.encode(&mut s7b);
2990        s7b.extend_from_slice(&params);
2991        s7b.extend_from_slice(&data_envelope);
2992        s7b.extend_from_slice(&szl_payload);
2993        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2994        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2995        let mut tb = BytesMut::new();
2996        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2997        server_io.write_all(&tb).await.unwrap();
2998    }
2999
3000    #[tokio::test]
3001    async fn get_plc_status_returns_run() {
3002        let (client_io, server_io) = duplex(4096);
3003        let params = ConnectParams::default();
3004        tokio::spawn(mock_plc_status(server_io, 0x08));
3005        let client = S7Client::from_transport(client_io, params).await.unwrap();
3006        let status = client.get_plc_status().await.unwrap();
3007        assert_eq!(status, crate::types::PlcStatus::Run);
3008    }
3009
3010    #[tokio::test]
3011    async fn get_plc_status_returns_stop() {
3012        let (client_io, server_io) = duplex(4096);
3013        let params = ConnectParams::default();
3014        tokio::spawn(mock_plc_status(server_io, 0x04));
3015        let client = S7Client::from_transport(client_io, params).await.unwrap();
3016        let status = client.get_plc_status().await.unwrap();
3017        assert_eq!(status, crate::types::PlcStatus::Stop);
3018    }
3019
3020    #[tokio::test]
3021    async fn get_plc_status_returns_unknown() {
3022        let (client_io, server_io) = duplex(4096);
3023        let params = ConnectParams::default();
3024        tokio::spawn(mock_plc_status(server_io, 0x00));
3025        let client = S7Client::from_transport(client_io, params).await.unwrap();
3026        let status = client.get_plc_status().await.unwrap();
3027        assert_eq!(status, crate::types::PlcStatus::Unknown);
3028    }
3029
3030    #[tokio::test]
3031    async fn get_plc_status_unknown_byte_returns_stop() {
3032        // Unknown status bytes default to Stop (C snap7 behavior)
3033        let (client_io, server_io) = duplex(4096);
3034        let params = ConnectParams::default();
3035        tokio::spawn(mock_plc_status(server_io, 0xFF));
3036        let client = S7Client::from_transport(client_io, params).await.unwrap();
3037        let status = client.get_plc_status().await.unwrap();
3038        assert_eq!(status, crate::types::PlcStatus::Stop);
3039    }
3040
3041    #[tokio::test]
3042    async fn mb_read_returns_data() {
3043        let (client_io, server_io) = duplex(4096);
3044        let params = ConnectParams::default();
3045        let expected = vec![0xAA, 0xBB];
3046        tokio::spawn(mock_plc_multi_read(server_io, vec![expected.clone()]));
3047        let client = S7Client::from_transport(client_io, params).await.unwrap();
3048        let data = client.mb_read(10, 2).await.unwrap();
3049        assert_eq!(&data[..], &expected[..]);
3050    }
3051
3052    #[tokio::test]
3053    async fn eb_read_returns_data() {
3054        let (client_io, server_io) = duplex(4096);
3055        let params = ConnectParams::default();
3056        let expected = vec![0x01, 0x02, 0x03];
3057        tokio::spawn(mock_plc_multi_read(server_io, vec![expected.clone()]));
3058        let client = S7Client::from_transport(client_io, params).await.unwrap();
3059        let data = client.eb_read(0, 3).await.unwrap();
3060        assert_eq!(&data[..], &expected[..]);
3061    }
3062
3063    #[tokio::test]
3064    async fn ib_read_returns_data() {
3065        let (client_io, server_io) = duplex(4096);
3066        let params = ConnectParams::default();
3067        let expected = vec![0x11, 0x22];
3068        tokio::spawn(mock_plc_multi_read(server_io, vec![expected.clone()]));
3069        let client = S7Client::from_transport(client_io, params).await.unwrap();
3070        let data = client.ib_read(0, 2).await.unwrap();
3071        assert_eq!(&data[..], &expected[..]);
3072    }
3073
3074    #[tokio::test]
3075    async fn tm_read_returns_data() {
3076        let (client_io, server_io) = duplex(4096);
3077        let params = ConnectParams::default();
3078        // Two timer words = 4 bytes
3079        let expected = vec![0x00, 0x14, 0x00, 0x28];
3080        tokio::spawn(mock_plc_multi_read(server_io, vec![expected.clone()]));
3081        let client = S7Client::from_transport(client_io, params).await.unwrap();
3082        let data = client.tm_read(0, 2).await.unwrap();
3083        assert_eq!(&data[..], &expected[..]);
3084    }
3085
3086    #[tokio::test]
3087    async fn ct_read_returns_data() {
3088        let (client_io, server_io) = duplex(4096);
3089        let params = ConnectParams::default();
3090        // One counter word = 2 bytes
3091        let expected = vec![0x00, 0x07];
3092        tokio::spawn(mock_plc_multi_read(server_io, vec![expected.clone()]));
3093        let client = S7Client::from_transport(client_io, params).await.unwrap();
3094        let data = client.ct_read(3, 1).await.unwrap();
3095        assert_eq!(&data[..], &expected[..]);
3096    }
3097
3098    async fn mock_set_clock(mut server_io: tokio::io::DuplexStream) {
3099        let mut buf = vec![0u8; 4096];
3100        mock_handshake(&mut server_io).await;
3101        let _ = server_io.read(&mut buf).await;
3102        // Send success AckData with no data
3103        let mut s7b = BytesMut::new();
3104        S7Header {
3105            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
3106            param_len: 0, data_len: 0, error_class: Some(0), error_code: Some(0),
3107        }.encode(&mut s7b);
3108        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3109        let mut cb = BytesMut::new(); dt.encode(&mut cb);
3110        let mut tb = BytesMut::new();
3111        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3112        server_io.write_all(&tb).await.unwrap();
3113    }
3114
3115    #[tokio::test]
3116    async fn set_clock_succeeds() {
3117        let (client_io, server_io) = duplex(4096);
3118        let params = ConnectParams::default();
3119        tokio::spawn(mock_set_clock(server_io));
3120        let client = S7Client::from_transport(client_io, params).await.unwrap();
3121        let dt = crate::proto::s7::clock::PlcDateTime {
3122            year: 2025, month: 5, day: 9, hour: 12, minute: 0, second: 0,
3123            millisecond: 0, weekday: 5,
3124        };
3125        client.set_clock(&dt).await.unwrap();
3126    }
3127
3128    #[tokio::test]
3129    async fn set_clock_to_now_succeeds() {
3130        let (client_io, server_io) = duplex(4096);
3131        let params = ConnectParams::default();
3132        tokio::spawn(mock_set_clock(server_io));
3133        let client = S7Client::from_transport(client_io, params).await.unwrap();
3134        client.set_clock_to_now().await.unwrap();
3135    }
3136
3137    async fn mock_read_clock(mut server_io: tokio::io::DuplexStream, dt: crate::proto::s7::clock::PlcDateTime) {
3138        let mut buf = vec![0u8; 4096];
3139        mock_handshake(&mut server_io).await;
3140        let _ = server_io.read(&mut buf).await;
3141        // Real PLC layout (observed): pdu_type=UserData, param_len=12, data_len=4.
3142        // The 8-byte datetime spans body[8..16]: last 4 bytes of param + all 4 data bytes.
3143        let mut datetime_bytes = bytes::BytesMut::new();
3144        dt.encode(&mut datetime_bytes);
3145        let param_len: u16 = 12;
3146        let data_len: u16 = 4;
3147        let mut s7b = BytesMut::new();
3148        S7Header {
3149            pdu_type: PduType::UserData, reserved: 0, pdu_ref: 2,
3150            param_len, data_len, error_class: None, error_code: None,
3151        }.encode(&mut s7b);
3152        // param: 8-byte echo + 4 bytes (first half of datetime)
3153        s7b.extend_from_slice(&[0x00, 0x01, 0x12, 0x08, 0x12, 0x87, 0x01, 0x00]);
3154        s7b.extend_from_slice(&datetime_bytes[..4]);
3155        // data: last 4 bytes of datetime
3156        s7b.extend_from_slice(&datetime_bytes[4..]);
3157        let dt_pdu = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3158        let mut cb = BytesMut::new(); dt_pdu.encode(&mut cb);
3159        let mut tb = BytesMut::new();
3160        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3161        server_io.write_all(&tb).await.unwrap();
3162    }
3163
3164    #[tokio::test]
3165    async fn read_clock_returns_correct_datetime() {
3166        let expected = crate::proto::s7::clock::PlcDateTime {
3167            year: 2025, month: 5, day: 9, hour: 14, minute: 30, second: 0,
3168            millisecond: 0, weekday: 5,
3169        };
3170        let (client_io, server_io) = duplex(4096);
3171        let params = ConnectParams::default();
3172        tokio::spawn(mock_read_clock(server_io, expected.clone()));
3173        let client = S7Client::from_transport(client_io, params).await.unwrap();
3174        let result = client.read_clock().await.unwrap();
3175        assert_eq!(result, expected);
3176    }
3177
3178    async fn mock_szl_list(mut server_io: tokio::io::DuplexStream, ids: Vec<u16>) {
3179        let mut buf = vec![0u8; 4096];
3180        mock_handshake(&mut server_io).await;
3181        let _ = server_io.read(&mut buf).await;
3182
3183        // Build SZL block: [szl_id=0x0000][szl_index=0][entry_len=4][entry_count=N][{id(2)+pad(2)}*N]
3184        let entry_len: u16 = 4;
3185        let entry_count = ids.len() as u16;
3186        let mut szl = BytesMut::new();
3187        szl.put_u16(0x0000); // szl_id
3188        szl.put_u16(0x0000); // szl_index
3189        szl.put_u16(entry_len);
3190        szl.put_u16(entry_count);
3191        for id in &ids {
3192            szl.put_u16(*id);
3193            szl.put_u16(0x0000); // padding
3194        }
3195        let szl_bytes = szl.freeze();
3196        let data_len = (4 + szl_bytes.len()) as u16; // envelope(4) + szl_block
3197
3198        let mut s7b = BytesMut::new();
3199        // param section (8 bytes echoed)
3200        let param_len: u16 = 8;
3201        S7Header {
3202            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
3203            param_len, data_len, error_class: Some(0), error_code: Some(0),
3204        }.encode(&mut s7b);
3205        // echoed param (8 bytes)
3206        s7b.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x44, 0x01, 0x00]);
3207        // data envelope
3208        s7b.put_u8(0xFF); s7b.put_u8(0x09);
3209        s7b.put_u16(szl_bytes.len() as u16);
3210        s7b.extend_from_slice(&szl_bytes);
3211
3212        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3213        let mut cb = BytesMut::new(); dt.encode(&mut cb);
3214        let mut tb = BytesMut::new();
3215        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3216        server_io.write_all(&tb).await.unwrap();
3217    }
3218
3219    #[tokio::test]
3220    async fn read_szl_list_returns_ids() {
3221        let (client_io, server_io) = duplex(4096);
3222        let params = ConnectParams::default();
3223        let ids = vec![0x0011u16, 0x001C, 0x0131, 0x0424];
3224        tokio::spawn(mock_szl_list(server_io, ids.clone()));
3225        let client = S7Client::from_transport(client_io, params).await.unwrap();
3226        let result = client.read_szl_list().await.unwrap();
3227        assert_eq!(result, ids);
3228    }
3229
3230    #[tokio::test]
3231    async fn read_szl_list_empty_returns_empty() {
3232        let (client_io, server_io) = duplex(4096);
3233        let params = ConnectParams::default();
3234        tokio::spawn(mock_szl_list(server_io, vec![]));
3235        let client = S7Client::from_transport(client_io, params).await.unwrap();
3236        let result = client.read_szl_list().await.unwrap();
3237        assert!(result.is_empty());
3238    }
3239
3240    /// Mock for full_upload: handshake + 3-message exchange (start, data, end).
3241    async fn mock_full_upload(mut server_io: tokio::io::DuplexStream, block_data: Vec<u8>) {
3242        let mut buf = vec![0u8; 4096];
3243        mock_handshake(&mut server_io).await;
3244
3245        // Start request (func=0x1F, sub-fn=0x00)
3246        let _ = server_io.read(&mut buf).await;
3247        let mut s7b = BytesMut::new();
3248        S7Header {
3249            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
3250            param_len: 2, data_len: 8, error_class: Some(0), error_code: Some(0),
3251        }.encode(&mut s7b);
3252        s7b.extend_from_slice(&[0x1F, 0x00]); // param echo
3253        s7b.put_u32(0xDEAD_BEEF_u32); // upload_id
3254        s7b.put_u32(block_data.len() as u32); // total_len
3255        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3256        let mut cb = BytesMut::new(); dt.encode(&mut cb);
3257        let mut tb = BytesMut::new();
3258        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3259        server_io.write_all(&tb).await.unwrap();
3260
3261        // Data request (func=0x1F, sub-fn=0x01)
3262        let _ = server_io.read(&mut buf).await;
3263        let data_payload_len = (4 + block_data.len()) as u16; // return_code+transport+len(2) + data
3264        let mut s7b = BytesMut::new();
3265        S7Header {
3266            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 3,
3267            param_len: 2, data_len: data_payload_len, error_class: Some(0), error_code: Some(0),
3268        }.encode(&mut s7b);
3269        s7b.extend_from_slice(&[0x1F, 0x01]);
3270        s7b.put_u8(0xFF); s7b.put_u8(0x04);
3271        s7b.put_u16((block_data.len() * 8) as u16);
3272        s7b.extend_from_slice(&block_data);
3273        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3274        let mut cb = BytesMut::new(); dt.encode(&mut cb);
3275        let mut tb = BytesMut::new();
3276        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3277        server_io.write_all(&tb).await.unwrap();
3278
3279        // End request (func=0x1F, sub-fn=0x02)
3280        let _ = server_io.read(&mut buf).await;
3281        let mut s7b = BytesMut::new();
3282        S7Header {
3283            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 4,
3284            param_len: 0, data_len: 0, error_class: Some(0), error_code: Some(0),
3285        }.encode(&mut s7b);
3286        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3287        let mut cb = BytesMut::new(); dt.encode(&mut cb);
3288        let mut tb = BytesMut::new();
3289        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3290        server_io.write_all(&tb).await.unwrap();
3291    }
3292
3293    #[tokio::test]
3294    async fn full_upload_returns_block_data() {
3295        let (client_io, server_io) = duplex(4096);
3296        let params = ConnectParams::default();
3297        let expected = vec![0x01u8, 0x02, 0x03, 0x04];
3298        tokio::spawn(mock_full_upload(server_io, expected.clone()));
3299        let client = S7Client::from_transport(client_io, params).await.unwrap();
3300        let data = client.full_upload(0x41, 1).await.unwrap();
3301        assert_eq!(data, expected);
3302    }
3303
3304    #[tokio::test]
3305    async fn get_pdu_length_returns_negotiated_size() {
3306        let (client_io, server_io) = duplex(4096);
3307        let params = ConnectParams::default();
3308        // mock_plc_db_read negotiates pdu_length=480
3309        tokio::spawn(mock_plc_db_read(server_io, vec![0x00]));
3310        let client = S7Client::from_transport(client_io, params).await.unwrap();
3311        let pdu_len = client.get_pdu_length().await;
3312        assert_eq!(pdu_len, 480);
3313    }
3314
3315    #[test]
3316    fn parse_block_info_valid() {
3317        type C = S7Client<tokio::io::DuplexStream>;
3318        // Build a minimal TS7CompactBlockInfo buffer (HDR=36, FOOTER=48, total=84)
3319        const TOTAL: usize = 84;
3320        let mut buf = vec![0u8; TOTAL];
3321        buf[0] = 0x70; buf[1] = 0x70;
3322        buf[3] = 0x09; // flags
3323        buf[4] = 0x01; // language
3324        buf[5] = 0x41; // SubBlkType (DB)
3325        buf[6] = 0x00; buf[7] = 0x05; // block_number = 5
3326        let total_be = (TOTAL as u32).to_be_bytes();
3327        buf[8..12].copy_from_slice(&total_be);
3328        buf[28] = 0x00; buf[29] = 0x10; // size_ram = 16
3329        buf[32] = 0x00; buf[33] = 0x08; // local_data = 8
3330        buf[34] = 0x00; buf[35] = 0x0A; // mc7_size = 10
3331        let footer_start = TOTAL - 48;
3332        buf[footer_start + 20..footer_start + 27].copy_from_slice(b"SIEMENS");
3333        buf[footer_start + 28..footer_start + 32].copy_from_slice(b"TEST");
3334        buf[footer_start + 36..footer_start + 40].copy_from_slice(b"V1.0");
3335        buf[footer_start + 44] = 0xAB; buf[footer_start + 45] = 0xCD;
3336
3337        let info = C::parse_block_info(&buf).unwrap();
3338        assert_eq!(info.block_number, 5);
3339        assert_eq!(info.block_type, 0x41);
3340        assert_eq!(info.language, 1);
3341        assert_eq!(info.flags, 9);
3342        assert_eq!(info.size, TOTAL as u16);
3343        assert_eq!(info.size_ram, 16);
3344        assert_eq!(info.mc7_size, 10);
3345        assert_eq!(info.local_data, 8);
3346        assert_eq!(info.checksum, 0xABCD);
3347        assert_eq!(info.author, "SIEMENS");
3348        assert_eq!(info.family, "TEST");
3349        assert_eq!(info.header, "V1.0");
3350    }
3351
3352    #[test]
3353    fn parse_block_info_too_short() {
3354        type C = S7Client<tokio::io::DuplexStream>;
3355        let buf = vec![0u8; 10];
3356        assert!(C::parse_block_info(&buf).is_err());
3357    }
3358
3359    #[test]
3360    fn parse_block_info_mismatched_load_size() {
3361        type C = S7Client<tokio::io::DuplexStream>;
3362        const TOTAL: usize = 84;
3363        let mut buf = vec![0u8; TOTAL];
3364        let wrong = 100u32.to_be_bytes();
3365        buf[8..12].copy_from_slice(&wrong);
3366        buf[34] = 0x00; buf[35] = 0x0A;
3367        assert!(C::parse_block_info(&buf).is_err());
3368    }
3369
3370    #[tokio::test]
3371    async fn reconnect_resets_state() {
3372        use std::net::SocketAddr;
3373
3374        // Spin up a tiny TCP listener that does a full S7 handshake then closes.
3375        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3376        let addr: SocketAddr = listener.local_addr().unwrap();
3377
3378        // Spawn a server task: handles two sequential connections (initial + reconnect).
3379        tokio::spawn(async move {
3380            for _ in 0..2 {
3381                if let Ok((stream, _)) = listener.accept().await {
3382                    tokio::spawn(mock_tcp_plc(stream));
3383                }
3384            }
3385        });
3386
3387        let params = ConnectParams::default();
3388        let client = S7Client::<crate::transport::TcpTransport>::connect(addr, params)
3389            .await
3390            .unwrap();
3391
3392        assert!(client.is_connected().await);
3393
3394        // Simulate disconnect by calling reconnect (server will accept second conn).
3395        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3396        client.reconnect().await.unwrap();
3397        assert!(client.is_connected().await);
3398    }
3399
3400    /// Minimal TCP mock: complete COTP CR/CC + S7 negotiate, then drop.
3401    async fn mock_tcp_plc(mut stream: tokio::net::TcpStream) {
3402        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3403        let mut buf = vec![0u8; 512];
3404
3405        // COTP CR
3406        let _ = stream.read(&mut buf).await;
3407        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
3408        let mut cb = BytesMut::new();
3409        cc.encode(&mut cb);
3410        let mut tb = BytesMut::new();
3411        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
3412        let _ = stream.write_all(&tb).await;
3413
3414        // S7 negotiate
3415        let _ = stream.read(&mut buf).await;
3416        let neg_resp = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: 480 };
3417        let ack = S7Header {
3418            pdu_type: PduType::AckData,
3419            reserved: 0,
3420            pdu_ref: 1,
3421            param_len: 8,
3422            data_len: 0,
3423            error_class: Some(0),
3424            error_code: Some(0),
3425        };
3426        let mut s7b = BytesMut::new();
3427        ack.encode(&mut s7b);
3428        neg_resp.encode(&mut s7b);
3429        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
3430        let mut cotpb = BytesMut::new();
3431        dt.encode(&mut cotpb);
3432        let mut tb2 = BytesMut::new();
3433        TpktFrame { payload: cotpb.freeze() }.encode(&mut tb2).unwrap();
3434        let _ = stream.write_all(&tb2).await;
3435        // hold connection open briefly then drop
3436        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3437    }
3438
3439    #[tokio::test]
3440    async fn get_exec_time_after_request() {
3441        let (client_io, server_io) = duplex(4096);
3442        let params = ConnectParams::default();
3443        tokio::spawn(mock_plc_db_read(server_io, vec![0x00, 0x01, 0x02, 0x03]));
3444        let client = S7Client::from_transport(client_io, params).await.unwrap();
3445        client.db_read(1, 0, 4).await.unwrap();
3446        let exec_ms = client.get_exec_time().await;
3447        // Just verify it was set — exact value is timing-dependent.
3448        // In tests with in-process duplex the round-trip is < 1 ms so often 0; just check it doesn't panic.
3449        let _ = exec_ms;
3450    }
3451}