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.
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/METADATA +27 -1
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/RECORD +13 -13
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/WHEEL +1 -1
- snap7/async_client.py +399 -67
- snap7/client.py +96 -92
- snap7/client_base.py +75 -2
- snap7/s7protocol.py +251 -231
- snap7/server/__init__.py +216 -249
- snap7/szl.py +1 -1
- snap7/util/getters.py +19 -10
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/top_level.txt +0 -0
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
|
|
1634
|
-
|
|
1635
|
-
# Step 2: Upload
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
|
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
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
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
|
|
1802
|
-
|
|
1803
|
-
# Step 2: Upload
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
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 "
|
|
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 "
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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.
|