Skip to content

Commit 0669b39

Browse files
committed
fix(s7commplus): correct V2 wire format for symbolic reads on a real S7-1500
The S7CommPlus V2 path (symbolic LID access, GetMultiVariables, session setup) was merged without testing against real hardware. Validated against a TIA V17 S7-1500, several framing assumptions turned out wrong — and the client and the bundled emulator were consistently wrong *together*, so the round-trip tests passed without actually being correct. Client (s7/connection.py) and async client (s7/_s7commplus_async_client.py): - Response data header is 10 bytes (opcode + reserved + function + reserved + seqnr + transport); responses carry NO SessionId field (requests do, hence their 14-byte header). We were stripping 14, corrupting every parsed payload and rejecting short (e.g. write) responses as "too short". - Session id is ObjectIds[0] from the CreateObject response body, not the header SessionId field. - ServerSessionVersion is a Struct (0x17) on real PLCs, not a scalar UDInt: capture its raw typed value and echo it back verbatim during session setup. - IntegrityId (V2+) travels at the END of the request payload, just before the trailing UInt32 — not immediately after the header. - GetMultiVariables requests use transport flag 0x34 (reads); 0x36 otherwise. - Normal-mode struct skip: UInt32 struct-id + members [VLQ key + typed value] terminated by a 0x00 byte (was assuming a VLQ element count). - Data PDUs over TLS use ProtocolVersion V2 after the V1 CreateObject. Emulator (s7/_s7commplus_server.py) corrected to speak the same wire format (10-byte response header, ObjectIds in the CreateObject body, flags-prefixed PValues, IntegrityId at the payload tail) so the round-trip tests validate the corrected protocol instead of a shared mistake. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 999c065 commit 0669b39

4 files changed

Lines changed: 248 additions & 164 deletions

File tree

s7/_s7commplus_async_client.py

Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@
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+
5266
class 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

Comments
 (0)