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/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 and dies 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 @@ + FC CodeBlockData 633706353564000671 False False true DB DataBlockData 633706353581345975 False False true DB DataBlockData 633706353587127743 True True false OB.ProgramCycle CodeBlockData 633706354424546519 False False true 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/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 @@ + Bool Int DInt Real Bool DInt 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 @@ + FC CodeBlockData 633706353564000671 False true DB DataBlockData 633706353581345975 False true DB DataBlockData 633706353587127743 True false OB.ProgramCycle CodeBlockData 633706354424546519 False true 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: