python-snap7 3.1.1__py3-none-any.whl → 3.2.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/METADATA +103 -0
- python_snap7-3.2.0.dist-info/RECORD +34 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.2.0.dist-info}/top_level.txt +1 -0
- s7/__init__.py +52 -0
- snap7/__init__.py +15 -4
- snap7/async_client.py +169 -21
- snap7/cli.py +2 -3
- snap7/client.py +244 -43
- snap7/client_base.py +27 -24
- snap7/connection.py +21 -10
- snap7/datatypes.py +24 -3
- snap7/error.py +6 -0
- snap7/logo.py +48 -6
- snap7/ppi.py +421 -0
- snap7/rate_limiter.py +110 -0
- snap7/s7protocol.py +200 -16
- snap7/server/__init__.py +360 -113
- snap7/szl.py +106 -9
- snap7/tags.py +6 -6
- snap7/type.py +14 -1
- snap7/util/getters.py +21 -4
- snap7/util/setters.py +4 -1
- python_snap7-3.1.1.dist-info/METADATA +0 -131
- python_snap7-3.1.1.dist-info/RECORD +0 -32
- snap7/util/symbols.py +0 -547
- {python_snap7-3.1.1.dist-info → python_snap7-3.2.0.dist-info}/WHEEL +0 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.2.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-snap7
|
|
3
|
+
Version: 3.2.0
|
|
4
|
+
Summary: Pure Python S7 communication library for Siemens PLCs
|
|
5
|
+
Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gijzelaerr/python-snap7
|
|
8
|
+
Project-URL: Documentation, https://python-snap7.readthedocs.io/en/latest/
|
|
9
|
+
Keywords: snap7,s7,siemens,plc
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Topic :: System :: Hardware
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Manufacturing
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/x-rst
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-html; extra == "test"
|
|
29
|
+
Requires-Dist: hypothesis; extra == "test"
|
|
30
|
+
Requires-Dist: mypy; extra == "test"
|
|
31
|
+
Requires-Dist: types-setuptools; extra == "test"
|
|
32
|
+
Requires-Dist: ruff; extra == "test"
|
|
33
|
+
Requires-Dist: tox; extra == "test"
|
|
34
|
+
Requires-Dist: tox-uv; extra == "test"
|
|
35
|
+
Requires-Dist: types-click; extra == "test"
|
|
36
|
+
Requires-Dist: uv; extra == "test"
|
|
37
|
+
Provides-Extra: cli
|
|
38
|
+
Requires-Dist: rich; extra == "cli"
|
|
39
|
+
Requires-Dist: click; extra == "cli"
|
|
40
|
+
Provides-Extra: demo
|
|
41
|
+
Requires-Dist: psutil; extra == "demo"
|
|
42
|
+
Requires-Dist: rich; extra == "demo"
|
|
43
|
+
Requires-Dist: click; extra == "demo"
|
|
44
|
+
Provides-Extra: doc
|
|
45
|
+
Requires-Dist: sphinx; extra == "doc"
|
|
46
|
+
Requires-Dist: sphinx_rtd_theme; extra == "doc"
|
|
47
|
+
Provides-Extra: discovery
|
|
48
|
+
Requires-Dist: pnio-dcp; extra == "discovery"
|
|
49
|
+
Provides-Extra: ppi
|
|
50
|
+
Requires-Dist: pyserial>=3.5; extra == "ppi"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
.. image:: https://img.shields.io/pypi/v/python-snap7.svg
|
|
54
|
+
:target: https://pypi.org/project/python-snap7/
|
|
55
|
+
|
|
56
|
+
.. image:: https://img.shields.io/pypi/pyversions/python-snap7.svg
|
|
57
|
+
:target: https://pypi.org/project/python-snap7/
|
|
58
|
+
|
|
59
|
+
.. image:: https://img.shields.io/github/license/gijzelaerr/python-snap7.svg
|
|
60
|
+
:target: https://github.com/gijzelaerr/python-snap7/blob/master/LICENSE
|
|
61
|
+
|
|
62
|
+
.. image:: https://github.com/gijzelaerr/python-snap7/actions/workflows/test.yml/badge.svg
|
|
63
|
+
:target: https://github.com/gijzelaerr/python-snap7/actions/workflows/test.yml
|
|
64
|
+
|
|
65
|
+
.. image:: https://readthedocs.org/projects/python-snap7/badge/
|
|
66
|
+
:target: https://python-snap7.readthedocs.io/en/latest/
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
python-snap7
|
|
70
|
+
============
|
|
71
|
+
|
|
72
|
+
python-snap7 is a pure-Python library for communicating with Siemens S7 PLCs
|
|
73
|
+
using the classic S7 protocol. It supports S7-300 and S7-400 controllers, as
|
|
74
|
+
well as S7-1200 and S7-1500 controllers with PUT/GET access enabled. It
|
|
75
|
+
supports Python 3.10+ on Windows, Linux, and macOS without native dependencies.
|
|
76
|
+
|
|
77
|
+
Installation
|
|
78
|
+
============
|
|
79
|
+
|
|
80
|
+
Install from PyPI::
|
|
81
|
+
|
|
82
|
+
pip install python-snap7
|
|
83
|
+
|
|
84
|
+
Quick start
|
|
85
|
+
===========
|
|
86
|
+
|
|
87
|
+
::
|
|
88
|
+
|
|
89
|
+
from s7 import Client
|
|
90
|
+
|
|
91
|
+
client = Client()
|
|
92
|
+
client.connect("192.168.1.10", 0, 1)
|
|
93
|
+
data = client.db_read(1, 0, 4)
|
|
94
|
+
client.disconnect()
|
|
95
|
+
|
|
96
|
+
The ``s7`` package is the recommended import. The ``snap7`` package name is
|
|
97
|
+
also available for backwards compatibility.
|
|
98
|
+
|
|
99
|
+
Documentation
|
|
100
|
+
=============
|
|
101
|
+
|
|
102
|
+
Read the full documentation at
|
|
103
|
+
`python-snap7.readthedocs.io <https://python-snap7.readthedocs.io/en/latest/>`_.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
python_snap7-3.2.0.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
|
|
2
|
+
s7/__init__.py,sha256=q6E4rA1BDx36PPkJS55xv7XCxwlaQeEK6HNotFjLeYo,1096
|
|
3
|
+
snap7/__init__.py,sha256=2NAAE1oaSSd7DqPl0dx7jNXtsM2PRBWRm9X-bkmgC6U,1275
|
|
4
|
+
snap7/async_client.py,sha256=epBMT21GN_VbjL1jwoPiC0fYOdsO7NzaEKZw62DKNnM,50279
|
|
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
|
|
8
|
+
snap7/connection.py,sha256=Qjis-ICK6E74_YfoJuy2_lgDqfSYs4ULux1FShJVvtw,18834
|
|
9
|
+
snap7/datatypes.py,sha256=Gqu1XDuKkQI76n4TYrFrhm0RlPz4XNA5GKA-GJJXJDU,12042
|
|
10
|
+
snap7/demo.py,sha256=JWSKLj2QPIXeSFTZSXuADAJzWVKls712MUZnuqwNkNs,17376
|
|
11
|
+
snap7/discovery.py,sha256=LONPYg83b2quKXht6y_GTTArdxitjD8DoGyaHfe9B1w,3964
|
|
12
|
+
snap7/error.py,sha256=S5BquFFSL1NpgdytIpOD_FzhN6gOCngSkxJVZT4rxdw,21323
|
|
13
|
+
snap7/log.py,sha256=XX4UyPgByz3EhTc39BTGY454fHvYmiJAX4v-a-gkIEY,4968
|
|
14
|
+
snap7/logo.py,sha256=0h-EZ5K86Qddna_lryBn6xLXLVwnznYJuMGuevzH6bI,9192
|
|
15
|
+
snap7/optimizer.py,sha256=Bu4zWCfdeA5GG3sWwjYOLkp0tWEfNbAXvbe_RldlYVM,9383
|
|
16
|
+
snap7/partner.py,sha256=HxaTYfojsf5Z170R4cAYFzoMSwqCOF42qPCkOaxbUkU,37916
|
|
17
|
+
snap7/ppi.py,sha256=p0fVelfUQOT3QH5uuWOjpDxA5IyT3gx5FzCMaYYEuWQ,16070
|
|
18
|
+
snap7/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
snap7/rate_limiter.py,sha256=dnXCOJLWmszo8oskbFM0om0gPC4LHHCOr-Nt22tImeU,4151
|
|
20
|
+
snap7/s7protocol.py,sha256=xQrCTzC2xEe8ykfa3T2-kR9sLFDP8M7GZMfsm-n2i34,64835
|
|
21
|
+
snap7/szl.py,sha256=a9NhH7Lyp8SXJ2_kvyyV-14srIxm3tXnAnclBy5alDk,7998
|
|
22
|
+
snap7/tags.py,sha256=TmbNRHNWZVe3Yl_1L5WXE3Qb0lxIEW-veoCRGt0giZQ,26987
|
|
23
|
+
snap7/type.py,sha256=EiXqU0ayYYxlM-5fUcPL7psgL1EGNNqAa7JXH4QlAnU,9752
|
|
24
|
+
snap7/server/__init__.py,sha256=MOX_0oeC5pBoKEN_4V5XPnaU9ONQM_uELoDfdjlDeQg,112740
|
|
25
|
+
snap7/server/__main__.py,sha256=OdOgyMmFThjSe2jSgvlbkquGYnZcBW6wubs2ldhNSOE,1206
|
|
26
|
+
snap7/util/__init__.py,sha256=d6n2oy7tNYke1ia0TUyWz_ni3l0XN1qvilM5sW0T-Es,1874
|
|
27
|
+
snap7/util/db.py,sha256=geesYS2oyOuTADOJa2me1p1rqJVzPuLTCfye6JIeav0,27120
|
|
28
|
+
snap7/util/getters.py,sha256=nKRfLC2yClfoLxd4MKq1SuBPhzVP1RpV9hwxxWlavug,25391
|
|
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,,
|
s7/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Alias for the ``snap7`` package.
|
|
2
|
+
|
|
3
|
+
``s7`` re-exports everything from ``snap7`` so that either name works::
|
|
4
|
+
|
|
5
|
+
from s7 import Client # same as: from snap7 import Client
|
|
6
|
+
from s7.type import Area # same as: from snap7.type import Area
|
|
7
|
+
|
|
8
|
+
Both import names are supported; ``s7`` is recommended for new code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import snap7 as snap7 # noqa: F401
|
|
15
|
+
|
|
16
|
+
from snap7 import * # noqa: F403
|
|
17
|
+
from snap7 import __all__ as __all__, __version__ as __version__
|
|
18
|
+
|
|
19
|
+
_SUBMODULES = [
|
|
20
|
+
"async_client",
|
|
21
|
+
"cli",
|
|
22
|
+
"client",
|
|
23
|
+
"client_base",
|
|
24
|
+
"connection",
|
|
25
|
+
"datatypes",
|
|
26
|
+
"demo",
|
|
27
|
+
"discovery",
|
|
28
|
+
"error",
|
|
29
|
+
"log",
|
|
30
|
+
"logo",
|
|
31
|
+
"optimizer",
|
|
32
|
+
"partner",
|
|
33
|
+
"ppi",
|
|
34
|
+
"rate_limiter",
|
|
35
|
+
"s7protocol",
|
|
36
|
+
"server",
|
|
37
|
+
"szl",
|
|
38
|
+
"tags",
|
|
39
|
+
"type",
|
|
40
|
+
"util",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
for _name in _SUBMODULES:
|
|
44
|
+
_fqn = f"s7.{_name}"
|
|
45
|
+
if _fqn not in sys.modules:
|
|
46
|
+
try:
|
|
47
|
+
_mod = importlib.import_module(f"snap7.{_name}")
|
|
48
|
+
sys.modules[_fqn] = _mod
|
|
49
|
+
except ImportError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
del _name, _fqn, _mod, _SUBMODULES
|
snap7/__init__.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
"""
|
|
2
|
-
The
|
|
2
|
+
The snap7 compatibility package.
|
|
3
3
|
|
|
4
|
-
Pure Python implementation of the S7 protocol for communicating with
|
|
5
|
-
Siemens S7 PLCs
|
|
4
|
+
Pure Python implementation of the classic S7 protocol for communicating with
|
|
5
|
+
Siemens S7 PLCs. This package is kept for backwards compatibility. For new
|
|
6
|
+
projects, use the ``s7`` package instead::
|
|
7
|
+
|
|
8
|
+
from s7 import Client
|
|
9
|
+
|
|
10
|
+
client = Client()
|
|
11
|
+
client.connect("192.168.1.10", 0, 1)
|
|
12
|
+
data = client.db_read(1, 0, 4)
|
|
6
13
|
"""
|
|
7
14
|
|
|
8
15
|
from importlib.metadata import version, PackageNotFoundError
|
|
@@ -11,16 +18,19 @@ from .client import Client
|
|
|
11
18
|
from .async_client import AsyncClient
|
|
12
19
|
from .server import Server
|
|
13
20
|
from .partner import Partner
|
|
21
|
+
from .ppi import PPIArea, PPIClient
|
|
14
22
|
from .logo import Logo
|
|
15
23
|
from .util.db import Row, DB
|
|
16
24
|
from .tags import NodeS7Tag, PLC4XTag, Tag, from_browse, load_csv, load_json, load_tia_xml, parse_tag
|
|
17
|
-
from .type import Area, Block, WordLen, SrvEvent, SrvArea
|
|
25
|
+
from .type import Area, Block, ForceEntry, WordLen, SrvEvent, SrvArea
|
|
18
26
|
|
|
19
27
|
__all__ = [
|
|
20
28
|
"Client",
|
|
21
29
|
"AsyncClient",
|
|
22
30
|
"Server",
|
|
23
31
|
"Partner",
|
|
32
|
+
"PPIClient",
|
|
33
|
+
"PPIArea",
|
|
24
34
|
"Logo",
|
|
25
35
|
"Row",
|
|
26
36
|
"DB",
|
|
@@ -34,6 +44,7 @@ __all__ = [
|
|
|
34
44
|
"from_browse",
|
|
35
45
|
"Area",
|
|
36
46
|
"Block",
|
|
47
|
+
"ForceEntry",
|
|
37
48
|
"WordLen",
|
|
38
49
|
"SrvEvent",
|
|
39
50
|
"SrvArea",
|
snap7/async_client.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Native async S7 client implementation.
|
|
1
|
+
"""Asynchronous client for the classic S7 protocol.
|
|
3
2
|
|
|
4
3
|
Uses asyncio streams for non-blocking I/O with an asyncio.Lock() to serialize
|
|
5
4
|
send/receive cycles, ensuring safe concurrent use via asyncio.gather().
|
|
5
|
+
|
|
6
|
+
``s7.AsyncClient`` and ``snap7.AsyncClient`` expose this same implementation.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import asyncio
|
|
@@ -15,14 +16,17 @@ from datetime import datetime
|
|
|
15
16
|
|
|
16
17
|
from .connection import TPDUSize
|
|
17
18
|
from .s7protocol import S7Protocol, get_return_code_description
|
|
18
|
-
from .datatypes import S7WordLen
|
|
19
|
+
from .datatypes import S7DataTypes, S7WordLen
|
|
19
20
|
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
|
|
20
21
|
from .client_base import ClientMixin
|
|
21
22
|
from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
|
|
23
|
+
from .client import _parse_force_szl
|
|
24
|
+
from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
|
|
22
25
|
from .type import (
|
|
23
26
|
Area,
|
|
24
27
|
Block,
|
|
25
28
|
BlocksList,
|
|
29
|
+
ForceEntry,
|
|
26
30
|
S7CpuInfo,
|
|
27
31
|
TS7BlockInfo,
|
|
28
32
|
S7CpInfo,
|
|
@@ -156,7 +160,7 @@ class AsyncISOTCPConnection:
|
|
|
156
160
|
raise S7ConnectionError(f"Invalid TPKT version: {version}")
|
|
157
161
|
|
|
158
162
|
remaining = length - 4
|
|
159
|
-
if
|
|
163
|
+
if length < 7:
|
|
160
164
|
raise S7ConnectionError("Invalid TPKT length")
|
|
161
165
|
|
|
162
166
|
payload = await self._recv_exact(remaining)
|
|
@@ -165,6 +169,10 @@ class AsyncISOTCPConnection:
|
|
|
165
169
|
if len(payload) < 3:
|
|
166
170
|
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
167
171
|
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
|
|
172
|
+
if pdu_len != 2:
|
|
173
|
+
raise S7ConnectionError("Invalid COTP DT header length")
|
|
174
|
+
if eot_num & 0x7F:
|
|
175
|
+
raise S7ConnectionError("Invalid Class 0 COTP TPDU number")
|
|
168
176
|
if pdu_type != self.COTP_DT:
|
|
169
177
|
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
170
178
|
return payload[3:]
|
|
@@ -214,6 +222,8 @@ class AsyncISOTCPConnection:
|
|
|
214
222
|
def _build_tpkt(self, payload: bytes) -> bytes:
|
|
215
223
|
"""Build TPKT frame."""
|
|
216
224
|
length = len(payload) + 4
|
|
225
|
+
if not 7 <= length <= 65535:
|
|
226
|
+
raise S7ConnectionError("Invalid TPKT length: expected 7..65535 bytes")
|
|
217
227
|
return struct.pack(">BBH", 3, 0, length) + payload
|
|
218
228
|
|
|
219
229
|
def _parse_cotp_cc(self, data: bytes) -> None:
|
|
@@ -239,9 +249,17 @@ class AsyncISOTCPConnection:
|
|
|
239
249
|
param_data = data[offset + 2 : offset + 2 + param_len]
|
|
240
250
|
if param_code == self.COTP_PARAM_PDU_SIZE:
|
|
241
251
|
if param_len == 1:
|
|
242
|
-
|
|
252
|
+
exponent = param_data[0]
|
|
253
|
+
if 7 <= exponent <= 13:
|
|
254
|
+
self.pdu_size = 1 << exponent
|
|
255
|
+
else:
|
|
256
|
+
logger.warning(f"Invalid COTP PDU size exponent {exponent}, using default")
|
|
243
257
|
elif param_len == 2:
|
|
244
|
-
|
|
258
|
+
raw = struct.unpack(">H", param_data)[0]
|
|
259
|
+
if 128 <= raw <= 8192:
|
|
260
|
+
self.pdu_size = raw
|
|
261
|
+
else:
|
|
262
|
+
logger.warning(f"Invalid COTP PDU size {raw}, using default")
|
|
245
263
|
logger.debug(f"Negotiated PDU size: {self.pdu_size}")
|
|
246
264
|
offset += 2 + param_len
|
|
247
265
|
|
|
@@ -277,23 +295,31 @@ class AsyncISOTCPConnection:
|
|
|
277
295
|
|
|
278
296
|
|
|
279
297
|
class AsyncClient(ClientMixin):
|
|
280
|
-
"""
|
|
281
|
-
Native async S7 client implementation.
|
|
298
|
+
"""Asynchronous client for classic S7 communication.
|
|
282
299
|
|
|
283
300
|
Uses asyncio streams for non-blocking I/O. An internal asyncio.Lock
|
|
284
301
|
serializes each send+receive cycle so that concurrent coroutines
|
|
285
302
|
(e.g. via asyncio.gather) never interleave on the same TCP socket.
|
|
286
303
|
|
|
304
|
+
For new projects, use ``s7.AsyncClient`` instead.
|
|
305
|
+
|
|
287
306
|
Examples:
|
|
288
|
-
>>> import
|
|
289
|
-
>>> async with
|
|
307
|
+
>>> from s7 import AsyncClient
|
|
308
|
+
>>> async with AsyncClient() as client:
|
|
290
309
|
... await client.connect("192.168.1.10", 0, 1)
|
|
291
310
|
... data = await client.db_read(1, 0, 4)
|
|
292
311
|
"""
|
|
293
312
|
|
|
294
313
|
MAX_VARS = 20
|
|
295
314
|
|
|
296
|
-
def __init__(
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
max_requests_per_second: float = 0,
|
|
319
|
+
rate_limit_algorithm: RateLimitAlgorithm = "fixed",
|
|
320
|
+
rate_limit_behavior: RateLimitBehavior = "block",
|
|
321
|
+
rate_limit_burst: int | None = None,
|
|
322
|
+
) -> None:
|
|
297
323
|
self.connection: Optional[AsyncISOTCPConnection] = None
|
|
298
324
|
self.protocol = S7Protocol()
|
|
299
325
|
self.connected = False
|
|
@@ -312,6 +338,12 @@ class AsyncClient(ClientMixin):
|
|
|
312
338
|
self._last_error = 0
|
|
313
339
|
|
|
314
340
|
self._lock = asyncio.Lock()
|
|
341
|
+
self._rate_limiter = RequestRateLimiter(
|
|
342
|
+
max_requests_per_second,
|
|
343
|
+
algorithm=rate_limit_algorithm,
|
|
344
|
+
behavior=rate_limit_behavior,
|
|
345
|
+
burst_capacity=rate_limit_burst,
|
|
346
|
+
)
|
|
315
347
|
|
|
316
348
|
self._params = {
|
|
317
349
|
Parameter.RemotePort: 102,
|
|
@@ -331,6 +363,11 @@ class AsyncClient(ClientMixin):
|
|
|
331
363
|
raise S7ConnectionError("Not connected to PLC")
|
|
332
364
|
return self.connection
|
|
333
365
|
|
|
366
|
+
async def _send_data(self, conn: AsyncISOTCPConnection, request: bytes) -> None:
|
|
367
|
+
"""Apply the per-client rate limit and send one S7 request PDU."""
|
|
368
|
+
await self._rate_limiter.acquire_async()
|
|
369
|
+
await conn.send_data(request)
|
|
370
|
+
|
|
334
371
|
async def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
|
|
335
372
|
"""Send a request and receive/parse the response, holding the lock.
|
|
336
373
|
|
|
@@ -350,7 +387,7 @@ class AsyncClient(ClientMixin):
|
|
|
350
387
|
expected_seq = struct.unpack(">H", request[4:6])[0]
|
|
351
388
|
|
|
352
389
|
async with self._lock:
|
|
353
|
-
await
|
|
390
|
+
await self._send_data(conn, request)
|
|
354
391
|
|
|
355
392
|
for attempt in range(max_stale_retries + 1):
|
|
356
393
|
response_data = await conn.receive_data()
|
|
@@ -517,7 +554,7 @@ class AsyncClient(ClientMixin):
|
|
|
517
554
|
else:
|
|
518
555
|
word_len = S7WordLen.BYTE
|
|
519
556
|
|
|
520
|
-
max_chunk = self.
|
|
557
|
+
max_chunk = self._read_chunk_count(word_len)
|
|
521
558
|
if size <= max_chunk:
|
|
522
559
|
request = self.protocol.build_read_request(
|
|
523
560
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, count=size
|
|
@@ -533,7 +570,11 @@ class AsyncClient(ClientMixin):
|
|
|
533
570
|
while remaining > 0:
|
|
534
571
|
chunk_size = min(remaining, max_chunk)
|
|
535
572
|
request = self.protocol.build_read_request(
|
|
536
|
-
area=s7_area,
|
|
573
|
+
area=s7_area,
|
|
574
|
+
db_number=db_number,
|
|
575
|
+
start=start + offset * self._element_address_step(word_len),
|
|
576
|
+
word_len=word_len,
|
|
577
|
+
count=chunk_size,
|
|
537
578
|
)
|
|
538
579
|
response = await self._send_receive(request)
|
|
539
580
|
values = self.protocol.extract_read_data(response, word_len, chunk_size)
|
|
@@ -559,7 +600,7 @@ class AsyncClient(ClientMixin):
|
|
|
559
600
|
else:
|
|
560
601
|
word_len = S7WordLen.BYTE
|
|
561
602
|
|
|
562
|
-
max_chunk = self.
|
|
603
|
+
max_chunk = self._write_chunk_bytes(word_len, len(data))
|
|
563
604
|
if len(data) <= max_chunk:
|
|
564
605
|
request = self.protocol.build_write_request(
|
|
565
606
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, data=bytes(data)
|
|
@@ -575,7 +616,11 @@ class AsyncClient(ClientMixin):
|
|
|
575
616
|
chunk_size = min(remaining, max_chunk)
|
|
576
617
|
chunk_data = data[offset : offset + chunk_size]
|
|
577
618
|
request = self.protocol.build_write_request(
|
|
578
|
-
area=s7_area,
|
|
619
|
+
area=s7_area,
|
|
620
|
+
db_number=db_number,
|
|
621
|
+
start=start + offset // S7DataTypes.get_size_bytes(word_len) * self._element_address_step(word_len),
|
|
622
|
+
word_len=word_len,
|
|
623
|
+
data=bytes(chunk_data),
|
|
579
624
|
)
|
|
580
625
|
response = await self._send_receive(request)
|
|
581
626
|
self.protocol.check_write_response(response)
|
|
@@ -695,7 +740,7 @@ class AsyncClient(ClientMixin):
|
|
|
695
740
|
|
|
696
741
|
async with self._lock:
|
|
697
742
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
698
|
-
await
|
|
743
|
+
await self._send_data(conn, followup)
|
|
699
744
|
response_data = await conn.receive_data()
|
|
700
745
|
|
|
701
746
|
response = self.protocol.parse_response(response_data)
|
|
@@ -819,7 +864,7 @@ class AsyncClient(ClientMixin):
|
|
|
819
864
|
)
|
|
820
865
|
|
|
821
866
|
async with self._lock:
|
|
822
|
-
await
|
|
867
|
+
await self._send_data(conn, header + param_data + data_section)
|
|
823
868
|
response_data = await conn.receive_data()
|
|
824
869
|
self.protocol.parse_response(response_data)
|
|
825
870
|
|
|
@@ -836,7 +881,7 @@ class AsyncClient(ClientMixin):
|
|
|
836
881
|
)
|
|
837
882
|
|
|
838
883
|
async with self._lock:
|
|
839
|
-
await
|
|
884
|
+
await self._send_data(conn, header + param_data)
|
|
840
885
|
response_data = await conn.receive_data()
|
|
841
886
|
self.protocol.parse_response(response_data)
|
|
842
887
|
|
|
@@ -1009,7 +1054,7 @@ class AsyncClient(ClientMixin):
|
|
|
1009
1054
|
|
|
1010
1055
|
async with self._lock:
|
|
1011
1056
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
1012
|
-
await
|
|
1057
|
+
await self._send_data(conn, followup)
|
|
1013
1058
|
response_data = await conn.receive_data()
|
|
1014
1059
|
|
|
1015
1060
|
response = self.protocol.parse_response(response_data)
|
|
@@ -1065,6 +1110,109 @@ class AsyncClient(ClientMixin):
|
|
|
1065
1110
|
raise S7ConnectionError("Not connected to PLC")
|
|
1066
1111
|
return parse_protection_szl(await self.read_szl(0x0232, 0))
|
|
1067
1112
|
|
|
1113
|
+
# ---------------------------------------------------------------
|
|
1114
|
+
# Force I/O
|
|
1115
|
+
# ---------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
_FORCE_AREAS: frozenset[int] = frozenset({Area.PE, Area.PA})
|
|
1118
|
+
|
|
1119
|
+
async def force_bit(self, area: Area, byte_offset: int, bit: int, value: bool) -> None:
|
|
1120
|
+
"""Force a single I/O bit in the process image.
|
|
1121
|
+
|
|
1122
|
+
Async equivalent of :meth:`snap7.client.Client.force_bit`.
|
|
1123
|
+
"""
|
|
1124
|
+
if area not in self._FORCE_AREAS:
|
|
1125
|
+
raise ValueError(f"Force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
1126
|
+
if not 0 <= bit <= 7:
|
|
1127
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
1128
|
+
|
|
1129
|
+
current = await self.read_area(area, 0, byte_offset, 1)
|
|
1130
|
+
if value:
|
|
1131
|
+
current[0] |= 1 << bit
|
|
1132
|
+
else:
|
|
1133
|
+
current[0] &= ~(1 << bit)
|
|
1134
|
+
await self.write_area(area, 0, byte_offset, current)
|
|
1135
|
+
logger.info(f"Forced {area.name} byte {byte_offset} bit {bit} = {value}")
|
|
1136
|
+
|
|
1137
|
+
async def cancel_force(self, area: Area, byte_offset: int, bit: int) -> None:
|
|
1138
|
+
"""Cancel a forced I/O bit by clearing it in the process image.
|
|
1139
|
+
|
|
1140
|
+
Async equivalent of :meth:`snap7.client.Client.cancel_force`.
|
|
1141
|
+
"""
|
|
1142
|
+
if area not in self._FORCE_AREAS:
|
|
1143
|
+
raise ValueError(f"Cancel force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
1144
|
+
if not 0 <= bit <= 7:
|
|
1145
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
1146
|
+
|
|
1147
|
+
current = await self.read_area(area, 0, byte_offset, 1)
|
|
1148
|
+
current[0] &= ~(1 << bit)
|
|
1149
|
+
await self.write_area(area, 0, byte_offset, current)
|
|
1150
|
+
logger.info(f"Cancelled force on {area.name} byte {byte_offset} bit {bit}")
|
|
1151
|
+
|
|
1152
|
+
async def read_force_table(self) -> list[ForceEntry]:
|
|
1153
|
+
"""Read the PLC force table via SZL 0x0025.
|
|
1154
|
+
|
|
1155
|
+
Async equivalent of :meth:`snap7.client.Client.read_force_table`.
|
|
1156
|
+
"""
|
|
1157
|
+
if not self.get_connected():
|
|
1158
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
szl = await self.read_szl(0x0025, 0x0000)
|
|
1162
|
+
except (S7ProtocolError, RuntimeError):
|
|
1163
|
+
logger.debug("SZL 0x0025 not available; returning empty force table")
|
|
1164
|
+
return []
|
|
1165
|
+
|
|
1166
|
+
raw = bytes(szl.Data[: szl.Header.LengthDR])
|
|
1167
|
+
return _parse_force_szl(raw)
|
|
1168
|
+
|
|
1169
|
+
async def set_session_password(self, password: str) -> int:
|
|
1170
|
+
"""Set the session password to unlock a password-protected PLC.
|
|
1171
|
+
|
|
1172
|
+
Sends an S7 USERDATA request (function group 5, subfunction 1)
|
|
1173
|
+
with the encoded password.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
password: Plaintext password (max 8 ASCII characters).
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
0 on success.
|
|
1180
|
+
|
|
1181
|
+
Raises:
|
|
1182
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
1183
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the password.
|
|
1184
|
+
"""
|
|
1185
|
+
if not self.get_connected():
|
|
1186
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1187
|
+
|
|
1188
|
+
encoded = self.protocol.encode_password(password)
|
|
1189
|
+
request = self.protocol.build_set_session_password_request(encoded)
|
|
1190
|
+
response = await self._send_receive(request)
|
|
1191
|
+
self.protocol.check_userdata_response(response)
|
|
1192
|
+
logger.info("Session password set successfully")
|
|
1193
|
+
return 0
|
|
1194
|
+
|
|
1195
|
+
async def clear_session_password(self) -> int:
|
|
1196
|
+
"""Clear the session password, returning to the default protection level.
|
|
1197
|
+
|
|
1198
|
+
Sends an S7 USERDATA request (function group 5, subfunction 2).
|
|
1199
|
+
|
|
1200
|
+
Returns:
|
|
1201
|
+
0 on success.
|
|
1202
|
+
|
|
1203
|
+
Raises:
|
|
1204
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
1205
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the request.
|
|
1206
|
+
"""
|
|
1207
|
+
if not self.get_connected():
|
|
1208
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1209
|
+
|
|
1210
|
+
request = self.protocol.build_clear_session_password_request()
|
|
1211
|
+
response = await self._send_receive(request)
|
|
1212
|
+
self.protocol.check_userdata_response(response)
|
|
1213
|
+
logger.info("Session password cleared successfully")
|
|
1214
|
+
return 0
|
|
1215
|
+
|
|
1068
1216
|
async def compress(self, timeout: int) -> int:
|
|
1069
1217
|
"""Compress PLC memory."""
|
|
1070
1218
|
if not self.get_connected():
|
|
@@ -1092,7 +1240,7 @@ class AsyncClient(ClientMixin):
|
|
|
1092
1240
|
conn = self._get_connection()
|
|
1093
1241
|
|
|
1094
1242
|
async with self._lock:
|
|
1095
|
-
await
|
|
1243
|
+
await self._send_data(conn, bytes(data))
|
|
1096
1244
|
response = await conn.receive_data()
|
|
1097
1245
|
return bytearray(response)
|
|
1098
1246
|
|
snap7/cli.py
CHANGED
|
@@ -15,9 +15,8 @@ from typing import Optional
|
|
|
15
15
|
|
|
16
16
|
try:
|
|
17
17
|
import click
|
|
18
|
-
except ImportError:
|
|
19
|
-
|
|
20
|
-
raise
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise ImportError("CLI dependencies not installed. Install them with: pip install python-snap7[cli]") from exc
|
|
21
20
|
|
|
22
21
|
from snap7 import __version__
|
|
23
22
|
from snap7.client import Client
|