Skip to content

Commit f0e47eb

Browse files
committed
fix: browse() and list_datablocks() for V3 multi-frame EXPLORE (S7-1200 FW V4.5)
On V3 PLCs (FW >= V4.5) the EXPLORE response for RID 0x8A11FFFF spans multiple TPKT frames and uses a zlib-compressed PlcContentInfo XML format instead of the PObject tree expected by _parse_explore_datablocks(). The existing reassemble=True path does not strip V3 HMAC prefixes from continuation frames, so list_datablocks() returned [] on these PLCs. Changes: connection.py: - Add collect_explore_frames(): collects V3 multi-fragment EXPLORE responses by receiving continuation frames and stripping their HMAC prefix, stopping when a shorter-than-reference frame is detected. _s7commplus_client.py: - Add _build_explore_payload_v3(): VLQ-encoded EXPLORE payload for V3 PLCs (required format for 0x8A11FFFF and per-DB RID explores). - Add _parse_explore_datablocks_xml(): decompresses the zlib PlcContentInfo XML blob and extracts Entity[@id="Block"][@type="DB"] entries; falls back to _parse_explore_datablocks() when no zlib magic is found. - list_datablocks(): when protocol_version >= V3, use _build_explore_payload_v3 + collect_explore_frames + _parse_explore_datablocks_xml. - browse(): when protocol_version >= V3, use V3 payload builder and frame collector for each per-DB EXPLORE. - _parse_explore_fields(): three fixes for V3 PLCs: * Accept WSTRING dtype 0x15 in addition to 0x13 for name attributes. * Auto-detect encoding: UTF-8 (V3, no null bytes) vs UTF-16-BE (V1/V2). * BLOB skip: account for the extra 0x00 byte V3 PLCs insert before VLQ len. * WSTRING skip: advance past string data bytes (was only skipping VLQ). Tested on S7-1200 CPU 1212C DC/DC/DC, firmware V4.5 (V3 protocol, no TLS): - list_datablocks() now returns [{"name": "Data_block_1", "number": 100, "rid": 2316173412}] where it previously returned []. - The PlcContentInfo XML (6131 bytes after decompression) is correctly parsed from a 3-frame response (first 946-byte frame + two continuations). Known limitation: on FW V4.5, DB field definitions and I/Q/M tag names are stored in zlib BLOBs with a Siemens preset dictionary (magic 78 7D, FDICT flag set). Python zlib.decompress() returns Z_NEED_DICT. browse() returns DB names/numbers but cannot enumerate individual field names on V3 PLCs.
1 parent 34ef7b6 commit f0e47eb

2 files changed

Lines changed: 170 additions & 7 deletions

File tree

s7/_s7commplus_client.py

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any, Optional
1313

1414
from .connection import S7CommPlusConnection
15-
from .protocol import FunctionCode, Ids, ElementID, DataType, ObjectId
15+
from .protocol import FunctionCode, Ids, ElementID, DataType, ObjectId, ProtocolVersion
1616
from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq
1717
from .codec import (
1818
encode_item_address,
@@ -365,6 +365,15 @@ def list_datablocks(self) -> list[dict[str, Any]]:
365365
if self._connection is None:
366366
raise RuntimeError("Not connected")
367367

368+
if self._connection._protocol_version >= ProtocolVersion.V3:
369+
# V3 PLCs (FW >= V4.5): EXPLORE 0x8A11FFFF returns a multi-frame
370+
# zlib-compressed PlcContentInfo XML blob. The existing reassemble
371+
# path does not strip V3 HMAC prefixes, so we collect frames manually.
372+
payload = _build_explore_payload_v3(0x8A11FFFF)
373+
first_response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5)
374+
response = self._connection.collect_explore_frames(first_response)
375+
return _parse_explore_datablocks_xml(response)
376+
368377
payload = _build_explore_request(Ids.NATIVE_THE_PLC_PROGRAM_RID, [Ids.OBJECT_VARIABLE_TYPE_NAME, Ids.BLOCK_BLOCK_NUMBER])
369378
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
370379
return _parse_explore_datablocks(response)
@@ -394,9 +403,17 @@ def browse(self) -> list[dict[str, Any]]:
394403
db_rid = db_info.get("rid", 0)
395404
if db_rid == 0:
396405
continue
397-
payload = _build_explore_request(db_rid, [Ids.OBJECT_VARIABLE_TYPE_NAME])
406+
is_v3 = self._connection._protocol_version >= ProtocolVersion.V3
407+
if is_v3:
408+
payload = _build_explore_payload_v3(db_rid)
409+
else:
410+
payload = _build_explore_request(db_rid, [Ids.OBJECT_VARIABLE_TYPE_NAME])
398411
try:
399-
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
412+
if is_v3:
413+
first_response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5)
414+
response = self._connection.collect_explore_frames(first_response)
415+
else:
416+
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
400417
fields = _parse_explore_fields(response, db_info["number"], db_info["name"])
401418
variables.extend(fields)
402419
except Exception:
@@ -763,6 +780,80 @@ def _build_explore_request(explore_id: int, attribute_ids: list[int]) -> bytes:
763780
return bytes(payload)
764781

765782

783+
def _build_explore_payload_v3(explore_id: int) -> bytes:
784+
"""Build a V3-style EXPLORE payload targeting a specific RID.
785+
786+
V3 PLCs (FW >= V4.5) use a compact VLQ-encoded format instead of
787+
the fixed big-endian layout of _build_explore_request(). The RID
788+
0x8A11FFFF triggers the PLC to return a ``PlcContentInfo`` XML blob
789+
compressed with zlib (magic ``78 DA``) spanning multiple TPKT frames.
790+
791+
Args:
792+
explore_id: RID to explore (e.g. ``0x8A11FFFF`` for all blocks).
793+
794+
Returns:
795+
Encoded EXPLORE payload.
796+
"""
797+
payload = bytearray()
798+
payload += encode_uint32_vlq(explore_id)
799+
# Trailing UInt32 fill + filler byte (same tail as _build_explore_request)
800+
payload += struct.pack(">I", 0) + bytes([0])
801+
return bytes(payload)
802+
803+
804+
def _parse_explore_datablocks_xml(response: bytes) -> list[dict[str, Any]]:
805+
"""Parse a V3 EXPLORE response containing a zlib-compressed PlcContentInfo XML blob.
806+
807+
On V3 PLCs the ``0x8A11FFFF`` EXPLORE returns a ``PlcContentInfo`` XML
808+
document compressed with standard zlib (magic ``78 DA``) embedded inside a
809+
large BLOB attribute that spans multiple TPKT frames. This parser locates
810+
the zlib header in the concatenated response, decompresses it, and extracts
811+
DB entities.
812+
813+
Falls back to :func:`_parse_explore_datablocks` when no ``78 DA`` magic is
814+
found so that V1/V2 responses are handled transparently.
815+
816+
Returns:
817+
List of dicts: ``{"name": str, "number": int, "rid": int}``
818+
"""
819+
import zlib
820+
import xml.etree.ElementTree as ET
821+
822+
zlib_pos = response.find(b"\x78\xda")
823+
if zlib_pos < 0:
824+
logger.debug("_parse_explore_datablocks_xml: no zlib magic, falling back to PObject parser")
825+
return _parse_explore_datablocks(response)
826+
827+
try:
828+
xml_bytes = zlib.decompress(response[zlib_pos:])
829+
except zlib.error as exc:
830+
logger.debug(f"_parse_explore_datablocks_xml: zlib error {exc}")
831+
return []
832+
833+
try:
834+
root = ET.fromstring(xml_bytes.decode("utf-8"))
835+
except Exception as exc:
836+
logger.debug(f"_parse_explore_datablocks_xml: XML parse error {exc}")
837+
return []
838+
839+
datablocks: list[dict[str, Any]] = []
840+
for entity in root.findall('.//Entity[@Id="Block"]'):
841+
header = entity.find("Header")
842+
if header is None or header.get("Type") != "DB":
843+
continue
844+
name = header.get("Name", "")
845+
try:
846+
number = int(header.get("Number", "0"))
847+
rid = int(entity.get("Rid", "0"))
848+
except ValueError:
849+
continue
850+
if name and number > 0:
851+
datablocks.append({"name": name, "number": number, "rid": rid})
852+
853+
logger.debug(f"_parse_explore_datablocks_xml: found {len(datablocks)} DB(s)")
854+
return datablocks
855+
856+
766857
def _parse_explore_datablocks(response: bytes) -> list[dict[str, Any]]:
767858
"""Parse an EXPLORE(thePLCProgram) response to extract datablock info.
768859
@@ -910,26 +1001,48 @@ def _parse_explore_fields(response: bytes, db_number: int, db_name: str) -> list
9101001
datatype = response[offset + 1]
9111002
offset += 2
9121003

913-
if attr_id == Ids.OBJECT_VARIABLE_TYPE_NAME and datatype == 0x13:
1004+
if attr_id == Ids.OBJECT_VARIABLE_TYPE_NAME and datatype in (0x13, 0x15): # S7String / WSTRING
9141005
if offset >= len(response):
9151006
break
9161007
str_len, consumed = _vlq32(response, offset)
9171008
offset += consumed
9181009
if offset + str_len <= len(response):
1010+
raw_str = response[offset : offset + str_len]
9191011
try:
920-
field_name = response[offset : offset + str_len].decode("utf-16-be", errors="replace")
1012+
# V3 PLCs send UTF-8; V1/V2 send UTF-16-BE.
1013+
# UTF-16-BE always contains null bytes for ASCII names;
1014+
# UTF-8 ASCII names never do — use that as the discriminator.
1015+
if b"\x00" in raw_str:
1016+
field_name = raw_str.decode("utf-16-be", errors="replace").rstrip("\x00")
1017+
else:
1018+
field_name = raw_str.decode("utf-8", errors="replace")
9211019
except Exception:
9221020
field_name = ""
9231021
offset += str_len
9241022
continue
9251023

926-
# Skip attribute value
927-
if flags & 0x10:
1024+
# Skip attribute value. V3 PLCs insert an extra 0x00 byte before
1025+
# the VLQ length of BLOB (0x14) attributes; WSTRING (0x15) skip
1026+
# must also advance past the string data bytes.
1027+
if flags & 0x10: # array
9281028
if offset >= len(response):
9291029
break
9301030
count, consumed = _vlq32(response, offset)
9311031
offset += consumed
9321032
offset += count
1033+
elif datatype == 0x14: # BLOB — V3 adds an extra 0x00 before VLQ length
1034+
if offset >= len(response):
1035+
break
1036+
offset += 1 # extra 0x00 byte present in V3 encoding
1037+
if offset >= len(response):
1038+
break
1039+
blob_len, consumed = _vlq32(response, offset)
1040+
offset += consumed + blob_len
1041+
elif datatype in (0x13, 0x15): # S7String / WSTRING not matched above
1042+
if offset >= len(response):
1043+
break
1044+
str_len, consumed = _vlq32(response, offset)
1045+
offset += consumed + str_len
9331046
else:
9341047
if offset >= len(response):
9351048
break

s7/connection.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,56 @@ def _send_legitimation_legacy(self, response: bytes) -> None:
408408
raise S7ConnectionError(f"Legacy legitimation rejected by PLC: return_value={return_value}")
409409
logger.debug(f"Legacy legitimation return_value={return_value}")
410410

411+
def collect_explore_frames(self, first_payload: bytes) -> bytes:
412+
"""Collect multi-fragment EXPLORE continuation frames for V3 PLCs.
413+
414+
On V3 PLCs (FW >= V4.5) a large EXPLORE response (e.g. RID 0x8A11FFFF)
415+
spans multiple TPKT frames. The first frame is the normal response
416+
(already stripped of its 10-byte header by send_request). Continuation
417+
frames carry **no** response header — they are raw BLOB data protected
418+
only by a V3 HMAC prefix. The caller must concatenate them before
419+
parsing.
420+
421+
Detection of the last fragment: a frame whose body (after HMAC strip)
422+
is measurably shorter than the first frame body is the last fragment.
423+
We use a 5-byte tolerance to absorb minor size jitter.
424+
425+
Args:
426+
first_payload: First EXPLORE response payload, already returned by
427+
send_request() (10-byte response header already stripped).
428+
429+
Returns:
430+
All fragment payloads concatenated (first_payload + continuations).
431+
"""
432+
# The first frame body (already header-stripped) was originally
433+
# len(first_payload) + 10 bytes on the wire (10-byte response header).
434+
# Continuation frames of the same "full" size will be that long after
435+
# HMAC strip; a shorter body signals the last fragment.
436+
reference_size = len(first_payload) + 10
437+
all_data = first_payload
438+
while True:
439+
try:
440+
raw = self._recv_s7_data()
441+
if not raw:
442+
break
443+
# Strip the 4-byte S7CommPlus fragment header (0x72 ver len:2)
444+
if len(raw) < 4 or raw[0] != 0x72:
445+
break
446+
frag_len = (raw[2] << 8) | raw[3]
447+
body = raw[4 : 4 + frag_len]
448+
# V3 non-TLS: strip the HMAC prefix ([hash_len][hash_bytes])
449+
if self._protocol_version >= ProtocolVersion.V3 and len(body) > 33:
450+
hash_len = body[0]
451+
body = body[1 + hash_len :]
452+
if not body:
453+
break
454+
all_data += body
455+
if len(body) < reference_size - 5:
456+
break # last fragment
457+
except Exception:
458+
break
459+
return all_data
460+
411461
def disconnect(self) -> None:
412462
"""Disconnect from PLC."""
413463
if self._connected and self._session_id:

0 commit comments

Comments
 (0)