python-snap7 3.1.0__py3-none-any.whl → 3.1.2__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.2
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.2.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=kc4OT8ZcBpTg3DBd9qEfi3yFlPBOX_EiEVkJV9ZAlRU,44560
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=4l5qeEO1LMSLU77c9ggo6FSK0NH_qs--Kdt7bcrGDv8,106197
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.2.dist-info/METADATA,sha256=di-S3uFMbV8bIQXkuriHZcQcrDnAUHvzVpxSBblcSJw,4663
29
+ python_snap7-3.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ python_snap7-3.1.2.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
31
+ python_snap7-3.1.2.dist-info/top_level.txt,sha256=VpFXm1w5_73qgJEIN_5OzQWINHyYJBtNoOW4DMoizgw,6
32
+ python_snap7-3.1.2.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
@@ -239,9 +239,17 @@ class AsyncISOTCPConnection:
239
239
  param_data = data[offset + 2 : offset + 2 + param_len]
240
240
  if param_code == self.COTP_PARAM_PDU_SIZE:
241
241
  if param_len == 1:
242
- self.pdu_size = 1 << param_data[0]
242
+ exponent = param_data[0]
243
+ if 7 <= exponent <= 13:
244
+ self.pdu_size = 1 << exponent
245
+ else:
246
+ logger.warning(f"Invalid COTP PDU size exponent {exponent}, using default")
243
247
  elif param_len == 2:
244
- self.pdu_size = struct.unpack(">H", param_data)[0]
248
+ raw = struct.unpack(">H", param_data)[0]
249
+ if 128 <= raw <= 8192:
250
+ self.pdu_size = raw
251
+ else:
252
+ logger.warning(f"Invalid COTP PDU size {raw}, using default")
245
253
  logger.debug(f"Negotiated PDU size: {self.pdu_size}")
246
254
  offset += 2 + param_len
247
255
 
@@ -389,7 +397,7 @@ class AsyncClient(ClientMixin):
389
397
  self.slot = slot
390
398
  self._params[Parameter.RemotePort] = tcp_port
391
399
 
392
- self.remote_tsap = 0x0100 | (rack << 5) | slot
400
+ self.remote_tsap = (self.connection_type << 8) | (rack << 5) | slot
393
401
 
394
402
  try:
395
403
  start_time = time.time()
@@ -1159,7 +1167,11 @@ class AsyncClient(ClientMixin):
1159
1167
  if response.get("parameters"):
1160
1168
  params = response["parameters"]
1161
1169
  if "pdu_length" in params:
1162
- self.pdu_length = params["pdu_length"]
1170
+ negotiated = params["pdu_length"]
1171
+ if negotiated < 64:
1172
+ logger.warning(f"Server negotiated implausible PDU length {negotiated}, using minimum 240")
1173
+ negotiated = 240
1174
+ self.pdu_length = negotiated
1163
1175
  self._params[Parameter.PDURequest] = self.pdu_length
1164
1176
  logger.info(f"Negotiated PDU length: {self.pdu_length}")
1165
1177
 
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
@@ -53,14 +53,18 @@ class Server:
53
53
  >>> server.stop()
54
54
  """
55
55
 
56
- def __init__(self, log: bool = True, **kwargs: object) -> None:
56
+ def __init__(self, log: bool = True, max_clients: int = 64, **kwargs: object) -> None:
57
57
  """
58
58
  Initialize S7 server.
59
59
 
60
60
  Args:
61
61
  log: Enable event logging
62
+ max_clients: Maximum number of simultaneous client connections
62
63
  **kwargs: Ignored. Kept for backwards compatibility.
63
64
  """
65
+ if max_clients < 1:
66
+ raise ValueError("max_clients must be at least 1")
67
+
64
68
  self.server_socket: Optional[socket.socket] = None
65
69
  self.server_thread: Optional[threading.Thread] = None
66
70
  self.running = False
@@ -86,6 +90,7 @@ class Server:
86
90
  # Client connections
87
91
  self.clients: List[threading.Thread] = []
88
92
  self.client_lock = threading.Lock()
93
+ self.max_clients = max_clients
89
94
 
90
95
  # Event queue for pick_event
91
96
  self._event_queue: List[SrvEvent] = []
@@ -177,9 +182,13 @@ class Server:
177
182
 
178
183
  # Close all client connections
179
184
  with self.client_lock:
180
- for client_thread in self.clients[:]:
181
- if client_thread.is_alive():
182
- client_thread.join(timeout=1.0)
185
+ client_threads = self.clients[:]
186
+
187
+ for client_thread in client_threads:
188
+ if client_thread.is_alive():
189
+ client_thread.join(timeout=1.0)
190
+
191
+ with self.client_lock:
183
192
  self.clients.clear()
184
193
  self.client_count = 0
185
194
 
@@ -448,6 +457,10 @@ class Server:
448
457
  """
449
458
  if param == Parameter.LocalPort:
450
459
  self.port = value
460
+ elif param == Parameter.MaxClients:
461
+ if value < 1:
462
+ raise ValueError("MaxClients must be at least 1")
463
+ self.max_clients = value
451
464
  logger.debug(f"Set parameter {param} = {value}")
452
465
  return 0
453
466
 
@@ -481,7 +494,7 @@ class Server:
481
494
  param_values = {
482
495
  Parameter.LocalPort: self.port,
483
496
  Parameter.WorkInterval: 100,
484
- Parameter.MaxClients: 1024,
497
+ Parameter.MaxClients: self.max_clients,
485
498
  }
486
499
  return param_values.get(param, 0)
487
500
 
@@ -579,6 +592,10 @@ class Server:
579
592
  client_thread = threading.Thread(target=self._handle_client, args=(client_socket, address), daemon=True)
580
593
 
581
594
  with self.client_lock:
595
+ if self.client_count >= self.max_clients:
596
+ logger.warning(f"Rejecting client {address}: maximum of {self.max_clients} clients reached")
597
+ client_socket.close()
598
+ continue
582
599
  self.clients.append(client_thread)
583
600
  self.client_count += 1
584
601
 
@@ -645,6 +662,11 @@ class Server:
645
662
  self.clients.remove(current_thread)
646
663
  self.client_count = max(0, self.client_count - 1)
647
664
 
665
+ if hasattr(self, "_download_contexts"):
666
+ self._download_contexts.pop(address, None)
667
+ if hasattr(self, "_upload_contexts"):
668
+ self._upload_contexts.pop(address, None)
669
+
648
670
  logger.info(f"Client {address} handler finished")
649
671
 
650
672
  def _process_request(self, request_data: bytes, client_address: Tuple[str, int]) -> Optional[bytes]:
@@ -816,9 +838,9 @@ class Server:
816
838
  word_len = addr.get("word_len", S7WordLen.BYTE)
817
839
 
818
840
  # Convert count to bytes
819
- if word_len in (S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD):
841
+ if word_len in (S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD, S7WordLen.INT):
820
842
  byte_count = count * 2
821
- elif word_len in (S7WordLen.DWORD, S7WordLen.REAL):
843
+ elif word_len in (S7WordLen.DWORD, S7WordLen.REAL, S7WordLen.DINT):
822
844
  byte_count = count * 4
823
845
  elif word_len == S7WordLen.BIT:
824
846
  byte_count = 1
@@ -876,9 +898,9 @@ class Server:
876
898
  word_len = addr_spec.get("word_len", S7WordLen.BYTE)
877
899
 
878
900
  # Convert count to bytes based on word length
879
- if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD]:
901
+ if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD, S7WordLen.INT]:
880
902
  byte_count = count * 2 # 16-bit items
881
- elif word_len in [S7WordLen.DWORD, S7WordLen.REAL]:
903
+ elif word_len in [S7WordLen.DWORD, S7WordLen.REAL, S7WordLen.DINT]:
882
904
  byte_count = count * 4 # 32-bit items
883
905
  elif word_len == S7WordLen.BIT:
884
906
  byte_count = 1 # Single bit needs at least 1 byte
@@ -2301,22 +2323,55 @@ class Server:
2301
2323
  try:
2302
2324
  raw_params = request.get("raw_parameters", b"")
2303
2325
 
2304
- # Parse block address from parameters
2305
- block_type = 0x41 # Default to DB
2306
- block_num = 1
2326
+ # Parse and validate the target before allocating transfer state.
2327
+ if len(raw_params) < 6:
2328
+ return self._build_error_response(request, 0x8104)
2307
2329
 
2308
- if len(raw_params) >= 6:
2309
- addr_len = raw_params[5]
2310
- if len(raw_params) >= 6 + addr_len:
2311
- block_addr = raw_params[6 : 6 + addr_len]
2312
- try:
2313
- block_type = int(block_addr[0:2], 16)
2314
- block_num = int(block_addr[2:7])
2315
- except (ValueError, IndexError):
2316
- pass
2330
+ addr_len = raw_params[4]
2331
+ address_end = 5 + addr_len
2332
+ if addr_len < 7 or len(raw_params) < address_end:
2333
+ return self._build_error_response(request, 0x8104)
2334
+
2335
+ block_addr = raw_params[5:address_end]
2336
+ try:
2337
+ block_type = int(block_addr[0:2], 16)
2338
+ block_num = int(block_addr[2:7])
2339
+ except (ValueError, IndexError):
2340
+ return self._build_error_response(request, 0x8104)
2317
2341
 
2318
2342
  logger.info(f"Request download from {client_address}: type={block_type:#02x}, num={block_num}")
2319
2343
 
2344
+ if block_type != 0x41: # Only DB downloads are implemented.
2345
+ return self._build_error_response(request, 0x8104)
2346
+
2347
+ area_key = (S7Area.DB, block_num)
2348
+ if area_key not in self.memory_areas:
2349
+ logger.warning(f"Download rejected: area DB{block_num} not registered")
2350
+ return self._build_error_response(request, 0x8104)
2351
+
2352
+ area_capacity = len(self.memory_areas[area_key])
2353
+
2354
+ # The request includes an ASCII block length. Reject an oversized
2355
+ # transfer early, while still enforcing the limit on every chunk.
2356
+ if len(raw_params) <= address_end:
2357
+ return self._build_error_response(request, 0x8104)
2358
+
2359
+ length_len = raw_params[address_end]
2360
+ length_start = address_end + 1
2361
+ length_end = length_start + length_len
2362
+ if not length_len or len(raw_params) < length_end:
2363
+ return self._build_error_response(request, 0x8104)
2364
+
2365
+ try:
2366
+ declared_size = int(raw_params[length_start:length_end])
2367
+ except ValueError:
2368
+ return self._build_error_response(request, 0x8104)
2369
+ if declared_size < 0 or declared_size > area_capacity:
2370
+ logger.warning(
2371
+ f"Download rejected: declared size {declared_size} outside DB{block_num} capacity 0..{area_capacity}"
2372
+ )
2373
+ return self._build_error_response(request, 0x8104)
2374
+
2320
2375
  # Store download context
2321
2376
  if not hasattr(self, "_download_contexts"):
2322
2377
  self._download_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {}
@@ -2324,6 +2379,7 @@ class Server:
2324
2379
  "block_type": block_type,
2325
2380
  "block_num": block_num,
2326
2381
  "data": bytearray(),
2382
+ "max_size": declared_size,
2327
2383
  }
2328
2384
 
2329
2385
  # Build response acknowledging download
@@ -2370,7 +2426,14 @@ class Server:
2370
2426
  data_info = request.get("data", {})
2371
2427
  block_data = data_info.get("data", b"")
2372
2428
 
2373
- # Append data to context
2429
+ # Bound accumulated data by the pre-registered target area. The
2430
+ # final storage copy is not the only allocation: this bytearray is
2431
+ # grown for every DOWNLOAD_BLOCK request.
2432
+ if len(ctx["data"]) + len(block_data) > ctx["max_size"]:
2433
+ logger.warning(f"Download rejected: data exceeds target capacity {ctx['max_size']}")
2434
+ del self._download_contexts[client_address]
2435
+ return self._build_error_response(request, 0x8104)
2436
+
2374
2437
  ctx["data"].extend(block_data)
2375
2438
 
2376
2439
  logger.info(f"Download block from {client_address}: received {len(block_data)} bytes")
@@ -2418,7 +2481,9 @@ class Server:
2418
2481
  block_num = ctx["block_num"]
2419
2482
  block_data = ctx["data"]
2420
2483
 
2421
- # Store block data
2484
+ # Store block data — only into pre-registered areas.
2485
+ # Reject downloads to unknown areas to prevent unbounded memory
2486
+ # allocation from attacker-controlled block numbers.
2422
2487
  if block_type == 0x41: # DB
2423
2488
  area_key = (S7Area.DB, block_num)
2424
2489
  if area_key in self.memory_areas:
@@ -2428,9 +2493,9 @@ class Server:
2428
2493
  copy_len = min(len(block_data), len(existing_area))
2429
2494
  existing_area[0:copy_len] = block_data[0:copy_len]
2430
2495
  else:
2431
- # Create new area
2432
- self.memory_areas[area_key] = bytearray(block_data)
2433
- self.area_locks[area_key] = threading.Lock()
2496
+ logger.warning(f"Download rejected: area DB{block_num} not registered")
2497
+ del self._download_contexts[client_address]
2498
+ return self._build_error_response(request, 0x8104)
2434
2499
 
2435
2500
  logger.info(f"Download ended from {client_address}: stored {len(block_data)} bytes to {block_type:#02x}:{block_num}")
2436
2501
 
@@ -2486,6 +2551,9 @@ class Server:
2486
2551
  class ServerISOConnection:
2487
2552
  """ISO connection wrapper for server-side communication."""
2488
2553
 
2554
+ RECEIVE_DEADLINE = 5.0
2555
+ MAX_REASSEMBLED_SIZE = 1024 * 1024
2556
+
2489
2557
  # COTP PDU types
2490
2558
  COTP_CR = 0xE0 # Connection Request
2491
2559
  COTP_CC = 0xD0 # Connection Confirm
@@ -2493,6 +2561,9 @@ class ServerISOConnection:
2493
2561
  COTP_DC = 0xC0 # Disconnect Confirm
2494
2562
  COTP_DT = 0xF0 # Data Transfer
2495
2563
 
2564
+ # COTP parameter code for TPDU size (ISO 8073)
2565
+ COTP_PARAM_PDU_SIZE = 0xC0
2566
+
2496
2567
  def __init__(self, client_socket: socket.socket):
2497
2568
  """Initialize server ISO connection."""
2498
2569
  self.socket = client_socket
@@ -2500,19 +2571,25 @@ class ServerISOConnection:
2500
2571
  self.connected = False
2501
2572
  self.src_ref = 0x0001 # Server reference
2502
2573
  self.dst_ref = 0x0000 # Client reference (assigned during handshake)
2574
+ self.tpdu_size = 0x0A # Default: 1024 bytes (2^10)
2503
2575
 
2504
2576
  def accept_connection(self) -> bool:
2505
2577
  """Accept ISO connection from client."""
2506
2578
  try:
2579
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2580
+
2507
2581
  # Receive COTP Connection Request
2508
- tpkt_header = self._recv_exact(4)
2582
+ tpkt_header = self._recv_exact(4, deadline)
2509
2583
  version, reserved, length = struct.unpack(">BBH", tpkt_header)
2510
2584
 
2511
2585
  if version != 3:
2512
2586
  logger.error(f"Invalid TPKT version: {version}")
2513
2587
  return False
2588
+ if length < 4:
2589
+ logger.error(f"Invalid TPKT length: {length}")
2590
+ return False
2514
2591
 
2515
- payload = self._recv_exact(length - 4)
2592
+ payload = self._recv_exact(length - 4, deadline)
2516
2593
 
2517
2594
  # Parse COTP Connection Request
2518
2595
  if not self._parse_cotp_cr(payload):
@@ -2532,25 +2609,46 @@ class ServerISOConnection:
2532
2609
  return False
2533
2610
 
2534
2611
  def receive_data(self) -> bytes:
2535
- """Receive data from client."""
2536
- # Receive TPKT header (4 bytes)
2537
- tpkt_header = self._recv_exact(4)
2612
+ """Receive data from client.
2613
+
2614
+ Reassembles COTP DT fragments by reading frames until the EOT
2615
+ bit (bit 7 of the third COTP header byte) is set, then returns
2616
+ the concatenated payload.
2617
+ """
2618
+ fragments: list[bytes] = []
2619
+ total_size = 0
2620
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2621
+ while True:
2622
+ tpkt_header = self._recv_exact(4, deadline)
2623
+ version, reserved, length = struct.unpack(">BBH", tpkt_header)
2624
+
2625
+ if version != 3:
2626
+ raise S7ConnectionError(f"Invalid TPKT version: {version}")
2538
2627
 
2539
- # Parse TPKT header
2540
- version, reserved, length = struct.unpack(">BBH", tpkt_header)
2628
+ remaining = length - 4
2629
+ if remaining <= 0:
2630
+ raise S7ConnectionError("Invalid TPKT length")
2541
2631
 
2542
- if version != 3:
2543
- raise S7ConnectionError(f"Invalid TPKT version: {version}")
2632
+ payload = self._recv_exact(remaining, deadline)
2544
2633
 
2545
- # Receive remaining data
2546
- remaining = length - 4
2547
- if remaining <= 0:
2548
- raise S7ConnectionError("Invalid TPKT length")
2634
+ if len(payload) < 3:
2635
+ raise S7ConnectionError("Invalid COTP DT: too short")
2549
2636
 
2550
- payload = self._recv_exact(remaining)
2637
+ pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
2551
2638
 
2552
- # Parse COTP header and extract data
2553
- return self._parse_cotp_data(payload)
2639
+ if pdu_type != self.COTP_DT:
2640
+ raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
2641
+
2642
+ fragment = payload[3:]
2643
+ total_size += len(fragment)
2644
+ if total_size > self.MAX_REASSEMBLED_SIZE:
2645
+ raise S7ConnectionError(f"Reassembled COTP request exceeds {self.MAX_REASSEMBLED_SIZE} bytes")
2646
+ fragments.append(fragment)
2647
+
2648
+ if eot_num & 0x80:
2649
+ break
2650
+
2651
+ return b"".join(fragments)
2554
2652
 
2555
2653
  def send_data(self, data: bytes) -> None:
2556
2654
  """Send data to client."""
@@ -2578,29 +2676,68 @@ class ServerISOConnection:
2578
2676
  # Store client reference
2579
2677
  self.dst_ref = src_ref
2580
2678
 
2679
+ # Parse variable parameters for TPDU size
2680
+ offset = 7
2681
+ while offset + 2 <= len(data):
2682
+ param_code = data[offset]
2683
+ param_len = data[offset + 1]
2684
+ if offset + 2 + param_len > len(data):
2685
+ break
2686
+ if param_code == self.COTP_PARAM_PDU_SIZE and param_len == 1:
2687
+ exponent = data[offset + 2]
2688
+ if 7 <= exponent <= 13:
2689
+ self.tpdu_size = exponent
2690
+ logger.debug(f"Client requested TPDU size 2^{exponent} = {1 << exponent}")
2691
+ offset += 2 + param_len
2692
+
2581
2693
  logger.debug(f"Received COTP CR from client ref {src_ref}")
2582
2694
  return True
2583
2695
 
2584
2696
  def _build_cotp_cc(self) -> bytes:
2585
- """Build COTP Connection Confirm."""
2586
- # Basic COTP CC
2697
+ """Build COTP Connection Confirm.
2698
+
2699
+ Includes the TPDU size parameter (0xC0) so clients know the
2700
+ negotiated maximum segment size and don't fall back to the
2701
+ ISO 8073 class-0 default of 128 bytes.
2702
+ """
2703
+ pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
2704
+ pdu_length = 6 + len(pdu_size_param)
2587
2705
  base_pdu = struct.pack(
2588
- ">BBHHB",
2589
- 6, # PDU length
2706
+ ">BBBHHB",
2707
+ pdu_length, # PDU length
2590
2708
  self.COTP_CC, # PDU type
2709
+ 0x00, # Reserved / CDT
2591
2710
  self.dst_ref, # Destination reference (client's source ref)
2592
2711
  self.src_ref, # Source reference (our ref)
2593
2712
  0x00, # Class/option
2594
2713
  )
2595
2714
 
2596
- return struct.pack(">B", 6) + base_pdu[1:]
2715
+ return base_pdu + pdu_size_param
2716
+
2717
+ def _recv_exact(self, size: int, deadline: float | None = None) -> bytes:
2718
+ """Receive exactly the specified bytes within one absolute deadline."""
2719
+ if size < 0:
2720
+ raise S7ConnectionError(f"Invalid receive size: {size}")
2721
+
2722
+ if deadline is None:
2723
+ deadline = time.monotonic() + self.RECEIVE_DEADLINE
2597
2724
 
2598
- def _recv_exact(self, size: int) -> bytes:
2599
- """Receive exactly the specified number of bytes."""
2600
2725
  data = bytearray()
2601
2726
 
2602
2727
  while len(data) < size:
2603
- chunk = self.socket.recv(size - len(data))
2728
+ remaining_time = deadline - time.monotonic()
2729
+ if remaining_time <= 0:
2730
+ if data:
2731
+ raise S7ConnectionError("Receive deadline exceeded after partial frame")
2732
+ raise TimeoutError("Receive deadline exceeded")
2733
+
2734
+ self.socket.settimeout(remaining_time)
2735
+ try:
2736
+ chunk = self.socket.recv(size - len(data))
2737
+ except TimeoutError as e:
2738
+ if data:
2739
+ raise S7ConnectionError("Receive deadline exceeded after partial frame") from e
2740
+ raise
2604
2741
  if not chunk:
2605
2742
  raise ConnectionResetError("Connection closed by peer")
2606
2743
  data.extend(chunk)
@@ -2617,18 +2754,6 @@ class ServerISOConnection:
2617
2754
  header = struct.pack(">BBB", 2, self.COTP_DT, 0x80)
2618
2755
  return header + data
2619
2756
 
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
2757
 
2633
2758
  def mainloop(tcp_port: int = 1102, init_standard_values: bool = False) -> None:
2634
2759
  """
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_