python-snap7 3.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-snap7
3
- Version: 3.2.1
3
+ Version: 3.3.0
4
4
  Summary: Pure Python S7 communication library for Siemens PLCs
5
5
  Author-email: Gijs Molenaar <gijsmolenaar@gmail.com>
6
6
  License-Expression: MIT
@@ -24,7 +24,7 @@ License-File: LICENSE
24
24
  Provides-Extra: test
25
25
  Requires-Dist: pytest; extra == "test"
26
26
  Requires-Dist: pytest-asyncio; extra == "test"
27
- Requires-Dist: pytest-bdd<9,>=8.1; extra == "test"
27
+ Requires-Dist: pytest-bdd<10,>=8.1; extra == "test"
28
28
  Requires-Dist: pytest-cov; extra == "test"
29
29
  Requires-Dist: pytest-html; extra == "test"
30
30
  Requires-Dist: hypothesis; extra == "test"
@@ -75,6 +75,26 @@ using the classic S7 protocol. It supports S7-300 and S7-400 controllers, as
75
75
  well as S7-1200 and S7-1500 controllers with PUT/GET access enabled. It
76
76
  supports Python 3.10+ on Windows, Linux, and macOS without native dependencies.
77
77
 
78
+ S7-1200 and S7-1500
79
+ ===================
80
+
81
+ On S7-1200 and S7-1500 controllers, python-snap7 only works when PUT/GET access
82
+ is enabled in TIA Portal, and it cannot access optimized data blocks. PUT/GET
83
+ is unauthenticated and disabled by default, so leaving it disabled is the more
84
+ secure configuration.
85
+
86
+ If PUT/GET is disabled, or you need optimized data blocks or symbolic access,
87
+ use `s7commplus <https://github.com/gijzelaerr/s7commplus>`_ instead::
88
+
89
+ pip install s7commplus
90
+
91
+ s7commplus is a separate, younger project that implements S7CommPlus, the
92
+ protocol these controllers speak natively. Check the
93
+ `firmware versions validated so far <https://s7commplus.readthedocs.io/en/latest/connections.html>`_
94
+ before relying on it, and see the
95
+ `PLC support matrix <https://python-snap7.readthedocs.io/en/latest/plc-support.html>`_
96
+ for what python-snap7 supports on each controller family.
97
+
78
98
  Installation
79
99
  ============
80
100
 
@@ -105,3 +125,5 @@ Read the full documentation at
105
125
 
106
126
  The safe volunteer hardware-test procedure is in
107
127
  `Real-PLC acceptance testing <doc/real-plc-testing.rst>`_.
128
+ The CPUs and firmware versions it has passed on are listed under
129
+ `Tested and Verified Hardware <doc/plc-support.rst>`_.
@@ -1,9 +1,9 @@
1
- python_snap7-3.2.1.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
1
+ python_snap7-3.3.0.dist-info/licenses/LICENSE,sha256=PIihE_yP-8gc-ff9ikvQQPOa0biOCZ6Bc-6h3G3xRUQ,1097
2
2
  s7/__init__.py,sha256=q6E4rA1BDx36PPkJS55xv7XCxwlaQeEK6HNotFjLeYo,1096
3
3
  snap7/__init__.py,sha256=2NAAE1oaSSd7DqPl0dx7jNXtsM2PRBWRm9X-bkmgC6U,1275
4
- snap7/async_client.py,sha256=yPyqzD4WH3544hKV4aDHt48r8LSWKsbFcVBZxtQOcYQ,52571
4
+ snap7/async_client.py,sha256=SxIf2W-mimITD6P0cL3J66vpEr1SeCHne645lwkmbPU,66346
5
5
  snap7/cli.py,sha256=L1B7NMAemDcl93JUSqJV3Ls_CN2ghlG75nTMD08JkMs,13787
6
- snap7/client.py,sha256=MWnqsxjjdSSAloluSqAXtmfpokyJEKezJj77e_SqR6I,107985
6
+ snap7/client.py,sha256=WYRZiQ6qwyHMl-SUd_ckhcPmIOzaZ9RiIOKe2v26MXg,108330
7
7
  snap7/client_base.py,sha256=tNZs_MA8FGit8pg9axNoljH1kFToDI1HIzXC8WQLYHs,11324
8
8
  snap7/connection.py,sha256=Qjis-ICK6E74_YfoJuy2_lgDqfSYs4ULux1FShJVvtw,18834
9
9
  snap7/datatypes.py,sha256=Gqu1XDuKkQI76n4TYrFrhm0RlPz4XNA5GKA-GJJXJDU,12042
@@ -17,18 +17,18 @@ snap7/partner.py,sha256=HxaTYfojsf5Z170R4cAYFzoMSwqCOF42qPCkOaxbUkU,37916
17
17
  snap7/ppi.py,sha256=p0fVelfUQOT3QH5uuWOjpDxA5IyT3gx5FzCMaYYEuWQ,16070
18
18
  snap7/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  snap7/rate_limiter.py,sha256=dnXCOJLWmszo8oskbFM0om0gPC4LHHCOr-Nt22tImeU,4151
20
- snap7/s7protocol.py,sha256=BLuI6h3Cfbr4R55-v20fYMlKCY6ASbtxdJOC5T0JyaI,69396
21
- snap7/szl.py,sha256=a9NhH7Lyp8SXJ2_kvyyV-14srIxm3tXnAnclBy5alDk,7998
20
+ snap7/s7protocol.py,sha256=gPfU4BL3S4iicKgDrqksSTjPuRnVmHmrrMDzcLSBiDY,70946
21
+ snap7/szl.py,sha256=952lFvuTTE4nBlG2Lvn_3SaS_hazkM0ZfboIPgTwFM8,7999
22
22
  snap7/tags.py,sha256=TmbNRHNWZVe3Yl_1L5WXE3Qb0lxIEW-veoCRGt0giZQ,26987
23
23
  snap7/type.py,sha256=EiXqU0ayYYxlM-5fUcPL7psgL1EGNNqAa7JXH4QlAnU,9752
24
- snap7/server/__init__.py,sha256=0XqMGlqblhVNmILT_Ddm-yxTw1r19bfFrr-VuPLEDnI,112468
24
+ snap7/server/__init__.py,sha256=BsLUWAn0X3kVJ6NNZvJ1iLXfw12ksjlO2cRfCWMDQAU,113218
25
25
  snap7/server/__main__.py,sha256=OdOgyMmFThjSe2jSgvlbkquGYnZcBW6wubs2ldhNSOE,1206
26
26
  snap7/util/__init__.py,sha256=d6n2oy7tNYke1ia0TUyWz_ni3l0XN1qvilM5sW0T-Es,1874
27
27
  snap7/util/db.py,sha256=geesYS2oyOuTADOJa2me1p1rqJVzPuLTCfye6JIeav0,27120
28
- snap7/util/getters.py,sha256=pMsiCz0soMV7Q-VDPBdFG5eHzcFESZtKPw6mxj4S5ko,25370
28
+ snap7/util/getters.py,sha256=UjojRLazJECrngor_8BI38R8VMEMdXusC95zqNEErJg,25767
29
29
  snap7/util/setters.py,sha256=BpFqVxxUbhqarV6Gzt4ZuxycHljtDYsONCr6e9G33Ms,27226
30
- python_snap7-3.2.1.dist-info/METADATA,sha256=-oAcJHaF7AZL-10-rfF1c73I5E-JElaLi-azSxq76zg,3662
31
- python_snap7-3.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
- python_snap7-3.2.1.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
33
- python_snap7-3.2.1.dist-info/top_level.txt,sha256=PTgDoDG45QNL2XIE9RM7S_ZCdWQjtMMuXNyrI2UdnbU,9
34
- python_snap7-3.2.1.dist-info/RECORD,,
30
+ python_snap7-3.3.0.dist-info/METADATA,sha256=_fe4bkkd7bKdlTxAUa6CbNOKpeuIYTytvvifNBSVY3g,4669
31
+ python_snap7-3.3.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
32
+ python_snap7-3.3.0.dist-info/entry_points.txt,sha256=IPZyYr1lKyavXXpAc4xO505afTGwTY8GmNFuy5lsSiM,75
33
+ python_snap7-3.3.0.dist-info/top_level.txt,sha256=PTgDoDG45QNL2XIE9RM7S_ZCdWQjtMMuXNyrI2UdnbU,9
34
+ python_snap7-3.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
snap7/async_client.py CHANGED
@@ -10,9 +10,9 @@ import asyncio
10
10
  import logging
11
11
  import struct
12
12
  import time
13
- from collections.abc import Callable
13
+ from collections.abc import Callable, Sequence
14
14
  from contextvars import ContextVar
15
- from typing import List, Any, Optional, Tuple, Type
15
+ from typing import List, Any, Optional, Tuple, Type, Union
16
16
  from types import TracebackType
17
17
  from datetime import datetime
18
18
 
@@ -21,6 +21,8 @@ from .s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction, get_
21
21
  from .datatypes import S7DataTypes, S7WordLen
22
22
  from .error import S7Error, S7ConnectionError, S7ProtocolError, S7TimeoutError
23
23
  from .client_base import ClientMixin, _instrumented
24
+ from .tags import Tag
25
+ from .client import Client, _decode_tag, _encode_tag
24
26
  from .szl import parse_cp_info_szl, parse_cpu_info_szl, parse_order_code_szl, parse_protection_szl
25
27
  from .client import _parse_force_szl
26
28
  from .rate_limiter import RateLimitAlgorithm, RateLimitBehavior, RequestRateLimiter
@@ -59,6 +61,8 @@ class AsyncISOTCPConnection:
59
61
  COTP_PARAM_PDU_SIZE = 0xC0
60
62
  COTP_PARAM_CALLING_TSAP = 0xC1
61
63
  COTP_PARAM_CALLED_TSAP = 0xC2
64
+ COTP_PARAM_SUBNET_ID = 0xC6
65
+ COTP_PARAM_ROUTING_TSAP = 0xC7
62
66
 
63
67
  def __init__(
64
68
  self,
@@ -83,6 +87,17 @@ class AsyncISOTCPConnection:
83
87
  self._reader: Optional[asyncio.StreamReader] = None
84
88
  self._writer: Optional[asyncio.StreamWriter] = None
85
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
+
86
101
  async def connect(self, timeout: float = 5.0) -> None:
87
102
  """Establish ISO on TCP connection."""
88
103
  self.timeout = timeout
@@ -204,6 +219,9 @@ class AsyncISOTCPConnection:
204
219
  called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, 2, self.remote_tsap)
205
220
  pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)
206
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)
207
225
  total_length = 6 + len(parameters)
208
226
  cr_pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters
209
227
 
@@ -457,6 +475,78 @@ class AsyncClient(ClientMixin):
457
475
 
458
476
  return self
459
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
+
460
550
  async def disconnect(self) -> int:
461
551
  """Disconnect from S7 PLC.
462
552
 
@@ -508,6 +598,224 @@ class AsyncClient(ClientMixin):
508
598
  await self.write_area(Area.DB, db_number, start, data)
509
599
  return 0
510
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
+
511
819
  async def db_get(self, db_number: int, size: int = 0) -> bytearray:
512
820
  """Get entire DB.
513
821
 
@@ -1010,7 +1318,9 @@ class AsyncClient(ClientMixin):
1010
1318
 
1011
1319
  request = self.protocol.build_set_clock_request(dt)
1012
1320
  response = await self._send_receive(request)
1013
- self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
1321
+ self.protocol.check_userdata_response(
1322
+ response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK, accept_null_ack=True
1323
+ )
1014
1324
  logger.info(f"Set PLC datetime to {dt}")
1015
1325
  return 0
1016
1326
 
@@ -1090,6 +1400,14 @@ class AsyncClient(ClientMixin):
1090
1400
 
1091
1401
  return szl
1092
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
+
1093
1411
  async def read_szl_list(self) -> bytes:
1094
1412
  """Read list of available SZL IDs."""
1095
1413
  if not self.get_connected():
@@ -1198,7 +1516,9 @@ class AsyncClient(ClientMixin):
1198
1516
  encoded = self.protocol.encode_password(password)
1199
1517
  request = self.protocol.build_set_session_password_request(encoded)
1200
1518
  response = await self._send_receive(request)
1201
- self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
1519
+ self.protocol.check_userdata_response(
1520
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD, accept_null_ack=True
1521
+ )
1202
1522
  logger.info("Session password set successfully")
1203
1523
  return 0
1204
1524
 
@@ -1219,7 +1539,9 @@ class AsyncClient(ClientMixin):
1219
1539
 
1220
1540
  request = self.protocol.build_clear_session_password_request()
1221
1541
  response = await self._send_receive(request)
1222
- self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
1542
+ self.protocol.check_userdata_response(
1543
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, accept_null_ack=True
1544
+ )
1223
1545
  logger.info("Session password cleared successfully")
1224
1546
  return 0
1225
1547
 
snap7/client.py CHANGED
@@ -1892,7 +1892,9 @@ class Client(ClientMixin):
1892
1892
  # Build and send set clock request
1893
1893
  request = self.protocol.build_set_clock_request(dt)
1894
1894
  response = self._send_receive(request)
1895
- self.protocol.check_userdata_response(response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK)
1895
+ self.protocol.check_userdata_response(
1896
+ response, S7UserDataGroup.TIME, S7UserDataSubfunction.SET_CLOCK, accept_null_ack=True
1897
+ )
1896
1898
 
1897
1899
  logger.info(f"Set PLC datetime to {dt}")
1898
1900
  return 0
@@ -1915,7 +1917,7 @@ class Client(ClientMixin):
1915
1917
  """
1916
1918
  Compress PLC memory.
1917
1919
 
1918
- Sends real S7 PLC_CONTROL protocol with PI service "_MSZL".
1920
+ Sends real S7 PLC_CONTROL protocol with PI service "_GARB".
1919
1921
 
1920
1922
  Args:
1921
1923
  timeout: Timeout in milliseconds (used for receive timeout)
@@ -1938,7 +1940,7 @@ class Client(ClientMixin):
1938
1940
  """
1939
1941
  Copy RAM to ROM.
1940
1942
 
1941
- Sends real S7 PLC_CONTROL protocol with PI service "_MSZL" and file ID "P".
1943
+ Sends real S7 PLC_CONTROL protocol with PI service "_MODU" and file system "EP".
1942
1944
 
1943
1945
  Args:
1944
1946
  timeout: Timeout in milliseconds (used for receive timeout)
@@ -2093,7 +2095,9 @@ class Client(ClientMixin):
2093
2095
  return self.protocol.build_set_session_password_request(encoded)
2094
2096
 
2095
2097
  response = self._send_receive_with_reconnect(build_request)
2096
- self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD)
2098
+ self.protocol.check_userdata_response(
2099
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.SET_SESSION_PASSWORD, accept_null_ack=True
2100
+ )
2097
2101
  logger.info("Session password set successfully")
2098
2102
  return 0
2099
2103
 
@@ -2116,7 +2120,9 @@ class Client(ClientMixin):
2116
2120
  return self.protocol.build_clear_session_password_request()
2117
2121
 
2118
2122
  response = self._send_receive_with_reconnect(build_request)
2119
- self.protocol.check_userdata_response(response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD)
2123
+ self.protocol.check_userdata_response(
2124
+ response, S7UserDataGroup.SECURITY, S7UserDataSubfunction.CLEAR_SESSION_PASSWORD, accept_null_ack=True
2125
+ )
2120
2126
  logger.info("Session password cleared successfully")
2121
2127
  return 0
2122
2128
 
@@ -2233,6 +2239,11 @@ class Client(ClientMixin):
2233
2239
  szl = self.read_szl(0x00A0, 0)
2234
2240
  raw = bytes(szl.Data[: szl.Header.LengthDR])
2235
2241
 
2242
+ return self._parse_diagnostic_buffer(raw)
2243
+
2244
+ @classmethod
2245
+ def _parse_diagnostic_buffer(cls, raw: bytes) -> list[dict[str, Any]]:
2246
+ """Decode raw SZL 0x00A0 data into diagnostic entries."""
2236
2247
  entries: list[dict[str, Any]] = []
2237
2248
  # Each diagnostic entry is 20 bytes
2238
2249
  entry_size = 20
@@ -2243,7 +2254,7 @@ class Client(ClientMixin):
2243
2254
  # BCD-encoded timestamp at offset 2..9
2244
2255
  ts_bytes = raw[offset + 2 : offset + 10]
2245
2256
  try:
2246
- ts = self._parse_bcd_timestamp(ts_bytes)
2257
+ ts = cls._parse_bcd_timestamp(ts_bytes)
2247
2258
  except Exception:
2248
2259
  ts = None
2249
2260
 
snap7/s7protocol.py CHANGED
@@ -114,6 +114,10 @@ _STOP_PARAMS = bytes.fromhex("29000000000009") + b"P_PROGRAM"
114
114
  _HOT_START_PARAMS = bytes.fromhex("28000000000000fd000009") + b"P_PROGRAM"
115
115
  # Cold start: same as hot start but with block "C " before PI service
116
116
  _COLD_START_PARAMS = bytes.fromhex("28000000000000fd0002432009") + b"P_PROGRAM"
117
+ # Compress: function(0x28) + 7 reserved + block count(0) + PI length(5) + "_GARB" (as native Snap7 TReqFunCompress)
118
+ _COMPRESS_PARAMS = bytes.fromhex("28000000000000fd000005") + b"_GARB"
119
+ # Copy RAM to ROM: like compress but with file system "EP" before PI service "_MODU" (native TReqFunCopyRamToRom)
120
+ _COPY_RAM_TO_ROM_PARAMS = bytes.fromhex("28000000000000fd0002455005") + b"_MODU"
117
121
 
118
122
 
119
123
  class S7Protocol:
@@ -402,7 +406,7 @@ class S7Protocol:
402
406
  Build PLC control request using the S7 PI service PDU format.
403
407
 
404
408
  Args:
405
- operation: Control operation ('stop', 'hot_start', 'cold_start')
409
+ operation: Control operation ('stop', 'hot_start', 'cold_start', 'compress', 'copy_ram_to_rom')
406
410
 
407
411
  Returns:
408
412
  Complete S7 PDU for PLC control
@@ -411,6 +415,8 @@ class S7Protocol:
411
415
  "stop": _STOP_PARAMS,
412
416
  "hot_start": _HOT_START_PARAMS,
413
417
  "cold_start": _COLD_START_PARAMS,
418
+ "compress": _COMPRESS_PARAMS,
419
+ "copy_ram_to_rom": _COPY_RAM_TO_ROM_PARAMS,
414
420
  }
415
421
 
416
422
  if operation not in params:
@@ -449,87 +455,23 @@ class S7Protocol:
449
455
  """
450
456
  Build PLC control request for memory compression.
451
457
 
452
- Uses PI service "_MSZL" (compress memory).
458
+ Uses PI service "_GARB" (compress memory), as native Snap7 does.
453
459
 
454
460
  Returns:
455
461
  Complete S7 PDU for compress request
456
462
  """
457
- # PI service command for compress
458
- pi_service = b"_MSZL"
459
-
460
- # Parameter section: function code + PI service
461
- # Format: func(1) + unknown(7) + pi_len(1) + pi_service
462
- param_data = (
463
- struct.pack(
464
- ">BBBBBBBBB",
465
- S7Function.PLC_CONTROL, # 0x28
466
- 0x00, # Reserved
467
- 0x00, # Reserved
468
- 0x00, # Reserved
469
- 0x00, # Reserved
470
- 0x00, # Reserved
471
- 0x00, # Reserved
472
- 0x00, # Reserved
473
- len(pi_service), # PI service length
474
- )
475
- + pi_service
476
- )
477
-
478
- header = struct.pack(
479
- ">BBHHHH",
480
- 0x32, # Protocol ID
481
- S7PDUType.REQUEST, # PDU type
482
- 0x0000, # Reserved
483
- self._next_sequence(), # Sequence
484
- len(param_data), # Parameter length
485
- 0x0000, # Data length
486
- )
487
-
488
- return header + param_data
463
+ return self.build_plc_control_request("compress")
489
464
 
490
465
  def build_copy_ram_to_rom_request(self) -> bytes:
491
466
  """
492
467
  Build PLC control request for copying RAM to ROM.
493
468
 
494
- Uses PI service "_MSZL" with file system parameters.
469
+ Uses PI service "_MODU" with the "EP" file system, as native Snap7 does.
495
470
 
496
471
  Returns:
497
472
  Complete S7 PDU for copy RAM to ROM request
498
473
  """
499
- # PI service command for copy RAM to ROM
500
- # Uses EP parameter for target file system
501
- pi_service = b"_MSZL"
502
- file_id = b"P" # P = passive file system (ROM)
503
-
504
- # Parameter section with file system identifier
505
- param_data = (
506
- struct.pack(
507
- ">BBBBBBBBB",
508
- S7Function.PLC_CONTROL, # 0x28
509
- 0x00, # Reserved
510
- 0x00, # Reserved
511
- 0x00, # Reserved
512
- 0x00, # Reserved
513
- 0x00, # Reserved
514
- 0x00, # Reserved
515
- len(file_id), # File ID length
516
- len(pi_service), # PI service length
517
- )
518
- + file_id
519
- + pi_service
520
- )
521
-
522
- header = struct.pack(
523
- ">BBHHHH",
524
- 0x32, # Protocol ID
525
- S7PDUType.REQUEST, # PDU type
526
- 0x0000, # Reserved
527
- self._next_sequence(), # Sequence
528
- len(param_data), # Parameter length
529
- 0x0000, # Data length
530
- )
531
-
532
- return header + param_data
474
+ return self.build_plc_control_request("copy_ram_to_rom")
533
475
 
534
476
  # ========================================================================
535
477
  # Block Transfer PDU Builders (Upload/Download)
@@ -913,10 +855,9 @@ class S7Protocol:
913
855
  data_info = response.get("data", {})
914
856
  raw_data = data_info.get("data", b"")
915
857
 
916
- if not raw_data:
917
- return result
918
- if len(raw_data) % 4 != 0:
919
- raise S7ProtocolError("Block-count response length is not a multiple of four")
858
+ # Native Snap7 requires exactly seven four-byte entries (28 bytes)
859
+ if len(raw_data) != 28:
860
+ raise S7ProtocolError("Block-count response must contain exactly seven entries (28 bytes)")
920
861
 
921
862
  # Parse block entries (4 bytes each: 0x30 | type | count_hi | count_lo)
922
863
  # Block type codes
@@ -930,18 +871,19 @@ class S7Protocol:
930
871
  0x46: "SFBCount", # System Function Block
931
872
  }
932
873
 
933
- offset = 0
934
- while offset + 4 <= len(raw_data):
874
+ seen: set[int] = set()
875
+ for offset in range(0, 28, 4):
935
876
  indicator = raw_data[offset]
936
877
  block_type = raw_data[offset + 1]
937
878
  count = struct.unpack(">H", raw_data[offset + 2 : offset + 4])[0]
938
879
 
939
880
  if indicator != 0x30 or block_type not in type_to_name:
940
881
  raise S7ProtocolError("Invalid block-count response entry")
882
+ if block_type in seen:
883
+ raise S7ProtocolError("Duplicate block type in block-count response")
884
+ seen.add(block_type)
941
885
  result[type_to_name[block_type]] = count
942
886
 
943
- offset += 4
944
-
945
887
  return result
946
888
 
947
889
  def parse_list_blocks_of_type_response(self, response: Dict[str, Any]) -> List[int]:
@@ -998,10 +940,10 @@ class S7Protocol:
998
940
  0x00, # DataRef (0x00 for initial request)
999
941
  )
1000
942
 
1001
- # Data section: [0x30, type, 'A', ASCII_num(5)] per Snap7 C format
943
+ # Data section: [0x30, type, ASCII_num(5), 'A'] per Snap7 C TReqDataBlockInfo
1002
944
  # Block number is 5-digit zero-padded ASCII (e.g., 1 -> "00001")
1003
945
  block_num_ascii = f"{block_num:05d}".encode("ascii")
1004
- data_payload = struct.pack(">BB", 0x30, block_type) + b"A" + block_num_ascii
946
+ data_payload = struct.pack(">BB", 0x30, block_type) + block_num_ascii + b"A"
1005
947
  data_section = struct.pack(">BBH", 0xFF, 0x09, len(data_payload)) + data_payload
1006
948
 
1007
949
  # S7 header for USER_DATA
@@ -1117,8 +1059,8 @@ class S7Protocol:
1117
1059
  # Data section: SZL ID and Index
1118
1060
  data_section = struct.pack(
1119
1061
  ">BBHHH",
1120
- 0x0A, # Return value (request)
1121
- 0x00, # Transport size
1062
+ 0xFF, # Return value (0xFF, as engineering tools and native Snap7 send for requests carrying data)
1063
+ 0x09, # Transport size (octet string)
1122
1064
  0x0004, # Length (4 bytes for ID + Index)
1123
1065
  szl_id, # SZL ID
1124
1066
  szl_index, # SZL Index
@@ -1149,18 +1091,23 @@ class S7Protocol:
1149
1091
  Returns:
1150
1092
  Complete S7 PDU for follow-up request
1151
1093
  """
1152
- # Parameter section: same as initial but with DataRef = sequence_number
1094
+ # Parameter section as sent by engineering tools for continuation requests: 12 bytes, method 0x12,
1095
+ # DataRef = sequence_number, followed by last-data-unit and error code fields set to zero
1153
1096
  type_group = 0x40 | (group & 0x0F) # Type 4 (request) | group
1154
1097
  param_data = struct.pack(
1155
- ">BBBBBBBB",
1098
+ ">BBBBBBBBBBBB",
1156
1099
  0x00, # Reserved
1157
1100
  0x01, # Parameter count
1158
1101
  0x12, # Type/length header
1159
- 0x04, # Length of following data
1160
- 0x11, # Method (0x11 = request)
1102
+ 0x08, # Length of following data
1103
+ 0x12, # Method (0x12)
1161
1104
  type_group, # Type | Group
1162
1105
  subfunction, # Subfunction
1163
1106
  sequence_number, # DataRef from previous response
1107
+ 0x00, # Last data unit
1108
+ 0x00, # Reserved
1109
+ 0x00, # Error code high
1110
+ 0x00, # Error code low
1164
1111
  )
1165
1112
 
1166
1113
  # Minimal data section for follow-up
@@ -1268,6 +1215,9 @@ class S7Protocol:
1268
1215
 
1269
1216
  Returns:
1270
1217
  Complete S7 PDU for set clock request
1218
+
1219
+ Raises:
1220
+ ValueError: If the year is outside 1990-2089, which the two-digit S7 year cannot represent.
1271
1221
  """
1272
1222
 
1273
1223
  # Convert datetime to BCD format
@@ -1275,6 +1225,9 @@ class S7Protocol:
1275
1225
  def to_bcd(value: int) -> int:
1276
1226
  return ((value // 10) << 4) | (value % 10)
1277
1227
 
1228
+ # The two-digit S7 year maps 90-99 to 1990-1999 and 00-89 to 2000-2089
1229
+ if not 1990 <= dt.year <= 2089:
1230
+ raise ValueError(f"S7 clock supports years 1990-2089, got {dt.year}")
1278
1231
  year = dt.year % 100
1279
1232
  millisecond = dt.microsecond // 1000
1280
1233
  bcd_time = struct.pack(
@@ -1288,7 +1241,7 @@ class S7Protocol:
1288
1241
  to_bcd(dt.minute), # Minute (BCD)
1289
1242
  to_bcd(dt.second), # Second (BCD)
1290
1243
  to_bcd(millisecond // 10),
1291
- ((millisecond % 10) << 4) | ((dt.weekday() + 1) & 0x0F),
1244
+ ((millisecond % 10) << 4) | (dt.isoweekday() % 7 + 1), # S7 weekday: 1=Sunday .. 7=Saturday
1292
1245
  )
1293
1246
 
1294
1247
  # Parameter section for USER_DATA clock request
@@ -1335,26 +1288,39 @@ class S7Protocol:
1335
1288
  data_info = response.get("data", {})
1336
1289
  raw_data = data_info.get("data", b"")
1337
1290
 
1338
- if len(raw_data) != 8:
1339
- raise S7ProtocolError("Clock response must contain exactly eight bytes")
1291
+ # The S7 clock data is ten bytes: reserved, century (0x19/0x20), then year, month, day,
1292
+ # hour, minute, second and milliseconds/weekday. The older eight-byte form (no century byte,
1293
+ # no milliseconds) is still accepted.
1294
+ if len(raw_data) == 10:
1295
+ time_bytes = raw_data[2:]
1296
+ elif len(raw_data) == 8:
1297
+ time_bytes = raw_data[1:]
1298
+ else:
1299
+ raise S7ProtocolError("Clock response must contain ten bytes (or eight in the legacy form)")
1340
1300
 
1341
- # Parse BCD time
1342
1301
  def from_bcd(value: int) -> int:
1302
+ if value >> 4 > 9 or value & 0x0F > 9:
1303
+ raise S7ProtocolError("Clock response contains an invalid BCD timestamp")
1343
1304
  return ((value >> 4) * 10) + (value & 0x0F)
1344
1305
 
1345
- # Skip first byte (reserved)
1346
- year = from_bcd(raw_data[1])
1347
- month = from_bcd(raw_data[2])
1348
- day = from_bcd(raw_data[3])
1349
- hour = from_bcd(raw_data[4])
1350
- minute = from_bcd(raw_data[5])
1351
- second = from_bcd(raw_data[6])
1352
-
1353
- # Determine century (assume 2000s for years 0-99)
1306
+ year = from_bcd(time_bytes[0])
1307
+ month = from_bcd(time_bytes[1])
1308
+ day = from_bcd(time_bytes[2])
1309
+ hour = from_bcd(time_bytes[3])
1310
+ minute = from_bcd(time_bytes[4])
1311
+ second = from_bcd(time_bytes[5])
1312
+ microsecond = 0
1313
+ if len(raw_data) == 10:
1314
+ # Byte 8: milliseconds hundreds/tens (BCD); high nibble of byte 9: milliseconds ones
1315
+ microsecond = (from_bcd(time_bytes[6]) * 10 + (time_bytes[7] >> 4)) * 1000
1316
+ if microsecond > 999000:
1317
+ raise S7ProtocolError("Clock response contains an invalid BCD timestamp")
1318
+
1319
+ # The two-digit S7 year maps 90-99 to 1990-1999 and 00-89 to 2000-2089
1354
1320
  full_year = 2000 + year if year < 90 else 1900 + year
1355
1321
 
1356
1322
  try:
1357
- return dt_class(full_year, month, day, hour, minute, second)
1323
+ return dt_class(full_year, month, day, hour, minute, second, microsecond)
1358
1324
  except ValueError as exc:
1359
1325
  raise S7ProtocolError("Clock response contains an invalid BCD timestamp") from exc
1360
1326
 
@@ -1469,6 +1435,8 @@ class S7Protocol:
1469
1435
  response: Dict[str, Any],
1470
1436
  expected_group: int | None = None,
1471
1437
  expected_subfunction: int | None = None,
1438
+ *,
1439
+ accept_null_ack: bool = False,
1472
1440
  ) -> None:
1473
1441
  """Check a USERDATA response for errors.
1474
1442
 
@@ -1477,6 +1445,11 @@ class S7Protocol:
1477
1445
 
1478
1446
  Args:
1479
1447
  response: Parsed S7 response from :meth:`parse_response`.
1448
+ expected_group: Expected function group, if it should be verified.
1449
+ expected_subfunction: Expected subfunction, if it should be verified.
1450
+ accept_null_ack: For services that return no data (set clock, session passwords). Real PLCs
1451
+ acknowledge these with return code 0x0A, transport size 0 and length 0 next to a zero
1452
+ parameter error code, which is then a success.
1480
1453
 
1481
1454
  Raises:
1482
1455
  ~snap7.error.S7ProtocolError: If the response indicates an error.
@@ -1487,6 +1460,8 @@ class S7Protocol:
1487
1460
  if param_error != 0:
1488
1461
  error_msg = get_protocol_error_message(param_error)
1489
1462
  raise S7ProtocolError(f"USERDATA request failed: {error_msg} (0x{param_error:04x})")
1463
+ if params.get("last_data_unit", 0) not in (0x00, 0x01):
1464
+ raise S7ProtocolError(f"Invalid USERDATA last data unit value 0x{params['last_data_unit']:02x}")
1490
1465
  if expected_group is not None and params.get("group") != expected_group:
1491
1466
  raise S7ProtocolError("Unexpected USERDATA response function group")
1492
1467
  if expected_subfunction is not None and params.get("subfunction") != expected_subfunction:
@@ -1497,7 +1472,13 @@ class S7Protocol:
1497
1472
  data_info = response.get("data", {})
1498
1473
  if isinstance(data_info, dict):
1499
1474
  return_code = data_info.get("return_code", 0xFF)
1500
- if return_code != 0xFF:
1475
+ null_ack = (
1476
+ accept_null_ack
1477
+ and return_code == 0x0A
1478
+ and data_info.get("transport_size", 0) == 0
1479
+ and data_info.get("data_length", 0) == 0
1480
+ )
1481
+ if return_code != 0xFF and not null_ack:
1501
1482
  desc = get_return_code_description(return_code)
1502
1483
  raise S7ProtocolError(f"USERDATA request failed: {desc} (0x{return_code:02x})")
1503
1484
  else:
@@ -1580,8 +1561,9 @@ class S7Protocol:
1580
1561
  if pdu_type not in (S7PDUType.ACK, S7PDUType.ACK_DATA, S7PDUType.USERDATA):
1581
1562
  raise S7ProtocolError(f"Expected response PDU, got {pdu_type}")
1582
1563
 
1564
+ # Like native Snap7, treat the whole 16-bit header error word as the failure indicator
1583
1565
  combined_error = (error_class << 8) | error_code
1584
- if error_class != 0:
1566
+ if combined_error != 0:
1585
1567
  error_msg = get_protocol_error_message(combined_error)
1586
1568
  raise S7ProtocolError(
1587
1569
  f"S7 protocol error (class={error_class:#04x}, code={error_code:#04x}): {error_msg}",
@@ -1633,7 +1615,9 @@ class S7Protocol:
1633
1615
  raise S7ProtocolError("S7 response contains trailing bytes")
1634
1616
 
1635
1617
  if pdu_type == S7PDUType.USERDATA:
1636
- self.check_userdata_response(response)
1618
+ # The null acknowledgement is only a success for no-data services; callers that expect data
1619
+ # run check_userdata_response again without accept_null_ack.
1620
+ self.check_userdata_response(response, accept_null_ack=True)
1637
1621
 
1638
1622
  return response
1639
1623
 
@@ -1707,7 +1691,7 @@ class S7Protocol:
1707
1691
  [6] Subfunction
1708
1692
  [7] Sequence number (used as DataRef in follow-up)
1709
1693
  [8] Data unit reference
1710
- [9] Last data unit (0x00 = last, non-zero = more)
1694
+ [9] Last data unit (0x00 = last, 0x01 = more)
1711
1695
  [10-11] Error code
1712
1696
  """
1713
1697
  type_group = param_data[5]
snap7/server/__init__.py CHANGED
@@ -1129,14 +1129,10 @@ class Server:
1129
1129
  pi_service = params.get("pi_service", b"")
1130
1130
 
1131
1131
  # Check for PI service operations
1132
- if pi_service == b"_MSZL":
1133
- file_id = params.get("file_id", b"")
1134
- if file_id == b"P":
1135
- # Copy RAM to ROM
1136
- logger.info(f"Copy RAM to ROM requested from {client_address}")
1137
- else:
1138
- # Compress memory
1139
- logger.info(f"Compress memory requested from {client_address}")
1132
+ if pi_service == b"_MODU":
1133
+ logger.info(f"Copy RAM to ROM requested from {client_address}")
1134
+ elif pi_service == b"_GARB":
1135
+ logger.info(f"Compress memory requested from {client_address}")
1140
1136
  elif len(params) >= 2:
1141
1137
  # Has restart type parameter - start operation
1142
1138
  restart_type = params.get("restart_type", 1)
@@ -1411,9 +1407,10 @@ class Server:
1411
1407
  if b"P_PROGRAM" in param_data:
1412
1408
  restart_type = 2 if b"C " in param_data else 1
1413
1409
  return {"function_code": function_code, "restart_type": restart_type, "pi_service": b"P_PROGRAM"}
1414
- elif b"_MSZL" in param_data:
1415
- file_id = b"P" if b"P_MSZL" in param_data else b""
1416
- return {"function_code": function_code, "pi_service": b"_MSZL", "file_id": file_id}
1410
+ elif b"_MODU" in param_data:
1411
+ return {"function_code": function_code, "pi_service": b"_MODU", "file_id": b"EP"}
1412
+ elif b"_GARB" in param_data:
1413
+ return {"function_code": function_code, "pi_service": b"_GARB"}
1417
1414
  elif b"_DELE" in param_data:
1418
1415
  return {"function_code": function_code, "pi_service": b"_DELE"}
1419
1416
  if len(param_data) >= 2 and param_data[1] in (1, 2):
@@ -1867,15 +1864,17 @@ class Server:
1867
1864
  """
1868
1865
  Handle get clock request - returns current server time.
1869
1866
 
1870
- Returns time in BCD format (8 bytes):
1867
+ Returns time in BCD format (10 bytes, as native Snap7 ``TResDataGetTime``):
1871
1868
  - Byte 0: Reserved (0x00)
1872
- - Byte 1: Year (BCD, 0-99)
1873
- - Byte 2: Month (BCD, 1-12)
1874
- - Byte 3: Day (BCD, 1-31)
1875
- - Byte 4: Hour (BCD, 0-23)
1876
- - Byte 5: Minute (BCD, 0-59)
1877
- - Byte 6: Second (BCD, 0-59)
1878
- - Byte 7: Day of week (1=Monday)
1869
+ - Byte 1: Century (0x19 for years 1990-1999, 0x20 for 2000-2089)
1870
+ - Byte 2: Year (BCD, 0-99)
1871
+ - Byte 3: Month (BCD, 1-12)
1872
+ - Byte 4: Day (BCD, 1-31)
1873
+ - Byte 5: Hour (BCD, 0-23)
1874
+ - Byte 6: Minute (BCD, 0-59)
1875
+ - Byte 7: Second (BCD, 0-59)
1876
+ - Byte 8: Milliseconds hundreds/tens (BCD)
1877
+ - Byte 9: Milliseconds ones (high nibble) and day of week (low nibble, 1=Sunday .. 7=Saturday)
1879
1878
  """
1880
1879
  from datetime import datetime
1881
1880
 
@@ -1885,16 +1884,19 @@ class Server:
1885
1884
  return ((value // 10) << 4) | (value % 10)
1886
1885
 
1887
1886
  year = now.year % 100
1887
+ millisecond = now.microsecond // 1000
1888
1888
  bcd_time = struct.pack(
1889
- ">BBBBBBBB",
1889
+ ">BBBBBBBBBB",
1890
1890
  0x00, # Reserved
1891
+ 0x19 if now.year < 2000 else 0x20, # Century
1891
1892
  to_bcd(year), # Year (BCD)
1892
1893
  to_bcd(now.month), # Month (BCD)
1893
1894
  to_bcd(now.day), # Day (BCD)
1894
1895
  to_bcd(now.hour), # Hour (BCD)
1895
1896
  to_bcd(now.minute), # Minute (BCD)
1896
1897
  to_bcd(now.second), # Second (BCD)
1897
- (now.weekday() + 1) & 0x0F, # Day of week (1=Monday)
1898
+ to_bcd(millisecond // 10), # Milliseconds hundreds/tens (BCD)
1899
+ ((millisecond % 10) << 4) | (now.isoweekday() % 7 + 1), # Milliseconds ones, day of week (1=Sunday)
1898
1900
  )
1899
1901
 
1900
1902
  logger.debug(f"Get clock from {client_address}: returning {now}")
@@ -1940,8 +1942,8 @@ class Server:
1940
1942
 
1941
1943
  logger.info(f"Set clock from {client_address}: {timestamp}")
1942
1944
 
1943
- # Return success (empty response data)
1944
- return self._build_userdata_success_response(request, userdata_params, b"")
1945
+ # Return success (null acknowledgement, as real PLCs send for set clock)
1946
+ return self._build_userdata_success_response(request, userdata_params, b"", null_ack=True)
1945
1947
 
1946
1948
  def _handle_security(
1947
1949
  self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
@@ -1953,7 +1955,7 @@ class Server:
1953
1955
  """
1954
1956
  logger.debug(f"Security request from {client_address} (returning success)")
1955
1957
  # Return success - emulator doesn't require password
1956
- return self._build_userdata_success_response(request, userdata_params, b"")
1958
+ return self._build_userdata_success_response(request, userdata_params, b"", null_ack=True)
1957
1959
 
1958
1960
  def _handle_list_all_blocks(
1959
1961
  self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]
@@ -2098,13 +2100,13 @@ class Server:
2098
2100
  # Block type code constants
2099
2101
  block_db = 0x41 # Data Block
2100
2102
 
2101
- # Parse request: handle new format [0x30, type, 'A', ASCII_num(5)]
2103
+ # Parse request: handle the Snap7 format [0x30, type, ASCII_num(5), 'A']
2102
2104
  # and old format [type, num(2), 0x41]
2103
2105
  if len(raw_data) >= 8 and raw_data[0] == 0x30:
2104
- # New format: 0x30 + type + 'A' + 5-digit ASCII number
2106
+ # Snap7 format: 0x30 + type + 5-digit ASCII number + 'A'
2105
2107
  requested_type = raw_data[1]
2106
2108
  try:
2107
- block_number = int(raw_data[3:8].decode("ascii"))
2109
+ block_number = int(raw_data[2:7].decode("ascii"))
2108
2110
  except (ValueError, UnicodeDecodeError):
2109
2111
  block_number = 1
2110
2112
  elif len(raw_data) >= 3:
@@ -2198,7 +2200,9 @@ class Server:
2198
2200
 
2199
2201
  return header + param_data + data_section
2200
2202
 
2201
- def _build_userdata_success_response(self, request: Dict[str, Any], userdata_params: Dict[str, Any], data: bytes) -> bytes:
2203
+ def _build_userdata_success_response(
2204
+ self, request: Dict[str, Any], userdata_params: Dict[str, Any], data: bytes, null_ack: bool = False
2205
+ ) -> bytes:
2202
2206
  """
2203
2207
  Build USER_DATA success response PDU.
2204
2208
 
@@ -2206,6 +2210,8 @@ class Server:
2206
2210
  request: Original request
2207
2211
  userdata_params: Parsed USER_DATA parameters
2208
2212
  data: Response data
2213
+ null_ack: Acknowledge with the no-data form a real PLC uses for services that return nothing
2214
+ (return code 0x0A, transport size 0, length 0) instead of 0xFF/0x09
2209
2215
 
2210
2216
  Returns:
2211
2217
  Success response PDU
@@ -2232,7 +2238,10 @@ class Server:
2232
2238
  )
2233
2239
 
2234
2240
  # Data section: return code (0xFF = success) + data
2235
- data_section = struct.pack(">BBH", 0xFF, 0x09, len(data)) + data
2241
+ if null_ack:
2242
+ data_section = struct.pack(">BBH", 0x0A, 0x00, 0)
2243
+ else:
2244
+ data_section = struct.pack(">BBH", 0xFF, 0x09, len(data)) + data
2236
2245
 
2237
2246
  # Build S7 header for USERDATA (10 bytes, no error_class/error_code in header)
2238
2247
  header = struct.pack(
snap7/szl.py CHANGED
@@ -104,7 +104,7 @@ def _parse_order_code_structured(data: bytes, record_len: int, ndr: int, order_c
104
104
 
105
105
  if record_id == 0x0001 and record_len >= 22:
106
106
  found_structured = True
107
- order_code.OrderCode = rec[2:22].rstrip(b"\x00")
107
+ order_code.OrderCode = rec[2:22].rstrip(b"\x00 ")
108
108
  if record_len >= 26 and not has_0x0007 and (rec[23] != 0 or rec[24] != 0):
109
109
  order_code.V1, order_code.V2, order_code.V3 = rec[23], rec[24], rec[25]
110
110
 
snap7/util/getters.py CHANGED
@@ -218,6 +218,10 @@ def get_string(bytearray_: Buffer, byte_index: int, encoding: str = "latin-1") -
218
218
  "String contains {str_length} chars, but max. {max_string_size} chars are expected or is "
219
219
  "larger than 254. Bytearray doesn't seem to be a valid string."
220
220
  )
221
+ if byte_index + 2 + str_length > len(bytearray_):
222
+ raise ValueError(
223
+ f"String contains {str_length} chars, but only {len(bytearray_) - byte_index - 2} bytes are in the buffer"
224
+ )
221
225
  raw = bytes(bytearray_[byte_index + 2 : byte_index + 2 + str_length])
222
226
  return raw.decode(encoding)
223
227
 
@@ -781,17 +785,22 @@ def get_wstring(bytearray_: Buffer, byte_index: int) -> str:
781
785
 
782
786
  max_wstring_size = bytearray_[byte_index : byte_index + 2]
783
787
  packed = struct.pack("2B", *max_wstring_size)
784
- max_wstring_symbols = struct.unpack(">H", packed)[0] * 2
788
+ max_wstring_chars = struct.unpack(">H", packed)[0]
785
789
 
786
790
  wstr_length_raw = bytearray_[byte_index + 2 : byte_index + 4]
787
- wstr_symbols_amount = struct.unpack(">H", struct.pack("2B", *wstr_length_raw))[0] * 2
791
+ wstr_chars = struct.unpack(">H", struct.pack("2B", *wstr_length_raw))[0]
788
792
 
789
- if wstr_symbols_amount > max_wstring_symbols or max_wstring_symbols > 16382:
793
+ if wstr_chars > max_wstring_chars or max_wstring_chars > 16382:
790
794
  logger.error("The wstring is too big for the size encountered in specification")
791
795
  logger.error("WRONG SIZED STRING ENCOUNTERED")
792
796
  raise TypeError(
793
- f"WString contains {wstr_symbols_amount} chars, but max {max_wstring_symbols} chars are "
797
+ f"WString contains {wstr_chars} chars, but max {max_wstring_chars} chars are "
794
798
  f"expected or is larger than 16382. Bytearray doesn't seem to be a valid string."
795
799
  )
796
800
 
797
- return bytes(bytearray_[wstring_start : wstring_start + wstr_symbols_amount]).decode("utf-16-be")
801
+ wstr_bytes = wstr_chars * 2
802
+ if wstring_start + wstr_bytes > len(bytearray_):
803
+ raise ValueError(
804
+ f"WString contains {wstr_chars} chars, but only {len(bytearray_) - wstring_start} bytes are in the buffer"
805
+ )
806
+ return bytes(bytearray_[wstring_start : wstring_start + wstr_bytes]).decode("utf-16-be")