python-snap7 3.1.2__py3-none-any.whl → 3.2.1__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.2.1.dist-info/METADATA +107 -0
- python_snap7-3.2.1.dist-info/RECORD +34 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.1.dist-info}/top_level.txt +1 -0
- s7/__init__.py +52 -0
- snap7/__init__.py +15 -4
- snap7/async_client.py +231 -81
- snap7/cli.py +2 -3
- snap7/client.py +322 -128
- snap7/client_base.py +102 -26
- snap7/connection.py +21 -10
- snap7/datatypes.py +24 -3
- snap7/error.py +6 -0
- snap7/logo.py +48 -6
- snap7/ppi.py +421 -0
- snap7/rate_limiter.py +110 -0
- snap7/s7protocol.py +350 -130
- snap7/server/__init__.py +420 -310
- snap7/szl.py +106 -9
- snap7/tags.py +6 -6
- snap7/type.py +14 -1
- snap7/util/getters.py +26 -9
- snap7/util/setters.py +4 -1
- python_snap7-3.1.2.dist-info/METADATA +0 -131
- python_snap7-3.1.2.dist-info/RECORD +0 -32
- snap7/util/symbols.py +0 -547
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.1.dist-info}/WHEEL +0 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.1.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.1.2.dist-info → python_snap7-3.2.1.dist-info}/licenses/LICENSE +0 -0
snap7/async_client.py
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Native async S7 client implementation.
|
|
1
|
+
"""Asynchronous client for the classic S7 protocol.
|
|
3
2
|
|
|
4
3
|
Uses asyncio streams for non-blocking I/O with an asyncio.Lock() to serialize
|
|
5
4
|
send/receive cycles, ensuring safe concurrent use via asyncio.gather().
|
|
5
|
+
|
|
6
|
+
``s7.AsyncClient`` and ``snap7.AsyncClient`` expose this same implementation.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import asyncio
|
|
9
10
|
import logging
|
|
10
11
|
import struct
|
|
11
12
|
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from contextvars import ContextVar
|
|
12
15
|
from typing import List, Any, Optional, Tuple, Type
|
|
13
16
|
from types import TracebackType
|
|
14
17
|
from datetime import datetime
|
|
15
18
|
|
|
16
19
|
from .connection import TPDUSize
|
|
17
|
-
from .s7protocol import S7Protocol, get_return_code_description
|
|
18
|
-
from .datatypes import S7WordLen
|
|
20
|
+
from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_return_code_description
|
|
21
|
+
from .datatypes import S7DataTypes, S7WordLen
|
|
19
22
|
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
|
|
20
|
-
from .client_base import ClientMixin
|
|
23
|
+
from .client_base import ClientMixin, _instrumented
|
|
21
24
|
from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
|
|
25
|
+
from .client import _parse_force_szl
|
|
26
|
+
from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
|
|
22
27
|
from .type import (
|
|
23
28
|
Area,
|
|
24
29
|
Block,
|
|
25
30
|
BlocksList,
|
|
31
|
+
ForceEntry,
|
|
26
32
|
S7CpuInfo,
|
|
27
33
|
TS7BlockInfo,
|
|
28
34
|
S7CpInfo,
|
|
@@ -156,7 +162,7 @@ class AsyncISOTCPConnection:
|
|
|
156
162
|
raise S7ConnectionError(f"Invalid TPKT version: {version}")
|
|
157
163
|
|
|
158
164
|
remaining = length - 4
|
|
159
|
-
if
|
|
165
|
+
if length < 7:
|
|
160
166
|
raise S7ConnectionError("Invalid TPKT length")
|
|
161
167
|
|
|
162
168
|
payload = await self._recv_exact(remaining)
|
|
@@ -165,6 +171,10 @@ class AsyncISOTCPConnection:
|
|
|
165
171
|
if len(payload) < 3:
|
|
166
172
|
raise S7ConnectionError("Invalid COTP DT: too short")
|
|
167
173
|
pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3])
|
|
174
|
+
if pdu_len != 2:
|
|
175
|
+
raise S7ConnectionError("Invalid COTP DT header length")
|
|
176
|
+
if eot_num & 0x7F:
|
|
177
|
+
raise S7ConnectionError("Invalid Class 0 COTP TPDU number")
|
|
168
178
|
if pdu_type != self.COTP_DT:
|
|
169
179
|
raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}")
|
|
170
180
|
return payload[3:]
|
|
@@ -214,6 +224,8 @@ class AsyncISOTCPConnection:
|
|
|
214
224
|
def _build_tpkt(self, payload: bytes) -> bytes:
|
|
215
225
|
"""Build TPKT frame."""
|
|
216
226
|
length = len(payload) + 4
|
|
227
|
+
if not 7 <= length <= 65535:
|
|
228
|
+
raise S7ConnectionError("Invalid TPKT length: expected 7..65535 bytes")
|
|
217
229
|
return struct.pack(">BBH", 3, 0, length) + payload
|
|
218
230
|
|
|
219
231
|
def _parse_cotp_cc(self, data: bytes) -> None:
|
|
@@ -285,23 +297,32 @@ class AsyncISOTCPConnection:
|
|
|
285
297
|
|
|
286
298
|
|
|
287
299
|
class AsyncClient(ClientMixin):
|
|
288
|
-
"""
|
|
289
|
-
Native async S7 client implementation.
|
|
300
|
+
"""Asynchronous client for classic S7 communication.
|
|
290
301
|
|
|
291
302
|
Uses asyncio streams for non-blocking I/O. An internal asyncio.Lock
|
|
292
303
|
serializes each send+receive cycle so that concurrent coroutines
|
|
293
304
|
(e.g. via asyncio.gather) never interleave on the same TCP socket.
|
|
294
305
|
|
|
306
|
+
For new projects, use ``s7.AsyncClient`` instead.
|
|
307
|
+
|
|
295
308
|
Examples:
|
|
296
|
-
>>> import
|
|
297
|
-
>>> async with
|
|
309
|
+
>>> from s7 import AsyncClient
|
|
310
|
+
>>> async with AsyncClient() as client:
|
|
298
311
|
... await client.connect("192.168.1.10", 0, 1)
|
|
299
312
|
... data = await client.db_read(1, 0, 4)
|
|
300
313
|
"""
|
|
301
314
|
|
|
302
315
|
MAX_VARS = 20
|
|
303
316
|
|
|
304
|
-
def __init__(
|
|
317
|
+
def __init__(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
max_requests_per_second: float = 0,
|
|
321
|
+
rate_limit_algorithm: RateLimitAlgorithm = "fixed",
|
|
322
|
+
rate_limit_behavior: RateLimitBehavior = "block",
|
|
323
|
+
rate_limit_burst: int | None = None,
|
|
324
|
+
on_operation: Optional[Callable[[str, float, bool], None]] = None,
|
|
325
|
+
) -> None:
|
|
305
326
|
self.connection: Optional[AsyncISOTCPConnection] = None
|
|
306
327
|
self.protocol = S7Protocol()
|
|
307
328
|
self.connected = False
|
|
@@ -318,8 +339,16 @@ class AsyncClient(ClientMixin):
|
|
|
318
339
|
|
|
319
340
|
self._exec_time = 0
|
|
320
341
|
self._last_error = 0
|
|
342
|
+
self._on_operation = on_operation
|
|
343
|
+
self._operation_depth: ContextVar[int] = ContextVar("snap7_async_client_operation_depth", default=0)
|
|
321
344
|
|
|
322
345
|
self._lock = asyncio.Lock()
|
|
346
|
+
self._rate_limiter = RequestRateLimiter(
|
|
347
|
+
max_requests_per_second,
|
|
348
|
+
algorithm=rate_limit_algorithm,
|
|
349
|
+
behavior=rate_limit_behavior,
|
|
350
|
+
burst_capacity=rate_limit_burst,
|
|
351
|
+
)
|
|
323
352
|
|
|
324
353
|
self._params = {
|
|
325
354
|
Parameter.RemotePort: 102,
|
|
@@ -339,6 +368,11 @@ class AsyncClient(ClientMixin):
|
|
|
339
368
|
raise S7ConnectionError("Not connected to PLC")
|
|
340
369
|
return self.connection
|
|
341
370
|
|
|
371
|
+
async def _send_data(self, conn: AsyncISOTCPConnection, request: bytes) -> None:
|
|
372
|
+
"""Apply the per-client rate limit and send one S7 request PDU."""
|
|
373
|
+
await self._rate_limiter.acquire_async()
|
|
374
|
+
await conn.send_data(request)
|
|
375
|
+
|
|
342
376
|
async def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]:
|
|
343
377
|
"""Send a request and receive/parse the response, holding the lock.
|
|
344
378
|
|
|
@@ -358,7 +392,7 @@ class AsyncClient(ClientMixin):
|
|
|
358
392
|
expected_seq = struct.unpack(">H", request[4:6])[0]
|
|
359
393
|
|
|
360
394
|
async with self._lock:
|
|
361
|
-
await
|
|
395
|
+
await self._send_data(conn, request)
|
|
362
396
|
|
|
363
397
|
for attempt in range(max_stale_retries + 1):
|
|
364
398
|
response_data = await conn.receive_data()
|
|
@@ -510,6 +544,7 @@ class AsyncClient(ClientMixin):
|
|
|
510
544
|
# Core read / write
|
|
511
545
|
# ---------------------------------------------------------------
|
|
512
546
|
|
|
547
|
+
@_instrumented("read_area")
|
|
513
548
|
async def read_area(self, area: Area, db_number: int, start: int, size: int) -> bytearray:
|
|
514
549
|
"""Read data from memory area.
|
|
515
550
|
|
|
@@ -525,7 +560,7 @@ class AsyncClient(ClientMixin):
|
|
|
525
560
|
else:
|
|
526
561
|
word_len = S7WordLen.BYTE
|
|
527
562
|
|
|
528
|
-
max_chunk = self.
|
|
563
|
+
max_chunk = self._read_chunk_count(word_len)
|
|
529
564
|
if size <= max_chunk:
|
|
530
565
|
request = self.protocol.build_read_request(
|
|
531
566
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, count=size
|
|
@@ -541,7 +576,11 @@ class AsyncClient(ClientMixin):
|
|
|
541
576
|
while remaining > 0:
|
|
542
577
|
chunk_size = min(remaining, max_chunk)
|
|
543
578
|
request = self.protocol.build_read_request(
|
|
544
|
-
area=s7_area,
|
|
579
|
+
area=s7_area,
|
|
580
|
+
db_number=db_number,
|
|
581
|
+
start=start + offset * self._element_address_step(word_len),
|
|
582
|
+
word_len=word_len,
|
|
583
|
+
count=chunk_size,
|
|
545
584
|
)
|
|
546
585
|
response = await self._send_receive(request)
|
|
547
586
|
values = self.protocol.extract_read_data(response, word_len, chunk_size)
|
|
@@ -552,6 +591,7 @@ class AsyncClient(ClientMixin):
|
|
|
552
591
|
self._exec_time = int((time.time() - start_time) * 1000)
|
|
553
592
|
return result
|
|
554
593
|
|
|
594
|
+
@_instrumented("write_area")
|
|
555
595
|
async def write_area(self, area: Area, db_number: int, start: int, data: bytearray) -> int:
|
|
556
596
|
"""Write data to memory area.
|
|
557
597
|
|
|
@@ -567,7 +607,7 @@ class AsyncClient(ClientMixin):
|
|
|
567
607
|
else:
|
|
568
608
|
word_len = S7WordLen.BYTE
|
|
569
609
|
|
|
570
|
-
max_chunk = self.
|
|
610
|
+
max_chunk = self._write_chunk_bytes(word_len, len(data))
|
|
571
611
|
if len(data) <= max_chunk:
|
|
572
612
|
request = self.protocol.build_write_request(
|
|
573
613
|
area=s7_area, db_number=db_number, start=start, word_len=word_len, data=bytes(data)
|
|
@@ -583,7 +623,11 @@ class AsyncClient(ClientMixin):
|
|
|
583
623
|
chunk_size = min(remaining, max_chunk)
|
|
584
624
|
chunk_data = data[offset : offset + chunk_size]
|
|
585
625
|
request = self.protocol.build_write_request(
|
|
586
|
-
area=s7_area,
|
|
626
|
+
area=s7_area,
|
|
627
|
+
db_number=db_number,
|
|
628
|
+
start=start + offset // S7DataTypes.get_size_bytes(word_len) * self._element_address_step(word_len),
|
|
629
|
+
word_len=word_len,
|
|
630
|
+
data=bytes(chunk_data),
|
|
587
631
|
)
|
|
588
632
|
response = await self._send_receive(request)
|
|
589
633
|
self.protocol.check_write_response(response)
|
|
@@ -593,6 +637,7 @@ class AsyncClient(ClientMixin):
|
|
|
593
637
|
self._exec_time = int((time.time() - start_time) * 1000)
|
|
594
638
|
return 0
|
|
595
639
|
|
|
640
|
+
@_instrumented("read_multi_vars")
|
|
596
641
|
async def read_multi_vars(self, items: List[dict[str, Any]]) -> Tuple[int, list[bytearray]]:
|
|
597
642
|
"""Read multiple variables (sequentially, one read_area per item).
|
|
598
643
|
|
|
@@ -617,6 +662,7 @@ class AsyncClient(ClientMixin):
|
|
|
617
662
|
results.append(data)
|
|
618
663
|
return (0, results)
|
|
619
664
|
|
|
665
|
+
@_instrumented("write_multi_vars")
|
|
620
666
|
async def write_multi_vars(self, items: List[dict[str, Any]]) -> int:
|
|
621
667
|
"""Write multiple variables (sequentially, one write_area per item).
|
|
622
668
|
|
|
@@ -650,6 +696,7 @@ class AsyncClient(ClientMixin):
|
|
|
650
696
|
|
|
651
697
|
request = self.protocol.build_list_blocks_request()
|
|
652
698
|
response = await self._send_receive(request)
|
|
699
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
|
|
653
700
|
|
|
654
701
|
data_info = response.get("data", {})
|
|
655
702
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -682,6 +729,7 @@ class AsyncClient(ClientMixin):
|
|
|
682
729
|
|
|
683
730
|
request = self.protocol.build_list_blocks_of_type_request(type_code)
|
|
684
731
|
response = await self._send_receive(request)
|
|
732
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
|
|
685
733
|
|
|
686
734
|
data_info = response.get("data", {})
|
|
687
735
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -703,10 +751,11 @@ class AsyncClient(ClientMixin):
|
|
|
703
751
|
|
|
704
752
|
async with self._lock:
|
|
705
753
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
706
|
-
await
|
|
754
|
+
await self._send_data(conn, followup)
|
|
707
755
|
response_data = await conn.receive_data()
|
|
708
756
|
|
|
709
757
|
response = self.protocol.parse_response(response_data)
|
|
758
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
|
|
710
759
|
|
|
711
760
|
data_info = response.get("data", {})
|
|
712
761
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -742,6 +791,7 @@ class AsyncClient(ClientMixin):
|
|
|
742
791
|
|
|
743
792
|
request = self.protocol.build_get_block_info_request(type_code, db_number)
|
|
744
793
|
response = await self._send_receive(request)
|
|
794
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
|
|
745
795
|
|
|
746
796
|
data_info = response.get("data", {})
|
|
747
797
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -771,6 +821,7 @@ class AsyncClient(ClientMixin):
|
|
|
771
821
|
# Upload / Download / Delete
|
|
772
822
|
# ---------------------------------------------------------------
|
|
773
823
|
|
|
824
|
+
@_instrumented("upload")
|
|
774
825
|
async def upload(self, block_num: int) -> bytearray:
|
|
775
826
|
"""Upload block from PLC (3-step: START_UPLOAD, UPLOAD, END_UPLOAD)."""
|
|
776
827
|
if not self.get_connected():
|
|
@@ -782,19 +833,27 @@ class AsyncClient(ClientMixin):
|
|
|
782
833
|
response = await self._send_receive(request)
|
|
783
834
|
|
|
784
835
|
upload_info = self.protocol.parse_start_upload_response(response)
|
|
785
|
-
upload_id = upload_info
|
|
786
|
-
|
|
787
|
-
request = self.protocol.build_upload_request(upload_id)
|
|
788
|
-
response = await self._send_receive(request)
|
|
836
|
+
upload_id = upload_info["upload_id"]
|
|
789
837
|
|
|
790
|
-
block_data =
|
|
838
|
+
block_data = bytearray()
|
|
839
|
+
for fragment_index in range(1000):
|
|
840
|
+
request = self.protocol.build_upload_request(upload_id)
|
|
841
|
+
response = await self._send_receive(request)
|
|
842
|
+
fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
|
|
843
|
+
block_data.extend(fragment)
|
|
844
|
+
if is_last:
|
|
845
|
+
break
|
|
846
|
+
else:
|
|
847
|
+
raise S7ProtocolError("Upload response exceeded fragment limit")
|
|
791
848
|
|
|
792
849
|
request = self.protocol.build_end_upload_request(upload_id)
|
|
793
850
|
response = await self._send_receive(request)
|
|
851
|
+
self.protocol.check_end_upload_response(response)
|
|
794
852
|
|
|
795
853
|
logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
|
|
796
|
-
return
|
|
854
|
+
return block_data
|
|
797
855
|
|
|
856
|
+
@_instrumented("download")
|
|
798
857
|
async def download(self, data: bytearray, block_num: int = -1) -> int:
|
|
799
858
|
"""Download block to PLC."""
|
|
800
859
|
if not self.get_connected():
|
|
@@ -809,44 +868,33 @@ class AsyncClient(ClientMixin):
|
|
|
809
868
|
else:
|
|
810
869
|
block_num = 1
|
|
811
870
|
|
|
812
|
-
# Step 1: Request download
|
|
813
871
|
request = self.protocol.build_download_request(block_type, block_num, bytes(data))
|
|
814
|
-
await self._send_receive(request)
|
|
815
|
-
|
|
816
|
-
# Step 2: Download block (send data)
|
|
817
|
-
param_data = struct.pack(">BBB", 0x1B, 0x01, 0x00)
|
|
818
|
-
data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data)
|
|
819
|
-
header = struct.pack(
|
|
820
|
-
">BBHHHH",
|
|
821
|
-
0x32,
|
|
822
|
-
0x01,
|
|
823
|
-
0x0000,
|
|
824
|
-
self.protocol._next_sequence(),
|
|
825
|
-
len(param_data),
|
|
826
|
-
len(data_section),
|
|
827
|
-
)
|
|
828
|
-
|
|
829
872
|
async with self._lock:
|
|
830
|
-
await
|
|
873
|
+
await self._send_data(conn, request)
|
|
831
874
|
response_data = await conn.receive_data()
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
875
|
+
response = self.protocol.parse_response(response_data)
|
|
876
|
+
if response["sequence"] != int.from_bytes(request[4:6], "big") or response.get("raw_parameters") != bytes((0x1A,)):
|
|
877
|
+
raise S7ProtocolError("Invalid request-download acknowledgement")
|
|
878
|
+
|
|
879
|
+
offset = 0
|
|
880
|
+
max_slice = self.pdu_length - 18
|
|
881
|
+
if max_slice <= 0:
|
|
882
|
+
raise S7ProtocolError("Negotiated PDU is too small for download")
|
|
883
|
+
for _ in range(1000):
|
|
884
|
+
request_data = await conn.receive_data()
|
|
885
|
+
sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
|
|
886
|
+
fragment = bytes(data[offset : offset + max_slice])
|
|
887
|
+
offset += len(fragment)
|
|
888
|
+
is_last = offset == len(data)
|
|
889
|
+
await self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
|
|
890
|
+
if is_last:
|
|
891
|
+
break
|
|
892
|
+
else:
|
|
893
|
+
raise S7ProtocolError("Download exceeded fragment limit")
|
|
845
894
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
self.protocol.parse_response(response_data)
|
|
895
|
+
request_data = await conn.receive_data()
|
|
896
|
+
sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
|
|
897
|
+
await self._send_data(conn, self.protocol.build_download_ended_response(sequence))
|
|
850
898
|
|
|
851
899
|
logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
|
|
852
900
|
return 0
|
|
@@ -894,32 +942,27 @@ class AsyncClient(ClientMixin):
|
|
|
894
942
|
response = await self._send_receive(request)
|
|
895
943
|
|
|
896
944
|
upload_info = self.protocol.parse_start_upload_response(response)
|
|
897
|
-
upload_id = upload_info
|
|
945
|
+
upload_id = upload_info["upload_id"]
|
|
898
946
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
947
|
+
block_data = bytearray()
|
|
948
|
+
for fragment_index in range(1000):
|
|
949
|
+
request = self.protocol.build_upload_request(upload_id)
|
|
950
|
+
response = await self._send_receive(request)
|
|
951
|
+
fragment, is_last = self.protocol.parse_upload_fragment(
|
|
952
|
+
response, first_fragment=fragment_index == 0, include_block_header=True
|
|
953
|
+
)
|
|
954
|
+
block_data.extend(fragment)
|
|
955
|
+
if is_last:
|
|
956
|
+
break
|
|
957
|
+
else:
|
|
958
|
+
raise S7ProtocolError("Upload response exceeded fragment limit")
|
|
902
959
|
|
|
903
960
|
request = self.protocol.build_end_upload_request(upload_id)
|
|
904
961
|
response = await self._send_receive(request)
|
|
962
|
+
self.protocol.check_end_upload_response(response)
|
|
905
963
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
0x70,
|
|
909
|
-
block_type.value,
|
|
910
|
-
block_num,
|
|
911
|
-
0x00,
|
|
912
|
-
0x00,
|
|
913
|
-
0x00,
|
|
914
|
-
0x00,
|
|
915
|
-
len(block_data) + 14,
|
|
916
|
-
len(block_data),
|
|
917
|
-
)
|
|
918
|
-
block_footer = b"\x00" * 4
|
|
919
|
-
full_block = bytearray(block_header + block_data + block_footer)
|
|
920
|
-
|
|
921
|
-
logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes")
|
|
922
|
-
return full_block, len(full_block)
|
|
964
|
+
logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
|
|
965
|
+
return block_data, len(block_data)
|
|
923
966
|
|
|
924
967
|
# ---------------------------------------------------------------
|
|
925
968
|
# PLC control
|
|
@@ -957,6 +1000,7 @@ class AsyncClient(ClientMixin):
|
|
|
957
1000
|
|
|
958
1001
|
request = self.protocol.build_get_clock_request()
|
|
959
1002
|
response = await self._send_receive(request)
|
|
1003
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
|
|
960
1004
|
return self.protocol.parse_get_clock_response(response)
|
|
961
1005
|
|
|
962
1006
|
async def set_plc_datetime(self, dt: datetime) -> int:
|
|
@@ -965,7 +1009,8 @@ class AsyncClient(ClientMixin):
|
|
|
965
1009
|
raise S7ConnectionError("Not connected to PLC")
|
|
966
1010
|
|
|
967
1011
|
request = self.protocol.build_set_clock_request(dt)
|
|
968
|
-
await self._send_receive(request)
|
|
1012
|
+
response = await self._send_receive(request)
|
|
1013
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
|
|
969
1014
|
logger.info(f"Set PLC datetime to {dt}")
|
|
970
1015
|
return 0
|
|
971
1016
|
|
|
@@ -995,6 +1040,7 @@ class AsyncClient(ClientMixin):
|
|
|
995
1040
|
|
|
996
1041
|
request = self.protocol.build_read_szl_request(ssl_id, index)
|
|
997
1042
|
response = await self._send_receive(request)
|
|
1043
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
|
|
998
1044
|
|
|
999
1045
|
data_info = response.get("data", {})
|
|
1000
1046
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -1017,10 +1063,11 @@ class AsyncClient(ClientMixin):
|
|
|
1017
1063
|
|
|
1018
1064
|
async with self._lock:
|
|
1019
1065
|
followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number)
|
|
1020
|
-
await
|
|
1066
|
+
await self._send_data(conn, followup)
|
|
1021
1067
|
response_data = await conn.receive_data()
|
|
1022
1068
|
|
|
1023
1069
|
response = self.protocol.parse_response(response_data)
|
|
1070
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
|
|
1024
1071
|
|
|
1025
1072
|
data_info = response.get("data", {})
|
|
1026
1073
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -1073,6 +1120,109 @@ class AsyncClient(ClientMixin):
|
|
|
1073
1120
|
raise S7ConnectionError("Not connected to PLC")
|
|
1074
1121
|
return parse_protection_szl(await self.read_szl(0x0232, 0))
|
|
1075
1122
|
|
|
1123
|
+
# ---------------------------------------------------------------
|
|
1124
|
+
# Force I/O
|
|
1125
|
+
# ---------------------------------------------------------------
|
|
1126
|
+
|
|
1127
|
+
_FORCE_AREAS: frozenset[int] = frozenset({Area.PE, Area.PA})
|
|
1128
|
+
|
|
1129
|
+
async def force_bit(self, area: Area, byte_offset: int, bit: int, value: bool) -> None:
|
|
1130
|
+
"""Force a single I/O bit in the process image.
|
|
1131
|
+
|
|
1132
|
+
Async equivalent of :meth:`snap7.client.Client.force_bit`.
|
|
1133
|
+
"""
|
|
1134
|
+
if area not in self._FORCE_AREAS:
|
|
1135
|
+
raise ValueError(f"Force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
1136
|
+
if not 0 <= bit <= 7:
|
|
1137
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
1138
|
+
|
|
1139
|
+
current = await self.read_area(area, 0, byte_offset, 1)
|
|
1140
|
+
if value:
|
|
1141
|
+
current[0] |= 1 << bit
|
|
1142
|
+
else:
|
|
1143
|
+
current[0] &= ~(1 << bit)
|
|
1144
|
+
await self.write_area(area, 0, byte_offset, current)
|
|
1145
|
+
logger.info(f"Forced {area.name} byte {byte_offset} bit {bit} = {value}")
|
|
1146
|
+
|
|
1147
|
+
async def cancel_force(self, area: Area, byte_offset: int, bit: int) -> None:
|
|
1148
|
+
"""Cancel a forced I/O bit by clearing it in the process image.
|
|
1149
|
+
|
|
1150
|
+
Async equivalent of :meth:`snap7.client.Client.cancel_force`.
|
|
1151
|
+
"""
|
|
1152
|
+
if area not in self._FORCE_AREAS:
|
|
1153
|
+
raise ValueError(f"Cancel force is only supported for PE (inputs) and PA (outputs), got {area!r}")
|
|
1154
|
+
if not 0 <= bit <= 7:
|
|
1155
|
+
raise ValueError(f"Bit must be 0-7, got {bit}")
|
|
1156
|
+
|
|
1157
|
+
current = await self.read_area(area, 0, byte_offset, 1)
|
|
1158
|
+
current[0] &= ~(1 << bit)
|
|
1159
|
+
await self.write_area(area, 0, byte_offset, current)
|
|
1160
|
+
logger.info(f"Cancelled force on {area.name} byte {byte_offset} bit {bit}")
|
|
1161
|
+
|
|
1162
|
+
async def read_force_table(self) -> list[ForceEntry]:
|
|
1163
|
+
"""Read the PLC force table via SZL 0x0025.
|
|
1164
|
+
|
|
1165
|
+
Async equivalent of :meth:`snap7.client.Client.read_force_table`.
|
|
1166
|
+
"""
|
|
1167
|
+
if not self.get_connected():
|
|
1168
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1169
|
+
|
|
1170
|
+
try:
|
|
1171
|
+
szl = await self.read_szl(0x0025, 0x0000)
|
|
1172
|
+
except (S7ProtocolError, RuntimeError):
|
|
1173
|
+
logger.debug("SZL 0x0025 not available; returning empty force table")
|
|
1174
|
+
return []
|
|
1175
|
+
|
|
1176
|
+
raw = bytes(szl.Data[: szl.Header.LengthDR])
|
|
1177
|
+
return _parse_force_szl(raw)
|
|
1178
|
+
|
|
1179
|
+
async def set_session_password(self, password: str) -> int:
|
|
1180
|
+
"""Set the session password to unlock a password-protected PLC.
|
|
1181
|
+
|
|
1182
|
+
Sends an S7 USERDATA request (function group 5, subfunction 1)
|
|
1183
|
+
with the encoded password.
|
|
1184
|
+
|
|
1185
|
+
Args:
|
|
1186
|
+
password: Plaintext password (max 8 ASCII characters).
|
|
1187
|
+
|
|
1188
|
+
Returns:
|
|
1189
|
+
0 on success.
|
|
1190
|
+
|
|
1191
|
+
Raises:
|
|
1192
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
1193
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the password.
|
|
1194
|
+
"""
|
|
1195
|
+
if not self.get_connected():
|
|
1196
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1197
|
+
|
|
1198
|
+
encoded = self.protocol.encode_password(password)
|
|
1199
|
+
request = self.protocol.build_set_session_password_request(encoded)
|
|
1200
|
+
response = await self._send_receive(request)
|
|
1201
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
|
|
1202
|
+
logger.info("Session password set successfully")
|
|
1203
|
+
return 0
|
|
1204
|
+
|
|
1205
|
+
async def clear_session_password(self) -> int:
|
|
1206
|
+
"""Clear the session password, returning to the default protection level.
|
|
1207
|
+
|
|
1208
|
+
Sends an S7 USERDATA request (function group 5, subfunction 2).
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
0 on success.
|
|
1212
|
+
|
|
1213
|
+
Raises:
|
|
1214
|
+
~snap7.error.S7ConnectionError: If not connected.
|
|
1215
|
+
~snap7.error.S7ProtocolError: If the PLC rejects the request.
|
|
1216
|
+
"""
|
|
1217
|
+
if not self.get_connected():
|
|
1218
|
+
raise S7ConnectionError("Not connected to PLC")
|
|
1219
|
+
|
|
1220
|
+
request = self.protocol.build_clear_session_password_request()
|
|
1221
|
+
response = await self._send_receive(request)
|
|
1222
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
|
|
1223
|
+
logger.info("Session password cleared successfully")
|
|
1224
|
+
return 0
|
|
1225
|
+
|
|
1076
1226
|
async def compress(self, timeout: int) -> int:
|
|
1077
1227
|
"""Compress PLC memory."""
|
|
1078
1228
|
if not self.get_connected():
|
|
@@ -1100,7 +1250,7 @@ class AsyncClient(ClientMixin):
|
|
|
1100
1250
|
conn = self._get_connection()
|
|
1101
1251
|
|
|
1102
1252
|
async with self._lock:
|
|
1103
|
-
await
|
|
1253
|
+
await self._send_data(conn, bytes(data))
|
|
1104
1254
|
response = await conn.receive_data()
|
|
1105
1255
|
return bytearray(response)
|
|
1106
1256
|
|
snap7/cli.py
CHANGED
|
@@ -15,9 +15,8 @@ from typing import Optional
|
|
|
15
15
|
|
|
16
16
|
try:
|
|
17
17
|
import click
|
|
18
|
-
except ImportError:
|
|
19
|
-
|
|
20
|
-
raise
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise ImportError("CLI dependencies not installed. Install them with: pip install python-snap7[cli]") from exc
|
|
21
20
|
|
|
22
21
|
from snap7 import __version__
|
|
23
22
|
from snap7.client import Client
|