python-snap7 3.2.0__py3-none-any.whl → 3.3.0__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.0.dist-info → python_snap7-3.3.0.dist-info}/METADATA +27 -1
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/RECORD +13 -13
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/WHEEL +1 -1
- snap7/async_client.py +399 -67
- snap7/client.py +96 -92
- snap7/client_base.py +75 -2
- snap7/s7protocol.py +251 -231
- snap7/server/__init__.py +216 -249
- snap7/szl.py +1 -1
- snap7/util/getters.py +19 -10
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/entry_points.txt +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_snap7-3.2.0.dist-info → python_snap7-3.3.0.dist-info}/top_level.txt +0 -0
snap7/async_client.py
CHANGED
|
@@ -10,15 +10,19 @@ import asyncio
|
|
|
10
10
|
import logging
|
|
11
11
|
import struct
|
|
12
12
|
import time
|
|
13
|
-
from
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from contextvars import ContextVar
|
|
15
|
+
from typing import List, Any, Optional, Tuple, Type, Union
|
|
14
16
|
from types import TracebackType
|
|
15
17
|
from datetime import datetime
|
|
16
18
|
|
|
17
19
|
from .connection import TPDUSize
|
|
18
|
-
from .s7protocol import S7Protocol, get_return_code_description
|
|
20
|
+
from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_return_code_description
|
|
19
21
|
from .datatypes import S7DataTypes, S7WordLen
|
|
20
22
|
from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
|
|
21
|
-
from .client_base import ClientMixin
|
|
23
|
+
from .client_base import ClientMixin, _instrumented
|
|
24
|
+
from .tags import Tag
|
|
25
|
+
from .client import Client, _decode_tag, _encode_tag
|
|
22
26
|
from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
|
|
23
27
|
from .client import _parse_force_szl
|
|
24
28
|
from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
|
|
@@ -57,6 +61,8 @@ class AsyncISOTCPConnection:
|
|
|
57
61
|
COTP_PARAM_PDU_SIZE = 0xC0
|
|
58
62
|
COTP_PARAM_CALLING_TSAP = 0xC1
|
|
59
63
|
COTP_PARAM_CALLED_TSAP = 0xC2
|
|
64
|
+
COTP_PARAM_SUBNET_ID = 0xC6
|
|
65
|
+
COTP_PARAM_ROUTING_TSAP = 0xC7
|
|
60
66
|
|
|
61
67
|
def __init__(
|
|
62
68
|
self,
|
|
@@ -81,6 +87,17 @@ class AsyncISOTCPConnection:
|
|
|
81
87
|
self._reader: Optional[asyncio.StreamReader] = None
|
|
82
88
|
self._writer: Optional[asyncio.StreamWriter] = None
|
|
83
89
|
|
|
90
|
+
# Routing parameters (set via set_routing)
|
|
91
|
+
self._routing: bool = False
|
|
92
|
+
self._subnet_id: int = 0
|
|
93
|
+
self._routing_tsap: int = 0
|
|
94
|
+
|
|
95
|
+
def set_routing(self, subnet_id: int, dest_rack: int, dest_slot: int) -> None:
|
|
96
|
+
"""Configure S7 routing parameters for multi-subnet access (experimental)."""
|
|
97
|
+
self._routing = True
|
|
98
|
+
self._subnet_id = subnet_id & 0xFFFF
|
|
99
|
+
self._routing_tsap = 0x0100 | (dest_rack << 5) | dest_slot
|
|
100
|
+
|
|
84
101
|
async def connect(self, timeout: float = 5.0) -> None:
|
|
85
102
|
"""Establish ISO on TCP connection."""
|
|
86
103
|
self.timeout = timeout
|
|
@@ -202,6 +219,9 @@ class AsyncISOTCPConnection:
|
|
|
202
219
|
called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, 2, self.remote_tsap)
|
|
203
220
|
pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
|
|
204
221
|
parameters = calling_tsap + called_tsap + pdu_size_param
|
|
222
|
+
if self._routing:
|
|
223
|
+
parameters += struct.pack(">BBH", self.COTP_PARAM_SUBNET_ID, 2, self._subnet_id)
|
|
224
|
+
parameters += struct.pack(">BBH", self.COTP_PARAM_ROUTING_TSAP, 2, self._routing_tsap)
|
|
205
225
|
total_length = 6 + len(parameters)
|
|
206
226
|
cr_pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters
|
|
207
227
|
|
|
@@ -319,6 +339,7 @@ class AsyncClient(ClientMixin):
|
|
|
319
339
|
rate_limit_algorithm: RateLimitAlgorithm = "fixed",
|
|
320
340
|
rate_limit_behavior: RateLimitBehavior = "block",
|
|
321
341
|
rate_limit_burst: int | None = None,
|
|
342
|
+
on_operation: Optional[Callable[[str, float, bool], None]] = None,
|
|
322
343
|
) -> None:
|
|
323
344
|
self.connection: Optional[AsyncISOTCPConnection] = None
|
|
324
345
|
self.protocol = S7Protocol()
|
|
@@ -336,6 +357,8 @@ class AsyncClient(ClientMixin):
|
|
|
336
357
|
|
|
337
358
|
self._exec_time = 0
|
|
338
359
|
self._last_error = 0
|
|
360
|
+
self._on_operation = on_operation
|
|
361
|
+
self._operation_depth: ContextVar[int] = ContextVar("snap7_async_client_operation_depth", default=0)
|
|
339
362
|
|
|
340
363
|
self._lock = asyncio.Lock()
|
|
341
364
|
self._rate_limiter = RequestRateLimiter(
|
|
@@ -452,6 +475,78 @@ class AsyncClient(ClientMixin):
|
|
|
452
475
|
|
|
453
476
|
return self
|
|
454
477
|
|
|
478
|
+
@property
|
|
479
|
+
def is_alive(self) -> bool:
|
|
480
|
+
"""Whether the client is connected.
|
|
481
|
+
|
|
482
|
+
Unlike the synchronous client there is no background heartbeat, so this
|
|
483
|
+
reflects the connection state seen by the last operation.
|
|
484
|
+
"""
|
|
485
|
+
return self.connected and self.connection is not None and self.connection.connected
|
|
486
|
+
|
|
487
|
+
async def connect_routed(
|
|
488
|
+
self,
|
|
489
|
+
host: str,
|
|
490
|
+
router_rack: int,
|
|
491
|
+
router_slot: int,
|
|
492
|
+
subnet: int,
|
|
493
|
+
dest_rack: int,
|
|
494
|
+
dest_slot: int,
|
|
495
|
+
port: int = 102,
|
|
496
|
+
timeout: float = 5.0,
|
|
497
|
+
) -> "AsyncClient":
|
|
498
|
+
"""Connect to an S7 PLC via a routing gateway on another subnet.
|
|
499
|
+
|
|
500
|
+
.. warning:: This method is experimental and may change in future versions.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
host: IP address of the routing gateway PLC
|
|
504
|
+
router_rack: Rack number of the gateway PLC
|
|
505
|
+
router_slot: Slot number of the gateway PLC
|
|
506
|
+
subnet: Subnet ID of the target network (0x0000-0xFFFF)
|
|
507
|
+
dest_rack: Rack number of the destination PLC
|
|
508
|
+
dest_slot: Slot number of the destination PLC
|
|
509
|
+
port: TCP port (default 102)
|
|
510
|
+
timeout: Connection timeout in seconds
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Self for method chaining
|
|
514
|
+
"""
|
|
515
|
+
self.host = host
|
|
516
|
+
self.port = port
|
|
517
|
+
self.rack = router_rack
|
|
518
|
+
self.slot = router_slot
|
|
519
|
+
self._params[Parameter.RemotePort] = port
|
|
520
|
+
|
|
521
|
+
# Remote TSAP targets the gateway rack/slot
|
|
522
|
+
self.remote_tsap = (self.connection_type << 8) | (router_rack << 5) | router_slot
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
start_time = time.time()
|
|
526
|
+
|
|
527
|
+
self.connection = AsyncISOTCPConnection(
|
|
528
|
+
host=host, port=port, local_tsap=self.local_tsap, remote_tsap=self.remote_tsap
|
|
529
|
+
)
|
|
530
|
+
self.connection.set_routing(subnet, dest_rack, dest_slot)
|
|
531
|
+
await self.connection.connect(timeout=timeout)
|
|
532
|
+
|
|
533
|
+
await self._setup_communication()
|
|
534
|
+
|
|
535
|
+
self.connected = True
|
|
536
|
+
self._exec_time = int((time.time() - start_time) * 1000)
|
|
537
|
+
logger.info(
|
|
538
|
+
f"Connected (routed) to {host}:{port} via rack {router_rack} slot {router_slot}, "
|
|
539
|
+
f"subnet {subnet:#06x} -> rack {dest_rack} slot {dest_slot}"
|
|
540
|
+
)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
await self.disconnect()
|
|
543
|
+
if isinstance(e, S7Error):
|
|
544
|
+
raise
|
|
545
|
+
else:
|
|
546
|
+
raise S7ConnectionError(f"Routed connection failed: {e}")
|
|
547
|
+
|
|
548
|
+
return self
|
|
549
|
+
|
|
455
550
|
async def disconnect(self) -> int:
|
|
456
551
|
"""Disconnect from S7 PLC.
|
|
457
552
|
|
|
@@ -503,6 +598,224 @@ class AsyncClient(ClientMixin):
|
|
|
503
598
|
await self.write_area(Area.DB, db_number, start, data)
|
|
504
599
|
return 0
|
|
505
600
|
|
|
601
|
+
# ---------------------------------------------------------------
|
|
602
|
+
# Typed DB access and tag helpers (parity with the sync Client)
|
|
603
|
+
# ---------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
async def db_read_array(self, db_number: int, start: int, count: int, fmt: str = ">f") -> list[Any]:
|
|
606
|
+
"""Read *count* consecutive values of struct format *fmt* from a DB."""
|
|
607
|
+
item_size = struct.calcsize(fmt)
|
|
608
|
+
data = await self.db_read(db_number, start, item_size * count)
|
|
609
|
+
return [struct.unpack_from(fmt, data, i * item_size)[0] for i in range(count)]
|
|
610
|
+
|
|
611
|
+
async def db_write_array(self, db_number: int, start: int, values: list[Any], fmt: str = ">f") -> int:
|
|
612
|
+
"""Write *values* packed with struct format *fmt* to a DB. Returns 0 on success."""
|
|
613
|
+
item_size = struct.calcsize(fmt)
|
|
614
|
+
data = bytearray(item_size * len(values))
|
|
615
|
+
for i, v in enumerate(values):
|
|
616
|
+
struct.pack_into(fmt, data, i * item_size, v)
|
|
617
|
+
return await self.db_write(db_number, start, data)
|
|
618
|
+
|
|
619
|
+
async def db_read_bool(self, db_number: int, byte_offset: int, bit_offset: int) -> bool:
|
|
620
|
+
"""Read a single bit from a DB."""
|
|
621
|
+
from .util import get_bool
|
|
622
|
+
|
|
623
|
+
data = await self.db_read(db_number, byte_offset, 1)
|
|
624
|
+
return get_bool(data, 0, bit_offset)
|
|
625
|
+
|
|
626
|
+
async def db_write_bool(self, db_number: int, byte_offset: int, bit_offset: int, value: bool) -> None:
|
|
627
|
+
"""Write a single bit to a DB, preserving the other bits in the byte."""
|
|
628
|
+
from .util import set_bool
|
|
629
|
+
|
|
630
|
+
data = await self.db_read(db_number, byte_offset, 1)
|
|
631
|
+
set_bool(data, 0, bit_offset, value)
|
|
632
|
+
await self.db_write(db_number, byte_offset, data)
|
|
633
|
+
|
|
634
|
+
async def db_read_byte(self, db_number: int, offset: int) -> int:
|
|
635
|
+
"""Read a BYTE (8-bit unsigned) from a DB."""
|
|
636
|
+
data = await self.db_read(db_number, offset, 1)
|
|
637
|
+
return data[0]
|
|
638
|
+
|
|
639
|
+
async def db_write_byte(self, db_number: int, offset: int, value: int) -> None:
|
|
640
|
+
"""Write a BYTE (8-bit unsigned) to a DB."""
|
|
641
|
+
from .util import set_byte
|
|
642
|
+
|
|
643
|
+
data = bytearray(1)
|
|
644
|
+
set_byte(data, 0, value)
|
|
645
|
+
await self.db_write(db_number, offset, data)
|
|
646
|
+
|
|
647
|
+
async def db_read_int(self, db_number: int, offset: int) -> int:
|
|
648
|
+
"""Read an INT (16-bit signed) from a DB."""
|
|
649
|
+
from .util import get_int
|
|
650
|
+
|
|
651
|
+
return get_int(await self.db_read(db_number, offset, 2), 0)
|
|
652
|
+
|
|
653
|
+
async def db_write_int(self, db_number: int, offset: int, value: int) -> None:
|
|
654
|
+
"""Write an INT (16-bit signed) to a DB."""
|
|
655
|
+
from .util import set_int
|
|
656
|
+
|
|
657
|
+
data = bytearray(2)
|
|
658
|
+
set_int(data, 0, value)
|
|
659
|
+
await self.db_write(db_number, offset, data)
|
|
660
|
+
|
|
661
|
+
async def db_read_uint(self, db_number: int, offset: int) -> int:
|
|
662
|
+
"""Read a UINT (16-bit unsigned) from a DB."""
|
|
663
|
+
from .util import get_uint
|
|
664
|
+
|
|
665
|
+
return get_uint(await self.db_read(db_number, offset, 2), 0)
|
|
666
|
+
|
|
667
|
+
async def db_write_uint(self, db_number: int, offset: int, value: int) -> None:
|
|
668
|
+
"""Write a UINT (16-bit unsigned) to a DB."""
|
|
669
|
+
from .util import set_uint
|
|
670
|
+
|
|
671
|
+
data = bytearray(2)
|
|
672
|
+
set_uint(data, 0, value)
|
|
673
|
+
await self.db_write(db_number, offset, data)
|
|
674
|
+
|
|
675
|
+
async def db_read_word(self, db_number: int, offset: int) -> int:
|
|
676
|
+
"""Read a WORD (16-bit unsigned) from a DB."""
|
|
677
|
+
data = await self.db_read(db_number, offset, 2)
|
|
678
|
+
return (data[0] << 8) | data[1]
|
|
679
|
+
|
|
680
|
+
async def db_write_word(self, db_number: int, offset: int, value: int) -> None:
|
|
681
|
+
"""Write a WORD (16-bit unsigned) to a DB."""
|
|
682
|
+
from .util import set_word
|
|
683
|
+
|
|
684
|
+
data = bytearray(2)
|
|
685
|
+
set_word(data, 0, value)
|
|
686
|
+
await self.db_write(db_number, offset, data)
|
|
687
|
+
|
|
688
|
+
async def db_read_dint(self, db_number: int, offset: int) -> int:
|
|
689
|
+
"""Read a DINT (32-bit signed) from a DB."""
|
|
690
|
+
from .util import get_dint
|
|
691
|
+
|
|
692
|
+
return get_dint(await self.db_read(db_number, offset, 4), 0)
|
|
693
|
+
|
|
694
|
+
async def db_write_dint(self, db_number: int, offset: int, value: int) -> None:
|
|
695
|
+
"""Write a DINT (32-bit signed) to a DB."""
|
|
696
|
+
from .util import set_dint
|
|
697
|
+
|
|
698
|
+
data = bytearray(4)
|
|
699
|
+
set_dint(data, 0, value)
|
|
700
|
+
await self.db_write(db_number, offset, data)
|
|
701
|
+
|
|
702
|
+
async def db_read_udint(self, db_number: int, offset: int) -> int:
|
|
703
|
+
"""Read a UDINT (32-bit unsigned) from a DB."""
|
|
704
|
+
from .util import get_udint
|
|
705
|
+
|
|
706
|
+
return get_udint(await self.db_read(db_number, offset, 4), 0)
|
|
707
|
+
|
|
708
|
+
async def db_write_udint(self, db_number: int, offset: int, value: int) -> None:
|
|
709
|
+
"""Write a UDINT (32-bit unsigned) to a DB."""
|
|
710
|
+
from .util import set_udint
|
|
711
|
+
|
|
712
|
+
data = bytearray(4)
|
|
713
|
+
set_udint(data, 0, value)
|
|
714
|
+
await self.db_write(db_number, offset, data)
|
|
715
|
+
|
|
716
|
+
async def db_read_dword(self, db_number: int, offset: int) -> int:
|
|
717
|
+
"""Read a DWORD (32-bit unsigned) from a DB."""
|
|
718
|
+
from .util import get_dword
|
|
719
|
+
|
|
720
|
+
return get_dword(await self.db_read(db_number, offset, 4), 0)
|
|
721
|
+
|
|
722
|
+
async def db_write_dword(self, db_number: int, offset: int, value: int) -> None:
|
|
723
|
+
"""Write a DWORD (32-bit unsigned) to a DB."""
|
|
724
|
+
from .util import set_dword
|
|
725
|
+
|
|
726
|
+
data = bytearray(4)
|
|
727
|
+
set_dword(data, 0, value)
|
|
728
|
+
await self.db_write(db_number, offset, data)
|
|
729
|
+
|
|
730
|
+
async def db_read_real(self, db_number: int, offset: int) -> float:
|
|
731
|
+
"""Read a REAL (32-bit float) from a DB."""
|
|
732
|
+
from .util import get_real
|
|
733
|
+
|
|
734
|
+
return get_real(await self.db_read(db_number, offset, 4), 0)
|
|
735
|
+
|
|
736
|
+
async def db_write_real(self, db_number: int, offset: int, value: float) -> None:
|
|
737
|
+
"""Write a REAL (32-bit float) to a DB."""
|
|
738
|
+
from .util import set_real
|
|
739
|
+
|
|
740
|
+
data = bytearray(4)
|
|
741
|
+
set_real(data, 0, value)
|
|
742
|
+
await self.db_write(db_number, offset, data)
|
|
743
|
+
|
|
744
|
+
async def db_read_lreal(self, db_number: int, offset: int) -> float:
|
|
745
|
+
"""Read a LREAL (64-bit float) from a DB."""
|
|
746
|
+
from .util import get_lreal
|
|
747
|
+
|
|
748
|
+
return get_lreal(await self.db_read(db_number, offset, 8), 0)
|
|
749
|
+
|
|
750
|
+
async def db_write_lreal(self, db_number: int, offset: int, value: float) -> None:
|
|
751
|
+
"""Write a LREAL (64-bit float) to a DB."""
|
|
752
|
+
from .util import set_lreal
|
|
753
|
+
|
|
754
|
+
data = bytearray(8)
|
|
755
|
+
set_lreal(data, 0, value)
|
|
756
|
+
await self.db_write(db_number, offset, data)
|
|
757
|
+
|
|
758
|
+
async def db_read_string(self, db_number: int, offset: int) -> str:
|
|
759
|
+
"""Read an S7 STRING from a DB (header first to learn the max length)."""
|
|
760
|
+
from .util import get_string
|
|
761
|
+
|
|
762
|
+
header = await self.db_read(db_number, offset, 2)
|
|
763
|
+
data = await self.db_read(db_number, offset, 2 + header[0])
|
|
764
|
+
return get_string(data, 0)
|
|
765
|
+
|
|
766
|
+
async def db_write_string(self, db_number: int, offset: int, value: str, max_length: int = 254) -> None:
|
|
767
|
+
"""Write an S7 STRING with the given maximum length to a DB."""
|
|
768
|
+
from .util import set_string
|
|
769
|
+
|
|
770
|
+
data = bytearray(2 + max_length)
|
|
771
|
+
set_string(data, 0, value, max_length)
|
|
772
|
+
await self.db_write(db_number, offset, data)
|
|
773
|
+
|
|
774
|
+
async def db_read_wstring(self, db_number: int, offset: int) -> str:
|
|
775
|
+
"""Read an S7 WSTRING from a DB (header first to learn the max length)."""
|
|
776
|
+
from .util import get_wstring
|
|
777
|
+
|
|
778
|
+
header = await self.db_read(db_number, offset, 4)
|
|
779
|
+
max_len = (header[0] << 8) | header[1]
|
|
780
|
+
data = await self.db_read(db_number, offset, 4 + max_len * 2)
|
|
781
|
+
return get_wstring(data, 0)
|
|
782
|
+
|
|
783
|
+
async def db_write_wstring(self, db_number: int, offset: int, value: str, max_length: int = 254) -> None:
|
|
784
|
+
"""Write an S7 WSTRING with the given maximum length (in characters) to a DB."""
|
|
785
|
+
from .util import set_wstring
|
|
786
|
+
|
|
787
|
+
data = bytearray(4 + max_length * 2)
|
|
788
|
+
set_wstring(data, 0, value, max_length)
|
|
789
|
+
await self.db_write(db_number, offset, data)
|
|
790
|
+
|
|
791
|
+
async def read_tag(self, tag: "Union[Tag, str]", encoding: str = "latin-1") -> Any:
|
|
792
|
+
"""Read a typed value by :class:`~snap7.tags.Tag` or PLC4X-style address string."""
|
|
793
|
+
resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
|
|
794
|
+
if resolved.is_symbolic:
|
|
795
|
+
raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
|
|
796
|
+
data = await self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, resolved.size)
|
|
797
|
+
return _decode_tag(resolved, bytearray(data), encoding=encoding)
|
|
798
|
+
|
|
799
|
+
async def write_tag(self, tag: "Union[Tag, str]", value: Any, encoding: str = "latin-1") -> int:
|
|
800
|
+
"""Write a typed value by :class:`~snap7.tags.Tag` or address string. Returns 0 on success."""
|
|
801
|
+
resolved = Tag.from_string(tag) if isinstance(tag, str) else tag
|
|
802
|
+
if resolved.is_symbolic:
|
|
803
|
+
raise NotImplementedError("Symbolic (LID-based) tag access is not supported by the classic S7 client.")
|
|
804
|
+
buf = bytearray(resolved.size)
|
|
805
|
+
if resolved.datatype.upper() == "BOOL":
|
|
806
|
+
# Preserve the other bits of the byte
|
|
807
|
+
current = await self.read_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, 1)
|
|
808
|
+
buf[0] = current[0]
|
|
809
|
+
_encode_tag(resolved, buf, value, encoding=encoding)
|
|
810
|
+
return await self.write_area(Area(resolved.area), resolved.db_number, resolved.byte_offset, buf)
|
|
811
|
+
|
|
812
|
+
async def read_tags(self, tags: "Sequence[Union[Tag, str]]", encoding: str = "latin-1") -> list[Any]:
|
|
813
|
+
"""Read multiple tags in a single optimized request, in input order."""
|
|
814
|
+
resolved = [Tag.from_string(t) if isinstance(t, str) else t for t in tags]
|
|
815
|
+
items = [{"area": Area(t.area), "db_number": t.db_number, "start": t.byte_offset, "size": t.size} for t in resolved]
|
|
816
|
+
_code, data_list = await self.read_multi_vars(items)
|
|
817
|
+
return [_decode_tag(t, bytearray(d), encoding=encoding) for t, d in zip(resolved, data_list)]
|
|
818
|
+
|
|
506
819
|
async def db_get(self, db_number: int, size: int = 0) -> bytearray:
|
|
507
820
|
"""Get entire DB.
|
|
508
821
|
|
|
@@ -539,6 +852,7 @@ class AsyncClient(ClientMixin):
|
|
|
539
852
|
# Core read / write
|
|
540
853
|
# ---------------------------------------------------------------
|
|
541
854
|
|
|
855
|
+
@_instrumented("read_area")
|
|
542
856
|
async def read_area(self, area: Area, db_number: int, start: int, size: int) -> bytearray:
|
|
543
857
|
"""Read data from memory area.
|
|
544
858
|
|
|
@@ -585,6 +899,7 @@ class AsyncClient(ClientMixin):
|
|
|
585
899
|
self._exec_time = int((time.time() - start_time) * 1000)
|
|
586
900
|
return result
|
|
587
901
|
|
|
902
|
+
@_instrumented("write_area")
|
|
588
903
|
async def write_area(self, area: Area, db_number: int, start: int, data: bytearray) -> int:
|
|
589
904
|
"""Write data to memory area.
|
|
590
905
|
|
|
@@ -630,6 +945,7 @@ class AsyncClient(ClientMixin):
|
|
|
630
945
|
self._exec_time = int((time.time() - start_time) * 1000)
|
|
631
946
|
return 0
|
|
632
947
|
|
|
948
|
+
@_instrumented("read_multi_vars")
|
|
633
949
|
async def read_multi_vars(self, items: List[dict[str, Any]]) -> Tuple[int, list[bytearray]]:
|
|
634
950
|
"""Read multiple variables (sequentially, one read_area per item).
|
|
635
951
|
|
|
@@ -654,6 +970,7 @@ class AsyncClient(ClientMixin):
|
|
|
654
970
|
results.append(data)
|
|
655
971
|
return (0, results)
|
|
656
972
|
|
|
973
|
+
@_instrumented("write_multi_vars")
|
|
657
974
|
async def write_multi_vars(self, items: List[dict[str, Any]]) -> int:
|
|
658
975
|
"""Write multiple variables (sequentially, one write_area per item).
|
|
659
976
|
|
|
@@ -687,6 +1004,7 @@ class AsyncClient(ClientMixin):
|
|
|
687
1004
|
|
|
688
1005
|
request = self.protocol.build_list_blocks_request()
|
|
689
1006
|
response = await self._send_receive(request)
|
|
1007
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_ALL)
|
|
690
1008
|
|
|
691
1009
|
data_info = response.get("data", {})
|
|
692
1010
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -719,6 +1037,7 @@ class AsyncClient(ClientMixin):
|
|
|
719
1037
|
|
|
720
1038
|
request = self.protocol.build_list_blocks_of_type_request(type_code)
|
|
721
1039
|
response = await self._send_receive(request)
|
|
1040
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
|
|
722
1041
|
|
|
723
1042
|
data_info = response.get("data", {})
|
|
724
1043
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -744,6 +1063,7 @@ class AsyncClient(ClientMixin):
|
|
|
744
1063
|
response_data = await conn.receive_data()
|
|
745
1064
|
|
|
746
1065
|
response = self.protocol.parse_response(response_data)
|
|
1066
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE)
|
|
747
1067
|
|
|
748
1068
|
data_info = response.get("data", {})
|
|
749
1069
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -779,6 +1099,7 @@ class AsyncClient(ClientMixin):
|
|
|
779
1099
|
|
|
780
1100
|
request = self.protocol.build_get_block_info_request(type_code, db_number)
|
|
781
1101
|
response = await self._send_receive(request)
|
|
1102
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.BLOCK_INFO, S7UserDataSubfunction.BLOCK_INFO)
|
|
782
1103
|
|
|
783
1104
|
data_info = response.get("data", {})
|
|
784
1105
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -808,6 +1129,7 @@ class AsyncClient(ClientMixin):
|
|
|
808
1129
|
# Upload / Download / Delete
|
|
809
1130
|
# ---------------------------------------------------------------
|
|
810
1131
|
|
|
1132
|
+
@_instrumented("upload")
|
|
811
1133
|
async def upload(self, block_num: int) -> bytearray:
|
|
812
1134
|
"""Upload block from PLC (3-step: START_UPLOAD, UPLOAD, END_UPLOAD)."""
|
|
813
1135
|
if not self.get_connected():
|
|
@@ -819,19 +1141,27 @@ class AsyncClient(ClientMixin):
|
|
|
819
1141
|
response = await self._send_receive(request)
|
|
820
1142
|
|
|
821
1143
|
upload_info = self.protocol.parse_start_upload_response(response)
|
|
822
|
-
upload_id = upload_info
|
|
823
|
-
|
|
824
|
-
request = self.protocol.build_upload_request(upload_id)
|
|
825
|
-
response = await self._send_receive(request)
|
|
1144
|
+
upload_id = upload_info["upload_id"]
|
|
826
1145
|
|
|
827
|
-
block_data =
|
|
1146
|
+
block_data = bytearray()
|
|
1147
|
+
for fragment_index in range(1000):
|
|
1148
|
+
request = self.protocol.build_upload_request(upload_id)
|
|
1149
|
+
response = await self._send_receive(request)
|
|
1150
|
+
fragment, is_last = self.protocol.parse_upload_fragment(response, first_fragment=fragment_index == 0)
|
|
1151
|
+
block_data.extend(fragment)
|
|
1152
|
+
if is_last:
|
|
1153
|
+
break
|
|
1154
|
+
else:
|
|
1155
|
+
raise S7ProtocolError("Upload response exceeded fragment limit")
|
|
828
1156
|
|
|
829
1157
|
request = self.protocol.build_end_upload_request(upload_id)
|
|
830
1158
|
response = await self._send_receive(request)
|
|
1159
|
+
self.protocol.check_end_upload_response(response)
|
|
831
1160
|
|
|
832
1161
|
logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}")
|
|
833
|
-
return
|
|
1162
|
+
return block_data
|
|
834
1163
|
|
|
1164
|
+
@_instrumented("download")
|
|
835
1165
|
async def download(self, data: bytearray, block_num: int = -1) -> int:
|
|
836
1166
|
"""Download block to PLC."""
|
|
837
1167
|
if not self.get_connected():
|
|
@@ -846,44 +1176,33 @@ class AsyncClient(ClientMixin):
|
|
|
846
1176
|
else:
|
|
847
1177
|
block_num = 1
|
|
848
1178
|
|
|
849
|
-
# Step 1: Request download
|
|
850
1179
|
request = self.protocol.build_download_request(block_type, block_num, bytes(data))
|
|
851
|
-
await self._send_receive(request)
|
|
852
|
-
|
|
853
|
-
# Step 2: Download block (send data)
|
|
854
|
-
param_data = struct.pack(">BBB", 0x1B, 0x01, 0x00)
|
|
855
|
-
data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data)
|
|
856
|
-
header = struct.pack(
|
|
857
|
-
">BBHHHH",
|
|
858
|
-
0x32,
|
|
859
|
-
0x01,
|
|
860
|
-
0x0000,
|
|
861
|
-
self.protocol._next_sequence(),
|
|
862
|
-
len(param_data),
|
|
863
|
-
len(data_section),
|
|
864
|
-
)
|
|
865
|
-
|
|
866
1180
|
async with self._lock:
|
|
867
|
-
await self._send_data(conn,
|
|
1181
|
+
await self._send_data(conn, request)
|
|
868
1182
|
response_data = await conn.receive_data()
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1183
|
+
response = self.protocol.parse_response(response_data)
|
|
1184
|
+
if response["sequence"] != int.from_bytes(request[4:6], "big") or response.get("raw_parameters") != bytes((0x1A,)):
|
|
1185
|
+
raise S7ProtocolError("Invalid request-download acknowledgement")
|
|
1186
|
+
|
|
1187
|
+
offset = 0
|
|
1188
|
+
max_slice = self.pdu_length - 18
|
|
1189
|
+
if max_slice <= 0:
|
|
1190
|
+
raise S7ProtocolError("Negotiated PDU is too small for download")
|
|
1191
|
+
for _ in range(1000):
|
|
1192
|
+
request_data = await conn.receive_data()
|
|
1193
|
+
sequence = self.protocol.parse_download_service_request(request_data, 0x1B, block_num)
|
|
1194
|
+
fragment = bytes(data[offset : offset + max_slice])
|
|
1195
|
+
offset += len(fragment)
|
|
1196
|
+
is_last = offset == len(data)
|
|
1197
|
+
await self._send_data(conn, self.protocol.build_download_fragment_response(sequence, is_last, fragment))
|
|
1198
|
+
if is_last:
|
|
1199
|
+
break
|
|
1200
|
+
else:
|
|
1201
|
+
raise S7ProtocolError("Download exceeded fragment limit")
|
|
882
1202
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
self.protocol.parse_response(response_data)
|
|
1203
|
+
request_data = await conn.receive_data()
|
|
1204
|
+
sequence = self.protocol.parse_download_service_request(request_data, 0x1C, block_num)
|
|
1205
|
+
await self._send_data(conn, self.protocol.build_download_ended_response(sequence))
|
|
887
1206
|
|
|
888
1207
|
logger.info(f"Downloaded {len(data)} bytes to block {block_num}")
|
|
889
1208
|
return 0
|
|
@@ -931,32 +1250,27 @@ class AsyncClient(ClientMixin):
|
|
|
931
1250
|
response = await self._send_receive(request)
|
|
932
1251
|
|
|
933
1252
|
upload_info = self.protocol.parse_start_upload_response(response)
|
|
934
|
-
upload_id = upload_info
|
|
1253
|
+
upload_id = upload_info["upload_id"]
|
|
935
1254
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1255
|
+
block_data = bytearray()
|
|
1256
|
+
for fragment_index in range(1000):
|
|
1257
|
+
request = self.protocol.build_upload_request(upload_id)
|
|
1258
|
+
response = await self._send_receive(request)
|
|
1259
|
+
fragment, is_last = self.protocol.parse_upload_fragment(
|
|
1260
|
+
response, first_fragment=fragment_index == 0, include_block_header=True
|
|
1261
|
+
)
|
|
1262
|
+
block_data.extend(fragment)
|
|
1263
|
+
if is_last:
|
|
1264
|
+
break
|
|
1265
|
+
else:
|
|
1266
|
+
raise S7ProtocolError("Upload response exceeded fragment limit")
|
|
939
1267
|
|
|
940
1268
|
request = self.protocol.build_end_upload_request(upload_id)
|
|
941
1269
|
response = await self._send_receive(request)
|
|
1270
|
+
self.protocol.check_end_upload_response(response)
|
|
942
1271
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
0x70,
|
|
946
|
-
block_type.value,
|
|
947
|
-
block_num,
|
|
948
|
-
0x00,
|
|
949
|
-
0x00,
|
|
950
|
-
0x00,
|
|
951
|
-
0x00,
|
|
952
|
-
len(block_data) + 14,
|
|
953
|
-
len(block_data),
|
|
954
|
-
)
|
|
955
|
-
block_footer = b"\x00" * 4
|
|
956
|
-
full_block = bytearray(block_header + block_data + block_footer)
|
|
957
|
-
|
|
958
|
-
logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes")
|
|
959
|
-
return full_block, len(full_block)
|
|
1272
|
+
logger.info(f"Full upload of block {block_type.name} {block_num}: {len(block_data)} bytes")
|
|
1273
|
+
return block_data, len(block_data)
|
|
960
1274
|
|
|
961
1275
|
# ---------------------------------------------------------------
|
|
962
1276
|
# PLC control
|
|
@@ -994,6 +1308,7 @@ class AsyncClient(ClientMixin):
|
|
|
994
1308
|
|
|
995
1309
|
request = self.protocol.build_get_clock_request()
|
|
996
1310
|
response = await self._send_receive(request)
|
|
1311
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.GET_CLOCK)
|
|
997
1312
|
return self.protocol.parse_get_clock_response(response)
|
|
998
1313
|
|
|
999
1314
|
async def set_plc_datetime(self, dt: datetime) -> int:
|
|
@@ -1002,7 +1317,10 @@ class AsyncClient(ClientMixin):
|
|
|
1002
1317
|
raise S7ConnectionError("Not connected to PLC")
|
|
1003
1318
|
|
|
1004
1319
|
request = self.protocol.build_set_clock_request(dt)
|
|
1005
|
-
await self._send_receive(request)
|
|
1320
|
+
response = await self._send_receive(request)
|
|
1321
|
+
self.protocol.check_userdata_response(
|
|
1322
|
+
response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK, accept_null_ack=True
|
|
1323
|
+
)
|
|
1006
1324
|
logger.info(f"Set PLC datetime to {dt}")
|
|
1007
1325
|
return 0
|
|
1008
1326
|
|
|
@@ -1032,6 +1350,7 @@ class AsyncClient(ClientMixin):
|
|
|
1032
1350
|
|
|
1033
1351
|
request = self.protocol.build_read_szl_request(ssl_id, index)
|
|
1034
1352
|
response = await self._send_receive(request)
|
|
1353
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
|
|
1035
1354
|
|
|
1036
1355
|
data_info = response.get("data", {})
|
|
1037
1356
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -1058,6 +1377,7 @@ class AsyncClient(ClientMixin):
|
|
|
1058
1377
|
response_data = await conn.receive_data()
|
|
1059
1378
|
|
|
1060
1379
|
response = self.protocol.parse_response(response_data)
|
|
1380
|
+
self.protocol.check_userdata_response(response, S7UserDataGroup.SZL, S7UserDataSubfunction.READ_SZL)
|
|
1061
1381
|
|
|
1062
1382
|
data_info = response.get("data", {})
|
|
1063
1383
|
return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF
|
|
@@ -1080,6 +1400,14 @@ class AsyncClient(ClientMixin):
|
|
|
1080
1400
|
|
|
1081
1401
|
return szl
|
|
1082
1402
|
|
|
1403
|
+
async def read_diagnostic_buffer(self) -> list[dict[str, Any]]:
|
|
1404
|
+
"""Read the PLC diagnostic buffer (experimental), newest first.
|
|
1405
|
+
|
|
1406
|
+
Each entry is a dict with keys ``event_id``, ``timestamp`` and ``info``.
|
|
1407
|
+
"""
|
|
1408
|
+
szl = await self.read_szl(0x00A0, 0)
|
|
1409
|
+
return Client._parse_diagnostic_buffer(bytes(szl.Data[: szl.Header.LengthDR]))
|
|
1410
|
+
|
|
1083
1411
|
async def read_szl_list(self) -> bytes:
|
|
1084
1412
|
"""Read list of available SZL IDs."""
|
|
1085
1413
|
if not self.get_connected():
|
|
@@ -1188,7 +1516,9 @@ class AsyncClient(ClientMixin):
|
|
|
1188
1516
|
encoded = self.protocol.encode_password(password)
|
|
1189
1517
|
request = self.protocol.build_set_session_password_request(encoded)
|
|
1190
1518
|
response = await self._send_receive(request)
|
|
1191
|
-
self.protocol.check_userdata_response(
|
|
1519
|
+
self.protocol.check_userdata_response(
|
|
1520
|
+
response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD, accept_null_ack=True
|
|
1521
|
+
)
|
|
1192
1522
|
logger.info("Session password set successfully")
|
|
1193
1523
|
return 0
|
|
1194
1524
|
|
|
@@ -1209,7 +1539,9 @@ class AsyncClient(ClientMixin):
|
|
|
1209
1539
|
|
|
1210
1540
|
request = self.protocol.build_clear_session_password_request()
|
|
1211
1541
|
response = await self._send_receive(request)
|
|
1212
|
-
self.protocol.check_userdata_response(
|
|
1542
|
+
self.protocol.check_userdata_response(
|
|
1543
|
+
response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, accept_null_ack=True
|
|
1544
|
+
)
|
|
1213
1545
|
logger.info("Session password cleared successfully")
|
|
1214
1546
|
return 0
|
|
1215
1547
|
|