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.
snap7/client.py CHANGED
@@ -1,7 +1,7 @@
1
- """
2
- Pure Python S7 client implementation.
1
+ """Synchronous client for the classic S7 protocol.
3
2
 
4
- Drop-in replacement for the ctypes-based client with native Python implementation.
3
+ ``s7.Client`` and ``snap7.Client`` expose this same implementation. The
4
+ ``s7`` import name is recommended for new projects.
5
5
  """
6
6
 
7
7
  import copy
@@ -11,6 +11,7 @@ import struct
11
11
  import sys
12
12
  import threading
13
13
  import time
14
+ from collections.abc import Sequence
14
15
  from typing import List, Any, Optional, Tuple, Union, Callable, cast
15
16
  from datetime import datetime
16
17
  from ctypes import (
@@ -21,11 +22,12 @@ from ctypes import (
21
22
 
22
23
  from .connection import ISOTCPConnection
23
24
  from .s7protocol import S7Protocol, get_return_code_description
24
- from .datatypes import S7WordLen
25
+ from .datatypes import S7DataTypes, S7WordLen
25
26
  from .error import S7Error, S7ConnectionError, S7ProtocolError, S7StalePacketError, S7TimeoutError
26
27
  from .client_base import ClientMixin
27
28
  from .log import PLCLoggerAdapter, OperationLogger
28
29
  from .optimizer import ReadItem, ReadPacket, sort_items, merge_items, packetize, extract_results
30
+ from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
29
31
  from .tags import Tag, _STRING_RE
30
32
  from . import util
31
33
 
@@ -34,6 +36,7 @@ from .type import (
34
36
  Area,
35
37
  Block,
36
38
  BlocksList,
39
+ ForceEntry,
37
40
  S7CpuInfo,
38
41
  TS7BlockInfo,
39
42
  S7DataItem,
@@ -231,6 +234,31 @@ def _encode_scalar(datatype: str, buf: bytearray, offset: int, value: Any, bit:
231
234
  raise ValueError(f"Unsupported tag datatype: {datatype}")
232
235
 
233
236
 
237
+ def _parse_force_szl(raw: bytes) -> list[ForceEntry]:
238
+ """Parse SZL 0x0025 (force table) data into :class:`ForceEntry` items.
239
+
240
+ Each SZL 0x0025 entry is 8 bytes:
241
+ - bytes 0-1: area code (big-endian, 0x0081=PE, 0x0082=PA)
242
+ - bytes 2-3: byte offset (big-endian)
243
+ - byte 4: bit number (0-7)
244
+ - byte 5: force value (0x00=False, 0x01=True)
245
+ - bytes 6-7: reserved
246
+
247
+ Returns an empty list if *raw* is too short or contains no entries.
248
+ """
249
+ entry_size = 8
250
+ entries: list[ForceEntry] = []
251
+ offset = 0
252
+ while offset + entry_size <= len(raw):
253
+ area_code = struct.unpack(">H", raw[offset : offset + 2])[0]
254
+ byte_off = struct.unpack(">H", raw[offset + 2 : offset + 4])[0]
255
+ bit_num = raw[offset + 4]
256
+ val = raw[offset + 5] != 0
257
+ entries.append(ForceEntry(area=area_code, byte_offset=byte_off, bit=bit_num, value=val))
258
+ offset += entry_size
259
+ return entries
260
+
261
+
234
262
  class _OptimizationPlan:
235
263
  """Cached optimization plan for repeated read_multi_vars calls with the same layout."""
236
264
 
@@ -241,15 +269,14 @@ class _OptimizationPlan:
241
269
 
242
270
 
243
271
  class Client(ClientMixin):
244
- """
245
- Pure Python S7 client implementation.
272
+ """Synchronous client for classic S7 communication.
246
273
 
247
- Drop-in replacement for the ctypes-based client that provides native Python
248
- communication with Siemens S7 PLCs without requiring the Snap7 C library.
274
+ Supports S7-300, S7-400, S7-1200 and S7-1500 PLCs via the classic S7
275
+ protocol. S7-1200 and S7-1500 access requires PUT/GET to be enabled.
249
276
 
250
277
  Examples:
251
- >>> import snap7
252
- >>> client = snap7.Client()
278
+ >>> from s7 import Client
279
+ >>> client = Client()
253
280
  >>> client.connect("192.168.1.10", 0, 1)
254
281
  >>> data = client.db_read(1, 0, 4)
255
282
  >>> client.disconnect()
@@ -267,6 +294,10 @@ class Client(ClientMixin):
267
294
  backoff_factor: float = 2.0,
268
295
  max_delay: float = 30.0,
269
296
  heartbeat_interval: float = 0,
297
+ max_requests_per_second: float = 0,
298
+ rate_limit_algorithm: RateLimitAlgorithm = "fixed",
299
+ rate_limit_behavior: RateLimitBehavior = "block",
300
+ rate_limit_burst: int | None = None,
270
301
  on_disconnect: Optional[Callable[[], None]] = None,
271
302
  on_reconnect: Optional[Callable[[], None]] = None,
272
303
  **kwargs: Any,
@@ -282,6 +313,10 @@ class Client(ClientMixin):
282
313
  backoff_factor: Multiplier for exponential backoff between retries.
283
314
  max_delay: Maximum delay between reconnection attempts in seconds.
284
315
  heartbeat_interval: Interval in seconds for heartbeat probes (0=disabled).
316
+ max_requests_per_second: Maximum outbound PLC requests per second (0=disabled).
317
+ rate_limit_algorithm: ``fixed`` for even spacing or ``token_bucket`` for bursts.
318
+ rate_limit_behavior: ``block`` to wait or ``raise`` to reject immediately.
319
+ rate_limit_burst: Token bucket capacity. Defaults to one second of requests.
285
320
  on_disconnect: Optional callback invoked when connection is lost.
286
321
  on_reconnect: Optional callback invoked after successful reconnection.
287
322
  **kwargs: Ignored. Kept for backwards compatibility.
@@ -341,6 +376,12 @@ class Client(ClientMixin):
341
376
  self._max_delay = max_delay
342
377
  self._on_disconnect = on_disconnect
343
378
  self._on_reconnect = on_reconnect
379
+ self._rate_limiter = RequestRateLimiter(
380
+ max_requests_per_second,
381
+ algorithm=rate_limit_algorithm,
382
+ behavior=rate_limit_behavior,
383
+ burst_capacity=rate_limit_burst,
384
+ )
344
385
 
345
386
  # Heartbeat settings
346
387
  self._heartbeat_interval = heartbeat_interval
@@ -373,6 +414,11 @@ class Client(ClientMixin):
373
414
  raise S7ConnectionError("Not connected to PLC")
374
415
  return self.connection
375
416
 
417
+ def _send_data(self, conn: ISOTCPConnection, request: bytes) -> None:
418
+ """Apply the per-client rate limit and send one S7 request PDU."""
419
+ self._rate_limiter.acquire()
420
+ conn.send_data(request)
421
+
376
422
  def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
377
423
  """Send a request and receive/parse the response with stale packet retry.
378
424
 
@@ -395,7 +441,7 @@ class Client(ClientMixin):
395
441
  conn = self._get_connection()
396
442
 
397
443
  with self._reconnect_lock:
398
- conn.send_data(request)
444
+ self._send_data(conn, request)
399
445
 
400
446
  for attempt in range(max_stale_retries + 1):
401
447
  response_data = conn.receive_data()
@@ -571,16 +617,22 @@ class Client(ClientMixin):
571
617
  Returns:
572
618
  Self for method chaining
573
619
  """
620
+ # Remote TSAP: connection type, rack and slot encoded per S7.
621
+ self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
622
+ return self._connect(address, rack, slot, tcp_port)
623
+
624
+ def _connect(self, address: str, rack: int, slot: int, tcp_port: int) -> "Client":
625
+ """Establish a connection using the configured local and remote TSAPs.
626
+
627
+ LOGO clients supply explicit TSAPs instead of deriving them from a
628
+ rack and slot. Both paths share connection and heartbeat setup.
629
+ """
574
630
  self.host = address
575
631
  self.port = tcp_port
576
632
  self.rack = rack
577
633
  self.slot = slot
578
634
  self._params[Parameter.RemotePort] = tcp_port
579
635
 
580
- # Calculate TSAP values from rack/slot
581
- # Remote TSAP: rack and slot encoded as per S7 specification
582
- self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
583
-
584
636
  try:
585
637
  start_time = time.time()
586
638
 
@@ -804,9 +856,7 @@ class Client(ClientMixin):
804
856
  """
805
857
  resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
806
858
  if resolved.is_symbolic:
807
- raise NotImplementedError(
808
- "Symbolic (LID-based) tag access requires S7CommPlus. Use s7.Client instead of snap7.Client."
809
- )
859
+ raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
810
860
  data = self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, resolved.size)
811
861
  return _decode_tag(resolved, bytearray(data), encoding=encoding)
812
862
 
@@ -823,9 +873,7 @@ class Client(ClientMixin):
823
873
  """
824
874
  resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
825
875
  if resolved.is_symbolic:
826
- raise NotImplementedError(
827
- "Symbolic (LID-based) tag access requires S7CommPlus. Use s7.Client instead of snap7.Client."
828
- )
876
+ raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
829
877
  size = resolved.size
830
878
  buf = bytearray(size)
831
879
  # For BOOL writes, we need the current byte to preserve other bits
@@ -835,14 +883,14 @@ class Client(ClientMixin):
835
883
  _encode_tag(resolved, buf, value, encoding=encoding)
836
884
  return self.write_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, buf)
837
885
 
838
- def read_tags(self, tags: "list[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
886
+ def read_tags(self, tags: "Sequence[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
839
887
  """Read multiple tags in a single optimized request.
840
888
 
841
889
  Uses the multi-variable read optimizer when available to batch
842
890
  reads into minimal PDU exchanges.
843
891
 
844
892
  Args:
845
- tags: List of :class:`~snap7.tags.Tag` instances or address strings.
893
+ tags: Sequence of :class:`~snap7.tags.Tag` instances or address strings.
846
894
  encoding: Character encoding for STRING/FSTRING values (default ``"latin-1"``).
847
895
 
848
896
  Returns:
@@ -956,7 +1004,8 @@ class Client(ClientMixin):
956
1004
  area: Memory area to read from
957
1005
  db_number: DB number (for DB area only)
958
1006
  start: Start address
959
- size: Number of items to read (for TM/CT: timers/counters, for others: bytes)
1007
+ size: Number of elements of the selected word length (bytes by default).
1008
+ BIT values are returned as one byte per bit; TM/CT use two-byte elements.
960
1009
  word_len: Optional word length override. If None, defaults to area-based logic
961
1010
  (TIMER for TM, COUNTER for CT, BYTE for others).
962
1011
 
@@ -978,7 +1027,7 @@ class Client(ClientMixin):
978
1027
  else:
979
1028
  s7_word_len = S7WordLen.BYTE
980
1029
 
981
- max_chunk = self._max_read_size()
1030
+ max_chunk = self._read_chunk_count(s7_word_len)
982
1031
  if size <= max_chunk:
983
1032
  # Single request - use reconnect-aware send/receive
984
1033
  def build_request() -> bytes:
@@ -997,7 +1046,7 @@ class Client(ClientMixin):
997
1046
  remaining = size
998
1047
  while remaining > 0:
999
1048
  chunk_size = min(remaining, max_chunk)
1000
- chunk_offset = offset
1049
+ chunk_offset = offset * self._element_address_step(s7_word_len)
1001
1050
 
1002
1051
  def build_chunk_request(o: int = chunk_offset, cs: int = chunk_size) -> bytes:
1003
1052
  return self.protocol.build_read_request(
@@ -1045,7 +1094,7 @@ class Client(ClientMixin):
1045
1094
  else:
1046
1095
  s7_word_len = S7WordLen.BYTE
1047
1096
 
1048
- max_chunk = self._max_write_size()
1097
+ max_chunk = self._write_chunk_bytes(s7_word_len, len(data))
1049
1098
  if len(data) <= max_chunk:
1050
1099
  # Single request
1051
1100
  def build_request() -> bytes:
@@ -1064,7 +1113,7 @@ class Client(ClientMixin):
1064
1113
  while remaining > 0:
1065
1114
  chunk_size = min(remaining, max_chunk)
1066
1115
  chunk_data = data[offset : offset + chunk_size]
1067
- chunk_offset = offset
1116
+ chunk_offset = offset // S7DataTypes.get_size_bytes(s7_word_len) * self._element_address_step(s7_word_len)
1068
1117
 
1069
1118
  def build_chunk_request(o: int = chunk_offset, cd: bytes = bytes(chunk_data)) -> bytes:
1070
1119
  return self.protocol.build_write_request(
@@ -1193,7 +1242,7 @@ class Client(ClientMixin):
1193
1242
 
1194
1243
  # Send all requests back-to-back
1195
1244
  for _, pdu in requests:
1196
- conn.send_data(pdu)
1245
+ self._send_data(conn, pdu)
1197
1246
 
1198
1247
  # Receive responses, matching by sequence number
1199
1248
  results: dict[int, dict[str, Any]] = {}
@@ -1354,26 +1403,33 @@ class Client(ClientMixin):
1354
1403
  raise ValueError(f"Too many items: {len(items)} exceeds MAX_VARS ({self.MAX_VARS})")
1355
1404
 
1356
1405
  # Handle S7DataItem list (ctypes)
1357
- if hasattr(items[0], "Area"):
1358
- s7_items = cast(List[S7DataItem], items)
1359
- for s7_item in s7_items:
1406
+ if isinstance(items[0], S7DataItem):
1407
+ for s7_item in items:
1408
+ if not isinstance(s7_item, S7DataItem):
1409
+ raise TypeError("items must contain either only S7DataItem objects or only dictionaries")
1360
1410
  area = Area(s7_item.Area)
1361
1411
  db_number = s7_item.DBNumber
1362
1412
  start = s7_item.Start
1363
- size = s7_item.Amount
1364
-
1365
- # Extract data from pData
1413
+ word_len = WordLen(s7_item.WordLen)
1414
+ size = S7DataTypes.get_size_bytes(S7WordLen(word_len), s7_item.Amount)
1415
+ if s7_item.Amount < 0:
1416
+ raise ValueError("Item amount must be non-negative")
1417
+ if size and not s7_item.pData:
1418
+ raise ValueError("Write item requires a data pointer")
1419
+
1420
+ # Extract all elements from pData, retaining the request datatype.
1366
1421
  data = bytearray(size)
1367
1422
  if s7_item.pData:
1368
1423
  for i in range(size):
1369
1424
  data[i] = s7_item.pData[i]
1370
1425
 
1371
- self.write_area(area, db_number, start, data)
1426
+ self.write_area(area, db_number, start, data, word_len)
1372
1427
  return 0
1373
1428
 
1374
1429
  # Handle dict list
1375
- dict_items = cast(List[dict[str, Any]], items)
1376
- for dict_item in dict_items:
1430
+ for dict_item in items:
1431
+ if not isinstance(dict_item, dict):
1432
+ raise TypeError("items must contain either only S7DataItem objects or only dictionaries")
1377
1433
  area = dict_item["area"]
1378
1434
  db_number = dict_item.get("db_number", 0)
1379
1435
  start = dict_item["start"]
@@ -1466,7 +1522,7 @@ class Client(ClientMixin):
1466
1522
  break
1467
1523
 
1468
1524
  followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
1469
- conn.send_data(followup)
1525
+ self._send_data(conn, followup)
1470
1526
 
1471
1527
  response_data = conn.receive_data()
1472
1528
  response = self.protocol.parse_response(response_data)
@@ -1644,7 +1700,7 @@ class Client(ClientMixin):
1644
1700
  len(data_section), # Data length
1645
1701
  )
1646
1702
 
1647
- conn.send_data(header + param_data + data_section)
1703
+ self._send_data(conn, header + param_data + data_section)
1648
1704
 
1649
1705
  response_data = conn.receive_data()
1650
1706
  self.protocol.parse_response(response_data)
@@ -1662,7 +1718,7 @@ class Client(ClientMixin):
1662
1718
  0x0000, # Data length
1663
1719
  )
1664
1720
 
1665
- conn.send_data(header + param_data)
1721
+ self._send_data(conn, header + param_data)
1666
1722
 
1667
1723
  response_data = conn.receive_data()
1668
1724
  self.protocol.parse_response(response_data)
@@ -1928,6 +1984,151 @@ class Client(ClientMixin):
1928
1984
  raise S7ConnectionError("Not connected to PLC")
1929
1985
  return parse_protection_szl(self.read_szl(0x0232, 0))
1930
1986
 
1987
+ # ---------------------------------------------------------------
1988
+ # Force I/O
1989
+ # ---------------------------------------------------------------
1990
+
1991
+ _FORCE_AREAS: frozenset[int] = frozenset({Area.PE, Area.PA})
1992
+
1993
+ def force_bit(self, area: Area, byte_offset: int, bit: int, value: bool) -> None:
1994
+ """Force a single I/O bit in the process image.
1995
+
1996
+ Performs a read-modify-write on the specified byte to set or clear
1997
+ the target bit without affecting neighbouring bits. This writes
1998
+ directly to the process image, so the value may be overwritten by
1999
+ the PLC's scan cycle. It is equivalent to the approach used by
2000
+ rs-snap7.
2001
+
2002
+ Only the I/O areas (PE = inputs, PA = outputs) are supported.
2003
+
2004
+ Args:
2005
+ area: Memory area (:attr:`~snap7.type.Area.PE` or
2006
+ :attr:`~snap7.type.Area.PA`).
2007
+ byte_offset: Byte offset within the area.
2008
+ bit: Bit number (0-7) within the byte.
2009
+ value: Desired bit value.
2010
+
2011
+ Raises:
2012
+ ValueError: If *area* is not PE or PA, or *bit* is out of range.
2013
+ """
2014
+ if area not in self._FORCE_AREAS:
2015
+ raise ValueError(f"Force is only supported for PE (inputs) and PA (outputs), got {area!r}")
2016
+ if not 0 <= bit <= 7:
2017
+ raise ValueError(f"Bit must be 0-7, got {bit}")
2018
+
2019
+ current = self.read_area(area, 0, byte_offset, 1)
2020
+ if value:
2021
+ current[0] |= 1 << bit
2022
+ else:
2023
+ current[0] &= ~(1 << bit)
2024
+ self.write_area(area, 0, byte_offset, current)
2025
+ logger.info(f"Forced {area.name} byte {byte_offset} bit {bit} = {value}")
2026
+
2027
+ def cancel_force(self, area: Area, byte_offset: int, bit: int) -> None:
2028
+ """Cancel a forced I/O bit by clearing it in the process image.
2029
+
2030
+ Clears the specified bit. On a real PLC the scan cycle will
2031
+ restore the natural I/O state once the force is released; on the
2032
+ emulated server the bit stays at 0 until explicitly written.
2033
+
2034
+ Args:
2035
+ area: Memory area (:attr:`~snap7.type.Area.PE` or
2036
+ :attr:`~snap7.type.Area.PA`).
2037
+ byte_offset: Byte offset within the area.
2038
+ bit: Bit number (0-7) within the byte.
2039
+
2040
+ Raises:
2041
+ ValueError: If *area* is not PE or PA, or *bit* is out of range.
2042
+ """
2043
+ if area not in self._FORCE_AREAS:
2044
+ raise ValueError(f"Cancel force is only supported for PE (inputs) and PA (outputs), got {area!r}")
2045
+ if not 0 <= bit <= 7:
2046
+ raise ValueError(f"Bit must be 0-7, got {bit}")
2047
+
2048
+ current = self.read_area(area, 0, byte_offset, 1)
2049
+ current[0] &= ~(1 << bit)
2050
+ self.write_area(area, 0, byte_offset, current)
2051
+ logger.info(f"Cancelled force on {area.name} byte {byte_offset} bit {bit}")
2052
+
2053
+ def read_force_table(self) -> list[ForceEntry]:
2054
+ """Read the PLC force table via SZL 0x0025.
2055
+
2056
+ On a real PLC this returns bits that have been forced through the
2057
+ CPU's built-in force mechanism (e.g. via TIA Portal). Bits set
2058
+ through :meth:`force_bit` (process-image writes) do **not** appear
2059
+ here because they bypass the CPU force table.
2060
+
2061
+ Returns:
2062
+ List of :class:`~snap7.type.ForceEntry` instances describing
2063
+ each forced bit. Returns an empty list when no bits are
2064
+ currently forced or when the PLC does not support SZL 0x0025.
2065
+ """
2066
+ if not self.get_connected():
2067
+ raise S7ConnectionError("Not connected to PLC")
2068
+
2069
+ try:
2070
+ szl = self.read_szl(0x0025, 0x0000)
2071
+ except (S7ProtocolError, RuntimeError):
2072
+ # PLC does not support force table SZL or returned an error
2073
+ logger.debug("SZL 0x0025 not available; returning empty force table")
2074
+ return []
2075
+
2076
+ raw = bytes(szl.Data[: szl.Header.LengthDR])
2077
+ return _parse_force_szl(raw)
2078
+
2079
+ def set_session_password(self, password: str) -> int:
2080
+ """Set the session password to unlock a password-protected PLC.
2081
+
2082
+ Sends an S7 USERDATA request (function group 5, subfunction 1)
2083
+ with the encoded password. After a successful call the PLC grants
2084
+ higher-privilege access for the duration of this session.
2085
+
2086
+ Args:
2087
+ password: Plaintext password (max 8 ASCII characters).
2088
+
2089
+ Returns:
2090
+ 0 on success.
2091
+
2092
+ Raises:
2093
+ ~snap7.error.S7ConnectionError: If not connected.
2094
+ ~snap7.error.S7ProtocolError: If the PLC rejects the password.
2095
+ """
2096
+ if not self.get_connected():
2097
+ raise S7ConnectionError("Not connected to PLC")
2098
+
2099
+ encoded = self.protocol.encode_password(password)
2100
+
2101
+ def build_request() -> bytes:
2102
+ return self.protocol.build_set_session_password_request(encoded)
2103
+
2104
+ response = self._send_receive_with_reconnect(build_request)
2105
+ self.protocol.check_userdata_response(response)
2106
+ logger.info("Session password set successfully")
2107
+ return 0
2108
+
2109
+ def clear_session_password(self) -> int:
2110
+ """Clear the session password, returning to the default protection level.
2111
+
2112
+ Sends an S7 USERDATA request (function group 5, subfunction 2).
2113
+
2114
+ Returns:
2115
+ 0 on success.
2116
+
2117
+ Raises:
2118
+ ~snap7.error.S7ConnectionError: If not connected.
2119
+ ~snap7.error.S7ProtocolError: If the PLC rejects the request.
2120
+ """
2121
+ if not self.get_connected():
2122
+ raise S7ConnectionError("Not connected to PLC")
2123
+
2124
+ def build_request() -> bytes:
2125
+ return self.protocol.build_clear_session_password_request()
2126
+
2127
+ response = self._send_receive_with_reconnect(build_request)
2128
+ self.protocol.check_userdata_response(response)
2129
+ logger.info("Session password cleared successfully")
2130
+ return 0
2131
+
1931
2132
  def read_szl(self, ssl_id: int, index: int = 0) -> S7SZL:
1932
2133
  """
1933
2134
  Read SZL (System Status List).
@@ -1975,7 +2176,7 @@ class Client(ClientMixin):
1975
2176
  break
1976
2177
 
1977
2178
  followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
1978
- conn.send_data(followup)
2179
+ self._send_data(conn, followup)
1979
2180
 
1980
2181
  response_data = conn.receive_data()
1981
2182
  response = self.protocol.parse_response(response_data)
@@ -2095,7 +2296,7 @@ class Client(ClientMixin):
2095
2296
  """
2096
2297
  conn = self._get_connection()
2097
2298
 
2098
- conn.send_data(bytes(data))
2299
+ self._send_data(conn, bytes(data))
2099
2300
  response = conn.receive_data()
2100
2301
  return bytearray(response)
2101
2302
 
snap7/client_base.py CHANGED
@@ -9,7 +9,7 @@ import logging
9
9
  import struct
10
10
  from typing import Optional
11
11
 
12
- from .datatypes import S7Area
12
+ from .datatypes import S7Area, S7DataTypes, S7WordLen
13
13
  from .error import S7ProtocolError
14
14
 
15
15
  from .type import (
@@ -147,29 +147,6 @@ class ClientMixin:
147
147
  self.connection_type = connection_type
148
148
  logger.debug(f"Connection type set to {connection_type}")
149
149
 
150
- def set_session_password(self, password: str) -> int:
151
- """Set session password.
152
-
153
- Args:
154
- password: Session password
155
-
156
- Returns:
157
- 0 on success
158
- """
159
- self.session_password = password
160
- logger.debug("Session password set")
161
- return 0
162
-
163
- def clear_session_password(self) -> int:
164
- """Clear session password.
165
-
166
- Returns:
167
- 0 on success
168
- """
169
- self.session_password = None
170
- logger.debug("Session password cleared")
171
- return 0
172
-
173
150
  def get_param(self, param: Parameter) -> int:
174
151
  """Get client parameter.
175
152
 
@@ -241,6 +218,32 @@ class ClientMixin:
241
218
  """
242
219
  return max(1, self.pdu_length - 35)
243
220
 
221
+ def _read_chunk_count(self, word_len: S7WordLen) -> int:
222
+ """Convert the response byte budget to whole requested elements."""
223
+ width = S7DataTypes.get_size_bytes(word_len)
224
+ count = min(self.pdu_length - 18, 8191) // width
225
+ if self.pdu_length < 24 or count < 1:
226
+ raise S7ProtocolError("Negotiated PDU cannot hold one read element")
227
+ # Represent each BIT as one byte in the public API, one bit per request.
228
+ return 1 if word_len == S7WordLen.BIT else count
229
+
230
+ def _write_chunk_bytes(self, word_len: S7WordLen, size: int) -> int:
231
+ """Keep write chunks aligned to elements and within the PDU budget."""
232
+ width = S7DataTypes.get_size_bytes(word_len)
233
+ if size % width:
234
+ raise ValueError("Write data length must be a multiple of the element width")
235
+ chunk = min(self.pdu_length - 35, 8191) // width * width
236
+ if chunk < width:
237
+ raise S7ProtocolError("Negotiated PDU cannot hold one write element")
238
+ return 1 if word_len == S7WordLen.BIT else chunk
239
+
240
+ @staticmethod
241
+ def _element_address_step(word_len: S7WordLen) -> int:
242
+ """BIT and timer/counter starts use indices; other starts use bytes."""
243
+ if word_len in (S7WordLen.BIT, S7WordLen.TIMER, S7WordLen.COUNTER):
244
+ return 1
245
+ return S7DataTypes.get_size_bytes(word_len)
246
+
244
247
  def _map_area(self, area: Area) -> S7Area:
245
248
  """Map library area enum to native S7 area."""
246
249
  area_mapping = {
snap7/connection.py CHANGED
@@ -10,7 +10,7 @@ import socket
10
10
  import struct
11
11
  import logging
12
12
  from enum import IntEnum
13
- from typing import Optional, Type
13
+ from typing import Optional, Type, Union
14
14
  from types import TracebackType
15
15
 
16
16
  from .error import S7ConnectionError, S7TimeoutError
@@ -71,7 +71,7 @@ class ISOTCPConnection:
71
71
  host: str,
72
72
  port: int = 102,
73
73
  local_tsap: int = 0x0100,
74
- remote_tsap: int = 0x0102,
74
+ remote_tsap: Union[int, bytes] = 0x0102,
75
75
  tpdu_size: TPDUSize = TPDUSize.S_1024,
76
76
  ):
77
77
  """
@@ -81,7 +81,8 @@ class ISOTCPConnection:
81
81
  host: Target PLC IP address
82
82
  port: TCP port (default 102 for S7)
83
83
  local_tsap: Local Transport Service Access Point
84
- remote_tsap: Remote Transport Service Access Point
84
+ remote_tsap: Remote Transport Service Access Point (int for 2-byte TSAP,
85
+ bytes for variable-length TSAP like b"SIMATIC-ROOT-HMI")
85
86
  tpdu_size: TPDU size to request during COTP negotiation
86
87
  """
87
88
  self.host = host
@@ -183,7 +184,7 @@ class ISOTCPConnection:
183
184
  # Send over TCP
184
185
  try:
185
186
  self.socket.sendall(tpkt_frame)
186
- logger.debug(f"Sent {len(tpkt_frame)} bytes")
187
+ logger.debug(f"Sent {len(tpkt_frame)} bytes: {tpkt_frame.hex(' ')}")
187
188
  except socket.error as e:
188
189
  self.connected = False
189
190
  raise S7ConnectionError(f"Send failed: {e}")
@@ -210,12 +211,13 @@ class ISOTCPConnection:
210
211
 
211
212
  # Receive remaining data
212
213
  remaining = length - 4
213
- if remaining <= 0:
214
+ if length < 7:
214
215
  raise S7ConnectionError("Invalid TPKT length")
215
216
 
216
217
  payload = self._recv_exact(remaining)
217
218
 
218
219
  # Parse COTP header and extract data
220
+ logger.debug(f"Received TPKT: version={version} length={length} payload ({len(payload)} bytes): {payload.hex(' ')}")
219
221
  return self._parse_cotp_data(payload)
220
222
 
221
223
  except socket.timeout:
@@ -284,6 +286,8 @@ class ISOTCPConnection:
284
286
  - Length (2 bytes): Total frame length including header
285
287
  """
286
288
  length = len(payload) + 4
289
+ if not 7 <= length <= 65535:
290
+ raise S7ConnectionError("Invalid TPKT length: expected 7..65535 bytes")
287
291
  return struct.pack(">BBH", 3, 0, length) + payload
288
292
 
289
293
  def _build_cotp_cr(self) -> bytes:
@@ -309,11 +313,13 @@ class ISOTCPConnection:
309
313
  )
310
314
 
311
315
  # Add TSAP parameters
312
- tsap_length = 2 # TSAP values are 2 bytes (unsigned short)
313
- # Calling TSAP (local)
314
- calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, tsap_length, self.local_tsap)
315
- # Called TSAP (remote)
316
- called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, tsap_length, self.remote_tsap)
316
+ # Calling TSAP (local) - always 2 bytes
317
+ calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, 2, self.local_tsap)
318
+ # Called TSAP (remote) - can be 2-byte int or variable-length bytes (e.g. "SIMATIC-ROOT-HMI")
319
+ if isinstance(self.remote_tsap, bytes):
320
+ called_tsap = struct.pack(">BB", self.COTP_PARAM_CALLED_TSAP, len(self.remote_tsap)) + self.remote_tsap
321
+ else:
322
+ called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, 2, self.remote_tsap)
317
323
  # PDU Size parameter (ISO 8073 code, e.g. 0x0A = 1024 bytes)
318
324
  pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
319
325
 
@@ -412,6 +418,11 @@ class ISOTCPConnection:
412
418
 
413
419
  pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
414
420
 
421
+ if pdu_len != 2:
422
+ raise S7ConnectionError("Invalid COTP DT header length")
423
+ if eot_num & 0x7F:
424
+ raise S7ConnectionError("Invalid Class 0 COTP TPDU number")
425
+
415
426
  if pdu_type != self.COTP_DT:
416
427
  raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
417
428