Skip to content

Commit 69005e6

Browse files
committed
fix(s7commplus): decode struct PValues per the real wire format (packed + normal)
decode_pvalue_to_bytes treated every Struct (0x17) as a VLQ element count followed by that many nested PValues. Real S7-1500 structs use neither shape: the leading struct id is a fixed UInt32, and the body comes in two forms (per ValueStruct.Deserialize in the thomas-v2/S7CommPlusDriver reference): - Packed struct — system datatypes and optimized-DB struct reads, id in 0x90000000..0x9fffffff or 0x02000000..0x02ffffff: a UInt64 interface timestamp, VLQ transport flags, a VLQ element count (a second count follows when the Count2Present flag is set), then the members as one raw byte blob, returned verbatim for the caller to interpret using the struct's layout. - Normal struct — members as [VLQ key][PValue], terminated by a 0 key. Verified against a real S7-1500: reading a struct node now returns the packed member bytes, which decode to the expected scalar values (matching the individual leaf reads). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 999c065 commit 69005e6

2 files changed

Lines changed: 76 additions & 10 deletions

File tree

s7/codec.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -466,14 +466,45 @@ def decode_pvalue_to_bytes(data: bytes, offset: int) -> tuple[bytes, int]:
466466
consumed += length
467467
return bytes(raw), consumed
468468
elif datatype == DataType.STRUCT:
469-
# Struct: read count, then nested PValues
470-
count, c = decode_uint32_vlq(data, offset + consumed)
471-
consumed += c
469+
# Struct value. Mirrors ValueStruct.Deserialize in the C# reference driver
470+
# (thomas-v2/S7CommPlusDriver, Core/PValue.cs).
471+
#
472+
# The leading struct id is a fixed UInt32 (not VLQ). Two transmission forms:
473+
#
474+
# * Packed struct — for system datatypes (DTL, optimized-DB structs, ...) whose
475+
# members are sent as one opaque blob. Detected by the id falling in the ranges
476+
# 0x90000000..0x9fffffff or 0x02000000..0x02ffffff. Layout: UInt64 interface
477+
# timestamp, VLQ transport flags, VLQ element count (a second count follows when
478+
# the Count2Present flag, bit 10, is set), then `count` raw bytes. We return those
479+
# raw bytes verbatim — the caller interprets them using the struct's member layout.
480+
#
481+
# * Normal struct — members as [VLQ key][PValue], terminated by a key of 0.
482+
struct_id = int.from_bytes(data[offset + consumed : offset + consumed + 4], "big")
483+
consumed += 4
484+
485+
if (0x90000000 < struct_id < 0x9FFFFFFF) or (0x02000000 < struct_id < 0x02FFFFFF):
486+
consumed += 8 # PackedStructInterfaceTimestamp (UInt64, fixed)
487+
transport_flags, c = decode_uint32_vlq(data, offset + consumed)
488+
consumed += c
489+
count, c = decode_uint32_vlq(data, offset + consumed)
490+
consumed += c
491+
if transport_flags & 0x400: # Count2Present: a second count follows
492+
count, c = decode_uint32_vlq(data, offset + consumed)
493+
consumed += c
494+
raw = data[offset + consumed : offset + consumed + count]
495+
consumed += count
496+
return bytes(raw), consumed
497+
498+
# Normal struct: concatenate member values, stopping at the 0 key terminator.
472499
result = bytearray()
473-
for _ in range(count):
500+
key, c = decode_uint32_vlq(data, offset + consumed)
501+
consumed += c
502+
while key > 0:
474503
val_bytes, c = decode_pvalue_to_bytes(data, offset + consumed)
475504
consumed += c
476505
result += val_bytes
506+
key, c = decode_uint32_vlq(data, offset + consumed)
507+
consumed += c
477508
return bytes(result), consumed
478509
else:
479510
raise ValueError(f"Unsupported PValue datatype: {datatype:#04x}")

tests/test_s7_codec.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,49 @@ def test_wstring(self) -> None:
538538
result, consumed = decode_pvalue_to_bytes(data, 0)
539539
assert result == text
540540

541-
def test_struct_nested(self) -> None:
542-
# Struct with 2 USINT elements
543-
vlq_count = encode_uint32_vlq(2)
544-
elem1 = bytes([0x00, DataType.USINT, 0x0A])
545-
elem2 = bytes([0x00, DataType.USINT, 0x14])
546-
data = bytes([0x00, DataType.STRUCT]) + vlq_count + elem1 + elem2
541+
def test_struct_normal(self) -> None:
542+
# Normal struct: UInt32 id, then [VLQ key][PValue] members, 0-key terminated.
543+
struct_id = struct.pack(">I", 0x00000001)
544+
member1 = encode_uint32_vlq(1) + bytes([0x00, DataType.USINT, 0x0A])
545+
member2 = encode_uint32_vlq(2) + bytes([0x00, DataType.USINT, 0x14])
546+
terminator = encode_uint32_vlq(0)
547+
data = bytes([0x00, DataType.STRUCT]) + struct_id + member1 + member2 + terminator
547548
result, consumed = decode_pvalue_to_bytes(data, 0)
548549
assert result == bytes([0x0A, 0x14])
550+
assert consumed == len(data)
551+
552+
def test_struct_packed(self) -> None:
553+
# Packed struct (system datatype): id in 0x90000000..0x9fffffff, then UInt64 interface
554+
# timestamp, VLQ transport flags, VLQ element count, then raw member bytes returned
555+
# verbatim. Mirrors a real S7-1500 read of a struct node (e.g. a BMS summary block).
556+
struct_id = struct.pack(">I", 0x91080009)
557+
timestamp = bytes.fromhex("58936099d03e9bd4")
558+
transport_flags = encode_uint32_vlq(0x03)
559+
members = struct.pack(">3h", 0, 20000, 1023) # SystemVoltage, SystemCurrent, SOC
560+
count = encode_uint32_vlq(len(members))
561+
data = bytes([0x00, DataType.STRUCT]) + struct_id + timestamp + transport_flags + count + members
562+
result, consumed = decode_pvalue_to_bytes(data, 0)
563+
assert result == members
564+
assert consumed == len(data)
565+
566+
def test_struct_packed_count2_present(self) -> None:
567+
# When the Count2Present transport flag (bit 10) is set, a second count follows.
568+
struct_id = struct.pack(">I", 0x91080009)
569+
timestamp = bytes(8)
570+
transport_flags = encode_uint32_vlq(0x402) # AlwaysSet | Count2Present
571+
members = bytes([0xAA, 0xBB])
572+
data = (
573+
bytes([0x00, DataType.STRUCT])
574+
+ struct_id
575+
+ timestamp
576+
+ transport_flags
577+
+ encode_uint32_vlq(999) # first count (ignored when Count2Present)
578+
+ encode_uint32_vlq(len(members)) # second count is the real one
579+
+ members
580+
)
581+
result, consumed = decode_pvalue_to_bytes(data, 0)
582+
assert result == members
583+
assert consumed == len(data)
549584

550585
def test_unsupported_type(self) -> None:
551586
data = bytes([0x00, 0xFF])

0 commit comments

Comments
 (0)