Skip to content

feat: tag_browser — parse I/Q/M tags and DB/FB/OB interfaces from decompressed EXPLORE XML#780

Open
tommasofaedo wants to merge 6 commits into
gijzelaerr:feat/zlib-preset-dictionariesfrom
tommasofaedo:followup-tag-browser
Open

feat: tag_browser — parse I/Q/M tags and DB/FB/OB interfaces from decompressed EXPLORE XML#780
tommasofaedo wants to merge 6 commits into
gijzelaerr:feat/zlib-preset-dictionariesfrom
tommasofaedo:followup-tag-browser

Conversation

@tommasofaedo

@tommasofaedo tommasofaedo commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #776. That PR added the preset-dictionary decompressor; this one adds the small, non-invasive layer on top that turns a decompressed EXPLORE response into structured records — for I/Q/M symbols and DB / FB / OB / UDT block interfaces. This is the wiring flagged as TODO in #776 / #757.

All of it lives in s7commplus/tag_browser.py and tests/test_tag_browser.py and touches no existing files, so you can merge it as-is, call the helpers from browse() yourself, or fold it into #776 — whatever is least noisy.

I/Q/M symbolic tags

  • parse_ident_container() / tags_from_explore() — decompress the <IdentContainer> blob (IntfDescTag dict, 0xce9b821b) and map Range+Width+ByteNumber+BitNumber to TIA syntax (%I0.0, %Q0.1, %MB100, %MW102, %MD104) as Tag records.

DB / FB / OB / UDT interfaces

  • parse_block_interface() / block_interface_from_explore() — decompress the <BlockInterface> blob (DebugInfo_IntfDesc dict, 0x66052b13) into Member records (name, type, LID, offset, section). A block EXPLORE bundles several preset-dict streams, so it targets the interface dict explicitly rather than taking the first zlib stream.
  • Types. A <Member>'s type is a RID reference 0x0200_00XX where XX is a Siemens SoftDataType id (SOFTDATATYPE map transcribed from thomas-v2/S7CommPlusDriver Core/Softdatatype.cs, the same source as the shipped dictionaries). Complex members (arrays, UDTs, strings) already carry a ready-made, human-readable Type attribute (Array[0..5] of IEC_TIMER, "myUdt"), which is used directly; the raw RID stays on Member.rid.
  • Sections. FB/OB members are tagged with their section (Input/Output/InOut/Static/Temp/Constant); section-header and list-wrapper <Member> elements (no RID) are skipped. DB/UDT members are flat (empty section).
  • Nested UDT fields. When a member is of a UDT/struct, its definition is embedded under the primary part's <SubParts> (selected by the member's SubPartIndex); it is expanded recursively into Member.fields. Library types (e.g. IEC_TIMER) are referenced but not inlined, so they expand to no fields. Embedded type definitions are never surfaced as top-level members.

DB listing

  • datablocks_from_explore() — parse the DB list 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.

Honest scope / testing

I/Q/M symbols (verified against TIA Portal): input_1 %I0.0, output_1 %Q0.1, merker_1 %M0.2, mtag_byte %MB100, mtag_word %MW102, mtag_dword %MD104, mtag_bool %M108.0.

Optimized DB Data_block_1 (11 elementary members: Bool/Int/Real, cross-checked against the physical <Offsets>), plus a live FB (FB_Macchina) with sections + Array[0..5] of IEC_TIMER / Array[0..2] of Bool, and a DB member of a user UDT elettrovalvola that expands into its 6 inner fields (fc_apertura, stato_ev, …). Tests use real EXPLORE blob fixtures from the G2; 10/10 pass, ruff clean.

This makes the oracle pipeline from #757 (_build_fdict / regex _extract_tags / symbolic-READ width workaround) obsolete: <SimpleType> carries the real M widths directly, and block interfaces resolve straight to clean member names, types, sections and nested UDT fields.

🤖 Generated with Claude Code

tommasofaedo and others added 2 commits July 20, 2026 06:37
…RE XML

Follow-up to gijzelaerr#776: wires find_and_decompress() into a small non-invasive
tag layer. Parses the clean <IdentContainer> 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 <noreply@anthropic.com>
Extend tag_browser beyond I/Q/M symbols to data-block / FB / OB interfaces.

- parse_block_interface(): parse the <BlockInterface> 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.
@tommasofaedo tommasofaedo changed the title feat: tag_browser — parse I/Q/M symbolic tags from decompressed EXPLORE XML feat: tag_browser — parse I/Q/M tags and DB/FB/OB interfaces from decompressed EXPLORE XML Jul 20, 2026
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 <Member> 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 <Part Kind="XxxSection">. 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.
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 ``<Part Kind="DataTypeSource">``. Those members describe the type,
  not the block, so keep a member only when its nearest enclosing ``<Part>`` 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.
When a member is of a UDT (or other structured type), its definition is
embedded under the primary part's <SubParts> 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 <Payload><Root>,
  FB/OB members from <Part Kind="*Section">, 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.

@gijzelaerr gijzelaerr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — clean module, real hardware fixtures, well-scoped. Three things I noticed:

1. Off-by-one in _find_preset_stream (tag_browser.py:262)

end = len(data) - 6 should be len(data) - 5. The zlib header is 6 bytes (CMF + FLG + 4-byte DICTID), so valid start positions go up to len(data) - 6 inclusive, but the while i < end loop excludes that last position. Unlikely to bite in practice (a stream starting 6 bytes from the end would have no compressed data), but still a real off-by-one.

2. No cycle guard in recursive UDT expansion (tag_browser.py:231)

_build_member follows SubPartIndex_parse_source_part_build_member without tracking visited parts. A malformed BlockInterface with circular SubPartIndex references would hit RecursionError. A seen: set[int] threaded through the recursion would prevent it. Not a concern with real PLCs, but cheap to add.

3. DB listing only matches 78 da (tag_browser.py:281)

datablocks_from_explore hardcodes b"\x78\xda" (zlib best-compression), but standard zlib streams can use other FLG bytes (78 9c, 78 5e, 78 01). If a firmware version emits PlcContentInfo with default compression the function silently returns []. Scanning for any 0x78 byte and validating the header checksum (CMF*256 + FLG must be divisible by 31) would be more robust.

None of these block merging — just worth fixing while the code is fresh.

Fixes for @gijzelaerr's review on PR gijzelaerr#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vSkgn4ifGkRznTqNXmpj
@tommasofaedo

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — all three fixed in 48c8659:

  1. Off-by-one in _find_preset_streamend = len(data) - 5, so the last valid 6-byte header start position is now scanned.
  2. Cycle guard in recursive UDT expansion — a seen set of <Part> identities is threaded through _parse_source_part/_build_member; a circular SubPartIndex now stops instead of hitting RecursionError.
  3. DB listing zlib headerdatablocks_from_explore now uses a new _find_zlib_stream helper that matches any CMF 0x78 with a valid header checksum (CMF*256 + FLG divisible by 31) and no preset-dict flag, rather than hardcoding 78 da, so 78 01/5e/9c from other firmwares are tolerated.

10/10 tests still pass, ruff clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants