python-snap7 3.1.0__tar.gz → 3.1.1__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.
- {python_snap7-3.1.0/python_snap7.egg-info → python_snap7-3.1.1}/PKG-INFO +2 -2
- {python_snap7-3.1.0 → python_snap7-3.1.1}/pyproject.toml +2 -2
- {python_snap7-3.1.0 → python_snap7-3.1.1/python_snap7.egg-info}/PKG-INFO +2 -2
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/async_client.py +6 -2
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/client.py +43 -18
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/client_base.py +8 -2
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/connection.py +11 -3
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/server/__init__.py +69 -39
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/util/getters.py +16 -8
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/util/setters.py +24 -35
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_reconnect.py +22 -1
- {python_snap7-3.1.0 → python_snap7-3.1.1}/LICENSE +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/MANIFEST.in +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/README.rst +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/python_snap7.egg-info/SOURCES.txt +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/python_snap7.egg-info/dependency_links.txt +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/python_snap7.egg-info/entry_points.txt +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/python_snap7.egg-info/requires.txt +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/python_snap7.egg-info/top_level.txt +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/setup.cfg +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/__init__.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/cli.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/datatypes.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/demo.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/discovery.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/error.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/log.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/logo.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/optimizer.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/partner.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/py.typed +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/s7protocol.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/server/__main__.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/szl.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/tags.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/type.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/util/__init__.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/util/db.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/snap7/util/symbols.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_api_surface.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_async_client.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_behavioral_compatibility.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_cli.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_client.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_client_e2e.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_datatypes.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_demo.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_discovery.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_finalize.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_hypothesis.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_logging.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_logo_client.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_mainloop.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_multipacket.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_optimizer.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_partner.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_property.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_routing.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_s7protocol.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_server.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_stress.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_tags.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/tests/test_typed_access.py +0 -0
- {python_snap7-3.1.0 → python_snap7-3.1.1}/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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-snap7"
|
|
7
|
-
version = "3.1.
|
|
7
|
+
version = "3.1.1"
|
|
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 =
|
|
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.
|
|
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
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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 _,
|
|
1293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}")
|
|
@@ -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
|
-
|
|
2432
|
-
self.
|
|
2433
|
-
self.
|
|
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
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
2540
|
-
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
2559
|
+
payload = self._recv_exact(remaining)
|
|
2541
2560
|
|
|
2542
|
-
|
|
2543
|
-
|
|
2561
|
+
if len(payload) < 3:
|
|
2562
|
+
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
2544
2563
|
|
|
2545
|
-
|
|
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
|
-
|
|
2566
|
+
if pdu_type != self.COTP_DT:
|
|
2567
|
+
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
2551
2568
|
|
|
2552
|
-
|
|
2553
|
-
|
|
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
|
-
|
|
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
|
-
">
|
|
2589
|
-
|
|
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
|
|
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
|
"""
|
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
175
|
-
string =
|
|
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
|
-
|
|
214
|
-
return
|
|
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:
|
|
@@ -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
|
|
174
|
-
``
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
for r in range(
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
243
|
-
bytearray_[byte_index + 1] =
|
|
233
|
+
# set actual byte length
|
|
234
|
+
bytearray_[byte_index + 1] = size
|
|
244
235
|
|
|
245
|
-
|
|
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
|
|
250
|
-
for r in range(
|
|
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_
|
|
@@ -10,7 +10,8 @@ import pytest
|
|
|
10
10
|
from snap7.client import Client
|
|
11
11
|
from snap7.error import S7ConnectionError
|
|
12
12
|
from snap7.server import Server
|
|
13
|
-
from snap7.
|
|
13
|
+
from snap7.tags import Tag
|
|
14
|
+
from snap7.type import Area, SrvArea
|
|
14
15
|
|
|
15
16
|
logging.basicConfig(level=logging.WARNING)
|
|
16
17
|
|
|
@@ -152,6 +153,26 @@ class TestAutoReconnect(unittest.TestCase):
|
|
|
152
153
|
finally:
|
|
153
154
|
client.disconnect()
|
|
154
155
|
|
|
156
|
+
def test_reconnect_on_multi_block_read(self) -> None:
|
|
157
|
+
"""Client reconnects when connection drops during multi-block sequential read."""
|
|
158
|
+
client = Client(auto_reconnect=True, max_retries=3, retry_delay=0.1)
|
|
159
|
+
client.connect(ip, rack, slot, tcpport)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
tags = [Tag(Area.DB, db_number, i * 2, "INT") for i in range(20)]
|
|
163
|
+
data = client.read_tags(tags)
|
|
164
|
+
assert len(data) == 20
|
|
165
|
+
|
|
166
|
+
if client.connection and client.connection.socket:
|
|
167
|
+
client.connection.socket.close()
|
|
168
|
+
client.connected = False
|
|
169
|
+
|
|
170
|
+
data = client.read_tags(tags)
|
|
171
|
+
assert len(data) == 20
|
|
172
|
+
assert client.connected is True
|
|
173
|
+
finally:
|
|
174
|
+
client.disconnect()
|
|
175
|
+
|
|
155
176
|
def test_no_reconnect_when_disabled(self) -> None:
|
|
156
177
|
"""Without auto_reconnect, connection errors propagate immediately."""
|
|
157
178
|
client = Client(auto_reconnect=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|