python-snap7 3.2.0__py3-none-any.whl → 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snap7/async_client.py CHANGED
@@ -10,15 +10,19 @@ import asyncio
10
10
  import logging
11
11
  import struct
12
12
  import time
13
- from typing import List, Any, Optional, Tuple, Type
13
+ from collections.abc import Callable, Sequence
14
+ from contextvars import ContextVar
15
+ from typing import List, Any, Optional, Tuple, Type, Union
14
16
  from types import TracebackType
15
17
  from datetime import datetime
16
18
 
17
19
  from .connection import TPDUSize
18
- from .s7protocol import S7Protocol, get_return_code_description
20
+ from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_return_code_description
19
21
  from .datatypes import S7DataTypes, S7WordLen
20
22
  from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
21
- from .client_base import ClientMixin
23
+ from .client_base import ClientMixin, _instrumented
24
+ from .tags import Tag
25
+ from .client import Client, _decode_tag, _encode_tag
22
26
  from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
23
27
  from .client import _parse_force_szl
24
28
  from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
@@ -57,6 +61,8 @@ class AsyncISOTCPConnection:
57
61
  COTP_PARAM_PDU_SIZE = 0xC0
58
62
  COTP_PARAM_CALLING_TSAP = 0xC1
59
63
  COTP_PARAM_CALLED_TSAP = 0xC2
64
+ COTP_PARAM_SUBNET_ID = 0xC6
65
+ COTP_PARAM_ROUTING_TSAP = 0xC7
60
66
 
61
67
  def __init__(
62
68
  self,
@@ -81,6 +87,17 @@ class AsyncISOTCPConnection:
81
87
  self._reader: Optional[asyncio.StreamReader] = None
82
88
  self._writer: Optional[asyncio.StreamWriter] = None
83
89
 
90
+ # Routing parameters (set via set_routing)
91
+ self._routing: bool = False
92
+ self._subnet_id: int = 0
93
+ self._routing_tsap: int = 0
94
+
95
+ def set_routing(self, subnet_id: int, dest_rack: int, dest_slot: int) -> None:
96
+ """Configure S7 routing parameters for multi-subnet access (experimental)."""
97
+ self._routing = True
98
+ self._subnet_id = subnet_id & 0xFFFF
99
+ self._routing_tsap = 0x0100 | (dest_rack << 5) | dest_slot
100
+
84
101
  async def connect(self, timeout: float = 5.0) -> None:
85
102
  """Establish ISO on TCP connection."""
86
103
  self.timeout = timeout
@@ -202,6 +219,9 @@ class AsyncISOTCPConnection:
202
219
  called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, 2, self.remote_tsap)
203
220
  pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
204
221
  parameters = calling_tsap + called_tsap + pdu_size_param
222
+ if self._routing:
223
+ parameters += struct.pack(">BBH", self.COTP_PARAM_SUBNET_ID, 2, self._subnet_id)
224
+ parameters += struct.pack(">BBH", self.COTP_PARAM_ROUTING_TSAP, 2, self._routing_tsap)
205
225
  total_length = 6 + len(parameters)
206
226
  cr_pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters
207
227
 
@@ -319,6 +339,7 @@ class AsyncClient(ClientMixin):
319
339
  rate_limit_algorithm: RateLimitAlgorithm = "fixed",
320
340
  rate_limit_behavior: RateLimitBehavior = "block",
321
341
  rate_limit_burst: int | None = None,
342
+ on_operation: Optional[Callable[[str, float, bool], None]] = None,
322
343
  ) -> None:
323
344
  self.connection: Optional[AsyncISOTCPConnection] = None
324
345
  self.protocol = S7Protocol()
@@ -336,6 +357,8 @@ class AsyncClient(ClientMixin):
336
357
 
337
358
  self._exec_time = 0
338
359
  self._last_error = 0
360
+ self._on_operation = on_operation
361
+ self._operation_depth: ContextVar[int] = ContextVar("snap7_async_client_operation_depth", default=0)
339
362
 
340
363
  self._lock = asyncio.Lock()
341
364
  self._rate_limiter = RequestRateLimiter(
@@ -452,6 +475,78 @@ class AsyncClient(ClientMixin):
452
475
 
453
476
  return self
454
477
 
478
+ @property
479
+ def is_alive(self) -> bool:
480
+ """Whether the client is connected.
481
+
482
+ Unlike the synchronous client there is no background heartbeat, so this
483
+ reflects the connection state seen by the last operation.
484
+ """
485
+ return self.connected and self.connection is not None and self.connection.connected
486
+
487
+ async def connect_routed(
488
+ self,
489
+ host: str,
490
+ router_rack: int,
491
+ router_slot: int,
492
+ subnet: int,
493
+ dest_rack: int,
494
+ dest_slot: int,
495
+ port: int = 102,
496
+ timeout: float = 5.0,
497
+ ) -> "AsyncClient":
498
+ """Connect to an S7 PLC via a routing gateway on another subnet.
499
+
500
+ .. warning:: This method is experimental and may change in future versions.
501
+
502
+ Args:
503
+ host: IP address of the routing gateway PLC
504
+ router_rack: Rack number of the gateway PLC
505
+ router_slot: Slot number of the gateway PLC
506
+ subnet: Subnet ID of the target network (0x0000-0xFFFF)
507
+ dest_rack: Rack number of the destination PLC
508
+ dest_slot: Slot number of the destination PLC
509
+ port: TCP port (default 102)
510
+ timeout: Connection timeout in seconds
511
+
512
+ Returns:
513
+ Self for method chaining
514
+ """
515
+ self.host = host
516
+ self.port = port
517
+ self.rack = router_rack
518
+ self.slot = router_slot
519
+ self._params[Parameter.RemotePort] = port
520
+
521
+ # Remote TSAP targets the gateway rack/slot
522
+ self.remote_tsap = (self.connection_type << 8) | (router_rack << 5) | router_slot
523
+
524
+ try:
525
+ start_time = time.time()
526
+
527
+ self.connection = AsyncISOTCPConnection(
528
+ host=host, port=port, local_tsap=self.local_tsap, remote_tsap=self.remote_tsap
529
+ )
530
+ self.connection.set_routing(subnet, dest_rack, dest_slot)
531
+ await self.connection.connect(timeout=timeout)
532
+
533
+ await self._setup_communication()
534
+
535
+ self.connected = True
536
+ self._exec_time = int((time.time() - start_time) * 1000)
537
+ logger.info(
538
+ f"Connected (routed) to {host}:{port} via rack {router_rack} slot {router_slot}, "
539
+ f"subnet {subnet:#06x} -> rack {dest_rack} slot {dest_slot}"
540
+ )
541
+ except Exception as e:
542
+ await self.disconnect()
543
+ if isinstance(e, S7Error):
544
+ raise
545
+ else:
546
+ raise S7ConnectionError(f"Routed connection failed: {e}")
547
+
548
+ return self
549
+
455
550
  async def disconnect(self) -> int:
456
551
  """Disconnect from S7 PLC.
457
552
 
@@ -503,6 +598,224 @@ class AsyncClient(ClientMixin):
503
598
  await self.write_area(Area.DB, db_number, start, data)
504
599
  return 0
505
600
 
601
+ # ---------------------------------------------------------------
602
+ # Typed DB access and tag helpers (parity with the sync Client)
603
+ # ---------------------------------------------------------------
604
+
605
+ async def db_read_array(self, db_number: int, start: int, count: int, fmt: str = ">f") -> list[Any]:
606
+ """Read *count* consecutive values of struct format *fmt* from a DB."""
607
+ item_size = struct.calcsize(fmt)
608
+ data = await self.db_read(db_number, start, item_size * count)
609
+ return [struct.unpack_from(fmt, data, i * item_size)[0] for i in range(count)]
610
+
611
+ async def db_write_array(self, db_number: int, start: int, values: list[Any], fmt: str = ">f") -> int:
612
+ """Write *values* packed with struct format *fmt* to a DB. Returns 0 on success."""
613
+ item_size = struct.calcsize(fmt)
614
+ data = bytearray(item_size * len(values))
615
+ for i, v in enumerate(values):
616
+ struct.pack_into(fmt, data, i * item_size, v)
617
+ return await self.db_write(db_number, start, data)
618
+
619
+ async def db_read_bool(self, db_number: int, byte_offset: int, bit_offset: int) -> bool:
620
+ """Read a single bit from a DB."""
621
+ from .util import get_bool
622
+
623
+ data = await self.db_read(db_number, byte_offset, 1)
624
+ return get_bool(data, 0, bit_offset)
625
+
626
+ async def db_write_bool(self, db_number: int, byte_offset: int, bit_offset: int, value: bool) -> None:
627
+ """Write a single bit to a DB, preserving the other bits in the byte."""
628
+ from .util import set_bool
629
+
630
+ data = await self.db_read(db_number, byte_offset, 1)
631
+ set_bool(data, 0, bit_offset, value)
632
+ await self.db_write(db_number, byte_offset, data)
633
+
634
+ async def db_read_byte(self, db_number: int, offset: int) -> int:
635
+ """Read a BYTE (8-bit unsigned) from a DB."""
636
+ data = await self.db_read(db_number, offset, 1)
637
+ return data[0]
638
+
639
+ async def db_write_byte(self, db_number: int, offset: int, value: int) -> None:
640
+ """Write a BYTE (8-bit unsigned) to a DB."""
641
+ from .util import set_byte
642
+
643
+ data = bytearray(1)
644
+ set_byte(data, 0, value)
645
+ await self.db_write(db_number, offset, data)
646
+
647
+ async def db_read_int(self, db_number: int, offset: int) -> int:
648
+ """Read an INT (16-bit signed) from a DB."""
649
+ from .util import get_int
650
+
651
+ return get_int(await self.db_read(db_number, offset, 2), 0)
652
+
653
+ async def db_write_int(self, db_number: int, offset: int, value: int) -> None:
654
+ """Write an INT (16-bit signed) to a DB."""
655
+ from .util import set_int
656
+
657
+ data = bytearray(2)
658
+ set_int(data, 0, value)
659
+ await self.db_write(db_number, offset, data)
660
+
661
+ async def db_read_uint(self, db_number: int, offset: int) -> int:
662
+ """Read a UINT (16-bit unsigned) from a DB."""
663
+ from .util import get_uint
664
+
665
+ return get_uint(await self.db_read(db_number, offset, 2), 0)
666
+
667
+ async def db_write_uint(self, db_number: int, offset: int, value: int) -> None:
668
+ """Write a UINT (16-bit unsigned) to a DB."""
669
+ from .util import set_uint
670
+
671
+ data = bytearray(2)
672
+ set_uint(data, 0, value)
673
+ await self.db_write(db_number, offset, data)
674
+
675
+ async def db_read_word(self, db_number: int, offset: int) -> int:
676
+ """Read a WORD (16-bit unsigned) from a DB."""
677
+ data = await self.db_read(db_number, offset, 2)
678
+ return (data[0] << 8) | data[1]
679
+
680
+ async def db_write_word(self, db_number: int, offset: int, value: int) -> None:
681
+ """Write a WORD (16-bit unsigned) to a DB."""
682
+ from .util import set_word
683
+
684
+ data = bytearray(2)
685
+ set_word(data, 0, value)
686
+ await self.db_write(db_number, offset, data)
687
+
688
+ async def db_read_dint(self, db_number: int, offset: int) -> int:
689
+ """Read a DINT (32-bit signed) from a DB."""
690
+ from .util import get_dint
691
+
692
+ return get_dint(await self.db_read(db_number, offset, 4), 0)
693
+
694
+ async def db_write_dint(self, db_number: int, offset: int, value: int) -> None:
695
+ """Write a DINT (32-bit signed) to a DB."""
696
+ from .util import set_dint
697
+
698
+ data = bytearray(4)
699
+ set_dint(data, 0, value)
700
+ await self.db_write(db_number, offset, data)
701
+
702
+ async def db_read_udint(self, db_number: int, offset: int) -> int:
703
+ """Read a UDINT (32-bit unsigned) from a DB."""
704
+ from .util import get_udint
705
+
706
+ return get_udint(await self.db_read(db_number, offset, 4), 0)
707
+
708
+ async def db_write_udint(self, db_number: int, offset: int, value: int) -> None:
709
+ """Write a UDINT (32-bit unsigned) to a DB."""
710
+ from .util import set_udint
711
+
712
+ data = bytearray(4)
713
+ set_udint(data, 0, value)
714
+ await self.db_write(db_number, offset, data)
715
+
716
+ async def db_read_dword(self, db_number: int, offset: int) -> int:
717
+ """Read a DWORD (32-bit unsigned) from a DB."""
718
+ from .util import get_dword
719
+
720
+ return get_dword(await self.db_read(db_number, offset, 4), 0)
721
+
722
+ async def db_write_dword(self, db_number: int, offset: int, value: int) -> None:
723
+ """Write a DWORD (32-bit unsigned) to a DB."""
724
+ from .util import set_dword
725
+
726
+ data = bytearray(4)
727
+ set_dword(data, 0, value)
728
+ await self.db_write(db_number, offset, data)
729
+
730
+ async def db_read_real(self, db_number: int, offset: int) -> float:
731
+ """Read a REAL (32-bit float) from a DB."""
732
+ from .util import get_real
733
+
734
+ return get_real(await self.db_read(db_number, offset, 4), 0)
735
+
736
+ async def db_write_real(self, db_number: int, offset: int, value: float) -> None:
737
+ """Write a REAL (32-bit float) to a DB."""
738
+ from .util import set_real
739
+
740
+ data = bytearray(4)
741
+ set_real(data, 0, value)
742
+ await self.db_write(db_number, offset, data)
743
+
744
+ async def db_read_lreal(self, db_number: int, offset: int) -> float:
745
+ """Read a LREAL (64-bit float) from a DB."""
746
+ from .util import get_lreal
747
+
748
+ return get_lreal(await self.db_read(db_number, offset, 8), 0)
749
+
750
+ async def db_write_lreal(self, db_number: int, offset: int, value: float) -> None:
751
+ """Write a LREAL (64-bit float) to a DB."""
752
+ from .util import set_lreal
753
+
754
+ data = bytearray(8)
755
+ set_lreal(data, 0, value)
756
+ await self.db_write(db_number, offset, data)
757
+
758
+ async def db_read_string(self, db_number: int, offset: int) -> str:
759
+ """Read an S7 STRING from a DB (header first to learn the max length)."""
760
+ from .util import get_string
761
+
762
+ header = await self.db_read(db_number, offset, 2)
763
+ data = await self.db_read(db_number, offset, 2 + header[0])
764
+ return get_string(data, 0)
765
+
766
+ async def db_write_string(self, db_number: int, offset: int, value: str, max_length: int = 254) -> None:
767
+ """Write an S7 STRING with the given maximum length to a DB."""
768
+ from .util import set_string
769
+
770
+ data = bytearray(2 + max_length)
771
+ set_string(data, 0, value, max_length)
772
+ await self.db_write(db_number, offset, data)
773
+
774
+ async def db_read_wstring(self, db_number: int, offset: int) -> str:
775
+ """Read an S7 WSTRING from a DB (header first to learn the max length)."""
776
+ from .util import get_wstring
777
+
778
+ header = await self.db_read(db_number, offset, 4)
779
+ max_len = (header[0] << 8) | header[1]
780
+ data = await self.db_read(db_number, offset, 4 + max_len * 2)
781
+ return get_wstring(data, 0)
782
+
783
+ async def db_write_wstring(self, db_number: int, offset: int, value: str, max_length: int = 254) -> None:
784
+ """Write an S7 WSTRING with the given maximum length (in characters) to a DB."""
785
+ from .util import set_wstring
786
+
787
+ data = bytearray(4 + max_length * 2)
788
+ set_wstring(data, 0, value, max_length)
789
+ await self.db_write(db_number, offset, data)
790
+
791
+ async def read_tag(self, tag: "Union[Tag, str]", encoding: str = "latin-1") -> Any:
792
+ """Read a typed value by :class:`~snap7.tags.Tag` or PLC4X-style address string."""
793
+ resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
794
+ if resolved.is_symbolic:
795
+ raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
796
+ data = await self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, resolved.size)
797
+ return _decode_tag(resolved, bytearray(data), encoding=encoding)
798
+
799
+ async def write_tag(self, tag: "Union[Tag, str]", value: Any, encoding: str = "latin-1") -> int:
800
+ """Write a typed value by :class:`~snap7.tags.Tag` or address string. Returns 0 on success."""
801
+ resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
802
+ if resolved.is_symbolic:
803
+ raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
804
+ buf = bytearray(resolved.size)
805
+ if resolved.datatype.upper() == "BOOL":
806
+ # Preserve the other bits of the byte
807
+ current = await self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, 1)
808
+ buf[0] = current[0]
809
+ _encode_tag(resolved, buf, value, encoding=encoding)
810
+ return await self.write_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, buf)
811
+
812
+ async def read_tags(self, tags: "Sequence[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
813
+ """Read multiple tags in a single optimized request, in input order."""
814
+ resolved = [Tag.from_string(t) if isinstance(t, str) else t for t in tags]
815
+ items = [{"area": Area(t.area), "db_number": t.db_number, "start": t.byte_offset, "size": t.size} for t in resolved]
816
+ _code, data_list = await self.read_multi_vars(items)
817
+ return [_decode_tag(t, bytearray(d), encoding=encoding) for t, d in zip(resolved, data_list)]
818
+
506
819
  async def db_get(self, db_number: int, size: int = 0) -> bytearray:
507
820
  """Get entire DB.
508
821
 
@@ -539,6 +852,7 @@ class AsyncClient(ClientMixin):
539
852
  # Core read / write
540
853
  # ---------------------------------------------------------------
541
854
 
855
+ @_instrumented("read_area")
542
856
  async def read_area(self, area: Area, db_number: int, start: int, size: int) -> bytearray:
543
857
  """Read data from memory area.
544
858
 
@@ -585,6 +899,7 @@ class AsyncClient(ClientMixin):
585
899
  self._exec_time = int((time.time() - start_time) * 1000)
586
900
  return result
587
901
 
902
+ @_instrumented("write_area")
588
903
  async def write_area(self, area: Area, db_number: int, start: int, data: bytearray) -> int:
589
904
  """Write data to memory area.
590
905
 
@@ -630,6 +945,7 @@ class AsyncClient(ClientMixin):
630
945
  self._exec_time = int((time.time() - start_time) * 1000)
631
946
  return 0
632
947
 
948
+ @_instrumented("read_multi_vars")
633
949
  async def read_multi_vars(self, items: List[dict[str, Any]]) -> Tuple[int, list[bytearray]]:
634
950
  """Read multiple variables (sequentially, one read_area per item).
635
951
 
@@ -654,6 +970,7 @@ class AsyncClient(ClientMixin):
654
970
  results.append(data)
655
971
  return (0, results)
656
972
 
973
+ @_instrumented("write_multi_vars")
657
974
  async def write_multi_vars(self, items: List[dict[str, Any]]) -> int:
658
975
  """Write multiple variables (sequentially, one write_area per item).
659
976
 
@@ -687,6 +1004,7 @@ class AsyncClient(ClientMixin):
687
1004
 
688
1005
  request = self.protocol.build_list_blocks_request()
689
1006
  response = await self._send_receive(request)
1007
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
690
1008
 
691
1009
  data_info = response.get("data", {})
692
1010
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -719,6 +1037,7 @@ class AsyncClient(ClientMixin):
719
1037
 
720
1038
  request = self.protocol.build_list_blocks_of_type_request(type_code)
721
1039
  response = await self._send_receive(request)
1040
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
722
1041
 
723
1042
  data_info = response.get("data", {})
724
1043
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -744,6 +1063,7 @@ class AsyncClient(ClientMixin):
744
1063
  response_data = await conn.receive_data()
745
1064
 
746
1065
  response = self.protocol.parse_response(response_data)
1066
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
747
1067
 
748
1068
  data_info = response.get("data", {})
749
1069
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -779,6 +1099,7 @@ class AsyncClient(ClientMixin):
779
1099
 
780
1100
  request = self.protocol.build_get_block_info_request(type_code, db_number)
781
1101
  response = await self._send_receive(request)
1102
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
782
1103
 
783
1104
  data_info = response.get("data", {})
784
1105
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -808,6 +1129,7 @@ class AsyncClient(ClientMixin):
808
1129
  # Upload / Download / Delete
809
1130
  # ---------------------------------------------------------------
810
1131
 
1132
+ @_instrumented("upload")
811
1133
  async def upload(self, block_num: int) -> bytearray:
812
1134
  """Upload block from PLC (3-step: START_UPLOAD, UPLOAD, END_UPLOAD)."""
813
1135
  if not self.get_connected():
@@ -819,19 +1141,27 @@ class AsyncClient(ClientMixin):
819
1141
  response = await self._send_receive(request)
820
1142
 
821
1143
  upload_info = self.protocol.parse_start_upload_response(response)
822
- upload_id = upload_info.get("upload_id", 1)
823
-
824
- request = self.protocol.build_upload_request(upload_id)
825
- response = await self._send_receive(request)
1144
+ upload_id = upload_info["upload_id"]
826
1145
 
827
- block_data = self.protocol.parse_upload_response(response)
1146
+ block_data = bytearray()
1147
+ for fragment_index in range(1000):
1148
+ request = self.protocol.build_upload_request(upload_id)
1149
+ response = await self._send_receive(request)
1150
+ fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
1151
+ block_data.extend(fragment)
1152
+ if is_last:
1153
+ break
1154
+ else:
1155
+ raise S7ProtocolError("Upload response exceeded fragment limit")
828
1156
 
829
1157
  request = self.protocol.build_end_upload_request(upload_id)
830
1158
  response = await self._send_receive(request)
1159
+ self.protocol.check_end_upload_response(response)
831
1160
 
832
1161
  logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
833
- return bytearray(block_data)
1162
+ return block_data
834
1163
 
1164
+ @_instrumented("download")
835
1165
  async def download(self, data: bytearray, block_num: int = -1) -> int:
836
1166
  """Download block to PLC."""
837
1167
  if not self.get_connected():
@@ -846,44 +1176,33 @@ class AsyncClient(ClientMixin):
846
1176
  else:
847
1177
  block_num = 1
848
1178
 
849
- # Step 1: Request download
850
1179
  request = self.protocol.build_download_request(block_type, block_num, bytes(data))
851
- await self._send_receive(request)
852
-
853
- # Step 2: Download block (send data)
854
- param_data = struct.pack(">BBB", 0x1B, 0x01, 0x00)
855
- data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data)
856
- header = struct.pack(
857
- ">BBHHHH",
858
- 0x32,
859
- 0x01,
860
- 0x0000,
861
- self.protocol._next_sequence(),
862
- len(param_data),
863
- len(data_section),
864
- )
865
-
866
1180
  async with self._lock:
867
- await self._send_data(conn, header + param_data + data_section)
1181
+ await self._send_data(conn, request)
868
1182
  response_data = await conn.receive_data()
869
- self.protocol.parse_response(response_data)
870
-
871
- # Step 3: Download ended
872
- param_data = struct.pack(">B", 0x1C)
873
- header = struct.pack(
874
- ">BBHHHH",
875
- 0x32,
876
- 0x01,
877
- 0x0000,
878
- self.protocol._next_sequence(),
879
- len(param_data),
880
- 0x0000,
881
- )
1183
+ response = self.protocol.parse_response(response_data)
1184
+ if response["sequence"] != int.from_bytes(request[4:6], "big") or response.get("raw_parameters") != bytes((0x1A,)):
1185
+ raise S7ProtocolError("Invalid request-download acknowledgement")
1186
+
1187
+ offset = 0
1188
+ max_slice = self.pdu_length - 18
1189
+ if max_slice <= 0:
1190
+ raise S7ProtocolError("Negotiated PDU is too small for download")
1191
+ for _ in range(1000):
1192
+ request_data = await conn.receive_data()
1193
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
1194
+ fragment = bytes(data[offset : offset + max_slice])
1195
+ offset += len(fragment)
1196
+ is_last = offset == len(data)
1197
+ await self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
1198
+ if is_last:
1199
+ break
1200
+ else:
1201
+ raise S7ProtocolError("Download exceeded fragment limit")
882
1202
 
883
- async with self._lock:
884
- await self._send_data(conn, header + param_data)
885
- response_data = await conn.receive_data()
886
- self.protocol.parse_response(response_data)
1203
+ request_data = await conn.receive_data()
1204
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
1205
+ await self._send_data(conn, self.protocol.build_download_ended_response(sequence))
887
1206
 
888
1207
  logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
889
1208
  return 0
@@ -931,32 +1250,27 @@ class AsyncClient(ClientMixin):
931
1250
  response = await self._send_receive(request)
932
1251
 
933
1252
  upload_info = self.protocol.parse_start_upload_response(response)
934
- upload_id = upload_info.get("upload_id", 1)
1253
+ upload_id = upload_info["upload_id"]
935
1254
 
936
- request = self.protocol.build_upload_request(upload_id)
937
- response = await self._send_receive(request)
938
- block_data = self.protocol.parse_upload_response(response)
1255
+ block_data = bytearray()
1256
+ for fragment_index in range(1000):
1257
+ request = self.protocol.build_upload_request(upload_id)
1258
+ response = await self._send_receive(request)
1259
+ fragment, is_last = self.protocol.parse_upload_fragment(
1260
+ response, first_fragment=fragment_index == 0, include_block_header=True
1261
+ )
1262
+ block_data.extend(fragment)
1263
+ if is_last:
1264
+ break
1265
+ else:
1266
+ raise S7ProtocolError("Upload response exceeded fragment limit")
939
1267
 
940
1268
  request = self.protocol.build_end_upload_request(upload_id)
941
1269
  response = await self._send_receive(request)
1270
+ self.protocol.check_end_upload_response(response)
942
1271
 
943
- block_header = struct.pack(
944
- ">BBHBBBBHH",
945
- 0x70,
946
- block_type.value,
947
- block_num,
948
- 0x00,
949
- 0x00,
950
- 0x00,
951
- 0x00,
952
- len(block_data) + 14,
953
- len(block_data),
954
- )
955
- block_footer = b"\x00" * 4
956
- full_block = bytearray(block_header + block_data + block_footer)
957
-
958
- logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes")
959
- return full_block, len(full_block)
1272
+ logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
1273
+ return block_data, len(block_data)
960
1274
 
961
1275
  # ---------------------------------------------------------------
962
1276
  # PLC control
@@ -994,6 +1308,7 @@ class AsyncClient(ClientMixin):
994
1308
 
995
1309
  request = self.protocol.build_get_clock_request()
996
1310
  response = await self._send_receive(request)
1311
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
997
1312
  return self.protocol.parse_get_clock_response(response)
998
1313
 
999
1314
  async def set_plc_datetime(self, dt: datetime) -> int:
@@ -1002,7 +1317,10 @@ class AsyncClient(ClientMixin):
1002
1317
  raise S7ConnectionError("Not connected to PLC")
1003
1318
 
1004
1319
  request = self.protocol.build_set_clock_request(dt)
1005
- await self._send_receive(request)
1320
+ response = await self._send_receive(request)
1321
+ self.protocol.check_userdata_response(
1322
+ response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK, accept_null_ack=True
1323
+ )
1006
1324
  logger.info(f"Set PLC datetime to {dt}")
1007
1325
  return 0
1008
1326
 
@@ -1032,6 +1350,7 @@ class AsyncClient(ClientMixin):
1032
1350
 
1033
1351
  request = self.protocol.build_read_szl_request(ssl_id, index)
1034
1352
  response = await self._send_receive(request)
1353
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
1035
1354
 
1036
1355
  data_info = response.get("data", {})
1037
1356
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1058,6 +1377,7 @@ class AsyncClient(ClientMixin):
1058
1377
  response_data = await conn.receive_data()
1059
1378
 
1060
1379
  response = self.protocol.parse_response(response_data)
1380
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
1061
1381
 
1062
1382
  data_info = response.get("data", {})
1063
1383
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1080,6 +1400,14 @@ class AsyncClient(ClientMixin):
1080
1400
 
1081
1401
  return szl
1082
1402
 
1403
+ async def read_diagnostic_buffer(self) -> list[dict[str, Any]]:
1404
+ """Read the PLC diagnostic buffer (experimental), newest first.
1405
+
1406
+ Each entry is a dict with keys ``event_id``, ``timestamp`` and ``info``.
1407
+ """
1408
+ szl = await self.read_szl(0x00A0, 0)
1409
+ return Client._parse_diagnostic_buffer(bytes(szl.Data[: szl.Header.LengthDR]))
1410
+
1083
1411
  async def read_szl_list(self) -> bytes:
1084
1412
  """Read list of available SZL IDs."""
1085
1413
  if not self.get_connected():
@@ -1188,7 +1516,9 @@ class AsyncClient(ClientMixin):
1188
1516
  encoded = self.protocol.encode_password(password)
1189
1517
  request = self.protocol.build_set_session_password_request(encoded)
1190
1518
  response = await self._send_receive(request)
1191
- self.protocol.check_userdata_response(response)
1519
+ self.protocol.check_userdata_response(
1520
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD, accept_null_ack=True
1521
+ )
1192
1522
  logger.info("Session password set successfully")
1193
1523
  return 0
1194
1524
 
@@ -1209,7 +1539,9 @@ class AsyncClient(ClientMixin):
1209
1539
 
1210
1540
  request = self.protocol.build_clear_session_password_request()
1211
1541
  response = await self._send_receive(request)
1212
- self.protocol.check_userdata_response(response)
1542
+ self.protocol.check_userdata_response(
1543
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, accept_null_ack=True
1544
+ )
1213
1545
  logger.info("Session password cleared successfully")
1214
1546
  return 0
1215
1547