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/client.py CHANGED
@@ -12,6 +12,7 @@ import sys
12
12
  import threading
13
13
  import time
14
14
  from collections.abc import Sequence
15
+ from contextvars import ContextVar
15
16
  from typing import List, Any, Optional, Tuple, Union, Callable, cast
16
17
  from datetime import datetime
17
18
  from ctypes import (
@@ -21,10 +22,10 @@ from ctypes import (
21
22
  )
22
23
 
23
24
  from .connection import ISOTCPConnection
24
- from .s7protocol import S7Protocol, get_return_code_description
25
+ from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_return_code_description
25
26
  from .datatypes import S7DataTypes, S7WordLen
26
27
  from .error import S7Error, S7ConnectionError, S7ProtocolError, S7StalePacketError, S7TimeoutError
27
- from .client_base import ClientMixin
28
+ from .client_base import ClientMixin, _instrumented
28
29
  from .log import PLCLoggerAdapter, OperationLogger
29
30
  from .optimizer import ReadItem, ReadPacket, sort_items, merge_items, packetize, extract_results
30
31
  from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
@@ -300,6 +301,7 @@ class Client(ClientMixin):
300
301
  rate_limit_burst: int | None = None,
301
302
  on_disconnect: Optional[Callable[[], None]] = None,
302
303
  on_reconnect: Optional[Callable[[], None]] = None,
304
+ on_operation: Optional[Callable[[str, float, bool], None]] = None,
303
305
  **kwargs: Any,
304
306
  ):
305
307
  """
@@ -319,6 +321,8 @@ class Client(ClientMixin):
319
321
  rate_limit_burst: Token bucket capacity. Defaults to one second of requests.
320
322
  on_disconnect: Optional callback invoked when connection is lost.
321
323
  on_reconnect: Optional callback invoked after successful reconnection.
324
+ on_operation: Optional callback invoked after each PLC operation with its
325
+ name, duration in seconds, and whether it raised.
322
326
  **kwargs: Ignored. Kept for backwards compatibility.
323
327
  """
324
328
  self.connection: Optional[ISOTCPConnection] = None
@@ -376,6 +380,8 @@ class Client(ClientMixin):
376
380
  self._max_delay = max_delay
377
381
  self._on_disconnect = on_disconnect
378
382
  self._on_reconnect = on_reconnect
383
+ self._on_operation = on_operation
384
+ self._operation_depth: ContextVar[int] = ContextVar("snap7_client_operation_depth", default=0)
379
385
  self._rate_limiter = RequestRateLimiter(
380
386
  max_requests_per_second,
381
387
  algorithm=rate_limit_algorithm,
@@ -994,6 +1000,7 @@ class Client(ClientMixin):
994
1000
  f"Try passing the actual DB size explicitly: client.db_fill({db_number}, {filler}, size=<actual_size>)"
995
1001
  )
996
1002
 
1003
+ @_instrumented("read_area")
997
1004
  def read_area(self, area: Area, db_number: int, start: int, size: int, word_len: Optional[WordLen] = None) -> bytearray:
998
1005
  """
999
1006
  Read data from memory area.
@@ -1062,6 +1069,7 @@ class Client(ClientMixin):
1062
1069
  self._exec_time = int((time.time() - start_time) * 1000)
1063
1070
  return result
1064
1071
 
1072
+ @_instrumented("write_area")
1065
1073
  def write_area(self, area: Area, db_number: int, start: int, data: bytearray, word_len: Optional[WordLen] = None) -> int:
1066
1074
  """
1067
1075
  Write data to memory area.
@@ -1128,6 +1136,7 @@ class Client(ClientMixin):
1128
1136
  self._exec_time = int((time.time() - start_time) * 1000)
1129
1137
  return 0
1130
1138
 
1139
+ @_instrumented("read_multi_vars")
1131
1140
  def read_multi_vars(self, items: Union[List[dict[str, Any]], "Array[S7DataItem]"]) -> Tuple[int, Any]:
1132
1141
  """Read multiple variables in a single request.
1133
1142
 
@@ -1383,6 +1392,7 @@ class Client(ClientMixin):
1383
1392
  for blk, buf in zip(packet.blocks, block_data_list):
1384
1393
  blk.buffer = buf
1385
1394
 
1395
+ @_instrumented("write_multi_vars")
1386
1396
  def write_multi_vars(self, items: Union[List[dict[str, Any]], List[S7DataItem]]) -> int:
1387
1397
  """
1388
1398
  Write multiple variables in a single request.
@@ -1453,6 +1463,7 @@ class Client(ClientMixin):
1453
1463
  # Build and send list blocks request
1454
1464
  request = self.protocol.build_list_blocks_request()
1455
1465
  response = self._send_receive(request)
1466
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
1456
1467
 
1457
1468
  # Check for errors in data section
1458
1469
  data_info = response.get("data", {})
@@ -1498,6 +1509,7 @@ class Client(ClientMixin):
1498
1509
  # Build and send list blocks of type request
1499
1510
  request = self.protocol.build_list_blocks_of_type_request(type_code)
1500
1511
  response = self._send_receive(request)
1512
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
1501
1513
 
1502
1514
  # Check for errors in data section
1503
1515
  data_info = response.get("data", {})
@@ -1526,6 +1538,7 @@ class Client(ClientMixin):
1526
1538
 
1527
1539
  response_data = conn.receive_data()
1528
1540
  response = self.protocol.parse_response(response_data)
1541
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
1529
1542
 
1530
1543
  # Check for errors
1531
1544
  data_info = response.get("data", {})
@@ -1596,6 +1609,7 @@ class Client(ClientMixin):
1596
1609
  # Build and send get block info request
1597
1610
  request = self.protocol.build_get_block_info_request(type_code, db_number)
1598
1611
  response = self._send_receive(request)
1612
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
1599
1613
 
1600
1614
  # Check for errors in data section
1601
1615
  data_info = response.get("data", {})
@@ -1606,6 +1620,7 @@ class Client(ClientMixin):
1606
1620
 
1607
1621
  return self.protocol.parse_get_block_info(response)
1608
1622
 
1623
+ @_instrumented("upload")
1609
1624
  def upload(self, block_num: int) -> bytearray:
1610
1625
  """
1611
1626
  Upload block from PLC.
@@ -1630,22 +1645,29 @@ class Client(ClientMixin):
1630
1645
 
1631
1646
  # Parse upload ID from response
1632
1647
  upload_info = self.protocol.parse_start_upload_response(response)
1633
- upload_id = upload_info.get("upload_id", 1)
1634
-
1635
- # Step 2: Upload (get data)
1636
- request = self.protocol.build_upload_request(upload_id)
1637
- response = self._send_receive(request)
1638
-
1639
- # Extract block data
1640
- block_data = self.protocol.parse_upload_response(response)
1648
+ upload_id = upload_info["upload_id"]
1649
+
1650
+ # Step 2: Upload all fragments.
1651
+ block_data = bytearray()
1652
+ for fragment_index in range(1000):
1653
+ request = self.protocol.build_upload_request(upload_id)
1654
+ response = self._send_receive(request)
1655
+ fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
1656
+ block_data.extend(fragment)
1657
+ if is_last:
1658
+ break
1659
+ else:
1660
+ raise S7ProtocolError("Upload response exceeded fragment limit")
1641
1661
 
1642
1662
  # Step 3: End upload
1643
1663
  request = self.protocol.build_end_upload_request(upload_id)
1644
1664
  response = self._send_receive(request)
1665
+ self.protocol.check_end_upload_response(response)
1645
1666
 
1646
1667
  logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
1647
- return bytearray(block_data)
1668
+ return block_data
1648
1669
 
1670
+ @_instrumented("download")
1649
1671
  def download(self, data: bytearray, block_num: int = -1) -> int:
1650
1672
  """
1651
1673
  Download block to PLC.
@@ -1674,54 +1696,31 @@ class Client(ClientMixin):
1674
1696
  else:
1675
1697
  block_num = 1 # Default
1676
1698
 
1677
- # Step 1: Request download
1678
- request = self.protocol.build_download_request(block_type, block_num, bytes(data))
1679
- self._send_receive(request)
1680
-
1681
- # Step 2: Download block (send data)
1682
- # Build a simple download block PDU
1683
- param_data = struct.pack(
1684
- ">BBB",
1685
- 0x1B, # S7Function.DOWNLOAD_BLOCK
1686
- 0x01, # Status: last packet
1687
- 0x00, # Reserved
1688
- )
1689
-
1690
- # Data section: data to write
1691
- data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data)
1692
-
1693
- header = struct.pack(
1694
- ">BBHHHH",
1695
- 0x32, # Protocol ID
1696
- 0x01, # PDU type REQUEST
1697
- 0x0000, # Reserved
1698
- self.protocol._next_sequence(), # Sequence
1699
- len(param_data), # Parameter length
1700
- len(data_section), # Data length
1701
- )
1702
-
1703
- self._send_data(conn, header + param_data + data_section)
1704
-
1705
- response_data = conn.receive_data()
1706
- self.protocol.parse_response(response_data)
1707
-
1708
- # Step 3: Download ended
1709
- param_data = struct.pack(">B", 0x1C) # S7Function.DOWNLOAD_ENDED
1710
-
1711
- header = struct.pack(
1712
- ">BBHHHH",
1713
- 0x32, # Protocol ID
1714
- 0x01, # PDU type REQUEST
1715
- 0x0000, # Reserved
1716
- self.protocol._next_sequence(), # Sequence
1717
- len(param_data), # Parameter length
1718
- 0x0000, # Data length
1719
- )
1720
-
1721
- self._send_data(conn, header + param_data)
1699
+ with self._reconnect_lock:
1700
+ request = self.protocol.build_download_request(block_type, block_num, bytes(data))
1701
+ response = self._send_receive(request)
1702
+ if response.get("raw_parameters") != bytes((0x1A,)):
1703
+ raise S7ProtocolError("Invalid request-download acknowledgement")
1704
+
1705
+ offset = 0
1706
+ max_slice = self.pdu_length - 18
1707
+ if max_slice <= 0:
1708
+ raise S7ProtocolError("Negotiated PDU is too small for download")
1709
+ for _ in range(1000):
1710
+ request_data = conn.receive_data()
1711
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
1712
+ fragment = bytes(data[offset : offset + max_slice])
1713
+ offset += len(fragment)
1714
+ is_last = offset == len(data)
1715
+ self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
1716
+ if is_last:
1717
+ break
1718
+ else:
1719
+ raise S7ProtocolError("Download exceeded fragment limit")
1722
1720
 
1723
- response_data = conn.receive_data()
1724
- self.protocol.parse_response(response_data)
1721
+ request_data = conn.receive_data()
1722
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
1723
+ self._send_data(conn, self.protocol.build_download_ended_response(sequence))
1725
1724
 
1726
1725
  logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
1727
1726
  return 0
@@ -1798,39 +1797,29 @@ class Client(ClientMixin):
1798
1797
 
1799
1798
  # Parse upload ID from response
1800
1799
  upload_info = self.protocol.parse_start_upload_response(response)
1801
- upload_id = upload_info.get("upload_id", 1)
1802
-
1803
- # Step 2: Upload (get data)
1804
- request = self.protocol.build_upload_request(upload_id)
1805
- response = self._send_receive(request)
1806
-
1807
- # Extract block data
1808
- block_data = self.protocol.parse_upload_response(response)
1800
+ upload_id = upload_info["upload_id"]
1801
+
1802
+ # Step 2: Upload all fragments.
1803
+ block_data = bytearray()
1804
+ for fragment_index in range(1000):
1805
+ request = self.protocol.build_upload_request(upload_id)
1806
+ response = self._send_receive(request)
1807
+ fragment, is_last = self.protocol.parse_upload_fragment(
1808
+ response, first_fragment=fragment_index == 0, include_block_header=True
1809
+ )
1810
+ block_data.extend(fragment)
1811
+ if is_last:
1812
+ break
1813
+ else:
1814
+ raise S7ProtocolError("Upload response exceeded fragment limit")
1809
1815
 
1810
1816
  # Step 3: End upload
1811
1817
  request = self.protocol.build_end_upload_request(upload_id)
1812
1818
  response = self._send_receive(request)
1819
+ self.protocol.check_end_upload_response(response)
1813
1820
 
1814
- # Build full block with MC7 header
1815
- # S7 block structure: MC7 header + data + footer
1816
- block_header = struct.pack(
1817
- ">BBHBBBBHH",
1818
- 0x70, # Block type marker
1819
- block_type.value, # Block type
1820
- block_num, # Block number
1821
- 0x00, # Language
1822
- 0x00, # Properties
1823
- 0x00, # Reserved
1824
- 0x00, # Reserved
1825
- len(block_data) + 14, # Block length (header + data + footer)
1826
- len(block_data), # MC7 code length
1827
- )
1828
-
1829
- block_footer = b"\x00" * 4 # Footer
1830
-
1831
- full_block = bytearray(block_header + block_data + block_footer)
1832
- logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes")
1833
- return full_block, len(full_block)
1821
+ logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
1822
+ return block_data, len(block_data)
1834
1823
 
1835
1824
  def plc_stop(self) -> int:
1836
1825
  """Stop PLC CPU.
@@ -1880,6 +1869,7 @@ class Client(ClientMixin):
1880
1869
  # Build and send get clock request
1881
1870
  request = self.protocol.build_get_clock_request()
1882
1871
  response = self._send_receive(request)
1872
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
1883
1873
 
1884
1874
  # Parse clock response
1885
1875
  return self.protocol.parse_get_clock_response(response)
@@ -1901,7 +1891,10 @@ class Client(ClientMixin):
1901
1891
 
1902
1892
  # Build and send set clock request
1903
1893
  request = self.protocol.build_set_clock_request(dt)
1904
- self._send_receive(request)
1894
+ response = self._send_receive(request)
1895
+ self.protocol.check_userdata_response(
1896
+ response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK, accept_null_ack=True
1897
+ )
1905
1898
 
1906
1899
  logger.info(f"Set PLC datetime to {dt}")
1907
1900
  return 0
@@ -1924,7 +1917,7 @@ class Client(ClientMixin):
1924
1917
  """
1925
1918
  Compress PLC memory.
1926
1919
 
1927
- Sends real S7 PLC_CONTROL protocol with PI service "_MSZL".
1920
+ Sends real S7 PLC_CONTROL protocol with PI service "_GARB".
1928
1921
 
1929
1922
  Args:
1930
1923
  timeout: Timeout in milliseconds (used for receive timeout)
@@ -1947,7 +1940,7 @@ class Client(ClientMixin):
1947
1940
  """
1948
1941
  Copy RAM to ROM.
1949
1942
 
1950
- Sends real S7 PLC_CONTROL protocol with PI service "_MSZL" and file ID "P".
1943
+ Sends real S7 PLC_CONTROL protocol with PI service "_MODU" and file system "EP".
1951
1944
 
1952
1945
  Args:
1953
1946
  timeout: Timeout in milliseconds (used for receive timeout)
@@ -2102,7 +2095,9 @@ class Client(ClientMixin):
2102
2095
  return self.protocol.build_set_session_password_request(encoded)
2103
2096
 
2104
2097
  response = self._send_receive_with_reconnect(build_request)
2105
- self.protocol.check_userdata_response(response)
2098
+ self.protocol.check_userdata_response(
2099
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD, accept_null_ack=True
2100
+ )
2106
2101
  logger.info("Session password set successfully")
2107
2102
  return 0
2108
2103
 
@@ -2125,7 +2120,9 @@ class Client(ClientMixin):
2125
2120
  return self.protocol.build_clear_session_password_request()
2126
2121
 
2127
2122
  response = self._send_receive_with_reconnect(build_request)
2128
- self.protocol.check_userdata_response(response)
2123
+ self.protocol.check_userdata_response(
2124
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, accept_null_ack=True
2125
+ )
2129
2126
  logger.info("Session password cleared successfully")
2130
2127
  return 0
2131
2128
 
@@ -2151,6 +2148,7 @@ class Client(ClientMixin):
2151
2148
  # Build and send read SZL request
2152
2149
  request = self.protocol.build_read_szl_request(ssl_id, index)
2153
2150
  response = self._send_receive(request)
2151
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
2154
2152
 
2155
2153
  # Check for errors in data section (for USERDATA - return_code != 0xFF means error)
2156
2154
  data_info = response.get("data", {})
@@ -2180,6 +2178,7 @@ class Client(ClientMixin):
2180
2178
 
2181
2179
  response_data = conn.receive_data()
2182
2180
  response = self.protocol.parse_response(response_data)
2181
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
2183
2182
 
2184
2183
  # Check for errors
2185
2184
  data_info = response.get("data", {})
@@ -2240,6 +2239,11 @@ class Client(ClientMixin):
2240
2239
  szl = self.read_szl(0x00A0, 0)
2241
2240
  raw = bytes(szl.Data[: szl.Header.LengthDR])
2242
2241
 
2242
+ return self._parse_diagnostic_buffer(raw)
2243
+
2244
+ @classmethod
2245
+ def _parse_diagnostic_buffer(cls, raw: bytes) -> list[dict[str, Any]]:
2246
+ """Decode raw SZL 0x00A0 data into diagnostic entries."""
2243
2247
  entries: list[dict[str, Any]] = []
2244
2248
  # Each diagnostic entry is 20 bytes
2245
2249
  entry_size = 20
@@ -2250,7 +2254,7 @@ class Client(ClientMixin):
2250
2254
  # BCD-encoded timestamp at offset 2..9
2251
2255
  ts_bytes = raw[offset + 2 : offset + 10]
2252
2256
  try:
2253
- ts = self._parse_bcd_timestamp(ts_bytes)
2257
+ ts = cls._parse_bcd_timestamp(ts_bytes)
2254
2258
  except Exception:
2255
2259
  ts = None
2256
2260
 
snap7/client_base.py CHANGED
@@ -5,9 +5,14 @@ Contains pure-computation methods (no I/O) that are identical between
5
5
  the two implementations.
6
6
  """
7
7
 
8
+ import inspect
8
9
  import logging
9
10
  import struct
10
- from typing import Optional
11
+ import time
12
+ from collections.abc import Callable
13
+ from contextvars import ContextVar
14
+ from functools import wraps
15
+ from typing import Any, Optional, TypeVar
11
16
 
12
17
  from .datatypes import S7Area, S7DataTypes, S7WordLen
13
18
  from .error import S7ProtocolError
@@ -20,6 +25,71 @@ from .type import (
20
25
 
21
26
  logger = logging.getLogger(__name__)
22
27
 
28
+ F = TypeVar("F", bound=Callable[..., Any])
29
+
30
+
31
+ def _report_operation(hook: Callable[[str, float, bool], None], operation: str, started: float, error: bool) -> None:
32
+ try:
33
+ hook(operation, time.monotonic() - started, error)
34
+ except Exception:
35
+ logger.debug("on_operation callback raised an exception", exc_info=True)
36
+
37
+
38
+ def _instrumented(operation: str) -> Callable[[F], F]:
39
+ """Wrap a Client/AsyncClient method to report its duration and outcome to ``self._on_operation``.
40
+
41
+ Calls nested inside an already-instrumented call (e.g. ``read_area`` invoked
42
+ from ``read_multi_vars``) run normally but are not reported on their own, so a
43
+ single logical operation is not counted and timed twice.
44
+ """
45
+
46
+ def decorator(func: F) -> F:
47
+ if inspect.iscoroutinefunction(func):
48
+
49
+ @wraps(func)
50
+ async def async_wrapper(self: "ClientMixin", *args: Any, **kwargs: Any) -> Any:
51
+ hook = self._on_operation
52
+ if hook is None:
53
+ return await func(self, *args, **kwargs)
54
+ depth = self._operation_depth.get()
55
+ token = self._operation_depth.set(depth + 1)
56
+ started = time.monotonic()
57
+ error = False
58
+ try:
59
+ return await func(self, *args, **kwargs)
60
+ except Exception:
61
+ error = True
62
+ raise
63
+ finally:
64
+ self._operation_depth.reset(token)
65
+ if depth == 0:
66
+ _report_operation(hook, operation, started, error)
67
+
68
+ return async_wrapper # type: ignore[return-value]
69
+
70
+ @wraps(func)
71
+ def sync_wrapper(self: "ClientMixin", *args: Any, **kwargs: Any) -> Any:
72
+ hook = self._on_operation
73
+ if hook is None:
74
+ return func(self, *args, **kwargs)
75
+ depth = self._operation_depth.get()
76
+ token = self._operation_depth.set(depth + 1)
77
+ started = time.monotonic()
78
+ error = False
79
+ try:
80
+ return func(self, *args, **kwargs)
81
+ except Exception:
82
+ error = True
83
+ raise
84
+ finally:
85
+ self._operation_depth.reset(token)
86
+ if depth == 0:
87
+ _report_operation(hook, operation, started, error)
88
+
89
+ return sync_wrapper # type: ignore[return-value]
90
+
91
+ return decorator
92
+
23
93
 
24
94
  class ClientMixin:
25
95
  """Methods shared between Client and AsyncClient.
@@ -30,7 +100,8 @@ class ClientMixin:
30
100
 
31
101
  Subclasses must provide the following attributes (set in __init__):
32
102
  host, local_tsap, remote_tsap, connection_type, session_password,
33
- pdu_length, connected, _exec_time, _last_error, _params
103
+ pdu_length, connected, _exec_time, _last_error, _params, _on_operation,
104
+ _operation_depth
34
105
  """
35
106
 
36
107
  # Declared for type checkers — concrete values set by subclass __init__
@@ -44,6 +115,8 @@ class ClientMixin:
44
115
  _exec_time: int
45
116
  _last_error: int
46
117
  _params: dict[Parameter, int]
118
+ _on_operation: Optional[Callable[[str, float, bool], None]]
119
+ _operation_depth: ContextVar[int]
47
120
 
48
121
  def get_pdu_length(self) -> int:
49
122
  """Get negotiated PDU length.