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}
74
75pub struct S7Client<T: AsyncRead + AsyncWrite + Unpin + Send> {
76    inner: Mutex<Inner<T>>,
77    params: ConnectParams,
78}
79
80impl<T: AsyncRead + AsyncWrite + Unpin + Send> S7Client<T> {
81    pub async fn from_transport(transport: T, params: ConnectParams) -> Result<Self> {
82        let mut t = transport;
83        let connection = connect(&mut t, &params).await?;
84        let timeout = params.request_timeout;
85        Ok(S7Client {
86            inner: Mutex::new(Inner {
87                transport: t,
88                connection,
89                pdu_ref: 1,
90                request_timeout: timeout,
91            }),
92            params,
93        })
94    }
95
96    /// Return the current request timeout.
97    pub fn request_timeout(&self) -> std::time::Duration {
98        self.params.request_timeout
99    }
100
101    /// Update the request timeout at runtime.
102    ///
103    /// This affects subsequent `recv_s7` calls made by this client instance.
104    pub async fn set_request_timeout(&self, timeout: std::time::Duration) {
105        let mut inner = self.inner.lock().await;
106        inner.request_timeout = timeout;
107    }
108
109    /// Read a client parameter by name.
110    ///
111    /// Supported names: `"request_timeout"`, `"connect_timeout"`, `"pdu_size"`.
112    pub fn get_param(&self, name: &str) -> Result<std::time::Duration> {
113        match name {
114            "request_timeout" => Ok(self.params.request_timeout),
115            "connect_timeout" => Ok(self.params.connect_timeout),
116            "pdu_size" => Err(Error::PlcError {
117                code: 0,
118                message: "pdu_size is not a Duration; use .params.pdu_size directly".into(),
119            }),
120            _ => Err(Error::PlcError {
121                code: 0,
122                message: format!("unknown parameter: {name}"),
123            }),
124        }
125    }
126
127    /// Set a client parameter at runtime.
128    ///
129    /// Supported names: `"request_timeout"` (Duration).
130    pub fn set_param(&mut self, name: &str, value: std::time::Duration) -> Result<()> {
131        match name {
132            "request_timeout" => {
133                self.params.request_timeout = value;
134                Ok(())
135            }
136            _ => Err(Error::PlcError {
137                code: 0,
138                message: format!("unknown parameter: {name}"),
139            }),
140        }
141    }
142
143    fn next_pdu_ref(inner: &mut Inner<T>) -> u16 {
144        inner.pdu_ref = inner.pdu_ref.wrapping_add(1);
145        inner.pdu_ref
146    }
147
148    async fn send_s7(
149        inner: &mut Inner<T>,
150        param_buf: Bytes,
151        data_buf: Bytes,
152        pdu_ref: u16,
153        pdu_type: PduType,
154    ) -> Result<()> {
155        let header = S7Header {
156            pdu_type,
157            reserved: 0,
158            pdu_ref,
159            param_len: param_buf.len() as u16,
160            data_len: data_buf.len() as u16,
161            error_class: None,
162            error_code: None,
163        };
164        let mut s7b = BytesMut::new();
165        header.encode(&mut s7b);
166        s7b.extend_from_slice(&param_buf);
167        s7b.extend_from_slice(&data_buf);
168
169        let dt = CotpPdu::Data {
170            tpdu_nr: 0,
171            last: true,
172            payload: s7b.freeze(),
173        };
174        let mut cotpb = BytesMut::new();
175        dt.encode(&mut cotpb);
176        let tpkt = TpktFrame {
177            payload: cotpb.freeze(),
178        };
179        let mut tb = BytesMut::new();
180        tpkt.encode(&mut tb)?;
181        inner.transport.write_all(&tb).await?;
182        Ok(())
183    }
184
185    async fn recv_s7(inner: &mut Inner<T>) -> Result<(S7Header, Bytes)> {
186        let timeout = inner.request_timeout;
187        let mut tpkt_hdr = [0u8; 4];
188        tokio::time::timeout(timeout, inner.transport.read_exact(&mut tpkt_hdr))
189            .await
190            .map_err(|_| Error::Timeout(timeout))??;
191        let total = u16::from_be_bytes([tpkt_hdr[2], tpkt_hdr[3]]) as usize;
192        if total < 4 {
193            return Err(Error::UnexpectedResponse);
194        }
195        let mut payload = vec![0u8; total - 4];
196        tokio::time::timeout(timeout, inner.transport.read_exact(&mut payload))
197            .await
198            .map_err(|_| Error::Timeout(timeout))??;
199        let mut b = Bytes::from(payload);
200
201        // COTP DT header: LI (1) + code (1) + tpdu_nr (1)
202        if b.remaining() < 3 {
203            return Err(Error::UnexpectedResponse);
204        }
205        let _li = b.get_u8();
206        let cotp_code = b.get_u8();
207        if cotp_code != 0xF0 {
208            return Err(Error::UnexpectedResponse);
209        }
210        b.advance(1); // tpdu_nr byte
211
212        let header = S7Header::decode(&mut b)?;
213        Ok((header, b))
214    }
215
216    pub async fn db_read(&self, db: u16, start: u32, length: u16) -> Result<Bytes> {
217        let mut inner = self.inner.lock().await;
218        let pdu_ref = Self::next_pdu_ref(&mut inner);
219
220        let req = ReadVarRequest {
221            items: vec![AddressItem {
222                area: Area::DataBlock,
223                db_number: db,
224                start,
225                bit_offset: 0,
226                length,
227                transport: TransportSize::Byte,
228            }],
229        };
230        let mut param_buf = BytesMut::new();
231        req.encode(&mut param_buf);
232
233        Self::send_s7(
234            &mut inner,
235            param_buf.freeze(),
236            Bytes::new(),
237            pdu_ref,
238            PduType::Job,
239        )
240        .await?;
241
242        let (header, mut body) = Self::recv_s7(&mut inner).await?;
243        check_plc_error(&header, "db_read")?;
244        if body.remaining() >= 2 {
245            body.advance(2); // skip param echo: func + item count
246        }
247        let resp = ReadVarResponse::decode(&mut body, 1)?;
248        if resp.items.is_empty() {
249            return Err(Error::UnexpectedResponse);
250        }
251        if resp.items[0].return_code != 0xFF {
252            return Err(Error::PlcError {
253                code: resp.items[0].return_code as u32,
254                message: "item error".into(),
255            });
256        }
257        Ok(resp.items[0].data.clone())
258    }
259
260    /// Read from any PLC area with explicit transport size.
261    ///
262    /// For DB areas use `db_read`. For Marker/Timer/Counter use this method.
263    /// Timer (`area=Timer, transport=Timer`) and Counter (`area=Counter, transport=Counter`)
264    /// use element-index addressing (no ×8 shift) and return 2 bytes per element.
265    pub async fn read_area(
266        &self,
267        area: Area,
268        db_number: u16,
269        start: u32,
270        element_count: u16,
271        transport: TransportSize,
272    ) -> Result<Bytes> {
273        let mut inner = self.inner.lock().await;
274        let pdu_ref = Self::next_pdu_ref(&mut inner);
275
276        let req = ReadVarRequest {
277            items: vec![AddressItem {
278                area,
279                db_number,
280                start,
281                bit_offset: 0,
282                length: element_count,
283                transport,
284            }],
285        };
286        let mut param_buf = BytesMut::new();
287        req.encode(&mut param_buf);
288
289        Self::send_s7(
290            &mut inner,
291            param_buf.freeze(),
292            Bytes::new(),
293            pdu_ref,
294            PduType::Job,
295        )
296        .await?;
297
298        let (header, mut body) = Self::recv_s7(&mut inner).await?;
299        check_plc_error(&header, "read_area")?;
300        if body.remaining() >= 2 {
301            body.advance(2);
302        }
303        let resp = ReadVarResponse::decode(&mut body, 1)?;
304        if resp.items.is_empty() {
305            return Err(Error::UnexpectedResponse);
306        }
307        if resp.items[0].return_code != 0xFF {
308            return Err(Error::PlcError {
309                code: resp.items[0].return_code as u32,
310                message: "item error".into(),
311            });
312        }
313        Ok(resp.items[0].data.clone())
314    }
315
316    /// Read multiple PLC regions in one or more S7 PDU exchanges.
317    ///
318    /// Automatically batches items when the item count would exceed the Siemens hard
319    /// limit of 20 per PDU, or when the encoded request or response would exceed the
320    /// negotiated PDU size. Returns one `Bytes` per item in input order.
321    ///
322    /// Unlike `db_read`, this accepts any `Area` and `TransportSize`.
323    pub async fn read_multi_vars(&self, items: &[MultiReadItem]) -> Result<Vec<Bytes>> {
324        if items.is_empty() {
325            return Ok(Vec::new());
326        }
327
328        // PDU size constants (in bytes)
329        // S7 header: 10, func+count: 2, per-item address: 12
330        const S7_HEADER: usize = 10;
331        const PARAM_OVERHEAD: usize = 2; // func + item count
332        const ADDR_ITEM_SIZE: usize = 12;
333        // Response data item: 4 header + data + 0/1 pad
334        const DATA_ITEM_OVERHEAD: usize = 4;
335        const MAX_ITEMS_PER_PDU: usize = 20;
336
337        let mut inner = self.inner.lock().await;
338        let pdu_size = inner.connection.pdu_size as usize;
339        let max_req_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
340        let max_resp_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
341
342        let mut results = vec![Bytes::new(); items.len()];
343        let mut batch_start = 0;
344
345        while batch_start < items.len() {
346            // Build a batch that fits within PDU limits
347            let mut batch_end = batch_start;
348            let mut req_bytes_used = 0usize;
349            let mut resp_bytes_used = 0usize;
350
351            while batch_end < items.len() && (batch_end - batch_start) < MAX_ITEMS_PER_PDU {
352                let item = &items[batch_end];
353                let item_resp_size =
354                    DATA_ITEM_OVERHEAD + item.length as usize + (item.length as usize % 2);
355
356                if batch_end > batch_start
357                    && (req_bytes_used + ADDR_ITEM_SIZE > max_req_payload
358                        || resp_bytes_used + item_resp_size > max_resp_payload)
359                {
360                    break;
361                }
362                req_bytes_used += ADDR_ITEM_SIZE;
363                resp_bytes_used += item_resp_size;
364                batch_end += 1;
365            }
366
367            let batch = &items[batch_start..batch_end];
368            let pdu_ref = Self::next_pdu_ref(&mut inner);
369
370            let req = ReadVarRequest {
371                items: batch
372                    .iter()
373                    .map(|item| AddressItem {
374                        area: item.area,
375                        db_number: item.db_number,
376                        start: item.start,
377                        bit_offset: 0,
378                        // Siemens requires Byte transport + byte-count length in the request.
379                        // The item's declared transport is only used to decode the response.
380                        length: item.length,
381                        transport: TransportSize::Byte,
382                    })
383                    .collect(),
384            };
385            let mut param_buf = BytesMut::new();
386            req.encode(&mut param_buf);
387
388            Self::send_s7(
389                &mut inner,
390                param_buf.freeze(),
391                Bytes::new(),
392                pdu_ref,
393                PduType::Job,
394            )
395            .await?;
396
397            let (header, mut body) = Self::recv_s7(&mut inner).await?;
398            check_plc_error(&header, "read_multi_vars")?;
399            if body.remaining() >= 2 {
400                body.advance(2); // skip func + item_count echo
401            }
402            let resp = ReadVarResponse::decode(&mut body, batch.len())?;
403
404            for (i, item) in resp.items.into_iter().enumerate() {
405                if item.return_code != 0xFF {
406                    return Err(Error::PlcError {
407                        code: item.return_code as u32,
408                        message: format!("item {} error", batch_start + i),
409                    });
410                }
411                results[batch_start + i] = item.data;
412            }
413
414            batch_start = batch_end;
415        }
416
417        Ok(results)
418    }
419
420    /// Write multiple PLC regions in one or more S7 PDU exchanges.
421    ///
422    /// Automatically batches items when the count or encoded size would exceed the
423    /// negotiated PDU size or the Siemens hard limit of 20 items per PDU.
424    /// Returns `Ok(())` only when all items are acknowledged with return code 0xFF.
425    pub async fn write_multi_vars(&self, items: &[MultiWriteItem]) -> Result<()> {
426        if items.is_empty() {
427            return Ok(());
428        }
429
430        const S7_HEADER: usize = 10;
431        const PARAM_OVERHEAD: usize = 2; // func + item count
432        const ADDR_ITEM_SIZE: usize = 12;
433        const DATA_ITEM_OVERHEAD: usize = 4; // reserved + transport + bit_len (2)
434        const MAX_ITEMS_PER_PDU: usize = 20;
435
436        let mut inner = self.inner.lock().await;
437        let pdu_size = inner.connection.pdu_size as usize;
438        let max_payload = pdu_size.saturating_sub(S7_HEADER + PARAM_OVERHEAD);
439
440        let mut batch_start = 0;
441
442        while batch_start < items.len() {
443            let mut batch_end = batch_start;
444            let mut bytes_used = 0usize;
445
446            while batch_end < items.len() && (batch_end - batch_start) < MAX_ITEMS_PER_PDU {
447                let item = &items[batch_end];
448                let data_len = item.data.len();
449                let item_size = ADDR_ITEM_SIZE + DATA_ITEM_OVERHEAD + data_len + (data_len % 2);
450
451                if batch_end > batch_start && bytes_used + item_size > max_payload {
452                    break;
453                }
454                bytes_used += item_size;
455                batch_end += 1;
456            }
457
458            let batch = &items[batch_start..batch_end];
459            let pdu_ref = Self::next_pdu_ref(&mut inner);
460
461            let req = WriteVarRequest {
462                items: batch
463                    .iter()
464                    .map(|item| WriteItem {
465                        address: AddressItem {
466                            area: item.area,
467                            db_number: item.db_number,
468                            start: item.start,
469                            bit_offset: 0,
470                            length: item.data.len() as u16,
471                            transport: TransportSize::Byte,
472                        },
473                        data: item.data.clone(),
474                    })
475                    .collect(),
476            };
477            let mut param_buf = BytesMut::new();
478            req.encode(&mut param_buf);
479
480            Self::send_s7(
481                &mut inner,
482                param_buf.freeze(),
483                Bytes::new(),
484                pdu_ref,
485                PduType::Job,
486            )
487            .await?;
488
489            let (header, mut body) = Self::recv_s7(&mut inner).await?;
490            check_plc_error(&header, "write_multi_vars")?;
491            if body.remaining() >= 2 {
492                body.advance(2); // skip func + item_count echo
493            }
494            let resp = WriteVarResponse::decode(&mut body, batch.len())?;
495            for (i, &code) in resp.return_codes.iter().enumerate() {
496                if code != 0xFF {
497                    return Err(Error::PlcError {
498                        code: code as u32,
499                        message: format!("item {} write error", batch_start + i),
500                    });
501                }
502            }
503
504            batch_start = batch_end;
505        }
506
507        Ok(())
508    }
509
510    pub async fn db_write(&self, db: u16, start: u32, data: &[u8]) -> Result<()> {
511        let mut inner = self.inner.lock().await;
512        let pdu_ref = Self::next_pdu_ref(&mut inner);
513
514        let req = WriteVarRequest {
515            items: vec![WriteItem {
516                address: AddressItem {
517                    area: Area::DataBlock,
518                    db_number: db,
519                    start,
520                    bit_offset: 0,
521                    length: data.len() as u16,
522                    transport: TransportSize::Byte,
523                },
524                data: Bytes::copy_from_slice(data),
525            }],
526        };
527        let mut param_buf = BytesMut::new();
528        req.encode(&mut param_buf);
529
530        Self::send_s7(
531            &mut inner,
532            param_buf.freeze(),
533            Bytes::new(),
534            pdu_ref,
535            PduType::Job,
536        )
537        .await?;
538
539        let (header, mut body) = Self::recv_s7(&mut inner).await?;
540        check_plc_error(&header, "db_write")?;
541        if body.has_remaining() {
542            body.advance(2); // skip func + item count
543        }
544        let resp = WriteVarResponse::decode(&mut body, 1)?;
545        if resp.return_codes[0] != 0xFF {
546            return Err(Error::PlcError {
547                code: resp.return_codes[0] as u32,
548                message: "write error".into(),
549            });
550        }
551        Ok(())
552    }
553
554    /// Write to any PLC area with explicit transport size.
555    ///
556    /// For Timer/Counter areas the transport size byte in the request must match
557    /// the area (0x1D / 0x1C). For Marker use `TransportSize::Byte`.
558    pub async fn write_area(
559        &self,
560        area: Area,
561        db_number: u16,
562        start: u32,
563        transport: TransportSize,
564        data: &[u8],
565    ) -> Result<()> {
566        let mut inner = self.inner.lock().await;
567        let pdu_ref = Self::next_pdu_ref(&mut inner);
568
569        let req = WriteVarRequest {
570            items: vec![WriteItem {
571                address: AddressItem {
572                    area,
573                    db_number,
574                    start,
575                    bit_offset: 0,
576                    length: data.len() as u16,
577                    transport,
578                },
579                data: Bytes::copy_from_slice(data),
580            }],
581        };
582        let mut param_buf = BytesMut::new();
583        req.encode(&mut param_buf);
584
585        Self::send_s7(
586            &mut inner,
587            param_buf.freeze(),
588            Bytes::new(),
589            pdu_ref,
590            PduType::Job,
591        )
592        .await?;
593
594        let (header, mut body) = Self::recv_s7(&mut inner).await?;
595        check_plc_error(&header, "write_area")?;
596        if body.has_remaining() {
597            body.advance(2);
598        }
599        let resp = WriteVarResponse::decode(&mut body, 1)?;
600        if resp.return_codes[0] != 0xFF {
601            return Err(Error::PlcError {
602                code: resp.return_codes[0] as u32,
603                message: "write_area error".into(),
604            });
605        }
606        Ok(())
607    }
608
609    /// Read from any PLC area using absolute addressing.
610    ///
611    /// A convenience wrapper around [`read_multi_vars`](Self::read_multi_vars)
612    /// for a single area read.
613    pub async fn ab_read(
614        &self,
615        area: Area,
616        db_number: u16,
617        start: u32,
618        length: u16,
619    ) -> Result<Bytes> {
620        let items = [MultiReadItem {
621            area,
622            db_number,
623            start,
624            length,
625            transport: TransportSize::Byte,
626        }];
627        let mut results = self.read_multi_vars(&items).await?;
628        Ok(results.swap_remove(0))
629    }
630
631    /// Write to any PLC area using absolute addressing.
632    ///
633    /// A convenience wrapper around [`write_multi_vars`](Self::write_multi_vars)
634    /// for a single area write.
635    pub async fn ab_write(
636        &self,
637        area: Area,
638        db_number: u16,
639        start: u32,
640        data: &[u8],
641    ) -> Result<()> {
642        let items = [MultiWriteItem {
643            area,
644            db_number,
645            start,
646            data: Bytes::copy_from_slice(data),
647        }];
648        self.write_multi_vars(&items).await
649    }
650
651    pub async fn read_szl(&self, szl_id: u16, szl_index: u16) -> Result<SzlResponse> {
652        let payload = self.read_szl_payload(szl_id, szl_index).await?;
653        let mut b = payload;
654        Ok(SzlResponse::decode(&mut b)?)
655    }
656
657    /// Send a UserData SZL query and return the raw SZL data block
658    /// (starting with block_len, szl_id, szl_index, then entry data).
659    async fn read_szl_payload(&self, szl_id: u16, szl_index: u16) -> Result<Bytes> {
660        let mut inner = self.inner.lock().await;
661        let pdu_ref = Self::next_pdu_ref(&mut inner);
662
663        let req = SzlRequest { szl_id, szl_index };
664        let mut param_buf = BytesMut::new();
665        req.encode_params(&mut param_buf);
666        let mut data_buf = BytesMut::new();
667        req.encode_data(&mut data_buf);
668
669        Self::send_s7(
670            &mut inner,
671            param_buf.freeze(),
672            data_buf.freeze(),
673            pdu_ref,
674            PduType::UserData,
675        )
676        .await?;
677
678        let (header, mut body) = Self::recv_s7(&mut inner).await?;
679
680        // Skip the echoed param section
681        if body.remaining() < header.param_len as usize {
682            return Err(Error::UnexpectedResponse);
683        }
684        body.advance(header.param_len as usize);
685
686        // body is now the data section.
687        // Data envelope: return_code(1) + transport(1) + data_len(2)
688        // If shorter than 4, the PLC returned an error with no data.
689        if body.remaining() < 4 {
690            return Ok(Bytes::new());
691        }
692        let return_code = body.get_u8();
693        let _transport = body.get_u8();
694        let _data_len = body.get_u16();
695
696        // return_code 0xFF = success; anything else = PLC error (function not available etc.)
697        // Return empty payload so callers can handle gracefully.
698        if return_code != 0xFF {
699            return Ok(Bytes::new());
700        }
701
702        // Remaining is the SZL data block.
703        Ok(body.copy_to_bytes(body.remaining()))
704    }
705
706    pub async fn read_clock(&self) -> Result<PlcDateTime> {
707        let mut inner = self.inner.lock().await;
708        let pdu_ref = Self::next_pdu_ref(&mut inner);
709        let mut param_buf = BytesMut::new();
710        param_buf.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0xF5, 0x00]);
711        Self::send_s7(
712            &mut inner,
713            param_buf.freeze(),
714            Bytes::new(),
715            pdu_ref,
716            PduType::UserData,
717        )
718        .await?;
719        let (_header, mut body) = Self::recv_s7(&mut inner).await?;
720        if body.remaining() > 8 {
721            body.advance(body.remaining() - 8);
722        }
723        Ok(PlcDateTime::decode(&mut body)?)
724    }
725
726    /// Copy RAM data to ROM (function 0x43).
727    ///
728    /// Copies the CPU's work memory to its load memory (retain on power-off).
729    pub async fn copy_ram_to_rom(&self) -> Result<()> {
730        let mut inner = self.inner.lock().await;
731        let pdu_ref = Self::next_pdu_ref(&mut inner);
732        let param = Bytes::copy_from_slice(&[
733            0x00, 0x01, 0x12, 0x04, 0x43, 0x44, 0x01, 0x00,
734        ]);
735        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::UserData).await?;
736        let (header, _body) = Self::recv_s7(&mut inner).await?;
737        check_plc_error(&header, "copy_ram_to_rom")?;
738        Ok(())
739    }
740
741    /// Compress the PLC work memory (function 0x42).
742    ///
743    /// Reorganises memory to eliminate fragmentation.  The PLC must be in STOP
744    /// mode before calling this.
745    pub async fn compress(&self) -> Result<()> {
746        let mut inner = self.inner.lock().await;
747        let pdu_ref = Self::next_pdu_ref(&mut inner);
748        let param = Bytes::copy_from_slice(&[
749            0x00, 0x01, 0x12, 0x04, 0x42, 0x44, 0x01, 0x00,
750        ]);
751        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::UserData).await?;
752        let (header, _body) = Self::recv_s7(&mut inner).await?;
753        check_plc_error(&header, "compress")?;
754        Ok(())
755    }
756
757    // -- PLC control & status -------------------------------------------------
758
759    /// Send a simple Job with a 2-byte parameter (func + 0x00) and no data.
760    async fn simple_control(inner: &mut Inner<T>, pdu_ref: u16, func: u8) -> Result<()> {
761        let param = Bytes::copy_from_slice(&[func, 0x00]);
762        Self::send_s7(inner, param, Bytes::new(), pdu_ref, PduType::Job).await?;
763        let (header, _body) = Self::recv_s7(inner).await?;
764        check_plc_error(&header, "plc_control")?;
765        Ok(())
766    }
767
768    /// Stop the PLC (S7 function code 0x29).
769    ///
770    /// Sends a Job request with no additional data. Returns `Ok(())` when the
771    /// PLC acknowledges the command, or an error if the PLC rejects it
772    /// (e.g., password-protected or CPU in a non-stoppable state).
773    pub async fn plc_stop(&self) -> Result<()> {
774        let mut inner = self.inner.lock().await;
775        let pdu_ref = Self::next_pdu_ref(&mut inner);
776        Self::simple_control(&mut inner, pdu_ref, 0x29).await
777    }
778
779    /// Hot-start (warm restart) the PLC (S7 function code 0x28).
780    ///
781    /// A warm restart retains the DB content and retentive memory.
782    pub async fn plc_hot_start(&self) -> Result<()> {
783        let mut inner = self.inner.lock().await;
784        let pdu_ref = Self::next_pdu_ref(&mut inner);
785        Self::simple_control(&mut inner, pdu_ref, 0x28).await
786    }
787
788    /// Cold-start (full restart) the PLC (S7 function code 0x2A).
789    ///
790    /// A cold start clears all DBs and non-retentive memory.
791    pub async fn plc_cold_start(&self) -> Result<()> {
792        let mut inner = self.inner.lock().await;
793        let pdu_ref = Self::next_pdu_ref(&mut inner);
794        Self::simple_control(&mut inner, pdu_ref, 0x2A).await
795    }
796
797    /// Read the current PLC status via SZL 0x0424.
798    ///
799    /// Returns one of [`PlcStatus::Run`], [`PlcStatus::Stop`], or
800    /// [`PlcStatus::Unknown`].
801    pub async fn get_plc_status(&self) -> Result<crate::types::PlcStatus> {
802        let payload = self.read_szl_payload(0x0424, 0x0000).await?;
803        // SZL 0x0424 response layout (after stripping 4-byte data envelope):
804        //   [0..1]  SZL_ID  (0x0424)
805        //   [2..3]  SZL_INDEX (0x0000)
806        //   [4..5]  LENTHDR (entry length in bytes, big-endian)
807        //   [6..7]  N_DR (entry count, big-endian)
808        //   [8..]   first entry data
809        // C snap7 strips SZL_ID+SZL_INDEX (4 bytes), so its opData[7] = payload[11].
810        // Status byte = 4th byte of first entry = payload[11].
811        if payload.len() < 12 {
812            return Ok(crate::types::PlcStatus::Unknown);
813        }
814        let status_byte = payload[11];
815        match status_byte {
816            0x00 => Ok(crate::types::PlcStatus::Unknown),
817            0x04 => Ok(crate::types::PlcStatus::Stop),
818            0x08 => Ok(crate::types::PlcStatus::Run),
819            // Old CPUs sometimes encode STOP as 0x03
820            0x03 => Ok(crate::types::PlcStatus::Stop),
821            _ => Ok(crate::types::PlcStatus::Stop),
822        }
823    }
824
825    // -- PLC information queries (via SZL UserData) ---------------------------
826
827    /// Read the PLC order code (SZL ID 0x0011).
828    ///
829    /// The order code is a 20-character ASCII string (e.g. `"6ES7 317-2EK14-0AB0"`).
830    pub async fn get_order_code(&self) -> Result<crate::types::OrderCode> {
831        let payload = self.read_szl_payload(0x0011, 0x0000).await?;
832        if payload.len() < 8 {
833            return Err(Error::UnexpectedResponse);
834        }
835
836        // SZL 0x0011 payload: [szl_id:2][szl_index:2][entry_len:2][entry_count:2][entries...]
837        // Each entry: [index:2][data: entry_len-2 bytes, null-padded]
838        // Entry 0x0001 = order code string; version bytes = last 3 bytes of entire payload.
839        let n = payload.len();
840        let (v1, v2, v3) = if n >= 3 {
841            (payload[n - 3], payload[n - 2], payload[n - 1])
842        } else {
843            (0, 0, 0)
844        };
845
846        let mut b = payload.clone();
847        let szl_id = b.get_u16();
848        let _szl_idx = b.get_u16();
849        let entry_len = b.get_u16() as usize;
850        let entry_count = b.get_u16() as usize;
851
852        if (szl_id == 0x0011 || szl_id == 0x001C) && entry_len >= 4 && entry_count > 0 {
853            for _ in 0..entry_count {
854                if b.remaining() < entry_len { break; }
855                let entry_idx = b.get_u16();
856                let string_len = entry_len - 2;
857                let raw = b.copy_to_bytes(string_len);
858                if entry_idx == 0x0001 {
859                    let null_end = raw.iter().position(|&x| x == 0).unwrap_or(string_len);
860                    let code = String::from_utf8_lossy(&raw[..null_end]).trim().to_string();
861                    if !code.is_empty() {
862                        return Ok(crate::types::OrderCode { code, v1, v2, v3 });
863                    }
864                }
865            }
866        }
867
868        // Fallback: scan for "6ES"/"6AV"/"6GK" pattern anywhere in payload.
869        let code = scan_ascii_fields(&payload, 10, 4).into_iter().find(|s| {
870            let su = s.to_uppercase();
871            (su.starts_with("6ES") || su.starts_with("6AV") || su.starts_with("6GK"))
872                && s.len() >= 10
873                && s.bytes().all(|c| c.is_ascii_graphic() || c == b' ')
874        }).unwrap_or_default();
875        Ok(crate::types::OrderCode { code, v1, v2, v3 })
876    }
877
878    /// Read detailed CPU information (SZL ID 0x001C).
879    ///
880    /// Returns module type, serial number, plant identification, copyright
881    /// and module name fields pre-parsed from the SZL response.
882    /// Handles both classic S7-300/400 and S7-1200/1500 response formats.
883    pub async fn get_cpu_info(&self) -> Result<crate::types::CpuInfo> {
884        let payload = self.read_szl_payload(0x001C, 0x0000).await?;
885        if payload.len() < 8 {
886            return Err(Error::UnexpectedResponse);
887        }
888
889        // SZL 0x001C payload layout (after correct request framing):
890        //   [szl_id:2=0x001C][szl_index:2][entry_len:2][entry_count:2]
891        //   followed by entry_count entries, each entry_len bytes:
892        //     [entry_index:2][string_data: entry_len-2 bytes, null-padded]
893        //
894        // Entry indices observed on S7-300/400:
895        //   0x0001 = plant identification (AS name)
896        //   0x0002 = module type name (e.g. "CPU 319-3 PN/DP")
897        //   0x0003 = module name (OB1 program name)
898        //   0x0004 = copyright
899        //   0x0005 = serial number
900        //   0x0007 = module type name (duplicate in some firmware)
901        //   0x0008 = module name (duplicate in some firmware)
902        let mut b = payload.clone();
903        let szl_id = b.get_u16();
904        let _szl_idx = b.get_u16();
905        let entry_len = b.get_u16() as usize;
906        let entry_count = b.get_u16() as usize;
907
908        if szl_id == 0x001C && entry_len >= 4 && entry_count > 0 {
909            let mut module_type = String::new();
910            let mut module_type_canonical = String::new(); // index 0x0007 — always authoritative
911            let mut serial_number = String::new();
912            let mut as_name = String::new();
913            let mut copyright = String::new();
914            let mut module_name = String::new();
915
916            for _ in 0..entry_count {
917                if b.remaining() < entry_len { break; }
918                let entry_idx = b.get_u16();
919                let string_len = entry_len - 2;
920                let raw = b.copy_to_bytes(string_len);
921                let null_end = raw.iter().position(|&x| x == 0).unwrap_or(string_len);
922                let val = String::from_utf8_lossy(&raw[..null_end]).trim().to_string();
923                match entry_idx {
924                    0x0001 => { if as_name.is_empty() { as_name = val; } }
925                    // 0x0002 is module type on S7-300, AS name on S7-1500 — only use if
926                    // 0x0007 is absent (module_type_canonical will override below).
927                    0x0002 => { if module_type.is_empty() { module_type = val; } }
928                    0x0003 => { if module_name.is_empty() { module_name = val; } }
929                    0x0004 => { if copyright.is_empty() { copyright = val; } }
930                    0x0005 => { if serial_number.is_empty() { serial_number = val; } }
931                    // 0x0007 is always the true module type name (both S7-300 and S7-1500)
932                    0x0007 => { if module_type_canonical.is_empty() { module_type_canonical = val; } }
933                    // 0x0008 is SMC memory card on S7-1500 — do not use for module_name
934                    _ => {}
935                }
936            }
937
938            // 0x0007 wins over 0x0002 for module_type
939            if !module_type_canonical.is_empty() {
940                module_type = module_type_canonical;
941            }
942
943            if module_name.is_empty() && !as_name.is_empty() {
944                module_name = as_name.clone();
945            }
946
947            if !module_type.is_empty() || !serial_number.is_empty() || !as_name.is_empty() {
948                let protocol = detect_protocol(&payload, &module_type);
949                return Ok(crate::types::CpuInfo {
950                    module_type,
951                    serial_number,
952                    as_name,
953                    copyright,
954                    module_name,
955                    protocol,
956                });
957            }
958        }
959
960        // S7-1500 and some firmware variants use a tagged sub-record format.
961        // Fall back to scanning the raw payload for tagged string fields.
962        let data = payload.as_ref();
963        let (module_type, serial_number, as_name, copyright, module_name) =
964            parse_sub_record_fields(data);
965
966        if !module_type.is_empty() || !serial_number.is_empty() {
967            let protocol = detect_protocol(&payload, &module_type);
968            return Ok(crate::types::CpuInfo {
969                module_type,
970                serial_number,
971                as_name,
972                copyright,
973                module_name,
974                protocol,
975            });
976        }
977
978        // Last-resort scan: extract printable strings and apply heuristics.
979        let mut module_type = String::new();
980        let mut serial_number = String::new();
981        let mut as_name = String::new();
982        let mut copyright = String::new();
983        let mut module_name = String::new();
984
985        let mut scan = 0;
986        while scan < data.len() {
987            if data[scan].is_ascii_graphic() || data[scan] == b' ' {
988                let start = scan;
989                while scan < data.len() && (data[scan].is_ascii_graphic() || data[scan] == b' ') {
990                    scan += 1;
991                }
992                let val = String::from_utf8_lossy(&data[start..scan]).trim().to_string();
993                if val.len() >= 3 {
994                    let tag = if start >= 2 && data[start - 2] == 0x00 {
995                        Some(data[start - 1])
996                    } else {
997                        None
998                    };
999                    let su = val.to_uppercase();
1000                    if su.contains("BOOT") || su.starts_with("P B") || su.starts_with("HBOOT") {
1001                        // skip firmware label
1002                    } else if tag == Some(0x07) && module_type.is_empty() {
1003                        module_type = val;
1004                    } else if tag == Some(0x08) && module_name.is_empty() {
1005                        module_name = val;
1006                    } else if tag == Some(0x05) && as_name.is_empty() {
1007                        as_name = val;
1008                    } else if tag == Some(0x06) && copyright.is_empty() {
1009                        copyright = val;
1010                    } else if tag == Some(0x04) && serial_number.is_empty() {
1011                        serial_number = val;
1012                    } else if val.contains('-')
1013                        && val.chars().filter(|c| c.is_ascii_digit()).count() >= 4
1014                        && !val.starts_with("6ES7")
1015                        && serial_number.is_empty()
1016                    {
1017                        serial_number = val;
1018                    } else if su.contains("CPU") && su.contains("PN") && module_type.is_empty() {
1019                        module_type = val;
1020                    } else if module_type.is_empty() && val.len() >= 8 && !su.contains("MC_") {
1021                        module_type = val;
1022                    }
1023                }
1024            } else {
1025                scan += 1;
1026            }
1027        }
1028
1029        let protocol = detect_protocol(&payload, &module_type);
1030        Ok(crate::types::CpuInfo {
1031            module_type,
1032            serial_number,
1033            as_name,
1034            copyright,
1035            module_name,
1036            protocol,
1037        })
1038    }
1039    
1040    /// Read communication processor information (SZL ID 0x0131, index 0x0001).
1041    ///
1042    /// Returns maximum PDU length, connection count, and baud rates.
1043    pub async fn get_cp_info(&self) -> Result<crate::types::CpInfo> {
1044        // Index 0x0001 = communication module info entry (used by C snap7).
1045        let payload = self.read_szl_payload(0x0131, 0x0001).await?;
1046
1047        // SZL 0x0131 response wire format (after stripping the 4-byte data envelope):
1048        //   [szl_id:2][szl_index:2][entry_len:2][entry_count:2][entries...]
1049        // Each entry for index 0x0001 (S7-300/400/1200/1500):
1050        //   [index:2][max_pdu_len:2][max_connections:2][max_mpi_rate:4][max_bus_rate:4] = 14 bytes
1051
1052        let mut b = payload.clone();
1053        if b.remaining() < 8 {
1054            return Ok(crate::types::CpInfo {
1055                max_pdu_len: 0, max_connections: 0, max_mpi_rate: 0, max_bus_rate: 0,
1056            });
1057        }
1058
1059        let szl_id = b.get_u16();
1060        let _szl_idx = b.get_u16();
1061        let entry_len = b.get_u16() as usize;
1062        let entry_count = b.get_u16() as usize;
1063
1064        // Classic format (S7-300/400/1200): szl_id=0x0131, entries with 14-byte records
1065        if szl_id == 0x0131 && entry_len >= 12 && entry_count >= 1 && b.remaining() >= entry_len {
1066            let _entry_idx = b.get_u16();
1067            let max_pdu_len = b.get_u16() as u32;
1068            let max_connections = b.get_u16() as u32;
1069            let max_mpi_rate = b.get_u32();
1070            let max_bus_rate = b.get_u32();
1071            return Ok(crate::types::CpInfo {
1072                max_pdu_len,
1073                max_connections,
1074                max_mpi_rate,
1075                max_bus_rate,
1076            });
1077        }
1078
1079        // Fallback: scan for any parseable numeric data
1080        Ok(crate::types::CpInfo {
1081            max_pdu_len: 0,
1082            max_connections: 0,
1083            max_mpi_rate: 0,
1084            max_bus_rate: 0,
1085        })
1086    }
1087
1088    /// Read the rack module list (SZL ID 0x00A0).
1089    ///
1090    /// Each entry is a 2-byte module type identifier.
1091    pub async fn read_module_list(&self) -> Result<Vec<crate::types::ModuleEntry>> {
1092        let payload = self.read_szl_payload(0x00A0, 0x0000).await?;
1093        if payload.len() < 6 {
1094            return Ok(Vec::new());
1095        }
1096        let mut b = payload;
1097        let _block_len = b.get_u16();
1098        let _szl_id = b.get_u16();
1099        let _szl_ix = b.get_u16();
1100        // Skip the optional SZL entry_length prefix (2 bytes).
1101        skip_szl_entry_header(&mut b);
1102        let mut modules = Vec::new();
1103        while b.remaining() >= 2 {
1104            modules.push(crate::types::ModuleEntry {
1105                module_type: b.get_u16(),
1106            });
1107        }
1108        Ok(modules)
1109    }
1110
1111    // -- Block list & block info (via UserData grBlocksInfo) ------------------
1112
1113    /// List all blocks in the PLC grouped by type.
1114    ///
1115    /// Uses UserData function group 0x43 (grBlocksInfo), SubFun 0x01 (ListAll).
1116    /// Response: 7 entries of [Zero(1) BType(1) BCount(2)] = 28 bytes data.
1117    pub async fn list_blocks(&self) -> Result<crate::types::BlockList> {
1118        let mut inner = self.inner.lock().await;
1119        let pdu_ref = Self::next_pdu_ref(&mut inner);
1120
1121        // Params: Head[00 01 12 04] + Uk=0x11 + Tg=0x43(grBlocksInfo) + SubFun=0x01(ListAll) + Seq=0x00
1122        let param = Bytes::from_static(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x01, 0x00]);
1123        // Data: 4 bytes constant
1124        let data = Bytes::from_static(&[0x0A, 0x00, 0x00, 0x00]);
1125
1126        Self::send_s7(&mut inner, param, data, pdu_ref, PduType::UserData).await?;
1127        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1128
1129        // Skip echoed param section
1130        if body.remaining() < header.param_len as usize {
1131            return Err(Error::UnexpectedResponse);
1132        }
1133        body.advance(header.param_len as usize);
1134
1135        // Data envelope: RetVal(1) + TRSize(1) + Length(2)
1136        if body.remaining() < 4 {
1137            return Ok(crate::types::BlockList { total_count: 0, entries: Vec::new() });
1138        }
1139        let _ret_val = body.get_u8();
1140        let _tr_size = body.get_u8();
1141        let data_len = body.get_u16() as usize;
1142
1143        // 7 entries × 4 bytes = 28 bytes
1144        if data_len < 28 || body.remaining() < 28 {
1145            return Ok(crate::types::BlockList { total_count: 0, entries: Vec::new() });
1146        }
1147
1148        let mut entries = Vec::new();
1149        let mut total_count: u32 = 0;
1150        for _ in 0..7 {
1151            let _zero = body.get_u8();
1152            let block_type = body.get_u8() as u16;
1153            let count = body.get_u16();
1154            total_count += count as u32;
1155            entries.push(crate::types::BlockListEntry { block_type, count });
1156        }
1157
1158        Ok(crate::types::BlockList { total_count, entries })
1159    }
1160
1161    /// List all block numbers of a given type (grBlocksInfo / SFun_ListBoT = 0x02).
1162    ///
1163    /// `block_type` is the raw byte: 0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC,
1164    /// 0x44=SFC, 0x45=FB, 0x46=SFB.
1165    /// Returns a sorted vec of block numbers.
1166    pub async fn list_blocks_of_type(&self, block_type: u8) -> Result<Vec<u16>> {
1167        let mut numbers: Vec<u16> = Vec::new();
1168        let mut first = true;
1169        let mut seq: u8 = 0x00;
1170
1171        loop {
1172            let mut inner = self.inner.lock().await;
1173            let pdu_ref = Self::next_pdu_ref(&mut inner);
1174
1175            let (param, data) = if first {
1176                // First request: 8-byte params + 6-byte data
1177                // Params: Head[00 01 12 04] Uk=0x11 Tg=0x43 SubFun=0x02 Seq=0x00
1178                // Data:   RetVal=0xFF TSize=0x09 Length=0x0002 Zero=0x30 BlkType
1179                let mut p = BytesMut::with_capacity(8);
1180                p.extend_from_slice(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x02, 0x00]);
1181                let mut d = BytesMut::with_capacity(6);
1182                d.extend_from_slice(&[0xFF, 0x09, 0x00, 0x02, 0x30, block_type]);
1183                (p.freeze(), d.freeze())
1184            } else {
1185                // Continuation: 12-byte params + 4-byte data
1186                // Params: Head[00 01 12 08] Uk=0x12 Tg=0x43 SubFun=0x02 Seq=<seq> + 4 zero pad
1187                // Data:   0x0A 0x00 0x00 0x00
1188                let mut p = BytesMut::with_capacity(12);
1189                p.extend_from_slice(&[0x00, 0x01, 0x12, 0x08, 0x12, 0x43, 0x02, seq, 0x00, 0x00, 0x00, 0x00]);
1190                let d = Bytes::from_static(&[0x0A, 0x00, 0x00, 0x00]);
1191                (p.freeze(), d)
1192            };
1193
1194            Self::send_s7(&mut inner, param, data, pdu_ref, PduType::UserData).await?;
1195            let (header, mut body) = Self::recv_s7(&mut inner).await?;
1196
1197            // Skip echoed params
1198            if body.remaining() < header.param_len as usize {
1199                return Err(Error::UnexpectedResponse);
1200            }
1201            // Grab seq + done flag from params before advancing
1202            // ResParams layout (after S7 header): Head[3] Plen Uk Tg SubFun Seq [Rsvd(2) ErrNo(2)]
1203            // Seq is at param offset 7, Rsvd high byte at offset 8 indicates done (0x00 = done)
1204            let param_bytes = body.slice(..header.param_len as usize);
1205            let done = param_bytes.len() >= 10 && param_bytes[8] == 0x00;
1206            seq = if param_bytes.len() >= 8 { param_bytes[7] } else { 0 };
1207            body.advance(header.param_len as usize);
1208            drop(inner);
1209
1210            // Data envelope: RetVal(1) TSize(1) DataLen(2)
1211            if body.remaining() < 4 { break; }
1212            let ret_val = body.get_u8();
1213            let _tr_size = body.get_u8();
1214            let data_len = body.get_u16() as usize;
1215
1216            if ret_val != 0xFF || data_len < 4 || body.remaining() < data_len { break; }
1217
1218            // Items: each 4 bytes [BlockNum(2) Unknown(1) BlockLang(1)]
1219            // Count = (data_len - 4) / 4 + 1  (from C snap7 source)
1220            let item_count = ((data_len - 4) / 4) + 1;
1221            for _ in 0..item_count {
1222                if body.remaining() < 4 { break; }
1223                let block_num = body.get_u16();
1224                let _unknown = body.get_u8();
1225                let _lang = body.get_u8();
1226                numbers.push(block_num);
1227            }
1228
1229            first = false;
1230            if done { break; }
1231        }
1232
1233        numbers.sort_unstable();
1234        Ok(numbers)
1235    }
1236
1237    /// Internal: send a UserData block-info request (grBlocksInfo / SFun_BlkInfo=0x03).
1238    ///
1239    /// Params (8 bytes): Head[00 01 12 04] Uk=0x11 Tg=0x43 SubFun=0x03 Seq=0x00
1240    /// Data  (12 bytes): FF 09 00 08 30 <blktype> <ascii5> 41
1241    async fn block_info_query(
1242        &self,
1243        _func: u8,
1244        block_type: u8,
1245        block_number: u16,
1246    ) -> Result<Bytes> {
1247        let mut inner = self.inner.lock().await;
1248        let pdu_ref = Self::next_pdu_ref(&mut inner);
1249
1250        // Params: Head[00 01 12 04] Uk=0x11 Tg=0x43(grBlocksInfo) SubFun=0x03(BlkInfo) Seq=0x00
1251        let param = Bytes::from_static(&[0x00, 0x01, 0x12, 0x04, 0x11, 0x43, 0x03, 0x00]);
1252
1253        // Data: RetVal=0xFF TSize=0x09 DataLen=0x0008 BlkPrfx=0x30 BlkType AsciiBlk[5] A=0x41
1254        let mut data_buf = BytesMut::with_capacity(12);
1255        data_buf.extend_from_slice(&[0xFF, 0x09, 0x00, 0x08, 0x30, block_type]);
1256        // block_number as 5-digit ASCII
1257        let n = block_number as u32;
1258        data_buf.put_u8((n / 10000) as u8 + 0x30);
1259        data_buf.put_u8(((n % 10000) / 1000) as u8 + 0x30);
1260        data_buf.put_u8(((n % 1000) / 100) as u8 + 0x30);
1261        data_buf.put_u8(((n % 100) / 10) as u8 + 0x30);
1262        data_buf.put_u8((n % 10) as u8 + 0x30);
1263        data_buf.put_u8(0x41); // 'A'
1264
1265        Self::send_s7(&mut inner, param, data_buf.freeze(), pdu_ref, PduType::UserData).await?;
1266
1267        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1268
1269        // Response params: TResFunGetBlockInfo (12 bytes)
1270        // Head[3] Plen Uk Tg SubFun Seq Rsvd[2] ErrNo[2]
1271        let param_len = header.param_len as usize;
1272        if body.remaining() < param_len {
1273            return Err(Error::UnexpectedResponse);
1274        }
1275        let params = body.slice(..param_len);
1276        body.advance(param_len);
1277
1278        // Check ErrNo (bytes 10-11 of params)
1279        if params.len() >= 12 {
1280            let err_no = u16::from_be_bytes([params[10], params[11]]);
1281            if err_no != 0 {
1282                return Err(Error::PlcError {
1283                    code: err_no as u32,
1284                    message: format!("block info error: ErrNo=0x{err_no:04X}"),
1285                });
1286            }
1287        }
1288
1289        // Data envelope: RetVal(1) TSize(1) DataLen(2)
1290        if body.remaining() < 4 {
1291            return Err(Error::UnexpectedResponse);
1292        }
1293        let ret_val = body.get_u8();
1294        let _tr_size = body.get_u8();
1295        let _data_len = body.get_u16();
1296
1297        if ret_val != 0xFF {
1298            return Err(Error::PlcError {
1299                code: ret_val as u32,
1300                message: format!("block info RetVal=0x{ret_val:02X}"),
1301            });
1302        }
1303
1304        Ok(body.copy_to_bytes(body.remaining()))
1305    }
1306
1307    /// Get detailed information about a block stored on the PLC.
1308    ///
1309    /// `block_type` should be one of the [`BlockType`](crate::types::BlockType)
1310    /// discriminant values (e.g. `0x41` for DB, `0x38` for OB).
1311    pub async fn get_ag_block_info(
1312        &self,
1313        block_type: u8,
1314        block_number: u16,
1315    ) -> Result<crate::types::BlockInfo> {
1316        self.get_block_info(0x13, block_type, block_number).await
1317    }
1318
1319    /// Get detailed block information from the PG perspective.
1320    ///
1321    /// Same fields as [`get_ag_block_info`](Self::get_ag_block_info) but the
1322    /// information is from the programming-device viewpoint.
1323    pub async fn get_pg_block_info(
1324        &self,
1325        block_type: u8,
1326        block_number: u16,
1327    ) -> Result<crate::types::BlockInfo> {
1328        self.get_block_info(0x14, block_type, block_number).await
1329    }
1330
1331    /// Shared implementation for AG and PG block info.
1332    async fn get_block_info(
1333        &self,
1334        func: u8,
1335        block_type: u8,
1336        block_number: u16,
1337    ) -> Result<crate::types::BlockInfo> {
1338        let payload = self
1339            .block_info_query(func, block_type, block_number)
1340            .await?;
1341
1342        // Payload = TResDataBlockInfo fields after the 4-byte envelope (RetVal/TSize/DataLen
1343        // already consumed in block_info_query). Struct layout:
1344        //   Cst_b(1) BlkType(1) Cst_w1(2) Cst_w2(2) Cst_pp(2)
1345        //   Unknown_1(1) BlkFlags(1) BlkLang(1) SubBlkType(1) BlkNumber(2)
1346        //   LenLoadMem(4) BlkSec(4) CodeTime_ms(4) CodeTime_dy(2)
1347        //   IntfTime_ms(4) IntfTime_dy(2) SbbLen(2) AddLen(2)
1348        //   LocDataLen(2) MC7Len(2)
1349        //   Author(8) Family(8) Header(8)
1350        //   Version(1) Unknown_2(1) BlkChksum(2) Resvd1(4) Resvd2(4)
1351        // Minimum meaningful size: 40 bytes
1352        if payload.len() < 40 {
1353            return Err(Error::UnexpectedResponse);
1354        }
1355        let mut b = payload;
1356
1357        let _cst_b       = b.get_u8();
1358        let blk_type: u16 = b.get_u8().into();
1359        let _cst_w1      = b.get_u16();
1360        let _cst_w2      = b.get_u16();
1361        let _cst_pp      = b.get_u16();
1362        let _unknown_1   = b.get_u8();
1363        let flags        = b.get_u8() as u16;
1364        let language     = b.get_u8() as u16;
1365        let _sub_blk     = b.get_u8();
1366        let _blk_number  = b.get_u16(); // echoes block_number from request
1367        let len_load_mem = b.get_u32();
1368        let _blk_sec     = b.get_u32();
1369        let _code_ms     = b.get_u32();
1370        let _code_dy     = b.get_u16();
1371        let _intf_ms     = b.get_u32();
1372        let _intf_dy     = b.get_u16();
1373        let sbb_len      = b.get_u16();
1374        let _add_len     = b.get_u16();
1375        let local_data   = b.get_u16();
1376        let mc7_size     = b.get_u16();
1377
1378        fn read_str(b: &mut Bytes, n: usize) -> String {
1379            let s = b.slice(..n.min(b.remaining()));
1380            b.advance(n.min(b.remaining()));
1381            let end = s.iter().position(|&x| x == 0).unwrap_or(s.len());
1382            String::from_utf8_lossy(&s[..end]).trim().to_string()
1383        }
1384
1385        let author   = read_str(&mut b, 8);
1386        let family   = read_str(&mut b, 8);
1387        let header   = read_str(&mut b, 8);
1388        let version  = if b.remaining() >= 1 { b.get_u8() as u16 } else { 0 };
1389        let _unk2    = if b.remaining() >= 1 { b.get_u8() } else { 0 };
1390        let checksum = if b.remaining() >= 2 { b.get_u16() } else { 0 };
1391
1392        Ok(crate::types::BlockInfo {
1393            block_type: blk_type,
1394            block_number,
1395            language,
1396            flags,
1397            size: (len_load_mem.min(0xFFFF)) as u16,
1398            size_ram: sbb_len,
1399            mc7_size,
1400            local_data,
1401            checksum,
1402            version,
1403            author,
1404            family,
1405            header,
1406            date: String::new(),
1407        })
1408    }
1409
1410    // -- Security / protection (set/clear password + get protection) ----------
1411
1412    /// Set a session password for protected PLC access.
1413    ///
1414    /// The password is obfuscated using the S7 nibble-swap + XOR-0x55 algorithm
1415    /// and sent as a Job PDU with function code 0x12.  Passwords longer than
1416    /// 8 bytes are truncated.
1417    pub async fn set_session_password(&self, password: &str) -> Result<()> {
1418        let encrypted = crate::types::encrypt_password(password);
1419        let mut inner = self.inner.lock().await;
1420        let pdu_ref = Self::next_pdu_ref(&mut inner);
1421        let param = Bytes::copy_from_slice(&[0x12, 0x00]);
1422        let data = Bytes::copy_from_slice(&encrypted);
1423        Self::send_s7(&mut inner, param, data, pdu_ref, PduType::Job).await?;
1424        let (header, _body) = Self::recv_s7(&mut inner).await?;
1425        check_plc_error(&header, "set_session_password")?;
1426        Ok(())
1427    }
1428
1429    /// Clear the session password on the PLC (function code 0x11).
1430    pub async fn clear_session_password(&self) -> Result<()> {
1431        let mut inner = self.inner.lock().await;
1432        let pdu_ref = Self::next_pdu_ref(&mut inner);
1433        let param = Bytes::copy_from_slice(&[0x11, 0x00]);
1434        Self::send_s7(&mut inner, param, Bytes::new(), pdu_ref, PduType::Job).await?;
1435        let (header, _body) = Self::recv_s7(&mut inner).await?;
1436        check_plc_error(&header, "clear_session_password")?;
1437        Ok(())
1438    }
1439
1440    /// Read the current protection level (SZL ID 0x0032, index 0x0004).
1441    ///
1442    /// Returns the protection scheme identifiers and level;
1443    /// `password_set` is `true` when the PLC reports a non-empty password.
1444    pub async fn get_protection(&self) -> Result<crate::types::Protection> {
1445        let payload = self.read_szl_payload(0x0032, 0x0004).await?;
1446        if payload.len() < 14 {
1447            return Err(Error::UnexpectedResponse);
1448        }
1449        let mut b = payload;
1450        let _block_len = b.get_u16();
1451        let _szl_id = b.get_u16();
1452        let _szl_ix = b.get_u16();
1453        // Skip the optional SZL entry_length prefix (2 bytes).
1454        skip_szl_entry_header(&mut b);
1455        let scheme_szl = b.get_u16();
1456        let scheme_module = b.get_u16();
1457        let scheme_bus = b.get_u16();
1458        let level = b.get_u16();
1459        // Next 8 bytes = pass_word field ("PASSWORD" if set, spaces otherwise)
1460        let pass_wort = if b.remaining() >= 8 {
1461            String::from_utf8_lossy(&b[..8]).trim().to_string()
1462        } else {
1463            String::new()
1464        };
1465        let password_set = pass_wort.eq_ignore_ascii_case("PASSWORD");
1466        Ok(crate::types::Protection {
1467            scheme_szl,
1468            scheme_module,
1469            scheme_bus,
1470            level,
1471            password_set,
1472        })
1473    }
1474
1475    // -- Block upload / download / delete ------------------------------------
1476    //
1477    // S7 function 0x1D = Upload  (sub-fn: 0=start, 1=data, 2=end)
1478    // S7 function 0x1E = Download (sub-fn: 0=start, 1=data, 2=end)
1479    // S7 function 0x1F = Delete
1480
1481    /// Delete a block from the PLC (S7 function code 0x1F).
1482    pub async fn delete_block(&self, block_type: u8, block_number: u16) -> Result<()> {
1483        let mut inner = self.inner.lock().await;
1484        let pdu_ref = Self::next_pdu_ref(&mut inner);
1485        // param: [0x1F, 0x00, block_type, 0x00, block_number(2)]
1486        let mut param = BytesMut::with_capacity(6);
1487        param.extend_from_slice(&[0x1F, 0x00, block_type, 0x00]);
1488        param.put_u16(block_number);
1489        Self::send_s7(
1490            &mut inner,
1491            param.freeze(),
1492            Bytes::new(),
1493            pdu_ref,
1494            PduType::Job,
1495        )
1496        .await?;
1497        let (header, _body) = Self::recv_s7(&mut inner).await?;
1498        check_plc_error(&header, "delete_block")?;
1499        Ok(())
1500    }
1501
1502    /// Upload a PLC block via S7 PI-Upload (function 0x1D).
1503    ///
1504    /// Returns the raw block bytes in Diagra format (20-byte header + payload).
1505    /// Use [`BlockData::from_bytes`] to parse the result.
1506    pub async fn upload(&self, block_type: u8, block_number: u16) -> Result<Vec<u8>> {
1507        let mut inner = self.inner.lock().await;
1508        let pdu_ref = Self::next_pdu_ref(&mut inner);
1509
1510        // --- Step 1: Start upload (sub-fn=0x00) ---
1511        // param: [0x1D, 0x00, block_type, 0x00, block_number(2)]
1512        let mut param = BytesMut::with_capacity(6);
1513        param.extend_from_slice(&[0x1D, 0x00, block_type, 0x00]);
1514        param.put_u16(block_number);
1515        Self::send_s7(
1516            &mut inner,
1517            param.freeze(),
1518            Bytes::new(),
1519            pdu_ref,
1520            PduType::Job,
1521        )
1522        .await?;
1523        let (header, mut body) = Self::recv_s7(&mut inner).await?;
1524        check_plc_error(&header, "upload_start")?;
1525        // Response data: [upload_id(4)][total_len(4)]
1526        if body.remaining() < 8 {
1527            return Err(Error::UnexpectedResponse);
1528        }
1529        if body.remaining() >= 2 {
1530            body.advance(2); // skip param echo
1531        }
1532        let upload_id = body.get_u32();
1533        let _total_len = body.get_u32();
1534
1535        // --- Step 2: Loop data chunks (sub-fn=0x01) ---
1536        let mut block_data = Vec::new();
1537        loop {
1538            let chunk_pdu_ref = Self::next_pdu_ref(&mut inner);
1539            let mut dparam = BytesMut::with_capacity(6);
1540            dparam.extend_from_slice(&[0x1D, 0x01]);
1541            dparam.put_u32(upload_id);
1542            Self::send_s7(
1543                &mut inner,
1544                dparam.freeze(),
1545                Bytes::new(),
1546                chunk_pdu_ref,
1547                PduType::Job,
1548            )
1549            .await?;
1550            let (dheader, mut dbody) = Self::recv_s7(&mut inner).await?;
1551            check_plc_error(&dheader, "upload_data")?;
1552            // Skip param echo
1553            if dbody.remaining() >= 2 {
1554                dbody.advance(2);
1555            }
1556            if dbody.is_empty() {
1557                break; // no more data
1558            }
1559            // The first data PDU may have a 4-byte "data header" before the actual block data
1560            // (return_code + transport + bit_len).  Skip it.
1561            if block_data.is_empty() && dbody.remaining() >= 4 {
1562                // Peek at the first byte — if it looks like a return_code (0xFF), skip 4
1563                if dbody[0] == 0xFF || dbody[0] == 0x00 {
1564                    dbody.advance(4);
1565                }
1566            }
1567            let chunk = dbody.copy_to_bytes(dbody.remaining());
1568            block_data.extend_from_slice(&chunk);
1569
1570            // If this chunk was smaller than PDU size, it's the last one
1571            if chunk.len() < inner.connection.pdu_size as usize - 50 {
1572                break;
1573            }
1574            // Safety: prevent infinite loop on broken PLC
1575            if block_data.len() > 1024 * 1024 * 4 {
1576                // 4 MB
1577                return Err(Error::UnexpectedResponse);
1578            }
1579        }
1580
1581        // --- Step 3: End upload (sub-fn=0x02) ---
1582        let end_pdu_ref = Self::next_pdu_ref(&mut inner);
1583        let mut eparam = BytesMut::with_capacity(6);
1584        eparam.extend_from_slice(&[0x1D, 0x02]);
1585        eparam.put_u32(upload_id);
1586        Self::send_s7(
1587            &mut inner,
1588            eparam.freeze(),
1589            Bytes::new(),
1590            end_pdu_ref,
1591            PduType::Job,
1592        )
1593        .await?;
1594        let (eheader, _ebody) = Self::recv_s7(&mut inner).await?;
1595        check_plc_error(&eheader, "upload_end")?;
1596
1597        Ok(block_data)
1598    }
1599
1600    /// Upload a DB block (convenience wrapper around [`upload`](Self::upload)).
1601    pub async fn db_get(&self, db_number: u16) -> Result<Vec<u8>> {
1602        self.upload(0x41, db_number).await // Block_DB = 0x41
1603    }
1604
1605    /// Download a block to the PLC (S7 function 0x1E).
1606    ///
1607    /// `data` should be in Diagra format (20-byte header + payload, as returned by
1608    /// [`upload`](Self::upload) or built via [`BlockData::to_bytes`]).
1609    pub async fn download(&self, block_type: u8, block_number: u16, data: &[u8]) -> Result<()> {
1610        let total_len = data.len() as u32;
1611        let mut inner = self.inner.lock().await;
1612        let pdu_avail = (inner.connection.pdu_size as usize).saturating_sub(50);
1613
1614        // --- Step 1: Start download (sub-fn=0x00) ---
1615        let start_ref = Self::next_pdu_ref(&mut inner);
1616        // param: [0x1E, 0x00, block_type, 0x00, block_number(2), total_len(4)]
1617        let mut sparam = BytesMut::with_capacity(10);
1618        sparam.extend_from_slice(&[0x1E, 0x00, block_type, 0x00]);
1619        sparam.put_u16(block_number);
1620        sparam.put_u32(total_len);
1621
1622        // First data chunk
1623        let chunk_len = pdu_avail.min(data.len());
1624        let first_chunk = Bytes::copy_from_slice(&data[..chunk_len]);
1625        Self::send_s7(
1626            &mut inner,
1627            sparam.freeze(),
1628            first_chunk,
1629            start_ref,
1630            PduType::Job,
1631        )
1632        .await?;
1633
1634        let (sheader, mut sbody) = Self::recv_s7(&mut inner).await?;
1635        check_plc_error(&sheader, "download_start")?;
1636        // Response: [download_id(4)]
1637        if sbody.remaining() >= 2 {
1638            sbody.advance(2); // skip param echo
1639        }
1640        if sbody.remaining() < 4 {
1641            return Err(Error::UnexpectedResponse);
1642        }
1643        let download_id = sbody.get_u32();
1644
1645        let mut offset = chunk_len;
1646
1647        // --- Step 2: Send remaining data chunks (sub-fn=0x01) ---
1648        while offset < data.len() {
1649            let chunk_ref = Self::next_pdu_ref(&mut inner);
1650            let end = (offset + pdu_avail).min(data.len());
1651            let chunk = Bytes::copy_from_slice(&data[offset..end]);
1652
1653            let mut dparam = BytesMut::with_capacity(6);
1654            dparam.extend_from_slice(&[0x1E, 0x01]);
1655            dparam.put_u32(download_id);
1656
1657            Self::send_s7(
1658                &mut inner,
1659                dparam.freeze(),
1660                chunk,
1661                chunk_ref,
1662                PduType::Job,
1663            )
1664            .await?;
1665
1666            let (dheader, _dbody) = Self::recv_s7(&mut inner).await?;
1667            check_plc_error(&dheader, "download_data")?;
1668            offset = end;
1669        }
1670
1671        // --- Step 3: End download (sub-fn=0x02) ---
1672        let end_ref = Self::next_pdu_ref(&mut inner);
1673        let mut eparam = BytesMut::with_capacity(6);
1674        eparam.extend_from_slice(&[0x1E, 0x02]);
1675        eparam.put_u32(download_id);
1676        Self::send_s7(
1677            &mut inner,
1678            eparam.freeze(),
1679            Bytes::new(),
1680            end_ref,
1681            PduType::Job,
1682        )
1683        .await?;
1684        let (eheader, _ebody) = Self::recv_s7(&mut inner).await?;
1685        check_plc_error(&eheader, "download_end")?;
1686
1687        Ok(())
1688    }
1689
1690    /// Fill a DB with a constant byte value.
1691    ///
1692    /// Uses [`get_ag_block_info`](Self::get_ag_block_info) to determine the DB
1693    /// size, then writes every byte to `value`.
1694    pub async fn db_fill(&self, db_number: u16, value: u8) -> Result<()> {
1695        let info = self.get_ag_block_info(0x41, db_number).await?; // Block_DB = 0x41
1696        let size = info.size as usize;
1697        if size == 0 {
1698            return Err(Error::PlcError {
1699                code: 0,
1700                message: format!("DB{db_number} has zero size"),
1701            });
1702        }
1703        let data = vec![value; size];
1704        // Write in chunks to respect PDU limits
1705        let chunk_size = 240usize; // conservative
1706        for offset in (0..size).step_by(chunk_size) {
1707            let end = (offset + chunk_size).min(size);
1708            self.db_write(db_number, offset as u32, &data[offset..end])
1709                .await?;
1710        }
1711        Ok(())
1712    }
1713}
1714
1715/// If the leading bytes look like an SZL entry_length header (2-byte big-endian u16
1716/// length value where the high byte is zero), skip them.  Real Siemens PLCs include
1717/// this header; our test server omits it.
1718fn skip_szl_entry_header(data: &mut Bytes) {
1719    if data.len() >= 2 && data[0] == 0x00 && data[1] > 0 && data[1] <= 200 {
1720        data.advance(2);
1721    }
1722}
1723
1724/// Scan byte data for sequences of visible ASCII characters and return them
1725/// as a vector of trimmed strings.  Skips non-ASCII and control bytes between
1726/// sequences.  Useful for extracting CPU info fields from SZL responses across
1727/// different PLC models and firmware versions.
1728fn scan_ascii_fields(data: &[u8], max_count: usize, min_len: usize) -> Vec<String> {
1729    let mut fields = Vec::new();
1730    let mut i = 0;
1731    while i < data.len() && fields.len() < max_count {
1732        // Skip bytes that are not visible ASCII (0x20-0x7E)
1733        if !data[i].is_ascii_graphic() && data[i] != b' ' {
1734            i += 1;
1735            continue;
1736        }
1737        // Collect a run of visible ASCII
1738        let start = i;
1739        while i < data.len() && (data[i].is_ascii_graphic() || data[i] == b' ') {
1740            i += 1;
1741        }
1742        let s = String::from_utf8_lossy(&data[start..i]).trim().to_string();
1743        if s.len() >= min_len {
1744            fields.push(s);
1745        }
1746    }
1747    fields
1748}
1749
1750/// Parse the S7-300 sub-record format used in SZL 0x001C responses.
1751///
1752/// This format uses tagged records: `[00 <tag> <string>] ...` where
1753/// known tags are:
1754/// - 0x01: order code / module identification
1755/// - 0x05: plant identification (AS name)
1756/// - 0x06: serial number
1757/// - 0x07: module type name
1758/// - 0x08: module name
1759fn parse_sub_record_fields(b: &[u8]) -> (String, String, String, String, String) {
1760    let mut module_type = String::new();
1761    let mut serial_number = String::new();
1762    let mut as_name = String::new();
1763    let mut copyright = String::new();
1764    let mut module_name = String::new();
1765
1766    let mut i = 0;
1767    while i + 2 < b.len() {
1768        // Look for 00 <tag> pattern with a known sub-record tag (1..=8)
1769        if b[i] == 0x00 && (1..=8).contains(&b[i + 1]) {
1770            let tag = b[i + 1];
1771            let start = i + 2;
1772
1773            // Find end of string: next 0x00 byte (including 00 C0)
1774            let mut end = start;
1775            while end < b.len() && b[end] != 0x00 {
1776                end += 1;
1777            }
1778
1779            let raw = &b[start..end];
1780            let val = String::from_utf8_lossy(raw).trim().to_string();
1781
1782            // Skip empty and firmware-label values
1783            let su = val.to_uppercase();
1784            if !val.is_empty() && !su.contains("BOOT") && !su.starts_with("P B") {
1785                match tag {
1786                    0x01 => {
1787                        // Tag 0x01 may be order code (starts with "6ES") or module type.
1788                        if !val.starts_with("6ES") && module_type.is_empty() {
1789                            module_type = val;
1790                        }
1791                    }
1792                    0x05 => { if as_name.is_empty() { as_name = val; } }
1793                    0x06 => { if serial_number.is_empty() { serial_number = val; } }
1794                    0x07 => { if module_type.is_empty() { module_type = val; } }
1795                    0x08 => { if module_name.is_empty() { module_name = val; } }
1796                    _ => {}
1797                }
1798            }
1799
1800            i = end;
1801        } else {
1802            i += 1;
1803        }
1804    }
1805
1806    // Also scan for free-standing printable strings that look like copyright
1807    // (e.g. "Boot Loader" appearing after the tagged records).
1808    if copyright.is_empty() {
1809        let mut scan = 0;
1810        while scan < b.len() {
1811            if b[scan].is_ascii_graphic() || b[scan] == b' ' {
1812                let s = scan;
1813                while scan < b.len() && (b[scan].is_ascii_graphic() || b[scan] == b' ') {
1814                    scan += 1;
1815                }
1816                let val = String::from_utf8_lossy(&b[s..scan]).trim().to_string();
1817                let su = val.to_uppercase();
1818                if val.len() >= 3 {
1819                    if su.contains("BOOT") || su.starts_with("P B") {
1820                        copyright = val;
1821                        break;
1822                    }
1823                }
1824            } else {
1825                scan += 1;
1826            }
1827        }
1828    }
1829
1830    (module_type, serial_number, as_name, copyright, module_name)
1831}
1832
1833/// Determine the S7 protocol variant from the raw SZL payload and extracted module type.
1834///
1835/// - S7-1200/1500/ET200SP uses S7+ protocol: detected from the 0x00 0x01 record marker in the
1836///   payload, or from a module_type containing `"15"` in its model number.
1837/// - Everything else (S7-300, S7-400, S7-1200) uses classic S7 protocol.
1838fn detect_protocol(_payload: &[u8], module_type: &str) -> crate::types::Protocol {
1839    // S7+ protocol: S7-1200, S7-1500, ET 200SP CPU
1840    // Classic S7: S7-300, S7-400
1841    let upper = module_type.to_uppercase();
1842    let is_s7plus = upper.contains("1500")
1843        || upper.contains("1200")
1844        || upper.contains("ET 200SP")
1845        || upper.contains("ET200SP")
1846        // "CPU 15xx" catches 1511, 1513, 1515, 1516, 1517, 1518
1847        || (upper.contains("CPU") && {
1848            let after_cpu = upper.find("CPU").map(|i| &upper[i+3..]).unwrap_or("");
1849            let num: String = after_cpu.chars().skip_while(|c| !c.is_ascii_digit()).take_while(|c| c.is_ascii_digit()).collect();
1850            matches!(num.get(..2), Some("12") | Some("15"))
1851        });
1852
1853    if is_s7plus {
1854        crate::types::Protocol::S7Plus
1855    } else {
1856        crate::types::Protocol::S7
1857    }
1858}
1859
1860
1861/// Decode common S7 protocol error class/code pairs into human-readable descriptions.
1862fn s7_error_description(ec: u8, ecd: u8) -> &'static str {
1863    match (ec, ecd) {
1864        (0x81, 0x04) => "function not supported or access denied by PLC",
1865        (0x81, 0x01) => "reserved by HW or SW function not available",
1866        (0x82, 0x04) => "PLC is in STOP mode, function not possible",
1867        (0x05, 0x01) => "invalid block type number",
1868        (0xD2, 0x01) => "object already exists, download rejected",
1869        (0xD2, 0x02) => "object does not exist, upload failed",
1870        (0xD6, 0x01) => "password protection violation",
1871        (0xD6, 0x05) => "insufficient privilege for this operation",
1872        _ => "unknown error",
1873    }
1874}
1875
1876fn check_plc_error(header: &S7Header, context: &str) -> Result<()> {
1877    if let (Some(ec), Some(ecd)) = (header.error_class, header.error_code) {
1878        if ec != 0 || ecd != 0 {
1879            let detail = s7_error_description(ec, ecd);
1880            return Err(Error::PlcError {
1881                code: ((ec as u32) << 8) | ecd as u32,
1882                message: format!("{}: {} (error_class=0x{ec:02X}, error_code=0x{ecd:02X})", context, detail),
1883            });
1884        }
1885    }
1886    Ok(())
1887}
1888
1889impl S7Client<crate::transport::TcpTransport> {
1890    pub async fn connect(addr: SocketAddr, params: ConnectParams) -> Result<Self> {
1891        let transport =
1892            crate::transport::TcpTransport::connect(addr, params.connect_timeout).await?;
1893        Self::from_transport(transport, params).await
1894    }
1895}
1896
1897impl S7Client<crate::UdpTransport> {
1898    /// Connect to a PLC using UDP transport.
1899    pub async fn connect_udp(addr: SocketAddr, params: ConnectParams) -> Result<Self> {
1900        let transport = crate::UdpTransport::connect(addr)
1901            .await
1902            .map_err(Error::Io)?;
1903        Self::from_transport(transport, params).await
1904    }
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910    use bytes::BufMut;
1911    use crate::proto::{
1912        cotp::CotpPdu,
1913        s7::{
1914            header::{PduType, S7Header},
1915            negotiate::NegotiateResponse,
1916        },
1917        tpkt::TpktFrame,
1918    };
1919    use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
1920
1921    async fn mock_plc_db_read(mut server_io: tokio::io::DuplexStream, response_data: Vec<u8>) {
1922        let mut buf = vec![0u8; 4096];
1923
1924        // respond to COTP CR
1925        let _ = server_io.read(&mut buf).await;
1926        let cc = CotpPdu::ConnectConfirm {
1927            dst_ref: 1,
1928            src_ref: 1,
1929        };
1930        let mut cb = BytesMut::new();
1931        cc.encode(&mut cb);
1932        let mut tb = BytesMut::new();
1933        TpktFrame {
1934            payload: cb.freeze(),
1935        }
1936        .encode(&mut tb)
1937        .unwrap();
1938        server_io.write_all(&tb).await.unwrap();
1939
1940        // respond to S7 negotiate
1941        let _ = server_io.read(&mut buf).await;
1942        let neg = NegotiateResponse {
1943            max_amq_calling: 1,
1944            max_amq_called: 1,
1945            pdu_length: 480,
1946        };
1947        let mut s7b = BytesMut::new();
1948        S7Header {
1949            pdu_type: PduType::AckData,
1950            reserved: 0,
1951            pdu_ref: 1,
1952            param_len: 8,
1953            data_len: 0,
1954            error_class: Some(0),
1955            error_code: Some(0),
1956        }
1957        .encode(&mut s7b);
1958        neg.encode(&mut s7b);
1959        let dt = CotpPdu::Data {
1960            tpdu_nr: 0,
1961            last: true,
1962            payload: s7b.freeze(),
1963        };
1964        let mut cb = BytesMut::new();
1965        dt.encode(&mut cb);
1966        let mut tb = BytesMut::new();
1967        TpktFrame {
1968            payload: cb.freeze(),
1969        }
1970        .encode(&mut tb)
1971        .unwrap();
1972        server_io.write_all(&tb).await.unwrap();
1973
1974        // respond to db_read
1975        let _ = server_io.read(&mut buf).await;
1976        let mut s7b = BytesMut::new();
1977        S7Header {
1978            pdu_type: PduType::AckData,
1979            reserved: 0,
1980            pdu_ref: 2,
1981            param_len: 2,
1982            data_len: (4 + response_data.len()) as u16,
1983            error_class: Some(0),
1984            error_code: Some(0),
1985        }
1986        .encode(&mut s7b);
1987        s7b.extend_from_slice(&[0x04, 0x01]); // ReadVar func + 1 item
1988        s7b.put_u8(0xFF); // return_code = success
1989        s7b.put_u8(0x04); // transport = word
1990        s7b.put_u16((response_data.len() * 8) as u16);
1991        s7b.extend_from_slice(&response_data);
1992        let dt = CotpPdu::Data {
1993            tpdu_nr: 0,
1994            last: true,
1995            payload: s7b.freeze(),
1996        };
1997        let mut cb = BytesMut::new();
1998        dt.encode(&mut cb);
1999        let mut tb = BytesMut::new();
2000        TpktFrame {
2001            payload: cb.freeze(),
2002        }
2003        .encode(&mut tb)
2004        .unwrap();
2005        server_io.write_all(&tb).await.unwrap();
2006    }
2007
2008    #[tokio::test]
2009    async fn db_read_returns_data() {
2010        let (client_io, server_io) = duplex(4096);
2011        let params = ConnectParams::default();
2012        let expected = vec![0xDE, 0xAD, 0xBE, 0xEF];
2013        tokio::spawn(mock_plc_db_read(server_io, expected.clone()));
2014        let client = S7Client::from_transport(client_io, params).await.unwrap();
2015        let data = client.db_read(1, 0, 4).await.unwrap();
2016        assert_eq!(&data[..], &expected[..]);
2017    }
2018
2019    /// Mock that handles COTP+Negotiate handshake then serves one multi-read response.
2020    async fn mock_plc_multi_read(
2021        mut server_io: tokio::io::DuplexStream,
2022        items: Vec<Vec<u8>>, // one byte vec per item
2023    ) {
2024        let mut buf = vec![0u8; 4096];
2025
2026        // COTP CR
2027        let _ = server_io.read(&mut buf).await;
2028        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2029        let mut cb = BytesMut::new();
2030        cc.encode(&mut cb);
2031        let mut tb = BytesMut::new();
2032        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2033        server_io.write_all(&tb).await.unwrap();
2034
2035        // S7 Negotiate
2036        let _ = server_io.read(&mut buf).await;
2037        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: 480 };
2038        let mut s7b = BytesMut::new();
2039        S7Header {
2040            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2041            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2042        }.encode(&mut s7b);
2043        neg.encode(&mut s7b);
2044        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2045        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2046        let mut tb = BytesMut::new();
2047        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2048        server_io.write_all(&tb).await.unwrap();
2049
2050        // ReadMultiVar request
2051        let _ = server_io.read(&mut buf).await;
2052
2053        // Build response data: one DataItem per input item
2054        let item_count = items.len() as u8;
2055        let mut data_bytes = BytesMut::new();
2056        for item_data in &items {
2057            data_bytes.put_u8(0xFF); // return_code OK
2058            data_bytes.put_u8(0x04); // transport byte
2059            data_bytes.put_u16((item_data.len() * 8) as u16);
2060            data_bytes.extend_from_slice(item_data);
2061            if item_data.len() % 2 != 0 {
2062                data_bytes.put_u8(0x00); // pad
2063            }
2064        }
2065        let data_len = data_bytes.len() as u16;
2066        let mut s7b = BytesMut::new();
2067        S7Header {
2068            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
2069            param_len: 2, data_len, error_class: Some(0), error_code: Some(0),
2070        }.encode(&mut s7b);
2071        s7b.extend_from_slice(&[0x04, item_count]); // func + item_count
2072        s7b.extend_from_slice(&data_bytes);
2073
2074        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2075        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2076        let mut tb = BytesMut::new();
2077        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2078        server_io.write_all(&tb).await.unwrap();
2079    }
2080
2081    #[tokio::test]
2082    async fn read_multi_vars_returns_all_items() {
2083        let (client_io, server_io) = duplex(4096);
2084        let params = ConnectParams::default();
2085        let item1 = vec![0xDE, 0xAD, 0xBE, 0xEF];
2086        let item2 = vec![0x01, 0x02];
2087        tokio::spawn(mock_plc_multi_read(server_io, vec![item1.clone(), item2.clone()]));
2088        let client = S7Client::from_transport(client_io, params).await.unwrap();
2089        let items = [MultiReadItem::db(1, 0, 4), MultiReadItem::db(2, 10, 2)];
2090        let results = client.read_multi_vars(&items).await.unwrap();
2091        assert_eq!(results.len(), 2);
2092        assert_eq!(&results[0][..], &item1[..]);
2093        assert_eq!(&results[1][..], &item2[..]);
2094    }
2095
2096    #[tokio::test]
2097    async fn read_multi_vars_empty_returns_empty() {
2098        let (client_io, server_io) = duplex(4096);
2099        let params = ConnectParams::default();
2100        tokio::spawn(mock_plc_multi_read(server_io, vec![]));
2101        let client = S7Client::from_transport(client_io, params).await.unwrap();
2102        let results = client.read_multi_vars(&[]).await.unwrap();
2103        assert!(results.is_empty());
2104    }
2105
2106    /// Mock that handles COTP+Negotiate then serves N write-response round-trips.
2107    /// `batches` is a list of item counts per round-trip; the mock sends 0xFF for each.
2108    async fn mock_plc_multi_write(
2109        mut server_io: tokio::io::DuplexStream,
2110        pdu_size: u16,
2111        batches: Vec<usize>,
2112    ) {
2113        let mut buf = vec![0u8; 65536];
2114
2115        // COTP CR
2116        let _ = server_io.read(&mut buf).await;
2117        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2118        let mut cb = BytesMut::new(); cc.encode(&mut cb);
2119        let mut tb = BytesMut::new();
2120        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2121        server_io.write_all(&tb).await.unwrap();
2122
2123        // S7 Negotiate
2124        let _ = server_io.read(&mut buf).await;
2125        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: pdu_size };
2126        let mut s7b = BytesMut::new();
2127        S7Header {
2128            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2129            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2130        }.encode(&mut s7b);
2131        neg.encode(&mut s7b);
2132        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2133        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2134        let mut tb = BytesMut::new();
2135        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2136        server_io.write_all(&tb).await.unwrap();
2137
2138        // One round-trip per batch
2139        for (i, item_count) in batches.iter().enumerate() {
2140            let _ = server_io.read(&mut buf).await;
2141            // WriteVar response: param = func(0x05) + count; data = return_code per item
2142            let mut s7b = BytesMut::new();
2143            S7Header {
2144                pdu_type: PduType::AckData, reserved: 0, pdu_ref: (i + 2) as u16,
2145                param_len: 2, data_len: *item_count as u16,
2146                error_class: Some(0), error_code: Some(0),
2147            }.encode(&mut s7b);
2148            s7b.extend_from_slice(&[0x05, *item_count as u8]); // func + count
2149            for _ in 0..*item_count {
2150                s7b.put_u8(0xFF); // success
2151            }
2152            let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2153            let mut cb = BytesMut::new(); dt.encode(&mut cb);
2154            let mut tb = BytesMut::new();
2155            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2156            server_io.write_all(&tb).await.unwrap();
2157        }
2158    }
2159
2160    #[tokio::test]
2161    async fn write_multi_vars_returns_ok() {
2162        let (client_io, server_io) = duplex(65536);
2163        let params = ConnectParams::default();
2164        tokio::spawn(mock_plc_multi_write(server_io, 480, vec![2]));
2165        let client = S7Client::from_transport(client_io, params).await.unwrap();
2166        let items = [
2167            MultiWriteItem::db(1, 0, vec![0xAA, 0xBB, 0xCC, 0xDD]),
2168            MultiWriteItem::db(2, 10, vec![0x01, 0x02]),
2169        ];
2170        client.write_multi_vars(&items).await.unwrap();
2171    }
2172
2173    #[tokio::test]
2174    async fn write_multi_vars_empty_returns_ok() {
2175        let (client_io, server_io) = duplex(4096);
2176        let params = ConnectParams::default();
2177        // No messages exchanged after handshake — the mock just needs to satisfy connect.
2178        tokio::spawn(mock_plc_multi_write(server_io, 480, vec![]));
2179        let client = S7Client::from_transport(client_io, params).await.unwrap();
2180        client.write_multi_vars(&[]).await.unwrap();
2181    }
2182
2183    /// Items split into two round-trips when PDU budget is exhausted.
2184    ///
2185    /// PDU = 64. max_payload = 64 - 10(hdr) - 2(overhead) = 52.
2186    /// Each item: 12(addr) + 4(data hdr) + 20(data) = 36.
2187    /// Two items = 72 > 52 → must split into two 1-item batches.
2188    #[tokio::test]
2189    async fn write_multi_vars_batches_when_pdu_limit_exceeded() {
2190        let (client_io, server_io) = duplex(65536);
2191        let params = ConnectParams::default();
2192        tokio::spawn(mock_plc_multi_write(server_io, 64, vec![1, 1]));
2193        let client = S7Client::from_transport(client_io, params).await.unwrap();
2194        let items = [
2195            MultiWriteItem::db(1, 0, vec![0x11u8; 20]),
2196            MultiWriteItem::db(2, 0, vec![0x22u8; 20]),
2197        ];
2198        client.write_multi_vars(&items).await.unwrap();
2199    }
2200
2201    /// Items are split into two round trips when response would exceed the negotiated PDU size.
2202    ///
2203    /// PDU = 64 bytes. max_resp_payload = 64 - 10(hdr) - 2(func+count) = 52 bytes.
2204    /// Each item with 30 bytes of data costs 4+30 = 34 bytes in the response.
2205    /// Two such items = 68 bytes → exceeds 52 → must split into 2 round trips.
2206    #[tokio::test]
2207    async fn read_multi_vars_batches_when_pdu_limit_exceeded() {
2208        use crate::proto::s7::negotiate::NegotiateResponse;
2209
2210        async fn mock_split_pdu(mut server_io: tokio::io::DuplexStream) {
2211            let mut buf = vec![0u8; 4096];
2212
2213            // COTP CR
2214            let _ = server_io.read(&mut buf).await;
2215            let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2216            let mut cb = BytesMut::new(); cc.encode(&mut cb);
2217            let mut tb = BytesMut::new();
2218            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2219            server_io.write_all(&tb).await.unwrap();
2220
2221            // Negotiate — PDU size 64
2222            let _ = server_io.read(&mut buf).await;
2223            let neg = NegotiateResponse {
2224                max_amq_calling: 1, max_amq_called: 1, pdu_length: 64,
2225            };
2226            let mut s7b = BytesMut::new();
2227            S7Header {
2228                pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2229                param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2230            }.encode(&mut s7b);
2231            neg.encode(&mut s7b);
2232            let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2233            let mut cb = BytesMut::new(); dt.encode(&mut cb);
2234            let mut tb = BytesMut::new();
2235            TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2236            server_io.write_all(&tb).await.unwrap();
2237
2238            // Two separate round-trips, one item each
2239            let payloads: &[&[u8]] = &[&[0x11u8; 30], &[0x22u8; 30]];
2240            for (i, payload) in payloads.iter().enumerate() {
2241                let _ = server_io.read(&mut buf).await;
2242                let bit_len = (payload.len() * 8) as u16;
2243                let mut data_bytes = BytesMut::new();
2244                data_bytes.put_u8(0xFF);
2245                data_bytes.put_u8(0x04);
2246                data_bytes.put_u16(bit_len);
2247                data_bytes.extend_from_slice(payload);
2248                if payload.len() % 2 != 0 { data_bytes.put_u8(0x00); }
2249                let data_len = data_bytes.len() as u16;
2250                let mut s7b = BytesMut::new();
2251                S7Header {
2252                    pdu_type: PduType::AckData, reserved: 0, pdu_ref: (i + 2) as u16,
2253                    param_len: 2, data_len, error_class: Some(0), error_code: Some(0),
2254                }.encode(&mut s7b);
2255                s7b.extend_from_slice(&[0x04, 0x01]);
2256                s7b.extend_from_slice(&data_bytes);
2257                let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2258                let mut cb = BytesMut::new(); dt.encode(&mut cb);
2259                let mut tb = BytesMut::new();
2260                TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2261                server_io.write_all(&tb).await.unwrap();
2262            }
2263        }
2264
2265        let (client_io, server_io) = duplex(4096);
2266        let params = ConnectParams::default();
2267        tokio::spawn(mock_split_pdu(server_io));
2268        let client = S7Client::from_transport(client_io, params).await.unwrap();
2269
2270        let items = [MultiReadItem::db(1, 0, 30), MultiReadItem::db(2, 0, 30)];
2271        let results = client.read_multi_vars(&items).await.unwrap();
2272        assert_eq!(results.len(), 2);
2273        assert_eq!(&results[0][..], &[0x11u8; 30][..]);
2274        assert_eq!(&results[1][..], &[0x22u8; 30][..]);
2275    }
2276
2277    // -- PLC control & status mocks & tests -----------------------------------
2278
2279    /// Common handshake for control tests: COTP CR → CC, S7 Negotiate.
2280    async fn mock_handshake(server_io: &mut (impl AsyncRead + AsyncWrite + Unpin)) {
2281        let mut buf = vec![0u8; 4096];
2282
2283        // COTP CR
2284        let _ = server_io.read(&mut buf).await;
2285        let cc = CotpPdu::ConnectConfirm { dst_ref: 1, src_ref: 1 };
2286        let mut cb = BytesMut::new(); cc.encode(&mut cb);
2287        let mut tb = BytesMut::new();
2288        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2289        server_io.write_all(&tb).await.unwrap();
2290
2291        // S7 Negotiate
2292        let _ = server_io.read(&mut buf).await;
2293        let neg = NegotiateResponse { max_amq_calling: 1, max_amq_called: 1, pdu_length: 480 };
2294        let mut s7b = BytesMut::new();
2295        S7Header {
2296            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 1,
2297            param_len: 8, data_len: 0, error_class: Some(0), error_code: Some(0),
2298        }.encode(&mut s7b);
2299        neg.encode(&mut s7b);
2300        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2301        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2302        let mut tb = BytesMut::new();
2303        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2304        server_io.write_all(&tb).await.unwrap();
2305    }
2306
2307    /// Mock for simple control commands (plc_stop / plc_hot_start / plc_cold_start).
2308    /// `ok` controls whether the mock sends success (error_class=0, error_code=0) or failure.
2309    async fn mock_plc_control(
2310        mut server_io: tokio::io::DuplexStream,
2311        ok: bool,
2312    ) {
2313        let mut buf = vec![0u8; 4096];
2314        mock_handshake(&mut server_io).await;
2315
2316        // Control request — consume
2317        let _ = server_io.read(&mut buf).await;
2318
2319        // AckData response
2320        let (ec, ecd) = if ok { (0u8, 0u8) } else { (0x81u8, 0x04u8) };
2321        let mut s7b = BytesMut::new();
2322        S7Header {
2323            pdu_type: PduType::AckData, reserved: 0, pdu_ref: 2,
2324            param_len: 0, data_len: 0,
2325            error_class: Some(ec), error_code: Some(ecd),
2326        }.encode(&mut s7b);
2327        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2328        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2329        let mut tb = BytesMut::new();
2330        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2331        server_io.write_all(&tb).await.unwrap();
2332    }
2333
2334    #[tokio::test]
2335    async fn plc_stop_succeeds() {
2336        let (client_io, server_io) = duplex(4096);
2337        let params = ConnectParams::default();
2338        tokio::spawn(mock_plc_control(server_io, true));
2339        let client = S7Client::from_transport(client_io, params).await.unwrap();
2340        client.plc_stop().await.unwrap();
2341    }
2342
2343    #[tokio::test]
2344    async fn plc_hot_start_succeeds() {
2345        let (client_io, server_io) = duplex(4096);
2346        let params = ConnectParams::default();
2347        tokio::spawn(mock_plc_control(server_io, true));
2348        let client = S7Client::from_transport(client_io, params).await.unwrap();
2349        client.plc_hot_start().await.unwrap();
2350    }
2351
2352    #[tokio::test]
2353    async fn plc_cold_start_succeeds() {
2354        let (client_io, server_io) = duplex(4096);
2355        let params = ConnectParams::default();
2356        tokio::spawn(mock_plc_control(server_io, true));
2357        let client = S7Client::from_transport(client_io, params).await.unwrap();
2358        client.plc_cold_start().await.unwrap();
2359    }
2360
2361    #[tokio::test]
2362    async fn plc_stop_rejected_returns_error() {
2363        let (client_io, server_io) = duplex(4096);
2364        let params = ConnectParams::default();
2365        tokio::spawn(mock_plc_control(server_io, false));
2366        let client = S7Client::from_transport(client_io, params).await.unwrap();
2367        let result = client.plc_stop().await;
2368        assert!(result.is_err());
2369    }
2370
2371    /// Mock for get_plc_status: sends back `status_byte` in the data section.
2372    async fn mock_plc_status(
2373        mut server_io: tokio::io::DuplexStream,
2374        status_byte: u8,
2375    ) {
2376        let mut buf = vec![0u8; 4096];
2377        mock_handshake(&mut server_io).await;
2378
2379        // GetPlcStatus request (UserData SZL) — consume
2380        let _ = server_io.read(&mut buf).await;
2381
2382        // SZL 0x0424 response payload layout (after data envelope):
2383        //   [0..1]  SZL_ID = 0x0424
2384        //   [2..3]  SZL_INDEX = 0x0000
2385        //   [4..5]  LENTHDR (entry length)
2386        //   [6..7]  N_DR = 0x0001
2387        //   [8..10] first 3 bytes of entry
2388        //   [11]    status byte (payload[11])
2389        let mut szl_payload = [0u8; 12];
2390        szl_payload[0..2].copy_from_slice(&0x0424u16.to_be_bytes());
2391        szl_payload[6..8].copy_from_slice(&0x0001u16.to_be_bytes()); // N_DR = 1
2392        szl_payload[11] = status_byte;
2393
2394        // UserData response body:
2395        //   params (8 bytes, echoed): [0x00,0x01,0x12,0x08,0x12,0x84,0x01,0x00]
2396        //   data envelope (4 bytes): return_code=0xFF, transport=0x09, len=12
2397        //   szl_payload (12 bytes)
2398        let params: [u8; 8] = [0x00, 0x01, 0x12, 0x08, 0x12, 0x84, 0x01, 0x00];
2399        let data_envelope: [u8; 4] = [0xFF, 0x09, 0x00, 0x0C];
2400        let param_len = params.len() as u16;
2401        let data_len = (data_envelope.len() + szl_payload.len()) as u16;
2402
2403        let mut s7b = BytesMut::new();
2404        S7Header {
2405            pdu_type: PduType::UserData, reserved: 0, pdu_ref: 2,
2406            param_len, data_len,
2407            error_class: None, error_code: None,
2408        }.encode(&mut s7b);
2409        s7b.extend_from_slice(&params);
2410        s7b.extend_from_slice(&data_envelope);
2411        s7b.extend_from_slice(&szl_payload);
2412        let dt = CotpPdu::Data { tpdu_nr: 0, last: true, payload: s7b.freeze() };
2413        let mut cb = BytesMut::new(); dt.encode(&mut cb);
2414        let mut tb = BytesMut::new();
2415        TpktFrame { payload: cb.freeze() }.encode(&mut tb).unwrap();
2416        server_io.write_all(&tb).await.unwrap();
2417    }
2418
2419    #[tokio::test]
2420    async fn get_plc_status_returns_run() {
2421        let (client_io, server_io) = duplex(4096);
2422        let params = ConnectParams::default();
2423        tokio::spawn(mock_plc_status(server_io, 0x08));
2424        let client = S7Client::from_transport(client_io, params).await.unwrap();
2425        let status = client.get_plc_status().await.unwrap();
2426        assert_eq!(status, crate::types::PlcStatus::Run);
2427    }
2428
2429    #[tokio::test]
2430    async fn get_plc_status_returns_stop() {
2431        let (client_io, server_io) = duplex(4096);
2432        let params = ConnectParams::default();
2433        tokio::spawn(mock_plc_status(server_io, 0x04));
2434        let client = S7Client::from_transport(client_io, params).await.unwrap();
2435        let status = client.get_plc_status().await.unwrap();
2436        assert_eq!(status, crate::types::PlcStatus::Stop);
2437    }
2438
2439    #[tokio::test]
2440    async fn get_plc_status_returns_unknown() {
2441        let (client_io, server_io) = duplex(4096);
2442        let params = ConnectParams::default();
2443        tokio::spawn(mock_plc_status(server_io, 0x00));
2444        let client = S7Client::from_transport(client_io, params).await.unwrap();
2445        let status = client.get_plc_status().await.unwrap();
2446        assert_eq!(status, crate::types::PlcStatus::Unknown);
2447    }
2448
2449    #[tokio::test]
2450    async fn get_plc_status_unknown_byte_returns_stop() {
2451        // Unknown status bytes default to Stop (C snap7 behavior)
2452        let (client_io, server_io) = duplex(4096);
2453        let params = ConnectParams::default();
2454        tokio::spawn(mock_plc_status(server_io, 0xFF));
2455        let client = S7Client::from_transport(client_io, params).await.unwrap();
2456        let status = client.get_plc_status().await.unwrap();
2457        assert_eq!(status, crate::types::PlcStatus::Stop);
2458    }
2459}