python-snap7 3.1.1__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.1.dist-info → python_snap7-3.1.2.dist-info}/METADATA +1 -1
- {python_snap7-3.1.1.dist-info → python_snap7-3.1.2.dist-info}/RECORD +8 -8
- snap7/async_client.py +10 -2
- snap7/server/__init__.py +121 -26
- {python_snap7-3.1.1.dist-info → python_snap7-3.1.2.dist-info}/WHEEL +0 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.1.2.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.1.2.dist-info}/licenses/LICENSE +0 -0
- {python_snap7-3.1.1.dist-info → python_snap7-3.1.2.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
snap7/client.py,sha256=wOy7-BzAIl9atE-uYWxEDvUo_HHrhCsRj8vhdMbhQVg,97264
|
|
6
6
|
snap7/client_base.py,sha256=0e1mAU6qsskzMBSYPjccSClznf4jpTNzYGlvGpCC0Cs,7716
|
|
@@ -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
25
|
snap7/util/getters.py,sha256=g-z6jve3JEQchsXzeS0ajn56cpVepq1nhbRpShAXD9I,24896
|
|
26
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
|
|
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]:
|
|
@@ -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")
|
|
@@ -2488,6 +2551,9 @@ class Server:
|
|
|
2488
2551
|
class ServerISOConnection:
|
|
2489
2552
|
"""ISO connection wrapper for server-side communication."""
|
|
2490
2553
|
|
|
2554
|
+
RECEIVE_DEADLINE = 5.0
|
|
2555
|
+
MAX_REASSEMBLED_SIZE = 1024 * 1024
|
|
2556
|
+
|
|
2491
2557
|
# COTP PDU types
|
|
2492
2558
|
COTP_CR = 0xE0 # Connection Request
|
|
2493
2559
|
COTP_CC = 0xD0 # Connection Confirm
|
|
@@ -2510,15 +2576,20 @@ class ServerISOConnection:
|
|
|
2510
2576
|
def accept_connection(self) -> bool:
|
|
2511
2577
|
"""Accept ISO connection from client."""
|
|
2512
2578
|
try:
|
|
2579
|
+
deadline = time.monotonic() + self.RECEIVE_DEADLINE
|
|
2580
|
+
|
|
2513
2581
|
# Receive COTP Connection Request
|
|
2514
|
-
tpkt_header = self._recv_exact(4)
|
|
2582
|
+
tpkt_header = self._recv_exact(4, deadline)
|
|
2515
2583
|
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
2516
2584
|
|
|
2517
2585
|
if version != 3:
|
|
2518
2586
|
logger.error(f"Invalid TPKT version: {version}")
|
|
2519
2587
|
return False
|
|
2588
|
+
if length < 4:
|
|
2589
|
+
logger.error(f"Invalid TPKT length: {length}")
|
|
2590
|
+
return False
|
|
2520
2591
|
|
|
2521
|
-
payload = self._recv_exact(length - 4)
|
|
2592
|
+
payload = self._recv_exact(length - 4, deadline)
|
|
2522
2593
|
|
|
2523
2594
|
# Parse COTP Connection Request
|
|
2524
2595
|
if not self._parse_cotp_cr(payload):
|
|
@@ -2545,8 +2616,10 @@ class ServerISOConnection:
|
|
|
2545
2616
|
the concatenated payload.
|
|
2546
2617
|
"""
|
|
2547
2618
|
fragments: list[bytes] = []
|
|
2619
|
+
total_size = 0
|
|
2620
|
+
deadline = time.monotonic() + self.RECEIVE_DEADLINE
|
|
2548
2621
|
while True:
|
|
2549
|
-
tpkt_header = self._recv_exact(4)
|
|
2622
|
+
tpkt_header = self._recv_exact(4, deadline)
|
|
2550
2623
|
version, reserved, length = struct.unpack(">BBH", tpkt_header)
|
|
2551
2624
|
|
|
2552
2625
|
if version != 3:
|
|
@@ -2556,7 +2629,7 @@ class ServerISOConnection:
|
|
|
2556
2629
|
if remaining <= 0:
|
|
2557
2630
|
raise S7ConnectionError("Invalid TPKT length")
|
|
2558
2631
|
|
|
2559
|
-
payload = self._recv_exact(remaining)
|
|
2632
|
+
payload = self._recv_exact(remaining, deadline)
|
|
2560
2633
|
|
|
2561
2634
|
if len(payload) < 3:
|
|
2562
2635
|
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
@@ -2566,7 +2639,11 @@ class ServerISOConnection:
|
|
|
2566
2639
|
if pdu_type != self.COTP_DT:
|
|
2567
2640
|
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
2568
2641
|
|
|
2569
|
-
|
|
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)
|
|
2570
2647
|
|
|
2571
2648
|
if eot_num & 0x80:
|
|
2572
2649
|
break
|
|
@@ -2637,12 +2714,30 @@ class ServerISOConnection:
|
|
|
2637
2714
|
|
|
2638
2715
|
return base_pdu + pdu_size_param
|
|
2639
2716
|
|
|
2640
|
-
def _recv_exact(self, size: int) -> bytes:
|
|
2641
|
-
"""Receive exactly the specified
|
|
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
|
|
2724
|
+
|
|
2642
2725
|
data = bytearray()
|
|
2643
2726
|
|
|
2644
2727
|
while len(data) < size:
|
|
2645
|
-
|
|
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
|
|
2646
2741
|
if not chunk:
|
|
2647
2742
|
raise ConnectionResetError("Connection closed by peer")
|
|
2648
2743
|
data.extend(chunk)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|