python-snap7 3.1.2__py3-none-any.whl → 3.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
snap7/async_client.py CHANGED
@@ -1,28 +1,34 @@
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
9
10
  import logging
10
11
  import struct
11
12
  import time
13
+ from collections.abc import Callable
14
+ from contextvars import ContextVar
12
15
  from typing import List, Any, Optional, Tuple, Type
13
16
  from types import TracebackType
14
17
  from datetime import datetime
15
18
 
16
19
  from .connection import TPDUSize
17
- from .s7protocol import S7Protocol, get_return_code_description
18
- from .datatypes import S7WordLen
20
+ from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_return_code_description
21
+ from .datatypes import S7DataTypes, S7WordLen
19
22
  from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
20
- from .client_base import ClientMixin
23
+ from .client_base import ClientMixin, _instrumented
21
24
  from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
25
+ from .client import _parse_force_szl
26
+ from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
22
27
  from .type import (
23
28
  Area,
24
29
  Block,
25
30
  BlocksList,
31
+ ForceEntry,
26
32
  S7CpuInfo,
27
33
  TS7BlockInfo,
28
34
  S7CpInfo,
@@ -156,7 +162,7 @@ class AsyncISOTCPConnection:
156
162
  raise S7ConnectionError(f"Invalid TPKT version: {version}")
157
163
 
158
164
  remaining = length - 4
159
- if remaining <= 0:
165
+ if length < 7:
160
166
  raise S7ConnectionError("Invalid TPKT length")
161
167
 
162
168
  payload = await self._recv_exact(remaining)
@@ -165,6 +171,10 @@ class AsyncISOTCPConnection:
165
171
  if len(payload) < 3:
166
172
  raise S7ConnectionError("Invalid COTP DT: too short")
167
173
  pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
174
+ if pdu_len != 2:
175
+ raise S7ConnectionError("Invalid COTP DT header length")
176
+ if eot_num & 0x7F:
177
+ raise S7ConnectionError("Invalid Class 0 COTP TPDU number")
168
178
  if pdu_type != self.COTP_DT:
169
179
  raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
170
180
  return payload[3:]
@@ -214,6 +224,8 @@ class AsyncISOTCPConnection:
214
224
  def _build_tpkt(self, payload: bytes) -> bytes:
215
225
  """Build TPKT frame."""
216
226
  length = len(payload) + 4
227
+ if not 7 <= length <= 65535:
228
+ raise S7ConnectionError("Invalid TPKT length: expected 7..65535 bytes")
217
229
  return struct.pack(">BBH", 3, 0, length) + payload
218
230
 
219
231
  def _parse_cotp_cc(self, data: bytes) -> None:
@@ -285,23 +297,32 @@ class AsyncISOTCPConnection:
285
297
 
286
298
 
287
299
  class AsyncClient(ClientMixin):
288
- """
289
- Native async S7 client implementation.
300
+ """Asynchronous client for classic S7 communication.
290
301
 
291
302
  Uses asyncio streams for non-blocking I/O. An internal asyncio.Lock
292
303
  serializes each send+receive cycle so that concurrent coroutines
293
304
  (e.g. via asyncio.gather) never interleave on the same TCP socket.
294
305
 
306
+ For new projects, use ``s7.AsyncClient`` instead.
307
+
295
308
  Examples:
296
- >>> import snap7
297
- >>> async with snap7.AsyncClient() as client:
309
+ >>> from s7 import AsyncClient
310
+ >>> async with AsyncClient() as client:
298
311
  ... await client.connect("192.168.1.10", 0, 1)
299
312
  ... data = await client.db_read(1, 0, 4)
300
313
  """
301
314
 
302
315
  MAX_VARS = 20
303
316
 
304
- def __init__(self) -> None:
317
+ def __init__(
318
+ self,
319
+ *,
320
+ max_requests_per_second: float = 0,
321
+ rate_limit_algorithm: RateLimitAlgorithm = "fixed",
322
+ rate_limit_behavior: RateLimitBehavior = "block",
323
+ rate_limit_burst: int | None = None,
324
+ on_operation: Optional[Callable[[str, float, bool], None]] = None,
325
+ ) -> None:
305
326
  self.connection: Optional[AsyncISOTCPConnection] = None
306
327
  self.protocol = S7Protocol()
307
328
  self.connected = False
@@ -318,8 +339,16 @@ class AsyncClient(ClientMixin):
318
339
 
319
340
  self._exec_time = 0
320
341
  self._last_error = 0
342
+ self._on_operation = on_operation
343
+ self._operation_depth: ContextVar[int] = ContextVar("snap7_async_client_operation_depth", default=0)
321
344
 
322
345
  self._lock = asyncio.Lock()
346
+ self._rate_limiter = RequestRateLimiter(
347
+ max_requests_per_second,
348
+ algorithm=rate_limit_algorithm,
349
+ behavior=rate_limit_behavior,
350
+ burst_capacity=rate_limit_burst,
351
+ )
323
352
 
324
353
  self._params = {
325
354
  Parameter.RemotePort: 102,
@@ -339,6 +368,11 @@ class AsyncClient(ClientMixin):
339
368
  raise S7ConnectionError("Not connected to PLC")
340
369
  return self.connection
341
370
 
371
+ async def _send_data(self, conn: AsyncISOTCPConnection, request: bytes) -> None:
372
+ """Apply the per-client rate limit and send one S7 request PDU."""
373
+ await self._rate_limiter.acquire_async()
374
+ await conn.send_data(request)
375
+
342
376
  async def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
343
377
  """Send a request and receive/parse the response, holding the lock.
344
378
 
@@ -358,7 +392,7 @@ class AsyncClient(ClientMixin):
358
392
  expected_seq = struct.unpack(">H", request[4:6])[0]
359
393
 
360
394
  async with self._lock:
361
- await conn.send_data(request)
395
+ await self._send_data(conn, request)
362
396
 
363
397
  for attempt in range(max_stale_retries + 1):
364
398
  response_data = await conn.receive_data()
@@ -510,6 +544,7 @@ class AsyncClient(ClientMixin):
510
544
  # Core read / write
511
545
  # ---------------------------------------------------------------
512
546
 
547
+ @_instrumented("read_area")
513
548
  async def read_area(self, area: Area, db_number: int, start: int, size: int) -> bytearray:
514
549
  """Read data from memory area.
515
550
 
@@ -525,7 +560,7 @@ class AsyncClient(ClientMixin):
525
560
  else:
526
561
  word_len = S7WordLen.BYTE
527
562
 
528
- max_chunk = self._max_read_size()
563
+ max_chunk = self._read_chunk_count(word_len)
529
564
  if size <= max_chunk:
530
565
  request = self.protocol.build_read_request(
531
566
  area=s7_area, db_number=db_number, start=start, word_len=word_len, count=size
@@ -541,7 +576,11 @@ class AsyncClient(ClientMixin):
541
576
  while remaining > 0:
542
577
  chunk_size = min(remaining, max_chunk)
543
578
  request = self.protocol.build_read_request(
544
- area=s7_area, db_number=db_number, start=start + offset, word_len=word_len, count=chunk_size
579
+ area=s7_area,
580
+ db_number=db_number,
581
+ start=start + offset * self._element_address_step(word_len),
582
+ word_len=word_len,
583
+ count=chunk_size,
545
584
  )
546
585
  response = await self._send_receive(request)
547
586
  values = self.protocol.extract_read_data(response, word_len, chunk_size)
@@ -552,6 +591,7 @@ class AsyncClient(ClientMixin):
552
591
  self._exec_time = int((time.time() - start_time) * 1000)
553
592
  return result
554
593
 
594
+ @_instrumented("write_area")
555
595
  async def write_area(self, area: Area, db_number: int, start: int, data: bytearray) -> int:
556
596
  """Write data to memory area.
557
597
 
@@ -567,7 +607,7 @@ class AsyncClient(ClientMixin):
567
607
  else:
568
608
  word_len = S7WordLen.BYTE
569
609
 
570
- max_chunk = self._max_write_size()
610
+ max_chunk = self._write_chunk_bytes(word_len, len(data))
571
611
  if len(data) <= max_chunk:
572
612
  request = self.protocol.build_write_request(
573
613
  area=s7_area, db_number=db_number, start=start, word_len=word_len, data=bytes(data)
@@ -583,7 +623,11 @@ class AsyncClient(ClientMixin):
583
623
  chunk_size = min(remaining, max_chunk)
584
624
  chunk_data = data[offset : offset + chunk_size]
585
625
  request = self.protocol.build_write_request(
586
- area=s7_area, db_number=db_number, start=start + offset, word_len=word_len, data=bytes(chunk_data)
626
+ area=s7_area,
627
+ db_number=db_number,
628
+ start=start + offset // S7DataTypes.get_size_bytes(word_len) * self._element_address_step(word_len),
629
+ word_len=word_len,
630
+ data=bytes(chunk_data),
587
631
  )
588
632
  response = await self._send_receive(request)
589
633
  self.protocol.check_write_response(response)
@@ -593,6 +637,7 @@ class AsyncClient(ClientMixin):
593
637
  self._exec_time = int((time.time() - start_time) * 1000)
594
638
  return 0
595
639
 
640
+ @_instrumented("read_multi_vars")
596
641
  async def read_multi_vars(self, items: List[dict[str, Any]]) -> Tuple[int, list[bytearray]]:
597
642
  """Read multiple variables (sequentially, one read_area per item).
598
643
 
@@ -617,6 +662,7 @@ class AsyncClient(ClientMixin):
617
662
  results.append(data)
618
663
  return (0, results)
619
664
 
665
+ @_instrumented("write_multi_vars")
620
666
  async def write_multi_vars(self, items: List[dict[str, Any]]) -> int:
621
667
  """Write multiple variables (sequentially, one write_area per item).
622
668
 
@@ -650,6 +696,7 @@ class AsyncClient(ClientMixin):
650
696
 
651
697
  request = self.protocol.build_list_blocks_request()
652
698
  response = await self._send_receive(request)
699
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
653
700
 
654
701
  data_info = response.get("data", {})
655
702
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -682,6 +729,7 @@ class AsyncClient(ClientMixin):
682
729
 
683
730
  request = self.protocol.build_list_blocks_of_type_request(type_code)
684
731
  response = await self._send_receive(request)
732
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
685
733
 
686
734
  data_info = response.get("data", {})
687
735
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -703,10 +751,11 @@ class AsyncClient(ClientMixin):
703
751
 
704
752
  async with self._lock:
705
753
  followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
706
- await conn.send_data(followup)
754
+ await self._send_data(conn, followup)
707
755
  response_data = await conn.receive_data()
708
756
 
709
757
  response = self.protocol.parse_response(response_data)
758
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
710
759
 
711
760
  data_info = response.get("data", {})
712
761
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -742,6 +791,7 @@ class AsyncClient(ClientMixin):
742
791
 
743
792
  request = self.protocol.build_get_block_info_request(type_code, db_number)
744
793
  response = await self._send_receive(request)
794
+ self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
745
795
 
746
796
  data_info = response.get("data", {})
747
797
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -771,6 +821,7 @@ class AsyncClient(ClientMixin):
771
821
  # Upload / Download / Delete
772
822
  # ---------------------------------------------------------------
773
823
 
824
+ @_instrumented("upload")
774
825
  async def upload(self, block_num: int) -> bytearray:
775
826
  """Upload block from PLC (3-step: START_UPLOAD, UPLOAD, END_UPLOAD)."""
776
827
  if not self.get_connected():
@@ -782,19 +833,27 @@ class AsyncClient(ClientMixin):
782
833
  response = await self._send_receive(request)
783
834
 
784
835
  upload_info = self.protocol.parse_start_upload_response(response)
785
- upload_id = upload_info.get("upload_id", 1)
786
-
787
- request = self.protocol.build_upload_request(upload_id)
788
- response = await self._send_receive(request)
836
+ upload_id = upload_info["upload_id"]
789
837
 
790
- block_data = self.protocol.parse_upload_response(response)
838
+ block_data = bytearray()
839
+ for fragment_index in range(1000):
840
+ request = self.protocol.build_upload_request(upload_id)
841
+ response = await self._send_receive(request)
842
+ fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
843
+ block_data.extend(fragment)
844
+ if is_last:
845
+ break
846
+ else:
847
+ raise S7ProtocolError("Upload response exceeded fragment limit")
791
848
 
792
849
  request = self.protocol.build_end_upload_request(upload_id)
793
850
  response = await self._send_receive(request)
851
+ self.protocol.check_end_upload_response(response)
794
852
 
795
853
  logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
796
- return bytearray(block_data)
854
+ return block_data
797
855
 
856
+ @_instrumented("download")
798
857
  async def download(self, data: bytearray, block_num: int = -1) -> int:
799
858
  """Download block to PLC."""
800
859
  if not self.get_connected():
@@ -809,44 +868,33 @@ class AsyncClient(ClientMixin):
809
868
  else:
810
869
  block_num = 1
811
870
 
812
- # Step 1: Request download
813
871
  request = self.protocol.build_download_request(block_type, block_num, bytes(data))
814
- await self._send_receive(request)
815
-
816
- # Step 2: Download block (send data)
817
- param_data = struct.pack(">BBB", 0x1B, 0x01, 0x00)
818
- data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data)
819
- header = struct.pack(
820
- ">BBHHHH",
821
- 0x32,
822
- 0x01,
823
- 0x0000,
824
- self.protocol._next_sequence(),
825
- len(param_data),
826
- len(data_section),
827
- )
828
-
829
872
  async with self._lock:
830
- await conn.send_data(header + param_data + data_section)
873
+ await self._send_data(conn, request)
831
874
  response_data = await conn.receive_data()
832
- self.protocol.parse_response(response_data)
833
-
834
- # Step 3: Download ended
835
- param_data = struct.pack(">B", 0x1C)
836
- header = struct.pack(
837
- ">BBHHHH",
838
- 0x32,
839
- 0x01,
840
- 0x0000,
841
- self.protocol._next_sequence(),
842
- len(param_data),
843
- 0x0000,
844
- )
875
+ response = self.protocol.parse_response(response_data)
876
+ if response["sequence"] != int.from_bytes(request[4:6], "big") or response.get("raw_parameters") != bytes((0x1A,)):
877
+ raise S7ProtocolError("Invalid request-download acknowledgement")
878
+
879
+ offset = 0
880
+ max_slice = self.pdu_length - 18
881
+ if max_slice <= 0:
882
+ raise S7ProtocolError("Negotiated PDU is too small for download")
883
+ for _ in range(1000):
884
+ request_data = await conn.receive_data()
885
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
886
+ fragment = bytes(data[offset : offset + max_slice])
887
+ offset += len(fragment)
888
+ is_last = offset == len(data)
889
+ await self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
890
+ if is_last:
891
+ break
892
+ else:
893
+ raise S7ProtocolError("Download exceeded fragment limit")
845
894
 
846
- async with self._lock:
847
- await conn.send_data(header + param_data)
848
- response_data = await conn.receive_data()
849
- self.protocol.parse_response(response_data)
895
+ request_data = await conn.receive_data()
896
+ sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
897
+ await self._send_data(conn, self.protocol.build_download_ended_response(sequence))
850
898
 
851
899
  logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
852
900
  return 0
@@ -894,32 +942,27 @@ class AsyncClient(ClientMixin):
894
942
  response = await self._send_receive(request)
895
943
 
896
944
  upload_info = self.protocol.parse_start_upload_response(response)
897
- upload_id = upload_info.get("upload_id", 1)
945
+ upload_id = upload_info["upload_id"]
898
946
 
899
- request = self.protocol.build_upload_request(upload_id)
900
- response = await self._send_receive(request)
901
- block_data = self.protocol.parse_upload_response(response)
947
+ block_data = bytearray()
948
+ for fragment_index in range(1000):
949
+ request = self.protocol.build_upload_request(upload_id)
950
+ response = await self._send_receive(request)
951
+ fragment, is_last = self.protocol.parse_upload_fragment(
952
+ response, first_fragment=fragment_index == 0, include_block_header=True
953
+ )
954
+ block_data.extend(fragment)
955
+ if is_last:
956
+ break
957
+ else:
958
+ raise S7ProtocolError("Upload response exceeded fragment limit")
902
959
 
903
960
  request = self.protocol.build_end_upload_request(upload_id)
904
961
  response = await self._send_receive(request)
962
+ self.protocol.check_end_upload_response(response)
905
963
 
906
- block_header = struct.pack(
907
- ">BBHBBBBHH",
908
- 0x70,
909
- block_type.value,
910
- block_num,
911
- 0x00,
912
- 0x00,
913
- 0x00,
914
- 0x00,
915
- len(block_data) + 14,
916
- len(block_data),
917
- )
918
- block_footer = b"\x00" * 4
919
- full_block = bytearray(block_header + block_data + block_footer)
920
-
921
- logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes")
922
- return full_block, len(full_block)
964
+ logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
965
+ return block_data, len(block_data)
923
966
 
924
967
  # ---------------------------------------------------------------
925
968
  # PLC control
@@ -957,6 +1000,7 @@ class AsyncClient(ClientMixin):
957
1000
 
958
1001
  request = self.protocol.build_get_clock_request()
959
1002
  response = await self._send_receive(request)
1003
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
960
1004
  return self.protocol.parse_get_clock_response(response)
961
1005
 
962
1006
  async def set_plc_datetime(self, dt: datetime) -> int:
@@ -965,7 +1009,8 @@ class AsyncClient(ClientMixin):
965
1009
  raise S7ConnectionError("Not connected to PLC")
966
1010
 
967
1011
  request = self.protocol.build_set_clock_request(dt)
968
- await self._send_receive(request)
1012
+ response = await self._send_receive(request)
1013
+ self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
969
1014
  logger.info(f"Set PLC datetime to {dt}")
970
1015
  return 0
971
1016
 
@@ -995,6 +1040,7 @@ class AsyncClient(ClientMixin):
995
1040
 
996
1041
  request = self.protocol.build_read_szl_request(ssl_id, index)
997
1042
  response = await self._send_receive(request)
1043
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
998
1044
 
999
1045
  data_info = response.get("data", {})
1000
1046
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1017,10 +1063,11 @@ class AsyncClient(ClientMixin):
1017
1063
 
1018
1064
  async with self._lock:
1019
1065
  followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
1020
- await conn.send_data(followup)
1066
+ await self._send_data(conn, followup)
1021
1067
  response_data = await conn.receive_data()
1022
1068
 
1023
1069
  response = self.protocol.parse_response(response_data)
1070
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
1024
1071
 
1025
1072
  data_info = response.get("data", {})
1026
1073
  return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
@@ -1073,6 +1120,109 @@ class AsyncClient(ClientMixin):
1073
1120
  raise S7ConnectionError("Not connected to PLC")
1074
1121
  return parse_protection_szl(await self.read_szl(0x0232, 0))
1075
1122
 
1123
+ # ---------------------------------------------------------------
1124
+ # Force I/O
1125
+ # ---------------------------------------------------------------
1126
+
1127
+ _FORCE_AREAS: frozenset[int] = frozenset({Area.PE, Area.PA})
1128
+
1129
+ async def force_bit(self, area: Area, byte_offset: int, bit: int, value: bool) -> None:
1130
+ """Force a single I/O bit in the process image.
1131
+
1132
+ Async equivalent of :meth:`snap7.client.Client.force_bit`.
1133
+ """
1134
+ if area not in self._FORCE_AREAS:
1135
+ raise ValueError(f"Force is only supported for PE (inputs) and PA (outputs), got {area!r}")
1136
+ if not 0 <= bit <= 7:
1137
+ raise ValueError(f"Bit must be 0-7, got {bit}")
1138
+
1139
+ current = await self.read_area(area, 0, byte_offset, 1)
1140
+ if value:
1141
+ current[0] |= 1 << bit
1142
+ else:
1143
+ current[0] &= ~(1 << bit)
1144
+ await self.write_area(area, 0, byte_offset, current)
1145
+ logger.info(f"Forced {area.name} byte {byte_offset} bit {bit} = {value}")
1146
+
1147
+ async def cancel_force(self, area: Area, byte_offset: int, bit: int) -> None:
1148
+ """Cancel a forced I/O bit by clearing it in the process image.
1149
+
1150
+ Async equivalent of :meth:`snap7.client.Client.cancel_force`.
1151
+ """
1152
+ if area not in self._FORCE_AREAS:
1153
+ raise ValueError(f"Cancel force is only supported for PE (inputs) and PA (outputs), got {area!r}")
1154
+ if not 0 <= bit <= 7:
1155
+ raise ValueError(f"Bit must be 0-7, got {bit}")
1156
+
1157
+ current = await self.read_area(area, 0, byte_offset, 1)
1158
+ current[0] &= ~(1 << bit)
1159
+ await self.write_area(area, 0, byte_offset, current)
1160
+ logger.info(f"Cancelled force on {area.name} byte {byte_offset} bit {bit}")
1161
+
1162
+ async def read_force_table(self) -> list[ForceEntry]:
1163
+ """Read the PLC force table via SZL 0x0025.
1164
+
1165
+ Async equivalent of :meth:`snap7.client.Client.read_force_table`.
1166
+ """
1167
+ if not self.get_connected():
1168
+ raise S7ConnectionError("Not connected to PLC")
1169
+
1170
+ try:
1171
+ szl = await self.read_szl(0x0025, 0x0000)
1172
+ except (S7ProtocolError, RuntimeError):
1173
+ logger.debug("SZL 0x0025 not available; returning empty force table")
1174
+ return []
1175
+
1176
+ raw = bytes(szl.Data[: szl.Header.LengthDR])
1177
+ return _parse_force_szl(raw)
1178
+
1179
+ async def set_session_password(self, password: str) -> int:
1180
+ """Set the session password to unlock a password-protected PLC.
1181
+
1182
+ Sends an S7 USERDATA request (function group 5, subfunction 1)
1183
+ with the encoded password.
1184
+
1185
+ Args:
1186
+ password: Plaintext password (max 8 ASCII characters).
1187
+
1188
+ Returns:
1189
+ 0 on success.
1190
+
1191
+ Raises:
1192
+ ~snap7.error.S7ConnectionError: If not connected.
1193
+ ~snap7.error.S7ProtocolError: If the PLC rejects the password.
1194
+ """
1195
+ if not self.get_connected():
1196
+ raise S7ConnectionError("Not connected to PLC")
1197
+
1198
+ encoded = self.protocol.encode_password(password)
1199
+ request = self.protocol.build_set_session_password_request(encoded)
1200
+ response = await self._send_receive(request)
1201
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
1202
+ logger.info("Session password set successfully")
1203
+ return 0
1204
+
1205
+ async def clear_session_password(self) -> int:
1206
+ """Clear the session password, returning to the default protection level.
1207
+
1208
+ Sends an S7 USERDATA request (function group 5, subfunction 2).
1209
+
1210
+ Returns:
1211
+ 0 on success.
1212
+
1213
+ Raises:
1214
+ ~snap7.error.S7ConnectionError: If not connected.
1215
+ ~snap7.error.S7ProtocolError: If the PLC rejects the request.
1216
+ """
1217
+ if not self.get_connected():
1218
+ raise S7ConnectionError("Not connected to PLC")
1219
+
1220
+ request = self.protocol.build_clear_session_password_request()
1221
+ response = await self._send_receive(request)
1222
+ self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
1223
+ logger.info("Session password cleared successfully")
1224
+ return 0
1225
+
1076
1226
  async def compress(self, timeout: int) -> int:
1077
1227
  """Compress PLC memory."""
1078
1228
  if not self.get_connected():
@@ -1100,7 +1250,7 @@ class AsyncClient(ClientMixin):
1100
1250
  conn = self._get_connection()
1101
1251
 
1102
1252
  async with self._lock:
1103
- await conn.send_data(bytes(data))
1253
+ await self._send_data(conn, bytes(data))
1104
1254
  response = await conn.receive_data()
1105
1255
  return bytearray(response)
1106
1256
 
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