python-snap7 3.1.2__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.2.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 +159 -19
- 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 +242 -90
- 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.2.dist-info/METADATA +0 -131
- python_snap7-3.1.2.dist-info/RECORD +0 -32
- snap7/util/symbols.py +0 -547
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/WHEEL +0 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.2.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:
|
|
@@ -285,23 +295,31 @@ class AsyncISOTCPConnection:
|
|
|
285
295
|
|
|
286
296
|
|
|
287
297
|
class AsyncClient(ClientMixin):
|
|
288
|
-
"""
|
|
289
|
-
Native async S7 client implementation.
|
|
298
|
+
"""Asynchronous client for classic S7 communication.
|
|
290
299
|
|
|
291
300
|
Uses asyncio streams for non-blocking I/O. An internal asyncio.Lock
|
|
292
301
|
serializes each send+receive cycle so that concurrent coroutines
|
|
293
302
|
(e.g. via asyncio.gather) never interleave on the same TCP socket.
|
|
294
303
|
|
|
304
|
+
For new projects, use ``s7.AsyncClient`` instead.
|
|
305
|
+
|
|
295
306
|
Examples:
|
|
296
|
-
>>> import
|
|
297
|
-
>>> async with
|
|
307
|
+
>>> from s7 import AsyncClient
|
|
308
|
+
>>> async with AsyncClient() as client:
|
|
298
309
|
... await client.connect("192.168.1.10", 0, 1)
|
|
299
310
|
... data = await client.db_read(1, 0, 4)
|
|
300
311
|
"""
|
|
301
312
|
|
|
302
313
|
MAX_VARS = 20
|
|
303
314
|
|
|
304
|
-
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:
|
|
305
323
|
self.connection: Optional[AsyncISOTCPConnection] = None
|
|
306
324
|
self.protocol = S7Protocol()
|
|
307
325
|
self.connected = False
|
|
@@ -320,6 +338,12 @@ class AsyncClient(ClientMixin):
|
|
|
320
338
|
self._last_error = 0
|
|
321
339
|
|
|
322
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
|
+
)
|
|
323
347
|
|
|
324
348
|
self._params = {
|
|
325
349
|
Parameter.RemotePort: 102,
|
|
@@ -339,6 +363,11 @@ class AsyncClient(ClientMixin):
|
|
|
339
363
|
raise S7ConnectionError("Not connected to PLC")
|
|
340
364
|
return self.connection
|
|
341
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
|
+
|
|
342
371
|
async def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
|
|
343
372
|
"""Send a request and receive/parse the response, holding the lock.
|
|
344
373
|
|
|
@@ -358,7 +387,7 @@ class AsyncClient(ClientMixin):
|
|
|
358
387
|
expected_seq = struct.unpack(">H", request[4:6])[0]
|
|
359
388
|
|
|
360
389
|
async with self._lock:
|
|
361
|
-
await
|
|
390
|
+
await self._send_data(conn, request)
|
|
362
391
|
|
|
363
392
|
for attempt in range(max_stale_retries + 1):
|
|
364
393
|
response_data = await conn.receive_data()
|
|
@@ -525,7 +554,7 @@ class AsyncClient(ClientMixin):
|
|
|
525
554
|
else:
|
|
526
555
|
word_len = S7WordLen.BYTE
|
|
527
556
|
|
|
528
|
-
max_chunk = self.
|
|
557
|
+
max_chunk = self._read_chunk_count(word_len)
|
|
529
558
|
if size <= max_chunk:
|
|
530
559
|
request = self.protocol.build_read_request(
|
|
531
560
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, count=size
|
|
@@ -541,7 +570,11 @@ class AsyncClient(ClientMixin):
|
|
|
541
570
|
while remaining > 0:
|
|
542
571
|
chunk_size = min(remaining, max_chunk)
|
|
543
572
|
request = self.protocol.build_read_request(
|
|
544
|
-
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,
|
|
545
578
|
)
|
|
546
579
|
response = await self._send_receive(request)
|
|
547
580
|
values = self.protocol.extract_read_data(response, word_len, chunk_size)
|
|
@@ -567,7 +600,7 @@ class AsyncClient(ClientMixin):
|
|
|
567
600
|
else:
|
|
568
601
|
word_len = S7WordLen.BYTE
|
|
569
602
|
|
|
570
|
-
max_chunk = self.
|
|
603
|
+
max_chunk = self._write_chunk_bytes(word_len, len(data))
|
|
571
604
|
if len(data) <= max_chunk:
|
|
572
605
|
request = self.protocol.build_write_request(
|
|
573
606
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, data=bytes(data)
|
|
@@ -583,7 +616,11 @@ class AsyncClient(ClientMixin):
|
|
|
583
616
|
chunk_size = min(remaining, max_chunk)
|
|
584
617
|
chunk_data = data[offset : offset + chunk_size]
|
|
585
618
|
request = self.protocol.build_write_request(
|
|
586
|
-
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),
|
|
587
624
|
)
|
|
588
625
|
response = await self._send_receive(request)
|
|
589
626
|
self.protocol.check_write_response(response)
|
|
@@ -703,7 +740,7 @@ class AsyncClient(ClientMixin):
|
|
|
703
740
|
|
|
704
741
|
async with self._lock:
|
|
705
742
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
706
|
-
await
|
|
743
|
+
await self._send_data(conn, followup)
|
|
707
744
|
response_data = await conn.receive_data()
|
|
708
745
|
|
|
709
746
|
response = self.protocol.parse_response(response_data)
|
|
@@ -827,7 +864,7 @@ class AsyncClient(ClientMixin):
|
|
|
827
864
|
)
|
|
828
865
|
|
|
829
866
|
async with self._lock:
|
|
830
|
-
await
|
|
867
|
+
await self._send_data(conn, header + param_data + data_section)
|
|
831
868
|
response_data = await conn.receive_data()
|
|
832
869
|
self.protocol.parse_response(response_data)
|
|
833
870
|
|
|
@@ -844,7 +881,7 @@ class AsyncClient(ClientMixin):
|
|
|
844
881
|
)
|
|
845
882
|
|
|
846
883
|
async with self._lock:
|
|
847
|
-
await
|
|
884
|
+
await self._send_data(conn, header + param_data)
|
|
848
885
|
response_data = await conn.receive_data()
|
|
849
886
|
self.protocol.parse_response(response_data)
|
|
850
887
|
|
|
@@ -1017,7 +1054,7 @@ class AsyncClient(ClientMixin):
|
|
|
1017
1054
|
|
|
1018
1055
|
async with self._lock:
|
|
1019
1056
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
1020
|
-
await
|
|
1057
|
+
await self._send_data(conn, followup)
|
|
1021
1058
|
response_data = await conn.receive_data()
|
|
1022
1059
|
|
|
1023
1060
|
response = self.protocol.parse_response(response_data)
|
|
@@ -1073,6 +1110,109 @@ class AsyncClient(ClientMixin):
|
|
|
1073
1110
|
raise S7ConnectionError("Not connected to PLC")
|
|
1074
1111
|
return parse_protection_szl(await self.read_szl(0x0232, 0))
|
|
1075
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
|
+
|
|
1076
1216
|
async def compress(self, timeout: int) -> int:
|
|
1077
1217
|
"""Compress PLC memory."""
|
|
1078
1218
|
if not self.get_connected():
|
|
@@ -1100,7 +1240,7 @@ class AsyncClient(ClientMixin):
|
|
|
1100
1240
|
conn = self._get_connection()
|
|
1101
1241
|
|
|
1102
1242
|
async with self._lock:
|
|
1103
|
-
await
|
|
1243
|
+
await self._send_data(conn, bytes(data))
|
|
1104
1244
|
response = await conn.receive_data()
|
|
1105
1245
|
return bytearray(response)
|
|
1106
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
|