python-snap7 3.2.0__py3-none-any.whl → 3.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snap7
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Pure Python S7 communication library for Siemens PLCs
5
5
  Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ License-File: LICENSE
24
24
  Provides-Extra: test
25
25
  Requires-Dist: pytest; extra == "test"
26
26
  Requires-Dist: pytest-asyncio; extra == "test"
27
+ Requires-Dist: pytest-bdd<9,>=8.1; extra == "test"
27
28
  Requires-Dist: pytest-cov; extra == "test"
28
29
  Requires-Dist: pytest-html; extra == "test"
29
30
  Requires-Dist: hypothesis; extra == "test"
@@ -101,3 +102,6 @@ Documentation
101
102
 
102
103
  Read the full documentation at
103
104
  `python-snap7.readthedocs.io <https://python-snap7.readthedocs.io/en/latest/>`_.
105
+
106
+ The safe volunteer hardware-test procedure is in
107
+ `Real-PLC acceptance testing <doc/real-plc-testing.rst>`_.
@@ -1,10 +1,10 @@
1
- python_snap7-3.2.0.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
1
+ python_snap7-3.2.1.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
2
2
  s7/__init__.py,sha256=q6E4rA1BDx36PPkJS55xv7XCxwlaQeEK6HNotFjLeYo,1096
3
3
  snap7/__init__.py,sha256=2NAAE1oaSSd7DqPl0dx7jNXtsM2PRBWRm9X-bkmgC6U,1275
4
- snap7/async_client.py,sha256=epBMT21GN_VbjL1jwoPiC0fYOdsO7NzaEKZw62DKNnM,50279
4
+ snap7/async_client.py,sha256=yPyqzD4WH3544hKV4aDHt48r8LSWKsbFcVBZxtQOcYQ,52571
5
5
  snap7/cli.py,sha256=L1B7NMAemDcl93JUSqJV3Ls_CN2ghlG75nTMD08JkMs,13787
6
- snap7/client.py,sha256=pXe2A8C0X6uBxg4ya6lXK4JpRhADtOxlfuJXyIod-bg,106424
7
- snap7/client_base.py,sha256=J6_PheEZb93_UE9fPKQaI2RRofF0P3o71EFcBySDW3s,8568
6
+ snap7/client.py,sha256=MWnqsxjjdSSAloluSqAXtmfpokyJEKezJj77e_SqR6I,107985
7
+ snap7/client_base.py,sha256=tNZs_MA8FGit8pg9axNoljH1kFToDI1HIzXC8WQLYHs,11324
8
8
  snap7/connection.py,sha256=Qjis-ICK6E74_YfoJuy2_lgDqfSYs4ULux1FShJVvtw,18834
9
9
  snap7/datatypes.py,sha256=Gqu1XDuKkQI76n4TYrFrhm0RlPz4XNA5GKA-GJJXJDU,12042
10
10
  snap7/demo.py,sha256=JWSKLj2QPIXeSFTZSXuADAJzWVKls712MUZnuqwNkNs,17376
@@ -17,18 +17,18 @@ snap7/partner.py,sha256=HxaTYfojsf5Z170R4cAYFzoMSwqCOF42qPCkOaxbUkU,37916
17
17
  snap7/ppi.py,sha256=p0fVelfUQOT3QH5uuWOjpDxA5IyT3gx5FzCMaYYEuWQ,16070
18
18
  snap7/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  snap7/rate_limiter.py,sha256=dnXCOJLWmszo8oskbFM0om0gPC4LHHCOr-Nt22tImeU,4151
20
- snap7/s7protocol.py,sha256=xQrCTzC2xEe8ykfa3T2-kR9sLFDP8M7GZMfsm-n2i34,64835
20
+ snap7/s7protocol.py,sha256=BLuI6h3Cfbr4R55-v20fYMlKCY6ASbtxdJOC5T0JyaI,69396
21
21
  snap7/szl.py,sha256=a9NhH7Lyp8SXJ2_kvyyV-14srIxm3tXnAnclBy5alDk,7998
22
22
  snap7/tags.py,sha256=TmbNRHNWZVe3Yl_1L5WXE3Qb0lxIEW-veoCRGt0giZQ,26987
23
23
  snap7/type.py,sha256=EiXqU0ayYYxlM-5fUcPL7psgL1EGNNqAa7JXH4QlAnU,9752
24
- snap7/server/__init__.py,sha256=MOX_0oeC5pBoKEN_4V5XPnaU9ONQM_uELoDfdjlDeQg,112740
24
+ snap7/server/__init__.py,sha256=0XqMGlqblhVNmILT_Ddm-yxTw1r19bfFrr-VuPLEDnI,112468
25
25
  snap7/server/__main__.py,sha256=OdOgyMmFThjSe2jSgvlbkquGYnZcBW6wubs2ldhNSOE,1206
26
26
  snap7/util/__init__.py,sha256=d6n2oy7tNYke1ia0TUyWz_ni3l0XN1qvilM5sW0T-Es,1874
27
27
  snap7/util/db.py,sha256=geesYS2oyOuTADOJa2me1p1rqJVzPuLTCfye6JIeav0,27120
28
- snap7/util/getters.py,sha256=nKRfLC2yClfoLxd4MKq1SuBPhzVP1RpV9hwxxWlavug,25391
28
+ snap7/util/getters.py,sha256=pMsiCz0soMV7Q-VDPBdFG5eHzcFESZtKPw6mxj4S5ko,25370
29
29
  snap7/util/setters.py,sha256=BpFqVxxUbhqarV6Gzt4ZuxycHljtDYsONCr6e9G33Ms,27226
30
- python_snap7-3.2.0.dist-info/METADATA,sha256=bII7Hr47W-S5gb3lW4I6vJd88XP1sba4R339occZajI,3502
31
- python_snap7-3.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
- python_snap7-3.2.0.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
33
- python_snap7-3.2.0.dist-info/top_level.txt,sha256=PTgDoDG45QNL2XIE9RM7S_ZCdWQjtMMuXNyrI2UdnbU,9
34
- python_snap7-3.2.0.dist-info/RECORD,,
30
+ python_snap7-3.2.1.dist-info/METADATA,sha256=-oAcJHaF7AZL-10-rfF1c73I5E-JElaLi-azSxq76zg,3662
31
+ python_snap7-3.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
+ python_snap7-3.2.1.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
33
+ python_snap7-3.2.1.dist-info/top_level.txt,sha256=PTgDoDG45QNL2XIE9RM7S_ZCdWQjtMMuXNyrI2UdnbU,9
34
+ python_snap7-3.2.1.dist-info/RECORD,,
snap7/async_client.py CHANGED
@@ -10,15 +10,17 @@ import asyncio
10
10
  import logging
11
11
  import struct
12
12
  import time
13
+ from collections.abc import Callable
14
+ from contextvars import ContextVar
13
15
  from typing import List, Any, Optional, Tuple, Type
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
22
24
  from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
23
25
  from .client import _parse_force_szl
24
26
  from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
@@ -319,6 +321,7 @@ class AsyncClient(ClientMixin):
319
321
  rate_limit_algorithm: RateLimitAlgorithm = "fixed",
320
322
  rate_limit_behavior: RateLimitBehavior = "block",
321
323
  rate_limit_burst: int | None = None,
324
+ on_operation: Optional[Callable[[str, float, bool], None]] = None,
322
325
  ) -> None:
323
326
  self.connection: Optional[AsyncISOTCPConnection] = None
324
327
  self.protocol = S7Protocol()
@@ -336,6 +339,8 @@ class AsyncClient(ClientMixin):
336
339
 
337
340
  self._exec_time = 0
338
341
  self._last_error = 0
342
+ self._on_operation = on_operation
343
+ self._operation_depth: ContextVar[int] = ContextVar("snap7_async_client_operation_depth", default=0)
339
344
 
340
345
  self._lock = asyncio.Lock()
341
346
  self._rate_limiter = RequestRateLimiter(
@@ -539,6 +544,7 @@ class AsyncClient(ClientMixin):
539
544
  # Core read / write
540
545
  # ---------------------------------------------------------------
541
546
 
547
+ @_instrumented("read_area")
542
548
  async def read_area(self, area: Area, db_number: int, start: int, size: int) -> bytearray:
543
549
  """Read data from memory area.
544
550
 
@@ -585,6 +591,7 @@ class AsyncClient(ClientMixin):
585
591
  self._exec_time = int((time.time() - start_time) * 1000)
586
592
  return result
587
593
 
594
+ @_instrumented("write_area")
588
595
  async def write_area(self, area: Area, db_number: int, start: int, data: bytearray) -> int:
589
596
  """Write data to memory area.
590
597
 
@@ -630,6 +637,7 @@ class AsyncClient(ClientMixin):
630
637
  self._exec_time = int((time.time() - start_time) * 1000)
631
638
  return 0
632
639
 
640
+ @_instrumented("read_multi_vars")
633
641
  async def read_multi_vars(self, items: List[dict[str, Any]]) -> Tuple[int, list[bytearray]]:
634
642
  """Read multiple variables (sequentially, one read_area per item).
635
643
 
@@ -654,6 +662,7 @@ class AsyncClient(ClientMixin):
654
662
  results.append(data)
655
663
  return (0, results)
656
664
 
665
+ @_instrumented("write_multi_vars")
657
666
  async def write_multi_vars(self, items: List[dict[str, Any]]) -> int:
658
667
  """Write multiple variables (sequentially, one write_area per item).
659
668
 
@@ -687,6 +696,7 @@ class AsyncClient(ClientMixin):
687
696
 
688
697
  request = self.protocol.build_list_blocks_request()
689
698
  response = await self._send_receive(request)
699
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
690
700
 
691
701
  data_info = response.get("data", {})
692
702
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -719,6 +729,7 @@ class AsyncClient(ClientMixin):
719
729
 
720
730
  request = self.protocol.build_list_blocks_of_type_request(type_code)
721
731
  response = await self._send_receive(request)
732
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
722
733
 
723
734
  data_info = response.get("data", {})
724
735
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -744,6 +755,7 @@ class AsyncClient(ClientMixin):
744
755
  response_data = await conn.receive_data()
745
756
 
746
757
  response = self.protocol.parse_response(response_data)
758
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
747
759
 
748
760
  data_info = response.get("data", {})
749
761
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -779,6 +791,7 @@ class AsyncClient(ClientMixin):
779
791
 
780
792
  request = self.protocol.build_get_block_info_request(type_code, db_number)
781
793
  response = await self._send_receive(request)
794
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
782
795
 
783
796
  data_info = response.get("data", {})
784
797
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -808,6 +821,7 @@ class AsyncClient(ClientMixin):
808
821
  # Upload / Download / Delete
809
822
  # ---------------------------------------------------------------
810
823
 
824
+ @_instrumented("upload")
811
825
  async def upload(self, block_num: int) -> bytearray:
812
826
  """Upload block from PLC (3-step: START_UPLOAD, UPLOAD, END_UPLOAD)."""
813
827
  if not self.get_connected():
@@ -819,19 +833,27 @@ class AsyncClient(ClientMixin):
819
833
  response = await self._send_receive(request)
820
834
 
821
835
  upload_info = self.protocol.parse_start_upload_response(response)
822
- upload_id = upload_info.get("upload_id", 1)
836
+ upload_id = upload_info["upload_id"]
823
837
 
824
- request = self.protocol.build_upload_request(upload_id)
825
- response = await self._send_receive(request)
826
-
827
- block_data = self.protocol.parse_upload_response(response)
838
+ block_data = bytearray()
839
+ for fragment_index in range(1000):
840
+ request = self.protocol.build_upload_request(upload_id)
841
+ response = await self._send_receive(request)
842
+ fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
843
+ block_data.extend(fragment)
844
+ if is_last:
845
+ break
846
+ else:
847
+ raise S7ProtocolError("Upload response exceeded fragment limit")
828
848
 
829
849
  request = self.protocol.build_end_upload_request(upload_id)
830
850
  response = await self._send_receive(request)
851
+ self.protocol.check_end_upload_response(response)
831
852
 
832
853
  logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
833
- return bytearray(block_data)
854
+ return block_data
834
855
 
856
+ @_instrumented("download")
835
857
  async def download(self, data: bytearray, block_num: int = -1) -> int:
836
858
  """Download block to PLC."""
837
859
  if not self.get_connected():
@@ -846,44 +868,33 @@ class AsyncClient(ClientMixin):
846
868
  else:
847
869
  block_num = 1
848
870
 
849
- # Step 1: Request download
850
871
  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
872
  async with self._lock:
867
- await self._send_data(conn, header + param_data + data_section)
873
+ await self._send_data(conn, request)
868
874
  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
- )
875
+ response = self.protocol.parse_response(response_data)
876
+ if response["sequence"] != int.from_bytes(request[4:6], "big") or response.get("raw_parameters") != bytes((0x1A,)):
877
+ raise S7ProtocolError("Invalid request-download acknowledgement")
878
+
879
+ offset = 0
880
+ max_slice = self.pdu_length - 18
881
+ if max_slice <= 0:
882
+ raise S7ProtocolError("Negotiated PDU is too small for download")
883
+ for _ in range(1000):
884
+ request_data = await conn.receive_data()
885
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
886
+ fragment = bytes(data[offset : offset + max_slice])
887
+ offset += len(fragment)
888
+ is_last = offset == len(data)
889
+ await self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
890
+ if is_last:
891
+ break
892
+ else:
893
+ raise S7ProtocolError("Download exceeded fragment limit")
882
894
 
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)
895
+ request_data = await conn.receive_data()
896
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
897
+ await self._send_data(conn, self.protocol.build_download_ended_response(sequence))
887
898
 
888
899
  logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
889
900
  return 0
@@ -931,32 +942,27 @@ class AsyncClient(ClientMixin):
931
942
  response = await self._send_receive(request)
932
943
 
933
944
  upload_info = self.protocol.parse_start_upload_response(response)
934
- upload_id = upload_info.get("upload_id", 1)
945
+ upload_id = upload_info["upload_id"]
935
946
 
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)
947
+ block_data = bytearray()
948
+ for fragment_index in range(1000):
949
+ request = self.protocol.build_upload_request(upload_id)
950
+ response = await self._send_receive(request)
951
+ fragment, is_last = self.protocol.parse_upload_fragment(
952
+ response, first_fragment=fragment_index == 0, include_block_header=True
953
+ )
954
+ block_data.extend(fragment)
955
+ if is_last:
956
+ break
957
+ else:
958
+ raise S7ProtocolError("Upload response exceeded fragment limit")
939
959
 
940
960
  request = self.protocol.build_end_upload_request(upload_id)
941
961
  response = await self._send_receive(request)
962
+ self.protocol.check_end_upload_response(response)
942
963
 
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)
964
+ logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
965
+ return block_data, len(block_data)
960
966
 
961
967
  # ---------------------------------------------------------------
962
968
  # PLC control
@@ -994,6 +1000,7 @@ class AsyncClient(ClientMixin):
994
1000
 
995
1001
  request = self.protocol.build_get_clock_request()
996
1002
  response = await self._send_receive(request)
1003
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
997
1004
  return self.protocol.parse_get_clock_response(response)
998
1005
 
999
1006
  async def set_plc_datetime(self, dt: datetime) -> int:
@@ -1002,7 +1009,8 @@ class AsyncClient(ClientMixin):
1002
1009
  raise S7ConnectionError("Not connected to PLC")
1003
1010
 
1004
1011
  request = self.protocol.build_set_clock_request(dt)
1005
- await self._send_receive(request)
1012
+ response = await self._send_receive(request)
1013
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
1006
1014
  logger.info(f"Set PLC datetime to {dt}")
1007
1015
  return 0
1008
1016
 
@@ -1032,6 +1040,7 @@ class AsyncClient(ClientMixin):
1032
1040
 
1033
1041
  request = self.protocol.build_read_szl_request(ssl_id, index)
1034
1042
  response = await self._send_receive(request)
1043
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
1035
1044
 
1036
1045
  data_info = response.get("data", {})
1037
1046
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1058,6 +1067,7 @@ class AsyncClient(ClientMixin):
1058
1067
  response_data = await conn.receive_data()
1059
1068
 
1060
1069
  response = self.protocol.parse_response(response_data)
1070
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
1061
1071
 
1062
1072
  data_info = response.get("data", {})
1063
1073
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1188,7 +1198,7 @@ class AsyncClient(ClientMixin):
1188
1198
  encoded = self.protocol.encode_password(password)
1189
1199
  request = self.protocol.build_set_session_password_request(encoded)
1190
1200
  response = await self._send_receive(request)
1191
- self.protocol.check_userdata_response(response)
1201
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
1192
1202
  logger.info("Session password set successfully")
1193
1203
  return 0
1194
1204
 
@@ -1209,7 +1219,7 @@ class AsyncClient(ClientMixin):
1209
1219
 
1210
1220
  request = self.protocol.build_clear_session_password_request()
1211
1221
  response = await self._send_receive(request)
1212
- self.protocol.check_userdata_response(response)
1222
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
1213
1223
  logger.info("Session password cleared successfully")
1214
1224
  return 0
1215
1225