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.
@@ -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 Snap7 Python library.
2
+ The snap7 compatibility package.
3
3
 
4
- Pure Python implementation of the S7 protocol for communicating with
5
- Siemens S7 PLCs without requiring the native Snap7 C library.
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 remaining <= 0:
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
- self.pdu_size = 1 << param_data[0]
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
- self.pdu_size = struct.unpack(">H", param_data)[0]
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 snap7
289
- >>> async with snap7.AsyncClient() as client:
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__(self) -> None:
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 conn.send_data(request)
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._max_read_size()
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, db_number=db_number, start=start + offset, word_len=word_len, count=chunk_size
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._max_write_size()
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, db_number=db_number, start=start + offset, word_len=word_len, data=bytes(chunk_data)
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 conn.send_data(followup)
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 conn.send_data(header + param_data + data_section)
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 conn.send_data(header + param_data)
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 conn.send_data(followup)
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 conn.send_data(bytes(data))
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
- print("CLI dependencies not installed. Try: pip install python-snap7[cli]")
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