From 36243ba42b63ebe0f8b461329c40b550461a83a3 Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 06:37:16 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20tag=5Fbrowser=20=E2=80=94=20parse?= =?UTF-8?q?=20I/Q/M=20symbolic=20tags=20from=20decompressed=20EXPLORE=20XM?= =?UTF-8?q?L?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #776: wires find_and_decompress() into a small non-invasive tag layer. Parses the clean XML into Tag records with TIA addresses (%I0.0, %Q0.1, %MB100, %MW102, %MD104). Includes a regression test built from a real S7-1200 G2 (FW V4.1) M-area EXPLORE blob. Co-Authored-By: Claude Opus 4.8 --- s7commplus/tag_browser.py | 92 +++++++++++++++++++++++++++++++++++++++ tests/test_tag_browser.py | 54 +++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 s7commplus/tag_browser.py create mode 100644 tests/test_tag_browser.py diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py new file mode 100644 index 00000000..e61866ab --- /dev/null +++ b/s7commplus/tag_browser.py @@ -0,0 +1,92 @@ +"""Parse symbolic I/Q/M tags from decompressed S7CommPlus EXPLORE responses. + +S7-1200/1500 PLCs answer an EXPLORE request for the I/Q/M areas with a +zlib blob compressed against the ``IntfDescTag`` preset dictionary +(Adler-32 ``0xce9b821b``). Once decompressed with +:func:`s7commplus.blob_decompressor.find_and_decompress`, the payload is a +clean ```` XML document:: + + + + Word + + + + + + +This module turns that XML into :class:`Tag` records with TIA-style +addresses (``%I0.0``, ``%Q0.1``, ``%MB100``, ``%MW102``, ``%MD104``). + +Validated end-to-end against a live S7-1200 G2 (FW V4.1). +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from dataclasses import dataclass + +from .blob_decompressor import find_and_decompress + +# EXPLORE relation IDs per area (S7-1200 V3 / FW V4.x). +AREA_RID = {"I": 80, "Q": 81, "M": 82} + +# -> TIA area letter. +_RANGE_LETTER = {"Input": "I", "Output": "Q", "Memory": "M"} +# -> TIA address suffix for non-bit widths. +_WIDTH_SUFFIX = {"Byte": "B", "Word": "W", "DWord": "D", "LWord": "L"} + + +@dataclass +class Tag: + """A symbolic PLC tag resolved from an EXPLORE response.""" + + name: str + data_type: str # , e.g. Bool / Byte / Word / DWord + address: str # TIA syntax, e.g. %I0.0 / %MB100 / %MW102 + lid: int + byte_offset: int + bit_offset: int | None + + +def _address(rng: str, width: str, byte_no: int, bit_no: int | None) -> str: + letter = _RANGE_LETTER.get(rng, "?") + if width == "Bit": + return f"%{letter}{byte_no}.{bit_no or 0}" + return f"%{letter}{_WIDTH_SUFFIX.get(width, '')}{byte_no}" + + +def parse_ident_container(xml_text: str) -> list[Tag]: + """Parse a decompressed ```` document into :class:`Tag`s.""" + tags: list[Tag] = [] + for ident in ET.fromstring(xml_text).findall("Ident"): + acc = ident.find("./Access/SimpleAccess") + if acc is None: + continue # only SimpleAccess idents carry a direct I/Q/M address + byte_no = int(acc.get("ByteNumber", "0")) + bit_raw = acc.get("BitNumber") + bit_no = int(bit_raw) if bit_raw is not None else None + tags.append( + Tag( + name=ident.get("Name", ""), + data_type=ident.findtext("SimpleType", default=""), + address=_address(acc.get("Range", ""), acc.get("Width", ""), byte_no, bit_no), + lid=int(ident.get("LID", "0")), + byte_offset=byte_no, + bit_offset=bit_no, + ) + ) + return tags + + +def tags_from_explore(explore_payload: bytes) -> list[Tag]: + """Decompress a raw EXPLORE payload and parse its symbolic tags. + + ``explore_payload`` is the (possibly multi-fragment) EXPLORE response for + one area. Returns an empty list if the payload contains no preset-dict + zlib stream. + """ + xml_text = find_and_decompress(explore_payload) + if not xml_text: + return [] + return parse_ident_container(xml_text) diff --git a/tests/test_tag_browser.py b/tests/test_tag_browser.py new file mode 100644 index 00000000..b49ffb55 --- /dev/null +++ b/tests/test_tag_browser.py @@ -0,0 +1,54 @@ +"""Tests for the symbolic tag browser. + +The fixture is a real preset-dictionary EXPLORE blob captured from a live +S7-1200 G2 (FW V4.1) M area, compressed against the shipped ``IntfDescTag`` +dictionary (Adler-32 ``0xce9b821b``). Decompressing it exercises the full +path: find_and_decompress -> clean XML -> Tag parsing. +""" + +import base64 + +from s7commplus.tag_browser import Tag, parse_ident_container, tags_from_explore + +# Live S7-1200 G2 (FW V4.1) EXPLORE response for the M area, from the zlib +# header onward: b"\x78\x7d" + dict-adler(0xce9b821b) + raw deflate. +_M_AREA_BLOB = base64.b64decode( + "eH3Om4Ibs7GvyM1RKEstKs7MzwNmJj1gMZCal5yfkpmXbqtUWpKma6Fkb2eDrgs5AHJTi7JTi2hVgOG" + "MSCNiii8sgQEPChQ/lABjMQloHHZPGBqi+QKokhJfIBdfBkguBzuAHKeXg1MAVqejlWCgtEI1pxthL3t" + "JcnoKHrejFWEuVHW8CfEZCE+aAaZnHI43o1XKN1BC84kFyWkfvRgAACgQWbajk3JAFAIAOJgAAAJ4fe" + "JynqGzsa/IzVEoSy0qBgoAvatnoKSQmpecn5KZl26rVFqSpmuhZI/NBiLNBwAy/SHEAKKiogAAAAA=" +) + + +def test_parse_ident_container_addresses(): + xml = ( + '' + 'Bool' + '' + 'Word' + '' + '' + ) + tags = parse_ident_container(xml) + assert tags == [ + Tag("input_1", "Bool", "%I0.0", 10, 0, 0), + Tag("mtag_word", "Word", "%MW102", 13, 102, None), + ] + + +def test_tags_from_live_g2_m_area(): + tags = tags_from_explore(_M_AREA_BLOB) + got = {t.name: (t.data_type, t.address) for t in tags} + assert got == { + "merker_1": ("Bool", "%M0.2"), + "mtag_byte": ("Byte", "%MB100"), + "mtag_word": ("Word", "%MW102"), + "mtag_dword": ("DWord", "%MD104"), + "mtag_bool": ("Bool", "%M108.0"), + } + + +def test_tags_from_explore_no_stream_returns_empty(): + assert tags_from_explore(b"no zlib stream here") == [] From 0ff05b9c16e72e070c02cef939d93370f79f0677 Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 09:52:47 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20tag=5Fbrowser=20=E2=80=94=20parse?= =?UTF-8?q?=20block=20interfaces=20(DB/FB/OB)=20and=20DB=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend tag_browser beyond I/Q/M symbols to data-block / FB / OB interfaces. - parse_block_interface(): parse the XML (DebugInfo_IntfDesc dict, Adler-32 0x66052b13) into Member records (name, type, LID, offset). A member's data type is a RID reference of the form 0x0200_00XX where XX is a Siemens SoftDataType id (mapping taken from thomas-v2/S7CommPlusDriver Core/Softdatatype.cs); complex types outside that namespace are kept as hex. - block_interface_from_explore(): a block EXPLORE response bundles several preset-dict streams (IdentES, interface, comments, ...), so target the interface dictionary explicitly via _find_preset_stream() instead of taking the first zlib stream. - datablocks_from_explore(): parse the DB listing from a 0x8A11FFFF wildcard EXPLORE. PlcContentInfo is a standard zlib stream (78 da) embedded in a larger BLOB, so it is raw-inflated to tolerate the trailing bytes. - I/Q/M path now targets the IntfDescTag dict (0xce9b821b) explicitly too. Validated end-to-end against a live S7-1200 G2 (FW V4.1): an optimized DB (Data_block_1, 11 members) resolves with correct names and Bool/Int/Real types. Tests use a real DB EXPLORE blob fixture plus a synthetic standard-zlib listing; 7/7 pass, ruff clean. --- s7commplus/tag_browser.py | 249 +++++++++++++++++++++++++++++++++----- tests/test_tag_browser.py | 115 ++++++++++++++++-- 2 files changed, 324 insertions(+), 40 deletions(-) diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py index e61866ab..325535c9 100644 --- a/s7commplus/tag_browser.py +++ b/s7commplus/tag_browser.py @@ -1,32 +1,34 @@ -"""Parse symbolic I/Q/M tags from decompressed S7CommPlus EXPLORE responses. - -S7-1200/1500 PLCs answer an EXPLORE request for the I/Q/M areas with a -zlib blob compressed against the ``IntfDescTag`` preset dictionary -(Adler-32 ``0xce9b821b``). Once decompressed with -:func:`s7commplus.blob_decompressor.find_and_decompress`, the payload is a -clean ```` XML document:: - - - - Word - - - - - - -This module turns that XML into :class:`Tag` records with TIA-style -addresses (``%I0.0``, ``%Q0.1``, ``%MB100``, ``%MW102``, ``%MD104``). - -Validated end-to-end against a live S7-1200 G2 (FW V4.1). +"""Parse symbolic tags and block interfaces from S7CommPlus EXPLORE responses. + +S7-1200/1500 PLCs answer an EXPLORE request with a zlib blob compressed +against a Siemens preset dictionary. Once decompressed with +:func:`s7commplus.blob_decompressor.decompress_blob`, the payload is clean +XML. Two shapes are handled here: + +* **I/Q/M areas** -> ```` with ```` entries that carry + a direct ```` and ```` address (dict + ``IntfDescTag`` / Adler-32 ``0xce9b821b``). Parsed into :class:`Tag`. + +* **Data blocks / FBs** -> ```` with ```` entries whose + data type is a *reference* (``RID="0x0200_00XX"``) into the Siemens + SoftDataType table, not an inline string (dict ``DebugInfo_IntfDesc`` / + Adler-32 ``0x66052b13``). Parsed into :class:`Member`. + +Validated end-to-end against a live S7-1200 G2 (FW V4.1): I/Q/M tags and an +optimized DB (``Data_block_1``, 11 members, names + Bool/Int/Real types). """ from __future__ import annotations import xml.etree.ElementTree as ET +import zlib from dataclasses import dataclass -from .blob_decompressor import find_and_decompress +from .blob_decompressor import decompress_blob + +# --------------------------------------------------------------------------- +# I/Q/M symbolic tags () +# --------------------------------------------------------------------------- # EXPLORE relation IDs per area (S7-1200 V3 / FW V4.x). AREA_RID = {"I": 80, "Q": 81, "M": 82} @@ -39,11 +41,11 @@ @dataclass class Tag: - """A symbolic PLC tag resolved from an EXPLORE response.""" + """A symbolic I/Q/M tag resolved from an EXPLORE response.""" name: str - data_type: str # , e.g. Bool / Byte / Word / DWord - address: str # TIA syntax, e.g. %I0.0 / %MB100 / %MW102 + data_type: str # , e.g. Bool / Byte / Word / DWord + address: str # TIA syntax, e.g. %I0.0 / %MB100 / %MW102 lid: int byte_offset: int bit_offset: int | None @@ -80,13 +82,196 @@ def parse_ident_container(xml_text: str) -> list[Tag]: def tags_from_explore(explore_payload: bytes) -> list[Tag]: - """Decompress a raw EXPLORE payload and parse its symbolic tags. + """Decompress a raw I/Q/M EXPLORE payload and parse its symbolic tags. - ``explore_payload`` is the (possibly multi-fragment) EXPLORE response for - one area. Returns an empty list if the payload contains no preset-dict - zlib stream. + Returns an empty list if the payload contains no preset-dict zlib stream. """ - xml_text = find_and_decompress(explore_payload) - if not xml_text: + pos = _find_preset_stream(explore_payload, _IDENT_ADLER) + if pos is None: return [] - return parse_ident_container(xml_text) + return parse_ident_container(decompress_blob(explore_payload, offset=pos)) + + +# --------------------------------------------------------------------------- +# Data-block / FB interfaces () +# --------------------------------------------------------------------------- + +# Adler-32 of the ``IntfDescTag`` preset dict (I/Q/M symbols). +_IDENT_ADLER = 0xCE9B821B +# Adler-32 of interface-description preset dicts. ``DebugInfo_IntfDesc`` carries +# the optimized (symbolic-access) block interface; ``IntfDesc`` the standard one. +_INTFDESC_ADLERS = (0x66052B13, 0x4B8416F0) + +# Siemens SoftDataType ids, from thomas-v2/S7CommPlusDriver Core/Softdatatype.cs +# (LGPL-3.0). A type is a RID of the form 0x0200_00XX where XX is the +# SoftDataType id below. +SOFTDATATYPE = { + 0: "Void", + 1: "Bool", + 2: "Byte", + 3: "Char", + 4: "Word", + 5: "Int", + 6: "DWord", + 7: "DInt", + 8: "Real", + 9: "Date", + 10: "TimeOfDay", + 11: "Time", + 12: "S5Time", + 13: "S5Count", + 14: "DateAndTime", + 15: "InternetTime", + 16: "Array", + 17: "Struct", + 18: "EndStruct", + 19: "String", + 20: "Pointer", + 21: "MultiFB", + 22: "Any", + 23: "BlockFB", + 24: "BlockFC", + 25: "BlockDB", + 26: "BlockSDB", + 28: "Counter", + 29: "Timer", + 30: "IEC_Counter", + 31: "IEC_Timer", + 37: "BlockUDT", + 40: "BBool", + 48: "LReal", + 49: "ULInt", + 50: "LInt", + 51: "LWord", + 52: "USInt", + 53: "UInt", + 54: "UDInt", + 55: "SInt", + 61: "WChar", + 62: "WString", + 63: "Variant", + 64: "LTime", + 65: "LTimeOfDay", + 66: "LDT", + 67: "DTL", +} + + +@dataclass +class Member: + """A single variable inside a data block or FB/OB interface.""" + + name: str + data_type: str # resolved SoftDataType name, or raw RID hex if complex + lid: int + offset: int # StdO: standard (non-optimized) byte offset + rid: int # raw type RID + + +def _member_type(rid_attr: str) -> str: + """Resolve a ```` attribute to a data-type name.""" + try: + rid = int(rid_attr, 16) + except (TypeError, ValueError): + return "" + # Elementary types live in the 0x0200_00XX namespace; anything else is a + # reference to a complex type (UDT/system struct) we don't inline-resolve. + if (rid & 0xFFFF0000) == 0x02000000: + return SOFTDATATYPE.get(rid & 0xFFFF, f"SoftType#{rid & 0xFFFF}") + return f"0x{rid:08X}" + + +def parse_block_interface(xml_text: str) -> list[Member]: + """Parse a decompressed ```` document into :class:`Member`s. + + Works for DB, FB and OB interfaces. Members are returned in declaration + order across all sections (Input/Output/InOut/Static/...). + """ + members: list[Member] = [] + for m in ET.fromstring(xml_text).iter("Member"): + members.append( + Member( + name=m.get("Name", ""), + data_type=_member_type(m.get("RID", "")), + lid=int(m.get("LID", "0")), + offset=int(m.get("StdO", "0")), + rid=int(m.get("RID", "0") or "0", 16), + ) + ) + return members + + +def block_interface_from_explore(explore_payload: bytes) -> list[Member]: + """Decompress a block EXPLORE payload and parse its interface members. + + The EXPLORE response for a block RID bundles several preset-dict streams + (IdentES, interface, comments, ...). This targets the interface dictionary + specifically rather than taking the first stream. Returns an empty list if + no interface stream is present. + """ + for adler in _INTFDESC_ADLERS: + pos = _find_preset_stream(explore_payload, adler) + if pos is not None: + return parse_block_interface(decompress_blob(explore_payload, offset=pos)) + return [] + + +# --------------------------------------------------------------------------- +# shared helpers +# --------------------------------------------------------------------------- + + +def _find_preset_stream(data: bytes, adler: int) -> int | None: + """Return the offset of the first preset-dict zlib stream matching ``adler``. + + A block EXPLORE response contains multiple ``78 xx`` FDICT streams; callers + need the one for a specific dictionary, not merely the first. + """ + i = 0 + end = len(data) - 6 + while i < end: + if data[i] == 0x78 and (data[i + 1] & 0x20) and int.from_bytes(data[i + 2 : i + 6], "big") == adler: + return i + i += 1 + return None + + +@dataclass +class DataBlock: + """A data block advertised in the PlcContentInfo listing.""" + + name: str + number: int + rid: int + + +def datablocks_from_explore(wildcard_payload: bytes) -> list[DataBlock]: + """Parse the DB list from a ``0x8A11FFFF`` wildcard EXPLORE response. + + PlcContentInfo is a *standard* zlib stream (``78 da``) embedded in a larger + BLOB, so it is raw-inflated (the Adler-32 trailer would fail on the trailing + bytes). Returns only ``Type="DB"`` blocks. + """ + pos = wildcard_payload.find(b"\x78\xda") + if pos < 0: + return [] + try: + xml_bytes = zlib.decompressobj(wbits=-15).decompress(wildcard_payload[pos + 2 :]) + except zlib.error: + return [] + dbs: list[DataBlock] = [] + for entity in ET.fromstring(xml_bytes.decode("utf-8")).findall('.//Entity[@Id="Block"]'): + header = entity.find("Header") + if header is None or header.get("Type") != "DB": + continue + try: + dbs.append( + DataBlock( + name=header.get("Name", ""), + number=int(header.get("Number", "0")), + rid=int(entity.get("Rid", "0")), + ) + ) + except ValueError: + continue + return dbs diff --git a/tests/test_tag_browser.py b/tests/test_tag_browser.py index b49ffb55..4233bb2d 100644 --- a/tests/test_tag_browser.py +++ b/tests/test_tag_browser.py @@ -1,14 +1,25 @@ -"""Tests for the symbolic tag browser. +"""Tests for the symbolic tag / block-interface browser. -The fixture is a real preset-dictionary EXPLORE blob captured from a live -S7-1200 G2 (FW V4.1) M area, compressed against the shipped ``IntfDescTag`` -dictionary (Adler-32 ``0xce9b821b``). Decompressing it exercises the full -path: find_and_decompress -> clean XML -> Tag parsing. +The I/Q/M fixture is a real preset-dictionary EXPLORE blob captured from a live +S7-1200 G2 (FW V4.1) M area (``IntfDescTag`` dict, Adler-32 ``0xce9b821b``). +The block-interface fixture is a real EXPLORE blob for an optimized DB +(``Data_block_1``) from the same PLC, exercising the ``DebugInfo_IntfDesc`` +dict (Adler-32 ``0x66052b13``) and the RID -> SoftDataType mapping. """ import base64 +import zlib -from s7commplus.tag_browser import Tag, parse_ident_container, tags_from_explore +from s7commplus.tag_browser import ( + DataBlock, + Member, + Tag, + block_interface_from_explore, + datablocks_from_explore, + parse_block_interface, + parse_ident_container, + tags_from_explore, +) # Live S7-1200 G2 (FW V4.1) EXPLORE response for the M area, from the zlib # header onward: b"\x78\x7d" + dict-adler(0xce9b821b) + raw deflate. @@ -19,17 +30,45 @@ "JynqGzsa/IzVEoSy0qBgoAvatnoKSQmpecn5KZl26rVFqSpmuhZI/NBiLNBwAy/SHEAKKiogAAAAA=" ) +# Live S7-1200 G2 EXPLORE response for optimized DB "Data_block_1" (DB25), from +# the IdentES stream onward. Contains several preset-dict streams; the parser +# must skip IdentES (0x5814b03b) and target the interface dict (0x66052b13). +_DB_INTERFACE_BLOB = base64.b64decode( + "eH1YFLA7rZXBToQwEIZfxezdUCosECtmdRc9oAfhBRqZYJPSGrZoeHtni2ukS0+7lx6+djqdmX+m" + "p4mgNhGbwehXK5Gj73/ECSejJKTrNCZhlJCUuP18WCvDu0/7xprcrY4mYZTF6xRtktVVHXo49fAb" + "D488PPbwxMPTZR5cfkCdqfLCVfkbSOB7eNSDMnlMWDADbKu/ldS8eRZ7o/sRR9T9NbllgcuZ1XPJ" + "VTvwFkr4ApnjP7VAWc37FkzBOyHHvMLpRMgTZcEMs11VDOrdYBBcCjNOphRvXNzA43Mn00nH8aZp" + "xGRY4suxKvAgFO9Hm7IDeoEOYylBteYDmzz7LeGf/n8AgprvX6OhQEAVAKOTcAAUAIZ3mAAAAnh9" + "ZgUrE42Y224bOQyGX8XwPWGROi+aAE1TYHvVxWLRW0NHINhsncZOEezTl5qjPVM3vUlGE/ET+YtD" + "Snm7Mrp1YbzckNuLDrTZ9T1o2Lv7u77icbL3O4TISRhez/PD9K+m75kUz/77A881pEhrb9WUA0Mz" + "udneffry8Y8W/T423/a4Szln8p5AIQpQpAyEEA1QwWKKTsmjHepPOyuV1xPdbHtNWWbVTj3cNPpX" + "TjT5h9ldrohX320I+q4h9zoiEk/idlUeb7ZfiGu66NNp+MoGuvK20QfxSYmpnbUoedLdh/v3+n2T" + "8p9nrl2s/Sa98GchijEhGw8YkwelLYE30oJ1yQWSJsla2ejPh2NLhg3DVZZaqhK5oVkHqlQCV50C" + "4ZRxwjprq9m2PTqzyRyFpGLA6aJBZV/ABRVA+sCvtYjV0tLG5GBTVR4cEq/jLPKTRJDCCMxBoo+4" + "tJEuVO+KBJfQgLIhQ6iSQyJHhLy2dnZpQ6FaVF6ArDlyPK6AjzlweNkEgw6VXsXD0aAK0UJIxPFE" + "ChxPSWC1z84a61Gs1qmOs0xKDcJGVhprBC+UZc29taiVMxRXGkShCvISxiPbRG1Za68guFyCSCkI" + "HZY2WlcKaAyw4xyP4um+mgop8/uSpE5FLm1SirypMUCKVYIKQYEzPPSBXIq2RGlrZ7Mbs+d2rtzt" + "KLFswknYLJzP4J2roGQUEKRLQNJirlkgJ9f2rLe3xByqbfsUNI7V5lgey4ndLHtuHPvwcjoMlXUu" + "X13if26Mvtr6ztNzGo20p5fH4/7YTpn88/C0Tw/p8ToQByCKFVHO/p2e2tFlX5/Lt/1/h+bqEqgn" + "oBmJ7NF3TqMVV43cf5/2f326X5LcSJI0ktQVkp5ID78kGTWS9IphJkb+JcNPcZkVw46MdPh6vCLQ" + "rDi5keSuxOVG3jN3w0P4nyt2eVN3NQbZiulPsX7E8lWs3Rf4ahrexJox5QiXwPanEfj12Dv5cGTo" + "KVyVEe2oI8nOTbmi4qzmM18XTr9BJTFq2roe84Y7y+Z40S6+X3acqb10nWXdRbpjYTO4hqGWWDy6" + "BPFNam5cnTeHzWEwnQduHqCneSDPno2an705s9bnA3GGIjoz6TJtGph++d0gzW13/e22jcVvNXL6" + "R0wvkRjmt9LHv6ZSuOvuxu8WV7LbHxNPf/Kjk3JAFAEAEJgAAAJ4fTxVQ2oDAAAAAAEAo6E/QBUA" + "o7sMQBQAo70tQBQAo70lQBQAo5NaAAEAo5NlQBQAoqKiAAAAAA==" +) + def test_parse_ident_container_addresses(): xml = ( - '' + "" 'Bool' '' 'Word' '' - '' + "" ) tags = parse_ident_container(xml) assert tags == [ @@ -52,3 +91,63 @@ def test_tags_from_live_g2_m_area(): def test_tags_from_explore_no_stream_returns_empty(): assert tags_from_explore(b"no zlib stream here") == [] + + +def test_parse_block_interface_types(): + # types are RID references into the SoftDataType table; a RID + # outside the 0x0200_00XX namespace is a complex type left as raw hex. + xml = ( + "" + '' + '' + '' + '' + "" + ) + assert parse_block_interface(xml) == [ + Member("flag", "Bool", 9, 0, 0x02000001), + Member("speed", "Int", 12, 16, 0x02000005), + Member("gain", "Real", 14, 32, 0x02000008), + Member("timer", "0x0300ABCD", 20, 48, 0x0300ABCD), + ] + + +def test_block_interface_from_live_g2_db(): + members = block_interface_from_explore(_DB_INTERFACE_BLOB) + got = [(m.name, m.data_type) for m in members] + assert got == [ + ("selettore_man_auto", "Bool"), + ("puls_start_stop_ciclo", "Bool"), + ("setpoint_freq_motore", "Int"), + ("kp_PID", "Real"), + ("ki_PID", "Real"), + ("kd_PID", "Real"), + ("cons_motore", "Bool"), + ("retroazione_motore", "Int"), + ("temperatura_motore", "Int"), + ("tensione_misurata", "Real"), + ("corrente_misurata", "Real"), + ] + + +def test_datablocks_from_explore_standard_zlib(): + # PlcContentInfo is a standard zlib stream (78 da) embedded in a larger BLOB; + # trailing bytes follow it, so decoding must raw-inflate and tolerate them. + xml = ( + b'' + b'
' + b'
' + b"" + ) + stream = zlib.compress(xml, 9) # -> 78 da header + assert stream[:2] == b"\x78\xda" + payload = b"\x00\x01prefix" + stream + b"trailing-blob-bytes\xff\x00" + assert datablocks_from_explore(payload) == [ + DataBlock("Data_block_1", 25, 2316173337), + ] + + +def test_datablocks_from_explore_no_stream_returns_empty(): + assert datablocks_from_explore(b"no zlib here") == [] From 89c0a4e44941551fe52a2d448d2fb2c9ba1e1fff Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 11:38:51 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20block=20interface=20=E2=80=94=20sect?= =?UTF-8?q?ions,=20complex=20types,=20skip=20section=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize parse_block_interface() from the flat DB case to full FB/OB interfaces, validated against a live S7-1200 G2 FB (FB_Macchina): - Skip section-header and list-wrapper elements (those without a RID); previously they were emitted as bogus members (Input/Output/...). - Tag each member with its section (Input/Output/InOut/Static/Temp/Constant), resolved from the enclosing . DB members stay "". - Resolve complex types from the ready-made ``Type`` attribute the PLC ships (e.g. ``Array[0..5] of IEC_TIMER``, ``Array[0..2] of Bool``); scalar members still resolve from the RID (arrays use 0x0201_00XX, hence the explicit Type). The raw RID stays available on Member.rid. Adds a Member.section field and an FB-interface test covering section tagging, header skipping and array/Type passthrough. 8/8 tests pass. --- s7commplus/tag_browser.py | 53 ++++++++++++++++++++++++++++++--------- tests/test_tag_browser.py | 31 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py index 325535c9..6294a456 100644 --- a/s7commplus/tag_browser.py +++ b/s7commplus/tag_browser.py @@ -162,40 +162,69 @@ class Member: """A single variable inside a data block or FB/OB interface.""" name: str - data_type: str # resolved SoftDataType name, or raw RID hex if complex + data_type: str # readable type: SoftDataType name or the inline complex type lid: int offset: int # StdO: standard (non-optimized) byte offset rid: int # raw type RID + section: str = "" # Input/Output/InOut/Static/Temp/Constant (FB/OB); "" for a DB -def _member_type(rid_attr: str) -> str: - """Resolve a ```` attribute to a data-type name.""" +def _member_type(member: ET.Element) -> str: + """Resolve a ````'s data type to a readable name. + + Complex members (arrays, UDTs, strings, structs) already carry a ready-made + ``Type`` attribute (e.g. ``Array[0..5] of IEC_TIMER``). Scalar elementary + members carry only a RID of the form ``0x0200_00XX`` whose low byte is a + Siemens SoftDataType id. (Arrays use ``0x0201_00XX``, hence the explicit + ``Type``; the raw RID stays available on :attr:`Member.rid`.) + """ + explicit = member.get("Type") + if explicit: + return explicit try: - rid = int(rid_attr, 16) - except (TypeError, ValueError): + rid = int(member.get("RID", "0") or "0", 16) + except ValueError: return "" - # Elementary types live in the 0x0200_00XX namespace; anything else is a - # reference to a complex type (UDT/system struct) we don't inline-resolve. if (rid & 0xFFFF0000) == 0x02000000: return SOFTDATATYPE.get(rid & 0xFFFF, f"SoftType#{rid & 0xFFFF}") return f"0x{rid:08X}" +def _enclosing_section(member: ET.Element, parent: dict) -> str: + """Walk up to the enclosing ```` and return ``Xxx``.""" + node = parent.get(member) + while node is not None: + kind = node.get("Kind", "") if node.tag == "Part" else "" + if kind.endswith("Section"): + return kind[: -len("Section")] + node = parent.get(node) + return "" + + def parse_block_interface(xml_text: str) -> list[Member]: """Parse a decompressed ```` document into :class:`Member`s. - Works for DB, FB and OB interfaces. Members are returned in declaration - order across all sections (Input/Output/InOut/Static/...). + Works for DB, FB and OB interfaces. Only ```` elements that carry a + ``RID`` are real variables; the section-header and list-wrapper ```` + elements (which have no ``RID``) are skipped. Members are returned in + declaration order, each tagged with its section (Input/Output/... for FB/OB; + empty for a DB). """ + root = ET.fromstring(xml_text) + parent = {child: p for p in root.iter() for child in p} members: list[Member] = [] - for m in ET.fromstring(xml_text).iter("Member"): + for m in root.iter("Member"): + rid_attr = m.get("RID") + if rid_attr is None: + continue # section header or list wrapper, not a variable members.append( Member( name=m.get("Name", ""), - data_type=_member_type(m.get("RID", "")), + data_type=_member_type(m), lid=int(m.get("LID", "0")), offset=int(m.get("StdO", "0")), - rid=int(m.get("RID", "0") or "0", 16), + rid=int(rid_attr or "0", 16), + section=_enclosing_section(m, parent), ) ) return members diff --git a/tests/test_tag_browser.py b/tests/test_tag_browser.py index 4233bb2d..8a126f57 100644 --- a/tests/test_tag_browser.py +++ b/tests/test_tag_browser.py @@ -112,6 +112,37 @@ def test_parse_block_interface_types(): ] +def test_parse_block_interface_fb_sections(): + # FB/OB interfaces are organized in sections; the section-header s + # (Input/Static, no RID) must be skipped, real members tagged with their + # section. Complex members carry a ready-made ``Type`` attribute (arrays), + # scalar ones resolve from the RID. + xml = ( + "" + '' + '' + "" + "" + '' + '' + "" + '' + '' + '' + '' + "" + "" + ) + assert parse_block_interface(xml) == [ + Member("puls_start_stop", "Bool", 9, 0, 0x02000001, "Input"), + Member("num_step", "Int", 19, 0, 0x02000005, "Static"), + Member("timers", "Array[0..5] of IEC_TIMER", 20, 16, 0x0201001F, "Static"), + Member("echo", "Array[0..2] of Bool", 21, 784, 0x02010001, "Static"), + ] + + def test_block_interface_from_live_g2_db(): members = block_interface_from_explore(_DB_INTERFACE_BLOB) got = [(m.name, m.data_type) for m in members] From 264fe885fd1de3ea15ccf8c5be95176efd2c4526 Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 11:48:42 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20block=20interface=20=E2=80=94=20UDT?= =?UTF-8?q?=20members=20and=20embedded=20type=20definitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated against a live S7-1200 G2 DB that uses a user UDT (Data_block_1 with an "elettrovalvola" member). - A UDT-typed member carries a readable ``Type`` attribute (e.g. ``"elettrovalvola"``, TIA-quoted) and a RID in the UDT object namespace (0x91......), so it already resolves via the existing Type-attribute path. - When a block uses a UDT, its interface embeds the UDT's own definition under a nested ````. Those members describe the type, not the block, so keep a member only when its nearest enclosing ```` is the block's primary part (flat DB/UDT) or a ``*Section`` part (FB/OB); members of a nested ``*Source`` part are skipped. Replaces the section-only ancestor walk with a nearest-Part check that drives both the keep decision and the section tag. Adds a DB-with-UDT test. 9/9 pass. --- s7commplus/tag_browser.py | 35 +++++++++++++++++++++++------------ tests/test_tag_browser.py | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py index 6294a456..15909427 100644 --- a/s7commplus/tag_browser.py +++ b/s7commplus/tag_browser.py @@ -190,33 +190,44 @@ def _member_type(member: ET.Element) -> str: return f"0x{rid:08X}" -def _enclosing_section(member: ET.Element, parent: dict) -> str: - """Walk up to the enclosing ```` and return ``Xxx``.""" +def _enclosing_part(member: ET.Element, parent: dict) -> ET.Element | None: + """Return the nearest enclosing ```` element, or ``None``.""" node = parent.get(member) while node is not None: - kind = node.get("Kind", "") if node.tag == "Part" else "" - if kind.endswith("Section"): - return kind[: -len("Section")] + if node.tag == "Part": + return node node = parent.get(node) - return "" + return None def parse_block_interface(xml_text: str) -> list[Member]: """Parse a decompressed ```` document into :class:`Member`s. - Works for DB, FB and OB interfaces. Only ```` elements that carry a - ``RID`` are real variables; the section-header and list-wrapper ```` - elements (which have no ``RID``) are skipped. Members are returned in - declaration order, each tagged with its section (Input/Output/... for FB/OB; - empty for a DB). + Works for DB, FB, OB and UDT interfaces. A member is a real variable only if + it carries a ``RID``; the section-header and list-wrapper ```` + elements (no ``RID``) are skipped. When a block uses a UDT, the interface + also embeds that UDT's own definition under a nested ```` — those members describe the *type*, not the block, + so they are skipped: a member is kept only when its nearest enclosing + ```` is the block's primary part (flat DB/UDT) or a ``*Section`` part + (FB/OB). Members are returned in declaration order, each tagged with its + section (Input/Output/... for FB/OB; empty for DB/UDT). """ root = ET.fromstring(xml_text) parent = {child: p for p in root.iter() for child in p} + primary = root.find("Part") # first top-level Part = the block's own source members: list[Member] = [] for m in root.iter("Member"): rid_attr = m.get("RID") if rid_attr is None: continue # section header or list wrapper, not a variable + part = _enclosing_part(m, parent) + if part is primary: + section = "" + elif part is not None and part.get("Kind", "").endswith("Section"): + section = part.get("Kind")[: -len("Section")] + else: + continue # member of an embedded type definition (nested *Source part) members.append( Member( name=m.get("Name", ""), @@ -224,7 +235,7 @@ def parse_block_interface(xml_text: str) -> list[Member]: lid=int(m.get("LID", "0")), offset=int(m.get("StdO", "0")), rid=int(rid_attr or "0", 16), - section=_enclosing_section(m, parent), + section=section, ) ) return members diff --git a/tests/test_tag_browser.py b/tests/test_tag_browser.py index 8a126f57..de51bd42 100644 --- a/tests/test_tag_browser.py +++ b/tests/test_tag_browser.py @@ -143,6 +143,28 @@ def test_parse_block_interface_fb_sections(): ] +def test_parse_block_interface_udt_member_and_embedded_type(): + # A member of a UDT type carries a readable ``Type`` ("MyUdt", TIA quotes) + # and a RID in the UDT object namespace (0x91......). The interface also + # embeds the UDT's own definition under a nested ; + # those members describe the type, not the block, and must be skipped. + xml = ( + '' + '' + '' + "" + '' + '' + "" + "" + ) + assert parse_block_interface(xml) == [ + Member("flag", "Bool", 9, 0, 0x02000001, ""), + Member("valve", '"MyUdt"', 10, 16, 0x91000001, ""), + ] + + def test_block_interface_from_live_g2_db(): members = block_interface_from_explore(_DB_INTERFACE_BLOB) got = [(m.name, m.data_type) for m in members] From 2c5fc5dcc6fcff3d640cda9a99e5f9076ff0b0e5 Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 11:55:18 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20block=20interface=20=E2=80=94=20exp?= =?UTF-8?q?and=20UDT/struct=20members=20into=20nested=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a member is of a UDT (or other structured type), its definition is embedded under the primary part's and selected by the member's SubPartIndex. Expand it recursively into a new Member.fields list, validated against a live S7-1200 G2 DB whose member is of a user UDT (elettrovalvola -> 6 inner fields fc_apertura/stato_ev/...). - Member gains a `fields: list[Member]` (default empty). - parse_block_interface() now walks the primary part structurally via _parse_source_part()/_build_member(): flat DB/UDT members from , FB/OB members from , and each member with a SubPartIndex into an inlined *Source subpart is expanded. Library types (IEC_TIMER) are referenced, not inlined, so they expand to no fields. Adds a real DB-with-UDT blob fixture and an inline expansion test. 10/10 pass. --- s7commplus/tag_browser.py | 100 ++++++++++++++++++++++---------------- tests/test_tag_browser.py | 89 ++++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py index 15909427..c2b82e1b 100644 --- a/s7commplus/tag_browser.py +++ b/s7commplus/tag_browser.py @@ -22,7 +22,7 @@ import xml.etree.ElementTree as ET import zlib -from dataclasses import dataclass +from dataclasses import dataclass, field from .blob_decompressor import decompress_blob @@ -167,6 +167,7 @@ class Member: offset: int # StdO: standard (non-optimized) byte offset rid: int # raw type RID section: str = "" # Input/Output/InOut/Static/Temp/Constant (FB/OB); "" for a DB + fields: list[Member] = field(default_factory=list) # sub-members for a UDT/Struct type def _member_type(member: ET.Element) -> str: @@ -190,57 +191,72 @@ def _member_type(member: ET.Element) -> str: return f"0x{rid:08X}" -def _enclosing_part(member: ET.Element, parent: dict) -> ET.Element | None: - """Return the nearest enclosing ```` element, or ``None``.""" - node = parent.get(member) - while node is not None: - if node.tag == "Part": - return node - node = parent.get(node) - return None - - def parse_block_interface(xml_text: str) -> list[Member]: """Parse a decompressed ```` document into :class:`Member`s. - Works for DB, FB, OB and UDT interfaces. A member is a real variable only if - it carries a ``RID``; the section-header and list-wrapper ```` - elements (no ``RID``) are skipped. When a block uses a UDT, the interface - also embeds that UDT's own definition under a nested ```` — those members describe the *type*, not the block, - so they are skipped: a member is kept only when its nearest enclosing - ```` is the block's primary part (flat DB/UDT) or a ``*Section`` part - (FB/OB). Members are returned in declaration order, each tagged with its - section (Input/Output/... for FB/OB; empty for DB/UDT). + Works for DB, FB, OB and UDT interfaces. The block's own variables come from + its primary ```` — directly under ```` for a flat + DB/UDT, or from the ```` entries for an FB/OB. Members + are returned in declaration order, each tagged with its section + (Input/Output/... for FB/OB; empty for DB/UDT). + + When a member is of a UDT (or other structured type), the interface embeds + that type's definition under the primary part's ````; the member's + ``SubPartIndex`` selects it. Such members are expanded recursively into + :attr:`Member.fields`. Library types (e.g. ``IEC_TIMER``) are referenced but + not inlined, so they expand to no fields. """ root = ET.fromstring(xml_text) - parent = {child: p for p in root.iter() for child in p} primary = root.find("Part") # first top-level Part = the block's own source + if primary is None: + return [] + return _parse_source_part(primary) + + +def _parse_source_part(part: ET.Element) -> list[Member]: + """Parse one source ```` (DBSource/BlockSource/DataTypeSource).""" + sub_el = part.find("SubParts") + subparts = list(sub_el) if sub_el is not None else [] # index-addressable + members: list[Member] = [] - for m in root.iter("Member"): - rid_attr = m.get("RID") - if rid_attr is None: - continue # section header or list wrapper, not a variable - part = _enclosing_part(m, parent) - if part is primary: - section = "" - elif part is not None and part.get("Kind", "").endswith("Section"): - section = part.get("Kind")[: -len("Section")] - else: - continue # member of an embedded type definition (nested *Source part) - members.append( - Member( - name=m.get("Name", ""), - data_type=_member_type(m), - lid=int(m.get("LID", "0")), - offset=int(m.get("StdO", "0")), - rid=int(rid_attr or "0", 16), - section=section, - ) - ) + # Flat DB/UDT: variables are direct children of . + root_el = part.find("./Payload/Root") + if root_el is not None: + for m in root_el.findall("Member"): + if m.get("RID") is not None: + members.append(_build_member(m, "", subparts)) + # FB/OB: variables live in the entries of . + for sp in subparts: + kind = sp.get("Kind", "") + if not kind.endswith("Section"): + continue + section = kind[: -len("Section")] + for m in sp.findall("./Payload/Member/Member"): + if m.get("RID") is not None: + members.append(_build_member(m, section, subparts)) return members +def _build_member(m: ET.Element, section: str, subparts: list) -> Member: + """Build a :class:`Member`, expanding a structured type via ``SubPartIndex``.""" + member = Member( + name=m.get("Name", ""), + data_type=_member_type(m), + lid=int(m.get("LID", "0")), + offset=int(m.get("StdO", "0")), + rid=int(m.get("RID", "0") or "0", 16), + section=section, + ) + spi = m.get("SubPartIndex") + if spi is not None: + idx = int(spi) + # Only an inlined type definition (a *Source part) carries sub-members; + # a *Section index or a library reference expands to nothing. + if 0 <= idx < len(subparts) and subparts[idx].get("Kind", "").endswith("Source"): + member.fields = _parse_source_part(subparts[idx]) + return member + + def block_interface_from_explore(explore_payload: bytes) -> list[Member]: """Decompress a block EXPLORE payload and parse its interface members. diff --git a/tests/test_tag_browser.py b/tests/test_tag_browser.py index de51bd42..d78764fc 100644 --- a/tests/test_tag_browser.py +++ b/tests/test_tag_browser.py @@ -58,6 +58,41 @@ "o7sMQBQAo70tQBQAo70lQBQAo5NaAAEAo5NlQBQAoqKiAAAAAA==" ) +# Live S7-1200 G2 EXPLORE response for DB "Data_block_1" after adding a member of +# a user UDT ("elettrovalvola"), from the interface stream onward. The blob +# bundles the DB interface and the UDT's own definition; the UDT member is +# expanded into Member.fields. +_DB_UDT_INTERFACE_BLOB = base64.b64decode( + "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" + "AKOTZUAUAKKiogAAAAA=" +) + def test_parse_ident_container_addresses(): xml = ( @@ -118,7 +153,7 @@ def test_parse_block_interface_fb_sections(): # section. Complex members carry a ready-made ``Type`` attribute (arrays), # scalar ones resolve from the RID. xml = ( - "" + '' '' '' "" @@ -133,7 +168,7 @@ def test_parse_block_interface_fb_sections(): '' "" - "" + "" ) assert parse_block_interface(xml) == [ Member("puls_start_stop", "Bool", 9, 0, 0x02000001, "Input"), @@ -143,25 +178,57 @@ def test_parse_block_interface_fb_sections(): ] -def test_parse_block_interface_udt_member_and_embedded_type(): - # A member of a UDT type carries a readable ``Type`` ("MyUdt", TIA quotes) - # and a RID in the UDT object namespace (0x91......). The interface also - # embeds the UDT's own definition under a nested ; - # those members describe the type, not the block, and must be skipped. +def test_parse_block_interface_udt_member_expands_fields(): + # A UDT member carries a readable ``Type`` ("MyUdt", TIA quotes), a RID in + # the UDT object namespace (0x91......) and a ``SubPartIndex`` pointing at the + # UDT's definition embedded under . That definition is expanded + # into Member.fields, not surfaced as top-level members. xml = ( '' '' - '' + '' "" '' - '' + '' + '' "" "" ) assert parse_block_interface(xml) == [ Member("flag", "Bool", 9, 0, 0x02000001, ""), - Member("valve", '"MyUdt"', 10, 16, 0x91000001, ""), + Member( + "valve", + '"MyUdt"', + 10, + 16, + 0x91000001, + "", + fields=[ + Member("open_cmd", "Bool", 1, 0, 0x02000001, ""), + Member("level", "Int", 2, 16, 0x02000005, ""), + ], + ), + ] + + +def test_block_interface_from_live_g2_db_with_udt(): + members = block_interface_from_explore(_DB_UDT_INTERFACE_BLOB) + # 12 top-level members; the embedded UDT definition is not surfaced flat. + assert len(members) == 12 + valve = members[-1] + assert (valve.name, valve.data_type, valve.rid) == ( + "elettrovalvola_1", + '"elettrovalvola"', + 0x91000001, + ) + assert [(f.name, f.data_type) for f in valve.fields] == [ + ("fc_apertura", "Bool"), + ("fc_chiusura", "Bool"), + ("stato_ev", "Int"), + ("allarme_ev", "Int"), + ("cons_ev", "Bool"), + ("perc_apertura", "Real"), ] From 48c86597ee09cf7318876bd0c9c78a25cd4f4246 Mon Sep 17 00:00:00 2001 From: tommasofaedo Date: Mon, 20 Jul 2026 16:21:18 +0200 Subject: [PATCH 6/6] Address review: zlib header robustness, cycle guard, off-by-one Fixes for @gijzelaerr's review on PR #780: - _find_zlib_stream: match any standard zlib CMF 0x78 with a valid header checksum (CMF*256+FLG % 31 == 0) and no preset-dict flag, instead of hardcoding 78 da. Tolerates other compression levels (78 01/5e/9c) that other firmwares may emit for PlcContentInfo. - _build_member / _parse_source_part: thread a `seen` set of Part identities through the recursive UDT expansion to stop a malformed circular SubPartIndex from causing RecursionError. - _find_preset_stream: fix off-by-one (end = len(data) - 5) so the last valid 6-byte header start position is scanned. 10/10 tests pass, ruff clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R8vSkgn4ifGkRznTqNXmpj --- s7commplus/tag_browser.py | 51 +++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/s7commplus/tag_browser.py b/s7commplus/tag_browser.py index c2b82e1b..3f32f6f3 100644 --- a/s7commplus/tag_browser.py +++ b/s7commplus/tag_browser.py @@ -210,11 +210,16 @@ def parse_block_interface(xml_text: str) -> list[Member]: primary = root.find("Part") # first top-level Part = the block's own source if primary is None: return [] - return _parse_source_part(primary) + return _parse_source_part(primary, set()) -def _parse_source_part(part: ET.Element) -> list[Member]: - """Parse one source ```` (DBSource/BlockSource/DataTypeSource).""" +def _parse_source_part(part: ET.Element, seen: set[int]) -> list[Member]: + """Parse one source ```` (DBSource/BlockSource/DataTypeSource). + + ``seen`` carries the identities of the ```` elements already being + expanded on the current path, so a malformed ``SubPartIndex`` cycle stops + instead of recursing forever. + """ sub_el = part.find("SubParts") subparts = list(sub_el) if sub_el is not None else [] # index-addressable @@ -224,7 +229,7 @@ def _parse_source_part(part: ET.Element) -> list[Member]: if root_el is not None: for m in root_el.findall("Member"): if m.get("RID") is not None: - members.append(_build_member(m, "", subparts)) + members.append(_build_member(m, "", subparts, seen)) # FB/OB: variables live in the entries of . for sp in subparts: kind = sp.get("Kind", "") @@ -233,11 +238,11 @@ def _parse_source_part(part: ET.Element) -> list[Member]: section = kind[: -len("Section")] for m in sp.findall("./Payload/Member/Member"): if m.get("RID") is not None: - members.append(_build_member(m, section, subparts)) + members.append(_build_member(m, section, subparts, seen)) return members -def _build_member(m: ET.Element, section: str, subparts: list) -> Member: +def _build_member(m: ET.Element, section: str, subparts: list, seen: set[int]) -> Member: """Build a :class:`Member`, expanding a structured type via ``SubPartIndex``.""" member = Member( name=m.get("Name", ""), @@ -253,7 +258,9 @@ def _build_member(m: ET.Element, section: str, subparts: list) -> Member: # Only an inlined type definition (a *Source part) carries sub-members; # a *Section index or a library reference expands to nothing. if 0 <= idx < len(subparts) and subparts[idx].get("Kind", "").endswith("Source"): - member.fields = _parse_source_part(subparts[idx]) + target = subparts[idx] + if id(target) not in seen: + member.fields = _parse_source_part(target, seen | {id(target)}) return member @@ -284,7 +291,9 @@ def _find_preset_stream(data: bytes, adler: int) -> int | None: need the one for a specific dictionary, not merely the first. """ i = 0 - end = len(data) - 6 + # A header needs 6 bytes (CMF + FLG + 4-byte DICTID), so the last valid + # start position is len(data) - 6 inclusive. + end = len(data) - 5 while i < end: if data[i] == 0x78 and (data[i + 1] & 0x20) and int.from_bytes(data[i + 2 : i + 6], "big") == adler: return i @@ -292,6 +301,21 @@ def _find_preset_stream(data: bytes, adler: int) -> int | None: return None +def _find_zlib_stream(data: bytes) -> int | None: + """Return the offset of the first standard (non-preset) zlib stream. + + Standard-compression ``PlcContentInfo`` uses CMF ``0x78`` but its FLG byte + varies with the compression level (``78 01`` / ``78 5e`` / ``78 9c`` / + ``78 da``). Match any ``0x78`` whose 2-byte header checksums correctly + (``CMF*256 + FLG`` divisible by 31) and has no preset-dict flag set, rather + than hardcoding a single FLG value. + """ + for i in range(len(data) - 1): + if data[i] == 0x78 and not (data[i + 1] & 0x20) and ((data[i] << 8) | data[i + 1]) % 31 == 0: + return i + return None + + @dataclass class DataBlock: """A data block advertised in the PlcContentInfo listing.""" @@ -304,12 +328,13 @@ class DataBlock: def datablocks_from_explore(wildcard_payload: bytes) -> list[DataBlock]: """Parse the DB list from a ``0x8A11FFFF`` wildcard EXPLORE response. - PlcContentInfo is a *standard* zlib stream (``78 da``) embedded in a larger - BLOB, so it is raw-inflated (the Adler-32 trailer would fail on the trailing - bytes). Returns only ``Type="DB"`` blocks. + PlcContentInfo is a *standard* zlib stream embedded in a larger BLOB, so it + is raw-inflated (the Adler-32 trailer would fail on the trailing bytes). The + stream header is located tolerantly (any compression level), not assumed to + be ``78 da``. Returns only ``Type="DB"`` blocks. """ - pos = wildcard_payload.find(b"\x78\xda") - if pos < 0: + pos = _find_zlib_stream(wildcard_payload) + if pos is None: return [] try: xml_bytes = zlib.decompressobj(wbits=-15).decompress(wildcard_payload[pos + 2 :])