python-snap7 3.1.0__tar.gz → 3.1.2__tar.gz

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.
Files changed (64) hide show
  1. {python_snap7-3.1.0/python_snap7.egg-info → python_snap7-3.1.2}/PKG-INFO +2 -2
  2. {python_snap7-3.1.0 → python_snap7-3.1.2}/pyproject.toml +2 -2
  3. {python_snap7-3.1.0 → python_snap7-3.1.2/python_snap7.egg-info}/PKG-INFO +2 -2
  4. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/async_client.py +16 -4
  5. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/client.py +43 -18
  6. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/client_base.py +8 -2
  7. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/connection.py +11 -3
  8. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/server/__init__.py +187 -62
  9. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/util/getters.py +16 -8
  10. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/util/setters.py +24 -35
  11. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_async_client.py +51 -2
  12. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_client.py +12 -1
  13. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_reconnect.py +22 -1
  14. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_server.py +110 -3
  15. {python_snap7-3.1.0 → python_snap7-3.1.2}/LICENSE +0 -0
  16. {python_snap7-3.1.0 → python_snap7-3.1.2}/MANIFEST.in +0 -0
  17. {python_snap7-3.1.0 → python_snap7-3.1.2}/README.rst +0 -0
  18. {python_snap7-3.1.0 → python_snap7-3.1.2}/python_snap7.egg-info/SOURCES.txt +0 -0
  19. {python_snap7-3.1.0 → python_snap7-3.1.2}/python_snap7.egg-info/dependency_links.txt +0 -0
  20. {python_snap7-3.1.0 → python_snap7-3.1.2}/python_snap7.egg-info/entry_points.txt +0 -0
  21. {python_snap7-3.1.0 → python_snap7-3.1.2}/python_snap7.egg-info/requires.txt +0 -0
  22. {python_snap7-3.1.0 → python_snap7-3.1.2}/python_snap7.egg-info/top_level.txt +0 -0
  23. {python_snap7-3.1.0 → python_snap7-3.1.2}/setup.cfg +0 -0
  24. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/__init__.py +0 -0
  25. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/cli.py +0 -0
  26. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/datatypes.py +0 -0
  27. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/demo.py +0 -0
  28. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/discovery.py +0 -0
  29. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/error.py +0 -0
  30. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/log.py +0 -0
  31. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/logo.py +0 -0
  32. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/optimizer.py +0 -0
  33. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/partner.py +0 -0
  34. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/py.typed +0 -0
  35. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/s7protocol.py +0 -0
  36. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/server/__main__.py +0 -0
  37. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/szl.py +0 -0
  38. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/tags.py +0 -0
  39. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/type.py +0 -0
  40. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/util/__init__.py +0 -0
  41. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/util/db.py +0 -0
  42. {python_snap7-3.1.0 → python_snap7-3.1.2}/snap7/util/symbols.py +0 -0
  43. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_api_surface.py +0 -0
  44. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_behavioral_compatibility.py +0 -0
  45. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_cli.py +0 -0
  46. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_client_e2e.py +0 -0
  47. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_datatypes.py +0 -0
  48. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_demo.py +0 -0
  49. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_discovery.py +0 -0
  50. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_finalize.py +0 -0
  51. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_hypothesis.py +0 -0
  52. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_logging.py +0 -0
  53. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_logo_client.py +0 -0
  54. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_mainloop.py +0 -0
  55. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_multipacket.py +0 -0
  56. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_optimizer.py +0 -0
  57. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_partner.py +0 -0
  58. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_property.py +0 -0
  59. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_routing.py +0 -0
  60. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_s7protocol.py +0 -0
  61. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_stress.py +0 -0
  62. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_tags.py +0 -0
  63. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_typed_access.py +0 -0
  64. {python_snap7-3.1.0 → python_snap7-3.1.2}/tests/test_util.py +0 -0
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-snap7"
7
- version = "3.1.0"
7
+ version = "3.1.2"
8
8
  description = "Pure Python S7 communication library for Siemens PLCs"
9
9
  readme = "README.rst"
10
10
  authors = [
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.13",
24
24
  "Programming Language :: Python :: 3.14",
25
25
  ]
26
- license = {text = "MIT"}
26
+ license = "MIT"
27
27
  requires-python = ">=3.10"
28
28
  keywords = ["snap7", "s7", "siemens", "plc"]
29
29
 
@@ -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
@@ -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
 
@@ -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
 
@@ -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."""
@@ -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}")