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.
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/METADATA +2 -2
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/RECORD +13 -13
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/WHEEL +1 -1
- snap7/async_client.py +16 -4
- snap7/client.py +43 -18
- snap7/client_base.py +8 -2
- snap7/connection.py +11 -3
- snap7/server/__init__.py +187 -62
- snap7/util/getters.py +16 -8
- snap7/util/setters.py +24 -35
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/licenses/LICENSE +0 -0
- {python_snap7-3.1.0.dist-info → python_snap7-3.1.2.dist-info}/top_level.txt +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.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.
|
|
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=
|
|
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=
|
|
6
|
-
snap7/client_base.py,sha256=
|
|
7
|
-
snap7/connection.py,sha256=
|
|
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=
|
|
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
|
|
26
|
-
snap7/util/setters.py,sha256=
|
|
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.
|
|
29
|
-
python_snap7-3.1.
|
|
30
|
-
python_snap7-3.1.
|
|
31
|
-
python_snap7-3.1.
|
|
32
|
-
python_snap7-3.1.
|
|
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,,
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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}")
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
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
|
|
2305
|
-
|
|
2306
|
-
|
|
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
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2432
|
-
self.
|
|
2433
|
-
self.
|
|
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
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
2540
|
-
|
|
2628
|
+
remaining = length - 4
|
|
2629
|
+
if remaining <= 0:
|
|
2630
|
+
raise S7ConnectionError("Invalid TPKT length")
|
|
2541
2631
|
|
|
2542
|
-
|
|
2543
|
-
raise S7ConnectionError(f"Invalid TPKT version: {version}")
|
|
2632
|
+
payload = self._recv_exact(remaining, deadline)
|
|
2544
2633
|
|
|
2545
|
-
|
|
2546
|
-
|
|
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
|
-
|
|
2637
|
+
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
|
|
2551
2638
|
|
|
2552
|
-
|
|
2553
|
-
|
|
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
|
-
|
|
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
|
-
">
|
|
2589
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
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
|
|
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_
|
|
File without changes
|
|
File without changes
|
|
File without changes
|