4949_COTP_DT = 0xF0
5050
5151
52+ def _element_size (datatype : int ) -> int :
53+ """Return the fixed byte size for an array element, or 0 for variable-length."""
54+ if datatype in (DataType .BOOL , DataType .USINT , DataType .BYTE , DataType .SINT ):
55+ return 1
56+ elif datatype in (DataType .UINT , DataType .WORD , DataType .INT ):
57+ return 2
58+ elif datatype in (DataType .REAL , DataType .RID ):
59+ return 4
60+ elif datatype in (DataType .LREAL , DataType .TIMESTAMP ):
61+ return 8
62+ else :
63+ return 0
64+
65+
5266class S7CommPlusAsyncClient :
5367 """Pure async S7CommPlus client without legacy fallback.
5468
@@ -72,7 +86,10 @@ def __init__(self) -> None:
7286 # TLS state
7387 self ._tls_active : bool = False
7488 self ._oms_secret : Optional [bytes ] = None
75- self ._server_session_version : Optional [int ] = None
89+ # ServerSessionVersion is captured as its raw typed value (flags+datatype+data)
90+ # so it can be echoed back verbatim — real S7-1500 PLCs send it as a Struct.
91+ self ._server_session_version : Optional [bytes ] = None
92+ self ._server_session_version_raw : Optional [bytes ] = None
7693 self ._session_setup_ok : bool = False
7794
7895 @property
@@ -145,6 +162,11 @@ async def connect(
145162 # Step 4: S7CommPlus session setup (CreateObject)
146163 await self ._create_session ()
147164
165+ # After CreateObject (which always uses V1 framing), data PDUs over TLS
166+ # use ProtocolVersion V2 on a real S7-1500 (matches the C# reference driver).
167+ if self ._tls_active :
168+ self ._protocol_version = ProtocolVersion .V2
169+
148170 # Step 5: Version-specific validation
149171 if self ._protocol_version >= ProtocolVersion .V3 :
150172 if not use_tls :
@@ -376,6 +398,7 @@ async def disconnect(self) -> None:
376398 self ._tls_active = False
377399 self ._oms_secret = None
378400 self ._server_session_version = None
401+ self ._server_session_version_raw = None
379402 self ._session_setup_ok = False
380403
381404 if self ._writer :
@@ -484,7 +507,7 @@ async def _send_request(self, function_code: int, payload: bytes) -> bytes:
484507 0x0000 ,
485508 seq_num ,
486509 self ._session_id ,
487- 0x36 ,
510+ 0x34 if function_code == FunctionCode . GET_MULTI_VARIABLES else 0x36 ,
488511 )
489512
490513 integrity_id_bytes = b""
@@ -493,7 +516,12 @@ async def _send_request(self, function_code: int, payload: bytes) -> bytes:
493516 integrity_id = self ._integrity_id_read if is_read else self ._integrity_id_write
494517 integrity_id_bytes = encode_uint32_vlq (integrity_id )
495518
496- request = request_header + integrity_id_bytes + payload
519+ # For V2+ the IntegrityId is spliced in just before the payload's trailing
520+ # UInt32 (i.e. at the end), not right after the header.
521+ if integrity_id_bytes and len (payload ) >= 4 :
522+ request = request_header + payload [:- 4 ] + integrity_id_bytes + payload [- 4 :]
523+ else :
524+ request = request_header + integrity_id_bytes + payload
497525
498526 frame = encode_header (self ._protocol_version , len (request )) + request
499527 frame += struct .pack (">BBH" , 0x72 , self ._protocol_version , 0x0000 )
@@ -510,16 +538,13 @@ async def _send_request(self, function_code: int, payload: bytes) -> bytes:
510538 version , data_length , consumed = decode_header (response_data )
511539 response = response_data [consumed : consumed + data_length ]
512540
513- if len (response ) < 14 :
541+ if len (response ) < 10 :
514542 raise RuntimeError ("Response too short" )
515543
516- resp_offset = 14
517- if self ._with_integrity_id and self ._protocol_version >= ProtocolVersion .V2 :
518- if resp_offset < len (response ):
519- _resp_iid , iid_consumed = decode_uint32_vlq (response , resp_offset )
520- resp_offset += iid_consumed
521-
522- return response [resp_offset :]
544+ # RESPONSE header is 10 bytes (opcode+res+func+res+seqnr+transport) — responses
545+ # carry no SessionId field (requests do, hence their 14-byte header). For V2+ the
546+ # IntegrityId travels at the END of the payload and is ignored by the parsers.
547+ return response [10 :]
523548
524549 async def _cotp_connect (self , local_tsap : int , remote_tsap : bytes ) -> None :
525550 """Perform COTP Connection Request / Confirm handshake."""
@@ -601,7 +626,7 @@ async def _create_session(self) -> None:
601626
602627 request += bytes ([ElementID .ATTRIBUTE ])
603628 request += encode_uint32_vlq (ObjectId .SERVER_SESSION_CLIENT_RID )
604- request += encode_typed_value (DataType .RID , 0x80C3C901 )
629+ request += bytes ([ 0x00 ]) + encode_typed_value (DataType .RID , 0x80C3C901 )
605630
606631 request += bytes ([ElementID .START_OF_OBJECT ])
607632 request += struct .pack (">I" , ObjectId .GET_NEW_RID_ON_SERVER )
@@ -624,10 +649,27 @@ async def _create_session(self) -> None:
624649 if len (response ) < 14 :
625650 raise RuntimeError ("CreateObject response too short" )
626651
627- self ._session_id = struct .unpack_from (">I" , response , 9 )[0 ]
652+ # Parse response body: ReturnValue(VLQ) + ObjectIdCount + ObjectIds(VLQ).
653+ # The usable session id is ObjectIds[0] (NOT the header SessionId field).
654+ body = response [14 :]
655+ boff = 0
656+ while boff < len (body ) and (body [boff ] & 0x80 ): # skip ReturnValue VLQ
657+ boff += 1
658+ boff += 1
659+ obj_count = body [boff ] if boff < len (body ) else 0
660+ boff += 1
661+ object_ids = []
662+ for _ in range (obj_count ):
663+ oid , _c = decode_uint32_vlq (body , boff )
664+ boff += _c
665+ object_ids .append (oid )
666+ if object_ids :
667+ self ._session_id = object_ids [0 ]
668+ else :
669+ self ._session_id = struct .unpack_from (">I" , response , 9 )[0 ]
628670 self ._protocol_version = version
629671
630- self ._parse_create_object_response (response [14 :])
672+ self ._parse_create_object_response (response [14 + boff :])
631673
632674 def _parse_create_object_response (self , payload : bytes ) -> None :
633675 """Parse CreateObject response to extract ServerSessionVersion (attribute 306)."""
@@ -643,26 +685,29 @@ def _parse_create_object_response(self, payload: bytes) -> None:
643685 offset += consumed
644686
645687 if attr_id == ObjectId .SERVER_SESSION_VERSION :
688+ # Capture the raw typed value (flags+datatype+data) to echo back —
689+ # real S7-1500 PLCs send ServerSessionVersion as a Struct (0x17).
646690 if offset + 2 > len (payload ):
647691 break
692+ value_start = offset
648693 _flags = payload [offset ]
649694 datatype = payload [offset + 1 ]
650- offset += 2
651- if datatype in (DataType .UDINT , DataType .DWORD ):
652- value , consumed = decode_uint32_vlq (payload , offset )
653- offset += consumed
654- self ._server_session_version = value
655- logger .info (f"ServerSessionVersion = { value } " )
656- return
695+ end = self ._skip_typed_value (payload , offset + 2 , datatype , _flags )
696+ self ._server_session_version_raw = bytes (payload [value_start :end ])
697+ self ._server_session_version = self ._server_session_version_raw
698+ offset = end
699+ logger .info (
700+ f"ServerSessionVersion captured: type={ datatype :#04x} , { len (self ._server_session_version_raw )} bytes"
701+ )
702+ return
657703 else :
704+ # Skip this attribute's typed value (flags + datatype + value)
658705 if offset + 2 > len (payload ):
659706 break
660707 _flags = payload [offset ]
661- _dt = payload [offset + 1 ]
708+ datatype = payload [offset + 1 ]
662709 offset += 2
663- if offset < len (payload ):
664- _ , consumed = decode_uint32_vlq (payload , offset )
665- offset += consumed
710+ offset = self ._skip_typed_value (payload , offset , datatype , _flags )
666711
667712 elif tag == ElementID .START_OF_OBJECT :
668713 offset += 1
@@ -685,6 +730,72 @@ def _parse_create_object_response(self, payload: bytes) -> None:
685730
686731 logger .debug ("ServerSessionVersion not found in CreateObject response" )
687732
733+ def _skip_typed_value (self , data : bytes , offset : int , datatype : int , flags : int ) -> int :
734+ """Skip over a typed value in the PObject tree (best-effort). Returns new offset."""
735+ is_array = bool (flags & 0x10 )
736+
737+ if is_array :
738+ if offset >= len (data ):
739+ return offset
740+ count , consumed = decode_uint32_vlq (data , offset )
741+ offset += consumed
742+ elem_size = _element_size (datatype )
743+ if elem_size > 0 :
744+ offset += count * elem_size
745+ else :
746+ for _ in range (count ):
747+ if offset >= len (data ):
748+ break
749+ _ , consumed = decode_uint32_vlq (data , offset )
750+ offset += consumed
751+ return offset
752+
753+ if datatype == DataType .NULL :
754+ return offset
755+ elif datatype in (DataType .BOOL , DataType .USINT , DataType .BYTE , DataType .SINT ):
756+ return offset + 1
757+ elif datatype in (DataType .UINT , DataType .WORD , DataType .INT ):
758+ return offset + 2
759+ elif datatype in (DataType .UDINT , DataType .DWORD , DataType .AID , DataType .DINT ):
760+ _ , consumed = decode_uint32_vlq (data , offset )
761+ return offset + consumed
762+ elif datatype in (DataType .ULINT , DataType .LWORD , DataType .LINT ):
763+ _ , consumed = decode_uint64_vlq (data , offset )
764+ return offset + consumed
765+ elif datatype == DataType .REAL :
766+ return offset + 4
767+ elif datatype == DataType .LREAL :
768+ return offset + 8
769+ elif datatype == DataType .TIMESTAMP :
770+ return offset + 8
771+ elif datatype == DataType .TIMESPAN :
772+ _ , consumed = decode_uint64_vlq (data , offset )
773+ return offset + consumed
774+ elif datatype == DataType .RID :
775+ return offset + 4
776+ elif datatype in (DataType .BLOB , DataType .WSTRING ):
777+ length , consumed = decode_uint32_vlq (data , offset )
778+ return offset + consumed + length
779+ elif datatype == DataType .STRUCT :
780+ # Normal-mode struct: UInt32 struct-id, then members [VLQ key][typed value],
781+ # terminated by a 0x00 list-terminator byte (keys always start with high bit set).
782+ offset += 4 # struct id (UInt32, not VLQ)
783+ while offset < len (data ):
784+ if data [offset ] == 0x00 :
785+ offset += 1
786+ break
787+ _key , consumed = decode_uint32_vlq (data , offset )
788+ offset += consumed
789+ if offset + 2 > len (data ):
790+ break
791+ sub_flags = data [offset ]
792+ sub_type = data [offset + 1 ]
793+ offset += 2
794+ offset = self ._skip_typed_value (data , offset , sub_type , sub_flags )
795+ return offset
796+ else :
797+ return offset
798+
688799 async def _setup_session (self ) -> bool :
689800 """Echo ServerSessionVersion back to the PLC via SetMultiVariables."""
690801 if self ._server_session_version is None :
@@ -696,8 +807,8 @@ async def _setup_session(self) -> bool:
696807 payload += encode_uint32_vlq (1 )
697808 payload += encode_uint32_vlq (ObjectId .SERVER_SESSION_VERSION )
698809 payload += encode_uint32_vlq (1 )
699- payload += bytes ([ 0x00 , DataType . UDINT ] )
700- payload += encode_uint32_vlq ( self ._server_session_version )
810+ # PValue: echo the ServerSessionVersion typed value verbatim (it may be a Struct )
811+ payload += self ._server_session_version_raw
701812 payload += bytes ([0x00 ])
702813 payload += encode_object_qualifier ()
703814 payload += struct .pack (">I" , 0 )
0 commit comments