python-snap7 3.1.0__py3-none-any.whl → 3.1.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.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snap7
3
- Version: 3.1.0
3
+ Version: 3.1.1
4
4
  Summary: Pure Python S7 communication library for Siemens PLCs
5
5
  Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/gijzelaerr/python-snap7
8
8
  Project-URL: Documentation, https://python-snap7.readthedocs.io/en/latest/
9
9
  Keywords: snap7,s7,siemens,plc
@@ -1,10 +1,10 @@
1
- python_snap7-3.1.0.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
1
+ python_snap7-3.1.1.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
2
2
  snap7/__init__.py,sha256=jWGpca2kG7gskQbcM5qoflWEyMMEJ7655kM0KxVVLaA,980
3
- snap7/async_client.py,sha256=IIWtZ1nwblUZY1Zn0zyLmVQUnp33K0cxl8xVu887Fgs,43911
3
+ snap7/async_client.py,sha256=7GwUslkmK27ADGrZ-BtF2UogbJbnzf6WOCLc-D2V3-8,44157
4
4
  snap7/cli.py,sha256=Td7z1O0gV7t-w9umUaX91MHGw-HuXYEDHKgtUO-9d3Q,13755
5
- snap7/client.py,sha256=ZKDM20dZ55EP-kEyncqjdq8oxB7M5ckgXtKrmFsN8Iw,95429
6
- snap7/client_base.py,sha256=2fKWy7eczyr-tdGQswH_USTdDdeIrdyTTQ_1F8LRAyU,7435
7
- snap7/connection.py,sha256=Aeo5ckyFEUR5cpJO88LZENzGitdKlkWdx7LB2tyQkXA,17643
5
+ snap7/client.py,sha256=wOy7-BzAIl9atE-uYWxEDvUo_HHrhCsRj8vhdMbhQVg,97264
6
+ snap7/client_base.py,sha256=0e1mAU6qsskzMBSYPjccSClznf4jpTNzYGlvGpCC0Cs,7716
7
+ snap7/connection.py,sha256=b2O8iKWQUWoL2n1hJ14TSjIjHh0EWDK8dDvAqND8PR0,18064
8
8
  snap7/datatypes.py,sha256=zJgPasEV79_H4qHabivIxYX50j74pNg64apBppAKI2Y,11205
9
9
  snap7/demo.py,sha256=JWSKLj2QPIXeSFTZSXuADAJzWVKls712MUZnuqwNkNs,17376
10
10
  snap7/discovery.py,sha256=LONPYg83b2quKXht6y_GTTArdxitjD8DoGyaHfe9B1w,3964
@@ -18,15 +18,15 @@ snap7/s7protocol.py,sha256=a-obZR5fLzHS1wHpZ88N37XxzYjKiS2JgK8SFi5qTjQ,57360
18
18
  snap7/szl.py,sha256=VPKNz8MCU7ok_Run-JVuwHKJSeGY-Wcj93SJu56tgtw,3898
19
19
  snap7/tags.py,sha256=Qh8dm6rDWcVnYhbSB_htrmqIAXsmWQWcDS8-wXqN_zE,27033
20
20
  snap7/type.py,sha256=-ad6v8vQP21DvWFtHCaSlk-BllFGPKOtbhprqKdOlJ0,9391
21
- snap7/server/__init__.py,sha256=I4Tk3IwPjQLVokrp3lOgGLoTwLRTNY6mTrWGSOHOrAM,100283
21
+ snap7/server/__init__.py,sha256=t-BM4Iphw8j5BzCIPf5xOFQaYHQmuLh7X8F-lvDOkaU,101708
22
22
  snap7/server/__main__.py,sha256=OdOgyMmFThjSe2jSgvlbkquGYnZcBW6wubs2ldhNSOE,1206
23
23
  snap7/util/__init__.py,sha256=d6n2oy7tNYke1ia0TUyWz_ni3l0XN1qvilM5sW0T-Es,1874
24
24
  snap7/util/db.py,sha256=geesYS2oyOuTADOJa2me1p1rqJVzPuLTCfye6JIeav0,27120
25
- snap7/util/getters.py,sha256=-rsgcczCYzc1Z_t98Q9_2LriQQipYji9T-VIsg_2lWs,24393
26
- snap7/util/setters.py,sha256=wi8MrABiGbnsqSj1NYjK5aTtcRVS0NbAHQXP6lLjAVg,27454
25
+ snap7/util/getters.py,sha256=g-z6jve3JEQchsXzeS0ajn56cpVepq1nhbRpShAXD9I,24896
26
+ snap7/util/setters.py,sha256=aTd-rrTS87G_tI0hAqNhyWv1T7U4eLxbKs5TkzEfpd0,27111
27
27
  snap7/util/symbols.py,sha256=L1dY7I1-HZ5Odtv6ZqGBeLppb3E5hjL64MyATT7XyEw,16454
28
- python_snap7-3.1.0.dist-info/METADATA,sha256=TiHXRRAXn75UcFn9PpNAzDbXQmetvpYH0cDZ8Vvkogk,4652
29
- python_snap7-3.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
30
- python_snap7-3.1.0.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
31
- python_snap7-3.1.0.dist-info/top_level.txt,sha256=VpFXm1w5_73qgJEIN_5OzQWINHyYJBtNoOW4DMoizgw,6
32
- python_snap7-3.1.0.dist-info/RECORD,,
28
+ python_snap7-3.1.1.dist-info/METADATA,sha256=bZ_pI6rndBbgswfDxQqH5YGw8c0L2Rcw6i0Xs7fR4zc,4663
29
+ python_snap7-3.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ python_snap7-3.1.1.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
31
+ python_snap7-3.1.1.dist-info/top_level.txt,sha256=VpFXm1w5_73qgJEIN_5OzQWINHyYJBtNoOW4DMoizgw,6
32
+ python_snap7-3.1.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
snap7/async_client.py CHANGED
@@ -389,7 +389,7 @@ class AsyncClient(ClientMixin):
389
389
  self.slot = slot
390
390
  self._params[Parameter.RemotePort] = tcp_port
391
391
 
392
- self.remote_tsap = 0x0100 | (rack << 5) | slot
392
+ self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
393
393
 
394
394
  try:
395
395
  start_time = time.time()
@@ -1159,7 +1159,11 @@ class AsyncClient(ClientMixin):
1159
1159
  if response.get("parameters"):
1160
1160
  params = response["parameters"]
1161
1161
  if "pdu_length" in params:
1162
- self.pdu_length = params["pdu_length"]
1162
+ negotiated = params["pdu_length"]
1163
+ if negotiated < 64:
1164
+ logger.warning(f"Server negotiated implausible PDU length {negotiated}, using minimum 240")
1165
+ negotiated = 240
1166
+ self.pdu_length = negotiated
1163
1167
  self._params[Parameter.PDURequest] = self.pdu_length
1164
1168
  logger.info(f"Negotiated PDU length: {self.pdu_length}")
1165
1169
 
snap7/client.py CHANGED
@@ -52,7 +52,7 @@ _VALID_AREA_VALUES: frozenset[int] = frozenset(a.value for a in Area)
52
52
  logger = logging.getLogger(__name__)
53
53
 
54
54
 
55
- def _decode_tag(tag: Tag, data: bytearray) -> Any:
55
+ def _decode_tag(tag: Tag, data: bytearray, encoding: str = "latin-1") -> Any:
56
56
  """Decode a Tag's raw bytes into a typed Python value."""
57
57
  upper = tag.datatype.upper()
58
58
  # Variable-length string types
@@ -60,9 +60,9 @@ def _decode_tag(tag: Tag, data: bytearray) -> Any:
60
60
  if match:
61
61
  kind, length = match.group(1), int(match.group(2))
62
62
  if kind == "FSTRING":
63
- return util.get_fstring(data, 0, length)
63
+ return util.get_fstring(data, 0, length, encoding=encoding)
64
64
  if kind == "STRING":
65
- return util.get_string(data, 0)
65
+ return util.get_string(data, 0, encoding=encoding)
66
66
  if kind == "WSTRING":
67
67
  return util.get_wstring(data, 0)
68
68
 
@@ -129,17 +129,17 @@ def _decode_scalar(datatype: str, data: bytearray, bit: int) -> Any:
129
129
  raise ValueError(f"Unsupported tag datatype: {datatype}")
130
130
 
131
131
 
132
- def _encode_tag(tag: Tag, buf: bytearray, value: Any) -> None:
132
+ def _encode_tag(tag: Tag, buf: bytearray, value: Any, encoding: str = "latin-1") -> None:
133
133
  """Encode a typed Python value into a Tag's byte buffer."""
134
134
  upper = tag.datatype.upper()
135
135
  match = _STRING_RE.match(upper)
136
136
  if match:
137
137
  kind, length = match.group(1), int(match.group(2))
138
138
  if kind == "FSTRING":
139
- util.set_fstring(buf, 0, value, length)
139
+ util.set_fstring(buf, 0, value, length, encoding=encoding)
140
140
  return
141
141
  if kind == "STRING":
142
- util.set_string(buf, 0, value, length)
142
+ util.set_string(buf, 0, value, length, encoding=encoding)
143
143
  return
144
144
  if kind == "WSTRING":
145
145
  util.set_wstring(buf, 0, value, length)
@@ -656,7 +656,7 @@ class Client(ClientMixin):
656
656
  self._params[Parameter.RemotePort] = port
657
657
 
658
658
  # Remote TSAP targets the gateway rack/slot
659
- self.remote_tsap = 0x0100 | (router_rack << 5) | router_slot
659
+ self.remote_tsap = (self.connection_type << 8) | (router_rack << 5) | router_slot
660
660
 
661
661
  try:
662
662
  start_time = time.time()
@@ -780,7 +780,7 @@ class Client(ClientMixin):
780
780
  struct.pack_into(fmt, data, i * item_size, v)
781
781
  return self.db_write(db_number, start, data)
782
782
 
783
- def read_tag(self, tag: "Union[Tag, str]") -> Any:
783
+ def read_tag(self, tag: "Union[Tag, str]", encoding: str = "latin-1") -> Any:
784
784
  """Read a typed value by :class:`~snap7.tags.Tag` or address string.
785
785
 
786
786
  Accepts a :class:`~snap7.tags.Tag` or a PLC4X-style address string
@@ -788,6 +788,8 @@ class Client(ClientMixin):
788
788
 
789
789
  Args:
790
790
  tag: A :class:`~snap7.tags.Tag` instance or a parseable address string.
791
+ encoding: Character encoding for STRING/FSTRING values (default ``"latin-1"``).
792
+ Use ``"gbk"`` for Chinese PLCs or ``"utf-8"`` for UTF-8 encoded strings.
791
793
 
792
794
  Returns:
793
795
  The typed value (bool/int/float/datetime/str depending on type).
@@ -798,6 +800,7 @@ class Client(ClientMixin):
798
800
  client.read_tag("DB1.DBD4:REAL") # float
799
801
  client.read_tag("DB1:20:STRING[30]") # variable-length string
800
802
  client.read_tag(Tag(Area.DB, 1, 0, "REAL")) # from Tag instance
803
+ client.read_tag("DB1:34:STRING[10]", encoding="gbk") # Chinese string
801
804
  """
802
805
  resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
803
806
  if resolved.is_symbolic:
@@ -805,14 +808,15 @@ class Client(ClientMixin):
805
808
  "Symbolic (LID-based) tag access requires S7CommPlus. Use s7.Client instead of snap7.Client."
806
809
  )
807
810
  data = self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, resolved.size)
808
- return _decode_tag(resolved, bytearray(data))
811
+ return _decode_tag(resolved, bytearray(data), encoding=encoding)
809
812
 
810
- def write_tag(self, tag: "Union[Tag, str]", value: Any) -> int:
813
+ def write_tag(self, tag: "Union[Tag, str]", value: Any, encoding: str = "latin-1") -> int:
811
814
  """Write a typed value by :class:`~snap7.tags.Tag` or address string.
812
815
 
813
816
  Args:
814
817
  tag: A :class:`~snap7.tags.Tag` instance or a parseable address string.
815
818
  value: The value to write (type must match the tag's datatype).
819
+ encoding: Character encoding for STRING/FSTRING values (default ``"latin-1"``).
816
820
 
817
821
  Returns:
818
822
  0 on success.
@@ -828,10 +832,10 @@ class Client(ClientMixin):
828
832
  if resolved.datatype.upper() == "BOOL":
829
833
  current = self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, 1)
830
834
  buf[0] = current[0]
831
- _encode_tag(resolved, buf, value)
835
+ _encode_tag(resolved, buf, value, encoding=encoding)
832
836
  return self.write_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, buf)
833
837
 
834
- def read_tags(self, tags: "list[Union[Tag, str]]") -> list[Any]:
838
+ def read_tags(self, tags: "list[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
835
839
  """Read multiple tags in a single optimized request.
836
840
 
837
841
  Uses the multi-variable read optimizer when available to batch
@@ -839,6 +843,7 @@ class Client(ClientMixin):
839
843
 
840
844
  Args:
841
845
  tags: List of :class:`~snap7.tags.Tag` instances or address strings.
846
+ encoding: Character encoding for STRING/FSTRING values (default ``"latin-1"``).
842
847
 
843
848
  Returns:
844
849
  List of decoded values in the same order as input.
@@ -846,7 +851,7 @@ class Client(ClientMixin):
846
851
  resolved = [Tag.from_string(t) if isinstance(t, str) else t for t in tags]
847
852
  items = [{"area": Area(t.area), "db_number": t.db_number, "start": t.byte_offset, "size": t.size} for t in resolved]
848
853
  _code, data_list = self.read_multi_vars(items)
849
- return [_decode_tag(t, bytearray(d)) for t, d in zip(resolved, data_list)]
854
+ return [_decode_tag(t, bytearray(d), encoding=encoding) for t, d in zip(resolved, data_list)]
850
855
 
851
856
  def db_read(self, db_number: int, start: int, size: int) -> bytearray:
852
857
  """
@@ -1289,8 +1294,13 @@ class Client(ClientMixin):
1289
1294
 
1290
1295
  def _execute_packets_sequential(self, packet_requests: list[Tuple[int, bytes, ReadPacket]]) -> None:
1291
1296
  """Execute multi-block packets one at a time."""
1292
- for _, request, packet in packet_requests:
1293
- response = self._send_receive(request)
1297
+ for _, _request, packet in packet_requests:
1298
+ block_specs = [(blk.area, blk.db_number, blk.start_offset, blk.byte_length) for blk in packet.blocks]
1299
+
1300
+ def build_request(specs: list[tuple[int, int, int, int]] = block_specs) -> bytes:
1301
+ return self.protocol.build_multi_read_request(specs)
1302
+
1303
+ response = self._send_receive_with_reconnect(build_request)
1294
1304
  block_data_list = self.protocol.extract_multi_read_data(response, len(packet.blocks))
1295
1305
  for blk, buf in zip(packet.blocks, block_data_list):
1296
1306
  blk.buffer = buf
@@ -1299,9 +1309,20 @@ class Client(ClientMixin):
1299
1309
  """Execute multi-block packets using parallel dispatch.
1300
1310
 
1301
1311
  Sends up to *max_parallel* PDUs back-to-back before reading
1302
- responses, reducing round-trip overhead.
1312
+ responses, reducing round-trip overhead. Falls back to
1313
+ sequential reconnect-aware execution on connection loss.
1303
1314
  """
1304
- # Process in chunks of max_parallel
1315
+ try:
1316
+ self._execute_packets_parallel_inner(packet_requests)
1317
+ except (S7ConnectionError, OSError) as e:
1318
+ if not self._auto_reconnect:
1319
+ raise
1320
+ logger.warning(f"Connection lost during parallel read: {e}")
1321
+ self._do_reconnect()
1322
+ self._execute_packets_sequential(packet_requests)
1323
+
1324
+ def _execute_packets_parallel_inner(self, packet_requests: list[Tuple[int, bytes, ReadPacket]]) -> None:
1325
+ """Inner parallel dispatch without reconnect handling."""
1305
1326
  for chunk_start in range(0, len(packet_requests), self.max_parallel):
1306
1327
  chunk = packet_requests[chunk_start : chunk_start + self.max_parallel]
1307
1328
  requests = [(pkt_idx, pdu) for pkt_idx, pdu, _ in chunk]
@@ -2641,7 +2662,11 @@ class Client(ClientMixin):
2641
2662
  if response.get("parameters"):
2642
2663
  params = response["parameters"]
2643
2664
  if "pdu_length" in params:
2644
- self.pdu_length = params["pdu_length"]
2665
+ negotiated = params["pdu_length"]
2666
+ if negotiated < 64:
2667
+ logger.warning(f"Server negotiated implausible PDU length {negotiated}, using minimum 240")
2668
+ negotiated = 240
2669
+ self.pdu_length = negotiated
2645
2670
  self._params[Parameter.PDURequest] = self.pdu_length
2646
2671
  logger.info(f"Negotiated PDU length: {self.pdu_length}")
2647
2672
 
snap7/client_base.py CHANGED
@@ -224,16 +224,22 @@ class ClientMixin:
224
224
 
225
225
  Calculated as PDU length minus overhead:
226
226
  12 bytes S7 header + 2 bytes param + 4 bytes data header = 18 bytes.
227
+
228
+ Returns at least 1 to prevent infinite loops in chunked reads
229
+ when the server negotiates an implausibly small PDU.
227
230
  """
228
- return self.pdu_length - 18
231
+ return max(1, self.pdu_length - 18)
229
232
 
230
233
  def _max_write_size(self) -> int:
231
234
  """Maximum payload bytes for a single write request.
232
235
 
233
236
  Calculated as PDU length minus overhead:
234
237
  12 bytes S7 header + 14 bytes param + 4 bytes data header + 5 bytes padding = 35 bytes.
238
+
239
+ Returns at least 1 to prevent infinite loops in chunked writes
240
+ when the server negotiates an implausibly small PDU.
235
241
  """
236
- return self.pdu_length - 35
242
+ return max(1, self.pdu_length - 35)
237
243
 
238
244
  def _map_area(self, area: Area) -> S7Area:
239
245
  """Map library area enum to native S7 area."""
snap7/connection.py CHANGED
@@ -371,11 +371,19 @@ class ISOTCPConnection:
371
371
  if param_code == self.COTP_PARAM_PDU_SIZE:
372
372
  # PDU Size parameter
373
373
  if param_len == 1:
374
- # ISO 8073 code: size = 2^code
375
- self.pdu_size = 1 << param_data[0]
374
+ # ISO 8073 code: size = 2^code, valid range 7-13
375
+ exponent = param_data[0]
376
+ if 7 <= exponent <= 13:
377
+ self.pdu_size = 1 << exponent
378
+ else:
379
+ logger.warning(f"Invalid COTP PDU size exponent {exponent}, using default")
376
380
  elif param_len == 2:
377
381
  # Raw 2-byte value
378
- self.pdu_size = struct.unpack(">H", param_data)[0]
382
+ raw = struct.unpack(">H", param_data)[0]
383
+ if 128 <= raw <= 8192:
384
+ self.pdu_size = raw
385
+ else:
386
+ logger.warning(f"Invalid COTP PDU size {raw}, using default")
379
387
  logger.debug(f"Negotiated PDU size: {self.pdu_size}")
380
388
  else:
381
389
  logger.debug(f"Unsupported COTP parameter: code={param_code:#04x}, length={param_len}")
snap7/server/__init__.py CHANGED
@@ -816,9 +816,9 @@ class Server:
816
816
  word_len = addr.get("word_len", S7WordLen.BYTE)
817
817
 
818
818
  # Convert count to bytes
819
- if word_len in (S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD):
819
+ if word_len in (S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD, S7WordLen.INT):
820
820
  byte_count = count * 2
821
- elif word_len in (S7WordLen.DWORD, S7WordLen.REAL):
821
+ elif word_len in (S7WordLen.DWORD, S7WordLen.REAL, S7WordLen.DINT):
822
822
  byte_count = count * 4
823
823
  elif word_len == S7WordLen.BIT:
824
824
  byte_count = 1
@@ -876,9 +876,9 @@ class Server:
876
876
  word_len = addr_spec.get("word_len", S7WordLen.BYTE)
877
877
 
878
878
  # Convert count to bytes based on word length
879
- if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD]:
879
+ if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD, S7WordLen.INT]:
880
880
  byte_count = count * 2 # 16-bit items
881
- elif word_len in [S7WordLen.DWORD, S7WordLen.REAL]:
881
+ elif word_len in [S7WordLen.DWORD, S7WordLen.REAL, S7WordLen.DINT]:
882
882
  byte_count = count * 4 # 32-bit items
883
883
  elif word_len == S7WordLen.BIT:
884
884
  byte_count = 1 # Single bit needs at least 1 byte
@@ -2418,7 +2418,9 @@ class Server:
2418
2418
  block_num = ctx["block_num"]
2419
2419
  block_data = ctx["data"]
2420
2420
 
2421
- # Store block data
2421
+ # Store block data — only into pre-registered areas.
2422
+ # Reject downloads to unknown areas to prevent unbounded memory
2423
+ # allocation from attacker-controlled block numbers.
2422
2424
  if block_type == 0x41: # DB
2423
2425
  area_key = (S7Area.DB, block_num)
2424
2426
  if area_key in self.memory_areas:
@@ -2428,9 +2430,9 @@ class Server:
2428
2430
  copy_len = min(len(block_data), len(existing_area))
2429
2431
  existing_area[0:copy_len] = block_data[0:copy_len]
2430
2432
  else:
2431
- # Create new area
2432
- self.memory_areas[area_key] = bytearray(block_data)
2433
- self.area_locks[area_key] = threading.Lock()
2433
+ logger.warning(f"Download rejected: area DB{block_num} not registered")
2434
+ del self._download_contexts[client_address]
2435
+ return self._build_error_response(request, 0x8104)
2434
2436
 
2435
2437
  logger.info(f"Download ended from {client_address}: stored {len(block_data)} bytes to {block_type:#02x}:{block_num}")
2436
2438
 
@@ -2493,6 +2495,9 @@ class ServerISOConnection:
2493
2495
  COTP_DC = 0xC0 # Disconnect Confirm
2494
2496
  COTP_DT = 0xF0 # Data Transfer
2495
2497
 
2498
+ # COTP parameter code for TPDU size (ISO 8073)
2499
+ COTP_PARAM_PDU_SIZE = 0xC0
2500
+
2496
2501
  def __init__(self, client_socket: socket.socket):
2497
2502
  """Initialize server ISO connection."""
2498
2503
  self.socket = client_socket
@@ -2500,6 +2505,7 @@ class ServerISOConnection:
2500
2505
  self.connected = False
2501
2506
  self.src_ref = 0x0001 # Server reference
2502
2507
  self.dst_ref = 0x0000 # Client reference (assigned during handshake)
2508
+ self.tpdu_size = 0x0A # Default: 1024 bytes (2^10)
2503
2509
 
2504
2510
  def accept_connection(self) -> bool:
2505
2511
  """Accept ISO connection from client."""
@@ -2532,25 +2538,40 @@ class ServerISOConnection:
2532
2538
  return False
2533
2539
 
2534
2540
  def receive_data(self) -> bytes:
2535
- """Receive data from client."""
2536
- # Receive TPKT header (4 bytes)
2537
- tpkt_header = self._recv_exact(4)
2541
+ """Receive data from client.
2542
+
2543
+ Reassembles COTP DT fragments by reading frames until the EOT
2544
+ bit (bit 7 of the third COTP header byte) is set, then returns
2545
+ the concatenated payload.
2546
+ """
2547
+ fragments: list[bytes] = []
2548
+ while True:
2549
+ tpkt_header = self._recv_exact(4)
2550
+ version, reserved, length = struct.unpack(">BBH", tpkt_header)
2551
+
2552
+ if version != 3:
2553
+ raise S7ConnectionError(f"Invalid TPKT version: {version}")
2554
+
2555
+ remaining = length - 4
2556
+ if remaining <= 0:
2557
+ raise S7ConnectionError("Invalid TPKT length")
2538
2558
 
2539
- # Parse TPKT header
2540
- version, reserved, length = struct.unpack(">BBH", tpkt_header)
2559
+ payload = self._recv_exact(remaining)
2541
2560
 
2542
- if version != 3:
2543
- raise S7ConnectionError(f"Invalid TPKT version: {version}")
2561
+ if len(payload) < 3:
2562
+ raise S7ConnectionError("Invalid COTP DT: too short")
2544
2563
 
2545
- # Receive remaining data
2546
- remaining = length - 4
2547
- if remaining <= 0:
2548
- raise S7ConnectionError("Invalid TPKT length")
2564
+ pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
2549
2565
 
2550
- payload = self._recv_exact(remaining)
2566
+ if pdu_type != self.COTP_DT:
2567
+ raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
2551
2568
 
2552
- # Parse COTP header and extract data
2553
- return self._parse_cotp_data(payload)
2569
+ fragments.append(payload[3:])
2570
+
2571
+ if eot_num & 0x80:
2572
+ break
2573
+
2574
+ return b"".join(fragments)
2554
2575
 
2555
2576
  def send_data(self, data: bytes) -> None:
2556
2577
  """Send data to client."""
@@ -2578,22 +2599,43 @@ class ServerISOConnection:
2578
2599
  # Store client reference
2579
2600
  self.dst_ref = src_ref
2580
2601
 
2602
+ # Parse variable parameters for TPDU size
2603
+ offset = 7
2604
+ while offset + 2 <= len(data):
2605
+ param_code = data[offset]
2606
+ param_len = data[offset + 1]
2607
+ if offset + 2 + param_len > len(data):
2608
+ break
2609
+ if param_code == self.COTP_PARAM_PDU_SIZE and param_len == 1:
2610
+ exponent = data[offset + 2]
2611
+ if 7 <= exponent <= 13:
2612
+ self.tpdu_size = exponent
2613
+ logger.debug(f"Client requested TPDU size 2^{exponent} = {1 << exponent}")
2614
+ offset += 2 + param_len
2615
+
2581
2616
  logger.debug(f"Received COTP CR from client ref {src_ref}")
2582
2617
  return True
2583
2618
 
2584
2619
  def _build_cotp_cc(self) -> bytes:
2585
- """Build COTP Connection Confirm."""
2586
- # Basic COTP CC
2620
+ """Build COTP Connection Confirm.
2621
+
2622
+ Includes the TPDU size parameter (0xC0) so clients know the
2623
+ negotiated maximum segment size and don't fall back to the
2624
+ ISO 8073 class-0 default of 128 bytes.
2625
+ """
2626
+ pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
2627
+ pdu_length = 6 + len(pdu_size_param)
2587
2628
  base_pdu = struct.pack(
2588
- ">BBHHB",
2589
- 6, # PDU length
2629
+ ">BBBHHB",
2630
+ pdu_length, # PDU length
2590
2631
  self.COTP_CC, # PDU type
2632
+ 0x00, # Reserved / CDT
2591
2633
  self.dst_ref, # Destination reference (client's source ref)
2592
2634
  self.src_ref, # Source reference (our ref)
2593
2635
  0x00, # Class/option
2594
2636
  )
2595
2637
 
2596
- return struct.pack(">B", 6) + base_pdu[1:]
2638
+ return base_pdu + pdu_size_param
2597
2639
 
2598
2640
  def _recv_exact(self, size: int) -> bytes:
2599
2641
  """Receive exactly the specified number of bytes."""
@@ -2617,18 +2659,6 @@ class ServerISOConnection:
2617
2659
  header = struct.pack(">BBB", 2, self.COTP_DT, 0x80)
2618
2660
  return header + data
2619
2661
 
2620
- def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes:
2621
- """Parse COTP Data Transfer PDU and extract S7 data."""
2622
- if len(cotp_pdu) < 3:
2623
- raise S7ConnectionError("Invalid COTP DT: too short")
2624
-
2625
- pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3])
2626
-
2627
- if pdu_type != self.COTP_DT:
2628
- raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
2629
-
2630
- return cotp_pdu[3:] # Return data portion
2631
-
2632
2662
 
2633
2663
  def mainloop(tcp_port: int = 1102, init_standard_values: bool = False) -> None:
2634
2664
  """
snap7/util/getters.py CHANGED
@@ -149,17 +149,22 @@ def get_real(bytearray_: Buffer, byte_index: int) -> float:
149
149
  return real
150
150
 
151
151
 
152
- def get_fstring(bytearray_: Buffer, byte_index: int, max_length: int, remove_padding: bool = True) -> str:
152
+ def get_fstring(
153
+ bytearray_: Buffer, byte_index: int, max_length: int, remove_padding: bool = True, encoding: str = "latin-1"
154
+ ) -> str:
153
155
  """Parse space-padded fixed-length string from bytearray
154
156
 
155
157
  Notes:
156
- This function supports fixed-length ASCII strings, right-padded with spaces.
158
+ This function supports fixed-length strings, right-padded with spaces.
159
+ Use the ``encoding`` parameter to decode multi-byte strings (e.g. ``"gbk"``
160
+ for Chinese PLCs or ``"utf-8"``).
157
161
 
158
162
  Args:
159
163
  bytearray_: buffer from where to get the string.
160
164
  byte_index: byte index from where to start reading.
161
- max_length: the maximum length of the string.
165
+ max_length: the maximum length of the string in bytes.
162
166
  remove_padding: whether to remove the right-padding.
167
+ encoding: character encoding used to decode the raw bytes (default ``"latin-1"``).
163
168
 
164
169
  Returns:
165
170
  String value.
@@ -171,8 +176,8 @@ def get_fstring(bytearray_: Buffer, byte_index: int, max_length: int, remove_pad
171
176
  >>> get_fstring(data, 0, 15, remove_padding=False)
172
177
  'hello world '
173
178
  """
174
- data = map(chr, bytearray_[byte_index : byte_index + max_length])
175
- string = "".join(data)
179
+ raw = bytes(bytearray_[byte_index : byte_index + max_length])
180
+ string = raw.decode(encoding)
176
181
 
177
182
  if remove_padding:
178
183
  return string.rstrip(" ")
@@ -180,16 +185,19 @@ def get_fstring(bytearray_: Buffer, byte_index: int, max_length: int, remove_pad
180
185
  return string
181
186
 
182
187
 
183
- def get_string(bytearray_: Buffer, byte_index: int) -> str:
188
+ def get_string(bytearray_: Buffer, byte_index: int, encoding: str = "latin-1") -> str:
184
189
  """Parse string from bytearray
185
190
 
186
191
  Notes:
187
192
  The first byte of the buffer will contain the max size posible for a string.
188
193
  The second byte contains the length of the string that contains.
194
+ Use the ``encoding`` parameter to decode multi-byte strings (e.g. ``"gbk"``
195
+ for Chinese PLCs or ``"utf-8"``).
189
196
 
190
197
  Args:
191
198
  bytearray_: buffer from where to get the string.
192
199
  byte_index: byte index from where to start reading.
200
+ encoding: character encoding used to decode the raw bytes (default ``"latin-1"``).
193
201
 
194
202
  Returns:
195
203
  String value.
@@ -210,8 +218,8 @@ def get_string(bytearray_: Buffer, byte_index: int) -> str:
210
218
  "String contains {str_length} chars, but max. {max_string_size} chars are expected or is "
211
219
  "larger than 254. Bytearray doesn't seem to be a valid string."
212
220
  )
213
- data = map(chr, bytearray_[byte_index + 2 : byte_index + 2 + str_length])
214
- return "".join(data)
221
+ raw = bytes(bytearray_[byte_index + 2 : byte_index + 2 + str_length])
222
+ return raw.decode(encoding)
215
223
 
216
224
 
217
225
  def get_dword(bytearray_: Buffer, byte_index: int) -> int:
snap7/util/setters.py CHANGED
@@ -159,19 +159,20 @@ def set_real(bytearray_: Buffer, byte_index: int, real: Union[bool, str, float,
159
159
  return bytearray_
160
160
 
161
161
 
162
- def set_fstring(bytearray_: Buffer, byte_index: int, value: str, max_length: int) -> Buffer:
162
+ def set_fstring(bytearray_: Buffer, byte_index: int, value: str, max_length: int, encoding: str = "latin-1") -> Buffer:
163
163
  """Set space-padded fixed-length string value
164
164
 
165
165
  Args:
166
166
  bytearray_: buffer to write to.
167
167
  byte_index: byte index to start writing from.
168
168
  value: string to write.
169
- max_length: maximum string length, i.e. the fixed size of the string.
169
+ max_length: maximum string length in bytes, i.e. the fixed size of the string.
170
+ encoding: character encoding used to encode the string (default ``"latin-1"``).
170
171
 
171
172
  Raises:
172
173
  :obj:`TypeError`: if the `value` is not a :obj:`str`.
173
- :obj:`ValueError`: if the length of the `value` is larger than the
174
- ``max_size`` or ``value`` contains non-ASCII characters.
174
+ :obj:`ValueError`: if the encoded byte length of the `value` is larger than
175
+ ``max_length``.
175
176
 
176
177
  Examples:
177
178
  >>> data = bytearray(20)
@@ -179,38 +180,34 @@ def set_fstring(bytearray_: Buffer, byte_index: int, value: str, max_length: int
179
180
  >>> data
180
181
  bytearray(b'hello world \x00\x00\x00\x00\x00')
181
182
  """
182
- if not value.isascii():
183
- raise ValueError("Value contains non-ascii values.")
184
- # FAIL HARD WHEN trying to write too much data into PLC
185
- size = len(value)
183
+ encoded = value.encode(encoding)
184
+ size = len(encoded)
186
185
  if size > max_length:
187
- raise ValueError(f"size {size} > max_length {max_length} {value}")
186
+ raise ValueError(f"encoded byte size {size} > max_length {max_length} {value}")
188
187
 
189
- # fill array with chr integers
190
- for i, c in enumerate(value):
191
- bytearray_[byte_index + i] = ord(c)
188
+ bytearray_[byte_index : byte_index + size] = encoded
192
189
 
193
- # fill the rest with empty space
194
- for r in range(len(value), max_length):
190
+ # fill the rest with spaces
191
+ for r in range(size, max_length):
195
192
  bytearray_[byte_index + r] = ord(" ")
196
193
 
197
194
  return bytearray_
198
195
 
199
196
 
200
- def set_string(bytearray_: Buffer, byte_index: int, value: str, max_size: int = 254) -> Buffer:
197
+ def set_string(bytearray_: Buffer, byte_index: int, value: str, max_size: int = 254, encoding: str = "latin-1") -> Buffer:
201
198
  """Set string value
202
199
 
203
200
  Args:
204
201
  bytearray_: buffer to write to.
205
202
  byte_index: byte index to start writing from.
206
203
  value: string to write.
207
- max_size: maximum possible string size, max. 254 as default.
204
+ max_size: maximum possible string size in bytes, max. 254 as default.
205
+ encoding: character encoding used to encode the string (default ``"latin-1"``).
208
206
 
209
207
  Raises:
210
208
  :obj:`TypeError`: if the `value` is not a :obj:`str`.
211
- :obj:`ValueError`: if the length of the `value` is larger than the
212
- ``max_size``, or ``max_size`` is greater than 254, or ``value``
213
- contains characters with ordinal > 255.
209
+ :obj:`ValueError`: if the encoded byte length of the `value` is larger than the
210
+ ``max_size``, or ``max_size`` is greater than 254.
214
211
 
215
212
  Examples:
216
213
  >>> from snap7.util import set_string
@@ -225,29 +222,21 @@ def set_string(bytearray_: Buffer, byte_index: int, value: str, max_size: int =
225
222
  if max_size > 254:
226
223
  raise ValueError(f"max_size: {max_size} > max. allowed 254 chars")
227
224
 
228
- if any(ord(char) < 0 or ord(char) > 255 for char in value):
229
- raise ValueError(
230
- "Value contains ascii values > 255, which is not compatible with PLC Type STRING. "
231
- "Check encoding of value or try set_wstring()."
232
- )
233
-
234
- size = len(value)
235
- # FAIL HARD WHEN trying to write too much data into PLC
225
+ encoded = value.encode(encoding)
226
+ size = len(encoded)
236
227
  if size > max_size:
237
- raise ValueError(f"size {size} > max_size {max_size} {value}")
228
+ raise ValueError(f"encoded byte size {size} > max_size {max_size} {value}")
238
229
 
239
230
  # set max string size
240
231
  bytearray_[byte_index] = max_size
241
232
 
242
- # set len count on first position
243
- bytearray_[byte_index + 1] = len(value)
233
+ # set actual byte length
234
+ bytearray_[byte_index + 1] = size
244
235
 
245
- # fill array with chr integers
246
- for i, c in enumerate(value):
247
- bytearray_[byte_index + 2 + i] = ord(c)
236
+ bytearray_[byte_index + 2 : byte_index + 2 + size] = encoded
248
237
 
249
- # fill the rest with empty space
250
- for r in range(len(value), bytearray_[byte_index]):
238
+ # fill the rest with spaces
239
+ for r in range(size, bytearray_[byte_index]):
251
240
  bytearray_[byte_index + 2 + r] = ord(" ")
252
241
 
253
242
  return bytearray_