diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..e7efbbac
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Zlib preset dictionaries: exact bytes matter for Adler-32 checksums
+s7commplus/zlib_dicts/*.xml binary
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1d7bfd62..47072175 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,6 +10,7 @@ repos:
- id: check-symlinks
- id: check-toml
- id: check-xml
+ exclude: ^s7commplus/zlib_dicts/
- id: check-yaml
- id: check-illegal-windows-names
- id: check-merge-conflict
diff --git a/pyproject.toml b/pyproject.toml
index cf3c1ff0..af468280 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,7 +41,7 @@ discovery = ["pnio-dcp"]
[tool.setuptools.package-data]
snap7 = ["py.typed"]
-s7commplus = ["py.typed"]
+s7commplus = ["py.typed", "zlib_dicts/*.xml"]
[tool.setuptools.packages.find]
include = ["snap7*", "s7*", "s7commplus*"]
diff --git a/s7commplus/__init__.py b/s7commplus/__init__.py
index ea33a6ba..0c57d706 100644
--- a/s7commplus/__init__.py
+++ b/s7commplus/__init__.py
@@ -13,6 +13,7 @@
data = client.db_read(1, 0, 4)
"""
+from .blob_decompressor import decompress_blob, find_and_decompress
from .client import S7CommPlusClient as Client
from .async_client import S7CommPlusAsyncClient as AsyncClient
from .server import S7CommPlusServer as Server, DataBlock, CPUState
@@ -25,4 +26,6 @@
"DataBlock",
"CPUState",
"S7CommPlusConnection",
+ "decompress_blob",
+ "find_and_decompress",
]
diff --git a/s7commplus/async_client.py b/s7commplus/async_client.py
index 37727c90..d126ebbc 100644
--- a/s7commplus/async_client.py
+++ b/s7commplus/async_client.py
@@ -30,6 +30,7 @@
parse_server_session_version,
)
from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq
+from .blob_decompressor import find_and_decompress
from .client import (
_build_read_payload,
_parse_read_response,
@@ -478,6 +479,26 @@ async def explore(self, explore_id: int = 0) -> bytes:
payload = _build_explore_payload(explore_id)
return await self._send_request(FunctionCode.EXPLORE, payload)
+ async def explore_xml(self, explore_id: int = 0) -> str | None:
+ """EXPLORE a PLC object and decompress the XML metadata from the response.
+
+ S7-1200/1500 PLCs (FW V4.5+) compress XML metadata — tag definitions,
+ interface descriptions, line comments, etc. — using zlib with Siemens
+ preset dictionaries. This method sends an EXPLORE request and
+ decompresses the first zlib stream found in the response.
+
+ .. warning:: This method is **experimental** and may change.
+
+ Args:
+ explore_id: Object to explore (0 = root).
+
+ Returns:
+ Decompressed XML as a UTF-8 string, or ``None`` if the response
+ contains no recognisable zlib stream.
+ """
+ raw = await self.explore(explore_id)
+ return find_and_decompress(raw)
+
async def set_plc_operating_state(self, state: int) -> None:
"""Set the PLC operating state (start/stop)."""
payload = _build_invoke_payload(state)
diff --git a/s7commplus/blob_decompressor.py b/s7commplus/blob_decompressor.py
new file mode 100644
index 00000000..76a8f5a3
--- /dev/null
+++ b/s7commplus/blob_decompressor.py
@@ -0,0 +1,89 @@
+"""Decompress zlib-compressed blobs from S7-1200/1500 PLCs.
+
+S7CommPlus PLCs compress XML metadata (tag definitions, network comments,
+interface descriptions, etc.) using zlib with Siemens-specific preset
+dictionaries. Python's ``zlib.decompress()`` returns ``Z_NEED_DICT`` for
+these blobs; this module supplies the matching dictionary.
+
+The 19 preset dictionaries were extracted from thomas-v2/S7CommPlusDriver
+(LGPL-3.0) and are stored in ``zlib_dicts.py``.
+
+Usage::
+
+ data = decompress_blob(compressed_bytes)
+ # data is the decompressed XML string (UTF-8)
+"""
+
+import logging
+import zlib
+
+from .zlib_dicts import ZLIB_DICTIONARIES, ZLIB_DICT_NAMES
+
+logger = logging.getLogger(__name__)
+
+
+def decompress_blob(data: bytes, offset: int = 0) -> str:
+ """Decompress a zlib blob, auto-supplying the preset dictionary if needed.
+
+ Args:
+ data: Raw bytes containing the zlib stream.
+ offset: Starting position within ``data``. Some blobs have a
+ 4-byte version prefix before the zlib header.
+
+ Returns:
+ Decompressed content as a UTF-8 string.
+
+ Raises:
+ ValueError: If the zlib stream requires an unknown dictionary.
+ zlib.error: On other decompression failures.
+ """
+ stream = data[offset:]
+
+ # Check for preset-dictionary zlib header: CMF=0x78, FLG with FDICT bit set (bit 5)
+ if len(stream) >= 6 and stream[0] == 0x78 and (stream[1] & 0x20):
+ dict_adler = int.from_bytes(stream[2:6], "big")
+ zdict = ZLIB_DICTIONARIES.get(dict_adler)
+ else:
+ # Standard zlib stream — no preset dictionary
+ return zlib.decompress(stream).decode("utf-8")
+
+ # Preset dictionary required
+
+ if zdict is None:
+ name = ZLIB_DICT_NAMES.get(dict_adler, "unknown")
+ raise ValueError(
+ f"Unknown zlib preset dictionary: adler32=0x{dict_adler:08x} ({name}). "
+ f"Known dictionaries: {', '.join(ZLIB_DICT_NAMES.values())}"
+ )
+
+ logger.debug("Using preset dictionary: %s (0x%08x)", ZLIB_DICT_NAMES.get(dict_adler, "?"), dict_adler)
+
+ dobj = zlib.decompressobj(wbits=-15, zdict=zdict)
+ # Skip the 6-byte zlib header (2 magic + 4 dict adler)
+ result = dobj.decompress(stream[6:])
+ return result.decode("utf-8")
+
+
+def find_and_decompress(data: bytes) -> str | None:
+ """Scan raw bytes for a zlib stream with preset dictionary and decompress it.
+
+ Searches for the ``78 7D`` magic (zlib level 6 + FDICT flag) that
+ indicates a preset-dictionary stream. Falls back to ``78 01``/``78 5E``/
+ ``78 9C``/``78 DA`` for standard streams.
+
+ Args:
+ data: Raw EXPLORE response payload (possibly multi-fragment).
+
+ Returns:
+ Decompressed UTF-8 string, or ``None`` if no zlib stream found.
+ """
+ # Preset-dictionary magic: CMF=0x78 (deflate, window=32K), FLG=0x7D (FDICT set)
+ for magic in (b"\x78\x7d", b"\x78\x01", b"\x78\x5e", b"\x78\x9c", b"\x78\xda"):
+ pos = data.find(magic)
+ if pos >= 0:
+ try:
+ return decompress_blob(data, offset=pos)
+ except (ValueError, zlib.error) as e:
+ logger.debug("Decompression failed at offset %d: %s", pos, e)
+ continue
+ return None
diff --git a/s7commplus/client.py b/s7commplus/client.py
index d91c5ff7..fc42eb8c 100644
--- a/s7commplus/client.py
+++ b/s7commplus/client.py
@@ -8,6 +8,7 @@
from typing import Any, Optional
from . import typeinfo
+from .blob_decompressor import find_and_decompress
from .connection import S7CommPlusConnection
from .protocol import FunctionCode, Ids, ElementID, DataType, ObjectId
from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq
@@ -284,6 +285,26 @@ def explore(self, explore_id: int = 0) -> bytes:
response = self._connection.send_request(FunctionCode.EXPLORE, payload, integrity_tail=5, reassemble=True)
return response
+ def explore_xml(self, explore_id: int = 0) -> str | None:
+ """EXPLORE a PLC object and decompress the XML metadata from the response.
+
+ S7-1200/1500 PLCs (FW V4.5+) compress XML metadata — tag definitions,
+ interface descriptions, line comments, etc. — using zlib with Siemens
+ preset dictionaries. This method sends an EXPLORE request and
+ decompresses the first zlib stream found in the response.
+
+ .. warning:: This method is **experimental** and may change.
+
+ Args:
+ explore_id: Object to explore (0 = root).
+
+ Returns:
+ Decompressed XML as a UTF-8 string, or ``None`` if the response
+ contains no recognisable zlib stream.
+ """
+ raw = self.explore(explore_id)
+ return find_and_decompress(raw)
+
def set_plc_operating_state(self, state: int) -> None:
"""Set the PLC operating state (start/stop).
diff --git a/s7commplus/zlib_dicts/1398a37f_CompilerSettings_90000001.xml b/s7commplus/zlib_dicts/1398a37f_CompilerSettings_90000001.xml
new file mode 100644
index 00000000..72c03308
--- /dev/null
+++ b/s7commplus/zlib_dicts/1398a37f_CompilerSettings_90000001.xml
@@ -0,0 +1 @@
+falsetrueChecksFlagsCompilerTypeOptimizationFlagsTargetTypeMC7PlusIECShortWires_x002C__x0020_NativePointerEnableAllCheck_Classic
diff --git a/s7commplus/zlib_dicts/3c55436a_LineComm_98000001.xml b/s7commplus/zlib_dicts/3c55436a_LineComm_98000001.xml
new file mode 100644
index 00000000..d440b140
--- /dev/null
+++ b/s7commplus/zlib_dicts/3c55436a_LineComm_98000001.xml
@@ -0,0 +1 @@
+UId=" RefID="51:52:53:54:55
diff --git a/s7commplus/zlib_dicts/4b8416f0_IntfDesc_90000001.xml b/s7commplus/zlib_dicts/4b8416f0_IntfDesc_90000001.xml
new file mode 100644
index 00000000..d4ce3714
--- /dev/null
+++ b/s7commplus/zlib_dicts/4b8416f0_IntfDesc_90000001.xml
@@ -0,0 +1 @@
+
diff --git a/s7commplus/zlib_dicts/5814b03b_IdentES_98000001.xml b/s7commplus/zlib_dicts/5814b03b_IdentES_98000001.xml
new file mode 100644
index 00000000..2b7f2e59
--- /dev/null
+++ b/s7commplus/zlib_dicts/5814b03b_IdentES_98000001.xml
@@ -0,0 +1 @@
+FCCodeBlockData633706353564000671FalsetrueDBDataBlockData633706353581345975FalsetrueDBDataBlockData633706353587127743TruefalseOB.ProgramCycleCodeBlockData633706354424546519Falsetrue
diff --git a/s7commplus/zlib_dicts/66052b13_DebugInfo_IntfDesc_98000001.xml b/s7commplus/zlib_dicts/66052b13_DebugInfo_IntfDesc_98000001.xml
new file mode 100644
index 00000000..59e2711e
--- /dev/null
+++ b/s7commplus/zlib_dicts/66052b13_DebugInfo_IntfDesc_98000001.xml
@@ -0,0 +1 @@
+ BlockNumber=" BlockType=" TypeName=" StructureModifiedTS=" InterfaceModifiedTS=" TypeObjectId=" VersionId=" TypeRId=" RId=" MIRetainPaddedBitSize=" MIVolatilePaddedBitSize=" MIClassicPaddedBitSize=" MIRetainRelativeBitOffset=" MIVolatileRelativeBitOffset=" MIClassicRelativeBitOffset=" Scope=" RefId=" RIdSlots=" IdentUID=" Parameter=" InterfaceGuid=" BlockObjectID=" VersionGuid="
diff --git a/s7commplus/zlib_dicts/79b2bda3_LineComm_90000001.xml b/s7commplus/zlib_dicts/79b2bda3_LineComm_90000001.xml
new file mode 100644
index 00000000..aae6fa1a
--- /dev/null
+++ b/s7commplus/zlib_dicts/79b2bda3_LineComm_90000001.xml
@@ -0,0 +1 @@
+this is a the in to an can be for are network anddies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/s7commplus/zlib_dicts/81d8db20_IdentES_90000002.xml b/s7commplus/zlib_dicts/81d8db20_IdentES_90000002.xml
new file mode 100644
index 00000000..531301e7
--- /dev/null
+++ b/s7commplus/zlib_dicts/81d8db20_IdentES_90000002.xml
@@ -0,0 +1 @@
+FCCodeBlockData633706353564000671FalseFalsetrueDBDataBlockData633706353581345975FalseFalsetrueDBDataBlockData633706353587127743TrueTruefalseOB.ProgramCycleCodeBlockData633706354424546519FalseFalsetrue
diff --git a/s7commplus/zlib_dicts/845fc605_NWT_98000001.xml b/s7commplus/zlib_dicts/845fc605_NWT_98000001.xml
new file mode 100644
index 00000000..779acfd4
--- /dev/null
+++ b/s7commplus/zlib_dicts/845fc605_NWT_98000001.xml
@@ -0,0 +1 @@
+ "Main Program Sweep (Cycle)"this is a the in to an can be for are network anddies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/s7commplus/zlib_dicts/9b6a3a92_ExtRefData_90000001.xml b/s7commplus/zlib_dicts/9b6a3a92_ExtRefData_90000001.xml
new file mode 100644
index 00000000..766eb4ca
--- /dev/null
+++ b/s7commplus/zlib_dicts/9b6a3a92_ExtRefData_90000001.xml
@@ -0,0 +1 @@
+
diff --git a/s7commplus/zlib_dicts/__init__.py b/s7commplus/zlib_dicts/__init__.py
new file mode 100644
index 00000000..cdde4e54
--- /dev/null
+++ b/s7commplus/zlib_dicts/__init__.py
@@ -0,0 +1,28 @@
+"""S7CommPlus zlib preset dictionaries for compressed blob decompression.
+
+Extracted from thomas-v2/S7CommPlusDriver (LGPL-3.0):
+ src/S7CommPlusDriver/Core/BlobDecompressor.cs
+
+S7-1200/1500 PLCs compress various XML blobs (interface descriptions,
+tag tables, line comments, debug info, etc.) using zlib with a preset
+dictionary. Python's zlib.decompress() returns Z_NEED_DICT; we provide
+the matching dictionary based on the Adler-32 checksum in the zlib header.
+
+Each dictionary is stored as a readable ``.xml`` file in this package
+directory, named ``{adler32}_{name}.xml``. They are loaded once at
+import time and indexed by Adler-32 checksum.
+"""
+
+from pathlib import Path as _Path
+
+_DIR = _Path(__file__).parent
+
+ZLIB_DICT_NAMES: dict[int, str] = {}
+ZLIB_DICTIONARIES: dict[int, bytes] = {}
+
+for _f in sorted(_DIR.glob("*.xml")):
+ _adler_hex, _rest = _f.stem.split("_", 1)
+ _adler = int(_adler_hex, 16)
+ _name = _rest.replace("_", " ")
+ ZLIB_DICT_NAMES[_adler] = _name
+ ZLIB_DICTIONARIES[_adler] = _f.read_bytes().rstrip(b"\n")
diff --git a/s7commplus/zlib_dicts/ab6fa31e_NWC_90000001.xml b/s7commplus/zlib_dicts/ab6fa31e_NWC_90000001.xml
new file mode 100644
index 00000000..121d1bb0
--- /dev/null
+++ b/s7commplus/zlib_dicts/ab6fa31e_NWC_90000001.xml
@@ -0,0 +1 @@
+this is a the in to an can be for are network anddies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/s7commplus/zlib_dicts/b0155ff8_IntRefData_98000001.xml b/s7commplus/zlib_dicts/b0155ff8_IntRefData_98000001.xml
new file mode 100644
index 00000000..f1a8a8f8
--- /dev/null
+++ b/s7commplus/zlib_dicts/b0155ff8_IntRefData_98000001.xml
@@ -0,0 +1 @@
+ StructureModifiedTS=" BlockType=" Section=" Area=" InterfaceFlags="S7_Visible"InterfaceFlags="Mandatory, S7_Visible"
diff --git a/s7commplus/zlib_dicts/c5d26ac3_NWC_98000001.xml b/s7commplus/zlib_dicts/c5d26ac3_NWC_98000001.xml
new file mode 100644
index 00000000..bf2e006a
--- /dev/null
+++ b/s7commplus/zlib_dicts/c5d26ac3_NWC_98000001.xml
@@ -0,0 +1 @@
+this is a the in to an can be for are network and dies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/s7commplus/zlib_dicts/ce9b821b_IntfDescTag_90000001.xml b/s7commplus/zlib_dicts/ce9b821b_IntfDescTag_90000001.xml
new file mode 100644
index 00000000..aafdbfcf
--- /dev/null
+++ b/s7commplus/zlib_dicts/ce9b821b_IntfDescTag_90000001.xml
@@ -0,0 +1 @@
+BoolIntDIntRealBoolDInt
diff --git a/s7commplus/zlib_dicts/da4a88f4_IntRefData_90000001.xml b/s7commplus/zlib_dicts/da4a88f4_IntRefData_90000001.xml
new file mode 100644
index 00000000..7003ba43
--- /dev/null
+++ b/s7commplus/zlib_dicts/da4a88f4_IntRefData_90000001.xml
@@ -0,0 +1 @@
+
diff --git a/s7commplus/zlib_dicts/df91b6bb_IdentES_90000001.xml b/s7commplus/zlib_dicts/df91b6bb_IdentES_90000001.xml
new file mode 100644
index 00000000..08cf8faf
--- /dev/null
+++ b/s7commplus/zlib_dicts/df91b6bb_IdentES_90000001.xml
@@ -0,0 +1 @@
+FCCodeBlockData633706353564000671FalsetrueDBDataBlockData633706353581345975FalsetrueDBDataBlockData633706353587127743TruefalseOB.ProgramCycleCodeBlockData633706354424546519Falsetrue
diff --git a/s7commplus/zlib_dicts/e2729ea1_TagLineComm_90000001.xml b/s7commplus/zlib_dicts/e2729ea1_TagLineComm_90000001.xml
new file mode 100644
index 00000000..b30b3314
--- /dev/null
+++ b/s7commplus/zlib_dicts/e2729ea1_TagLineComm_90000001.xml
@@ -0,0 +1 @@
+this is a the in to an can be for are network anddies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/s7commplus/zlib_dicts/efaeae49_BodyDesc_90000001.xml b/s7commplus/zlib_dicts/efaeae49_BodyDesc_90000001.xml
new file mode 100644
index 00000000..f25c0a2f
--- /dev/null
+++ b/s7commplus/zlib_dicts/efaeae49_BodyDesc_90000001.xml
@@ -0,0 +1 @@
+
diff --git a/s7commplus/zlib_dicts/fd69ac74_NWT_90000001.xml b/s7commplus/zlib_dicts/fd69ac74_NWT_90000001.xml
new file mode 100644
index 00000000..caa5bc34
--- /dev/null
+++ b/s7commplus/zlib_dicts/fd69ac74_NWT_90000001.xml
@@ -0,0 +1 @@
+ "Main Program Sweep (Cycle)"this is a the in to an can be for are network anddies ist ein der die das im nach einen kann sein für sind Netzwerk und
diff --git a/tests/test_blob_decompressor.py b/tests/test_blob_decompressor.py
new file mode 100644
index 00000000..420f799e
--- /dev/null
+++ b/tests/test_blob_decompressor.py
@@ -0,0 +1,71 @@
+"""Tests for the S7CommPlus blob decompressor."""
+
+import zlib
+
+import pytest
+
+from s7commplus import decompress_blob, find_and_decompress
+from s7commplus.zlib_dicts import ZLIB_DICTIONARIES, ZLIB_DICT_NAMES
+
+
+def test_all_dictionaries_have_correct_adler32():
+ for adler, data in ZLIB_DICTIONARIES.items():
+ actual = zlib.adler32(data) & 0xFFFFFFFF
+ assert actual == adler, f"Adler-32 mismatch for {ZLIB_DICT_NAMES.get(adler, '?')}: {actual:#x} != {adler:#x}"
+
+
+def test_all_dictionaries_have_names():
+ for adler in ZLIB_DICTIONARIES:
+ assert adler in ZLIB_DICT_NAMES, f"Missing name for dictionary {adler:#x}"
+
+
+def test_decompress_standard_blob():
+ original = b""
+ compressed = zlib.compress(original)
+ result = decompress_blob(compressed)
+ assert result == original.decode("utf-8")
+
+
+def test_decompress_with_preset_dict():
+ original = b""
+ intfdesc_dict = ZLIB_DICTIONARIES[0xCE9B821B]
+
+ # Compress with raw deflate (wbits=-15) + preset dictionary
+ cobj = zlib.compressobj(level=6, wbits=-15, zdict=intfdesc_dict)
+ raw_deflate = cobj.compress(original) + cobj.flush()
+ # Build the zlib header: CMF=0x78, FLG with FDICT set, then 4-byte dict Adler-32
+ header = b"\x78\x7d" + (0xCE9B821B).to_bytes(4, "big")
+ blob = header + raw_deflate
+
+ result = decompress_blob(blob)
+ assert result == original.decode("utf-8")
+
+
+def test_decompress_unknown_dict_raises():
+ blob = b"\x78\x7d\xde\xad\xbe\xef" + b"\x00" * 10
+ with pytest.raises(ValueError, match="Unknown zlib preset dictionary"):
+ decompress_blob(blob)
+
+
+def test_decompress_with_offset():
+ original = b"data"
+ compressed = zlib.compress(original)
+ # 4-byte version prefix
+ blob = b"\x00\x00\x00\x01" + compressed
+ result = decompress_blob(blob, offset=4)
+ assert result == original.decode("utf-8")
+
+
+def test_find_and_decompress_standard():
+ original = b"hello"
+ compressed = zlib.compress(original)
+ # Embed in some prefix junk
+ data = b"\x00\x01\x02" + compressed
+ result = find_and_decompress(data)
+ assert result is not None
+ assert result == original.decode("utf-8")
+
+
+def test_find_and_decompress_no_stream():
+ result = find_and_decompress(b"\x00\x01\x02\x03\x04\x05")
+ assert result is None
diff --git a/tests/test_s7_unit.py b/tests/test_s7_unit.py
index a684c0a9..1c03ffc8 100644
--- a/tests/test_s7_unit.py
+++ b/tests/test_s7_unit.py
@@ -506,6 +506,11 @@ def test_explore_not_connected(self) -> None:
with pytest.raises(RuntimeError, match="Not connected"):
client.explore()
+ def test_explore_xml_not_connected(self) -> None:
+ client = S7CommPlusClient()
+ with pytest.raises(RuntimeError, match="Not connected"):
+ client.explore_xml()
+
def test_context_manager_not_connected(self) -> None:
"""Test that context manager works without connection (disconnect is a no-op)."""
with S7CommPlusClient() as client: