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.
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,8 @@ 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(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
1905
1896
 
1906
1897
  logger.info(f"Set PLC datetime to {dt}")
1907
1898
  return 0
@@ -2102,7 +2093,7 @@ class Client(ClientMixin):
2102
2093
  return self.protocol.build_set_session_password_request(encoded)
2103
2094
 
2104
2095
  response = self._send_receive_with_reconnect(build_request)
2105
- self.protocol.check_userdata_response(response)
2096
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
2106
2097
  logger.info("Session password set successfully")
2107
2098
  return 0
2108
2099
 
@@ -2125,7 +2116,7 @@ class Client(ClientMixin):
2125
2116
  return self.protocol.build_clear_session_password_request()
2126
2117
 
2127
2118
  response = self._send_receive_with_reconnect(build_request)
2128
- self.protocol.check_userdata_response(response)
2119
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
2129
2120
  logger.info("Session password cleared successfully")
2130
2121
  return 0
2131
2122
 
@@ -2151,6 +2142,7 @@ class Client(ClientMixin):
2151
2142
  # Build and send read SZL request
2152
2143
  request = self.protocol.build_read_szl_request(ssl_id, index)
2153
2144
  response = self._send_receive(request)
2145
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
2154
2146
 
2155
2147
  # Check for errors in data section (for USERDATA - return_code != 0xFF means error)
2156
2148
  data_info = response.get("data", {})
@@ -2180,6 +2172,7 @@ class Client(ClientMixin):
2180
2172
 
2181
2173
  response_data = conn.receive_data()
2182
2174
  response = self.protocol.parse_response(response_data)
2175
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
2183
2176
 
2184
2177
  # Check for errors
2185
2178
  data_info = response.get("data", {})
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.