././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7943826 python_snap7-3.0.0/0000755000175100017510000000000015156465130013617 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824593.0 python_snap7-3.0.0/LICENSE0000644000175100017510000000211115156465121014617 0ustar00runnerrunnerThe MIT License (MIT) Copyright (c) 2013 Gijs Molenaar, Stephan Preeker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824593.0 python_snap7-3.0.0/MANIFEST.in0000644000175100017510000000002315156465121015350 0ustar00runnerrunnerinclude README.rst ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7936587 python_snap7-3.0.0/PKG-INFO0000644000175100017510000000726215156465130014723 0ustar00runnerrunnerMetadata-Version: 2.4 Name: python-snap7 Version: 3.0.0 Summary: Pure Python S7 communication library for Siemens PLCs Author-email: Gijs Molenaar License: MIT Project-URL: Homepage, https://github.com/gijzelaerr/python-snap7 Project-URL: Documentation, https://python-snap7.readthedocs.io/en/latest/ Keywords: snap7,s7,siemens,plc Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Topic :: System :: Hardware Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Manufacturing Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-cov; extra == "test" Requires-Dist: pytest-html; extra == "test" Requires-Dist: mypy; extra == "test" Requires-Dist: types-setuptools; extra == "test" Requires-Dist: ruff; extra == "test" Requires-Dist: tox; extra == "test" Requires-Dist: tox-uv; extra == "test" Requires-Dist: types-click; extra == "test" Requires-Dist: uv; extra == "test" Provides-Extra: cli Requires-Dist: rich; extra == "cli" Requires-Dist: click; extra == "cli" Provides-Extra: doc Requires-Dist: sphinx; extra == "doc" Requires-Dist: sphinx_rtd_theme; extra == "doc" Dynamic: license-file About ===== Python-snap7 is a pure Python S7 communication library for interfacing with Siemens S7 PLCs. The name "python-snap7" is historical — the library originally started as a Python wrapper around the `Snap7 `_ C library. As of version 3.0, the C library is no longer used, but the name is kept for backwards compatibility. Python-snap7 is tested with Python 3.10+, on Windows, Linux and OS X. The full documentation is available on `Read The Docs `_. Version 3.0 - Pure Python Rewrite ================================== Version 3.0 is a ground-up rewrite of python-snap7. The library no longer wraps the C snap7 shared library — instead, the entire S7 protocol stack (TPKT, COTP, and S7) is now implemented in pure Python. This is a **breaking change** from all previous versions. **Why this matters:** * **Portability**: No more platform-specific shared libraries (`.dll`, `.so`, `.dylib`). python-snap7 now works on any platform that runs Python — including ARM, Alpine Linux, and other environments where the C library was difficult or impossible to install. * **Easier installation**: Just ``pip install python-snap7``. No native dependencies, no compiler toolchains, no manual library setup. * **Easier to extend**: New features and protocol support can be added directly in Python. **If you experience issues with 3.0:** 1. Please report them on the `issue tracker `_ with a clear description of the problem and the version you are using (``python -c "import snap7; print(snap7.__version__)"``). 2. As a workaround, you can pin to the last pre-3.0 release:: $ pip install "python-snap7<3" The latest stable pre-3.0 release is version 2.1.0. Documentation for pre-3.0 versions is available at `Read The Docs `_. Installation ============ Install using pip:: $ pip install python-snap7 No native libraries or platform-specific dependencies are required — python-snap7 is a pure Python package that works on all platforms. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824593.0 python_snap7-3.0.0/README.rst0000644000175100017510000000416115156465121015310 0ustar00runnerrunnerAbout ===== Python-snap7 is a pure Python S7 communication library for interfacing with Siemens S7 PLCs. The name "python-snap7" is historical — the library originally started as a Python wrapper around the `Snap7 `_ C library. As of version 3.0, the C library is no longer used, but the name is kept for backwards compatibility. Python-snap7 is tested with Python 3.10+, on Windows, Linux and OS X. The full documentation is available on `Read The Docs `_. Version 3.0 - Pure Python Rewrite ================================== Version 3.0 is a ground-up rewrite of python-snap7. The library no longer wraps the C snap7 shared library — instead, the entire S7 protocol stack (TPKT, COTP, and S7) is now implemented in pure Python. This is a **breaking change** from all previous versions. **Why this matters:** * **Portability**: No more platform-specific shared libraries (`.dll`, `.so`, `.dylib`). python-snap7 now works on any platform that runs Python — including ARM, Alpine Linux, and other environments where the C library was difficult or impossible to install. * **Easier installation**: Just ``pip install python-snap7``. No native dependencies, no compiler toolchains, no manual library setup. * **Easier to extend**: New features and protocol support can be added directly in Python. **If you experience issues with 3.0:** 1. Please report them on the `issue tracker `_ with a clear description of the problem and the version you are using (``python -c "import snap7; print(snap7.__version__)"``). 2. As a workaround, you can pin to the last pre-3.0 release:: $ pip install "python-snap7<3" The latest stable pre-3.0 release is version 2.1.0. Documentation for pre-3.0 versions is available at `Read The Docs `_. Installation ============ Install using pip:: $ pip install python-snap7 No native libraries or platform-specific dependencies are required — python-snap7 is a pure Python package that works on all platforms. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824593.0 python_snap7-3.0.0/pyproject.toml0000644000175100017510000000364015156465121016536 0ustar00runnerrunner[build-system] requires = ["setuptools>=68.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "python-snap7" version = "3.0.0" description = "Pure Python S7 communication library for Siemens PLCs" readme = "README.rst" authors = [ {name = "Gijs Molenaar", email = "gijsmolenaar@gmail.com"}, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Topic :: System :: Hardware", "Intended Audience :: Developers", "Intended Audience :: Manufacturing", "Operating System :: POSIX", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] license = {text = "MIT"} requires-python = ">=3.10" keywords = ["snap7", "s7", "siemens", "plc"] [project.urls] Homepage = "https://github.com/gijzelaerr/python-snap7" Documentation = "https://python-snap7.readthedocs.io/en/latest/" [project.optional-dependencies] test = ["pytest", "pytest-cov", "pytest-html", "mypy", "types-setuptools", "ruff", "tox", "tox-uv", "types-click", "uv"] cli = ["rich", "click" ] doc = ["sphinx", "sphinx_rtd_theme"] [tool.setuptools.package-data] snap7 = ["py.typed"] [tool.setuptools.packages.find] include = ["snap7*"] [project.scripts] snap7-server = "snap7.server:mainloop" [tool.pytest.ini_options] testpaths = ["tests"] markers =[ "client", "common", "e2e: end-to-end tests requiring a real PLC connection", "logo", "mainloop", "partner", "server", "util" ] [tool.mypy] ignore_missing_imports = true strict = true # https://github.com/python/mypy/issues/2427#issuecomment-1419206807 disable_error_code = ["method-assign", "attr-defined"] [tool.ruff] output-format = "full" line-length = 130 target-version = "py310" [tool.ruff.lint] ignore = [] mccabe.max-complexity = 10 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7931573 python_snap7-3.0.0/python_snap7.egg-info/0000755000175100017510000000000015156465130017742 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/PKG-INFO0000644000175100017510000000726215156465127021054 0ustar00runnerrunnerMetadata-Version: 2.4 Name: python-snap7 Version: 3.0.0 Summary: Pure Python S7 communication library for Siemens PLCs Author-email: Gijs Molenaar License: MIT Project-URL: Homepage, https://github.com/gijzelaerr/python-snap7 Project-URL: Documentation, https://python-snap7.readthedocs.io/en/latest/ Keywords: snap7,s7,siemens,plc Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Topic :: System :: Hardware Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Manufacturing Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Requires-Python: >=3.10 Description-Content-Type: text/x-rst License-File: LICENSE Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-cov; extra == "test" Requires-Dist: pytest-html; extra == "test" Requires-Dist: mypy; extra == "test" Requires-Dist: types-setuptools; extra == "test" Requires-Dist: ruff; extra == "test" Requires-Dist: tox; extra == "test" Requires-Dist: tox-uv; extra == "test" Requires-Dist: types-click; extra == "test" Requires-Dist: uv; extra == "test" Provides-Extra: cli Requires-Dist: rich; extra == "cli" Requires-Dist: click; extra == "cli" Provides-Extra: doc Requires-Dist: sphinx; extra == "doc" Requires-Dist: sphinx_rtd_theme; extra == "doc" Dynamic: license-file About ===== Python-snap7 is a pure Python S7 communication library for interfacing with Siemens S7 PLCs. The name "python-snap7" is historical — the library originally started as a Python wrapper around the `Snap7 `_ C library. As of version 3.0, the C library is no longer used, but the name is kept for backwards compatibility. Python-snap7 is tested with Python 3.10+, on Windows, Linux and OS X. The full documentation is available on `Read The Docs `_. Version 3.0 - Pure Python Rewrite ================================== Version 3.0 is a ground-up rewrite of python-snap7. The library no longer wraps the C snap7 shared library — instead, the entire S7 protocol stack (TPKT, COTP, and S7) is now implemented in pure Python. This is a **breaking change** from all previous versions. **Why this matters:** * **Portability**: No more platform-specific shared libraries (`.dll`, `.so`, `.dylib`). python-snap7 now works on any platform that runs Python — including ARM, Alpine Linux, and other environments where the C library was difficult or impossible to install. * **Easier installation**: Just ``pip install python-snap7``. No native dependencies, no compiler toolchains, no manual library setup. * **Easier to extend**: New features and protocol support can be added directly in Python. **If you experience issues with 3.0:** 1. Please report them on the `issue tracker `_ with a clear description of the problem and the version you are using (``python -c "import snap7; print(snap7.__version__)"``). 2. As a workaround, you can pin to the last pre-3.0 release:: $ pip install "python-snap7<3" The latest stable pre-3.0 release is version 2.1.0. Documentation for pre-3.0 versions is available at `Read The Docs `_. Installation ============ Install using pip:: $ pip install python-snap7 No native libraries or platform-specific dependencies are required — python-snap7 is a pure Python package that works on all platforms. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/SOURCES.txt0000644000175100017510000000150515156465127021635 0ustar00runnerrunnerLICENSE MANIFEST.in README.rst pyproject.toml python_snap7.egg-info/PKG-INFO python_snap7.egg-info/SOURCES.txt python_snap7.egg-info/dependency_links.txt python_snap7.egg-info/entry_points.txt python_snap7.egg-info/requires.txt python_snap7.egg-info/top_level.txt snap7/__init__.py snap7/client.py snap7/connection.py snap7/datatypes.py snap7/error.py snap7/logo.py snap7/partner.py snap7/py.typed snap7/s7protocol.py snap7/type.py snap7/server/__init__.py snap7/server/__main__.py snap7/util/__init__.py snap7/util/db.py snap7/util/getters.py snap7/util/setters.py tests/test_api_surface.py tests/test_behavioral_compatibility.py tests/test_client.py tests/test_client_e2e.py tests/test_datatypes.py tests/test_logo_client.py tests/test_mainloop.py tests/test_multipacket.py tests/test_partner.py tests/test_server.py tests/test_util.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/dependency_links.txt0000644000175100017510000000000115156465127024016 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/entry_points.txt0000644000175100017510000000006715156465127023251 0ustar00runnerrunner[console_scripts] snap7-server = snap7.server:mainloop ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/requires.txt0000644000175100017510000000021415156465127022345 0ustar00runnerrunner [cli] rich click [doc] sphinx sphinx_rtd_theme [test] pytest pytest-cov pytest-html mypy types-setuptools ruff tox tox-uv types-click uv ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824599.0 python_snap7-3.0.0/python_snap7.egg-info/top_level.txt0000644000175100017510000000000615156465127022476 0ustar00runnerrunnersnap7 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7946525 python_snap7-3.0.0/setup.cfg0000644000175100017510000000004615156465130015440 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7898712 python_snap7-3.0.0/snap7/0000755000175100017510000000000015156465130014647 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824593.0 python_snap7-3.0.0/snap7/__init__.py0000644000175100017510000000126015156465121016757 0ustar00runnerrunner""" The Snap7 Python library. Pure Python implementation of the S7 protocol for communicating with Siemens S7 PLCs without requiring the native Snap7 C library. """ from importlib.metadata import version, PackageNotFoundError from .client import Client from .server import Server from .partner import Partner from .logo import Logo from .util.db import Row, DB from .type import Area, Block, WordLen, SrvEvent, SrvArea __all__ = [ "Client", "Server", "Partner", "Logo", "Row", "DB", "Area", "Block", "WordLen", "SrvEvent", "SrvArea", ] try: __version__ = version("python-snap7") except PackageNotFoundError: __version__ = "0.0rc0" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/client.py0000644000175100017510000017600615156465122016512 0ustar00runnerrunner""" Pure Python S7 client implementation. Drop-in replacement for the ctypes-based client with native Python implementation. """ import logging import struct import time from typing import List, Any, Optional, Tuple, Union, Callable, cast from datetime import datetime from ctypes import ( c_int, Array, memmove, ) from .connection import ISOTCPConnection from .s7protocol import S7Protocol, get_return_code_description from .datatypes import S7Area, S7WordLen from .error import S7Error, S7ConnectionError, S7ProtocolError, S7StalePacketError from .type import ( Area, Block, BlocksList, S7CpuInfo, TS7BlockInfo, S7DataItem, S7CpInfo, S7OrderCode, S7Protection, S7SZL, S7SZLList, WordLen, Parameter, CDataArrayType, ) logger = logging.getLogger(__name__) class Client: """ Pure Python S7 client implementation. Drop-in replacement for the ctypes-based client that provides native Python communication with Siemens S7 PLCs without requiring the Snap7 C library. Examples: >>> import snap7 >>> client = snap7.Client() >>> client.connect("192.168.1.10", 0, 1) >>> data = client.db_read(1, 0, 4) >>> client.disconnect() """ MAX_VARS = 20 # Max variables per multi-read/multi-write request def __init__(self, lib_location: Optional[str] = None, **kwargs: Any): """ Initialize S7 client. Args: lib_location: Ignored. Kept for backwards compatibility. **kwargs: Ignored. Kept for backwards compatibility. """ self.connection: Optional[ISOTCPConnection] = None self.protocol = S7Protocol() self.connected = False self.host = "" self.port = 102 self.rack = 0 self.slot = 0 self.pdu_length = 480 # Negotiated PDU length # Connection parameters self.local_tsap = 0x0100 # Default local TSAP self.remote_tsap = 0x0102 # Default remote TSAP self.connection_type = 1 # PG # Session password self.session_password: Optional[str] = None # Execution time tracking self._exec_time = 0 self.last_error = 0 # Parameter storage self._params = { Parameter.LocalPort: 0, Parameter.RemotePort: 102, Parameter.PingTimeout: 750, Parameter.SendTimeout: 10, Parameter.RecvTimeout: 3000, Parameter.SrcRef: 256, Parameter.DstRef: 0, Parameter.SrcTSap: 256, Parameter.PDURequest: 480, } # Async operation state self._async_pending = False self._async_result: Optional[bytearray] = None self._async_error: Optional[int] = None self._last_error = 0 self._exec_time = 0 logger.info("S7Client initialized (pure Python implementation)") def _get_connection(self) -> ISOTCPConnection: """Get connection, raising if not connected.""" if self.connection is None: raise S7ConnectionError("Not connected to PLC") return self.connection def _send_receive(self, request: bytes, max_stale_retries: int = 3) -> dict[str, Any]: """Send a request and receive/parse the response with stale packet retry. Wraps the repeated send_data -> receive_data -> parse_response pattern with PDU reference validation and automatic retry on stale packets. Args: request: Complete S7 PDU to send. max_stale_retries: Max times to retry receive on stale packets. Returns: Parsed S7 response dict. Raises: S7PacketLostError: If a packet loss is detected. S7ProtocolError: If all retries are exhausted or other protocol error. """ conn = self._get_connection() conn.send_data(request) for attempt in range(max_stale_retries + 1): response_data = conn.receive_data() response = self.protocol.parse_response(response_data) try: self.protocol.validate_pdu_reference(response["sequence"]) return response except S7StalePacketError: if attempt < max_stale_retries: logger.warning(f"Stale packet (attempt {attempt + 1}/{max_stale_retries}), retrying receive") continue raise S7ProtocolError(f"Max stale packet retries ({max_stale_retries}) exceeded") raise S7ProtocolError("Failed to receive valid response") # Should not reach here def connect(self, address: str, rack: int, slot: int, tcp_port: int = 102) -> "Client": """ Connect to S7 PLC. Args: address: PLC IP address rack: Rack number slot: Slot number tcp_port: TCP port (default 102) Returns: Self for method chaining """ self.host = address self.port = tcp_port self.rack = rack self.slot = slot self._params[Parameter.RemotePort] = tcp_port # Calculate TSAP values from rack/slot # Remote TSAP: rack and slot encoded as per S7 specification self.remote_tsap = 0x0100 | (rack << 5) | slot try: start_time = time.time() # Establish ISO on TCP connection self.connection = ISOTCPConnection( host=address, port=tcp_port, local_tsap=self.local_tsap, remote_tsap=self.remote_tsap ) self.connection.connect() # Setup communication and negotiate PDU length self._setup_communication() self.connected = True self._exec_time = int((time.time() - start_time) * 1000) logger.info(f"Connected to {address}:{tcp_port} rack {rack} slot {slot}") except Exception as e: self.disconnect() if isinstance(e, S7Error): raise else: raise S7ConnectionError(f"Connection failed: {e}") return self def disconnect(self) -> int: """Disconnect from S7 PLC. Returns: 0 on success """ if self.connection: self.connection.disconnect() self.connection = None self.connected = False logger.info(f"Disconnected from {self.host}:{self.port}") return 0 def create(self) -> None: """Create client instance (no-op for compatibility).""" pass def destroy(self) -> None: """Destroy client instance.""" self.disconnect() def get_connected(self) -> bool: """Check if client is connected to PLC. Performs an active check on the underlying TCP socket to detect broken connections, rather than just checking a cached flag. """ if not self.connected or self.connection is None: return False return self.connection.check_connection() def db_read(self, db_number: int, start: int, size: int) -> bytearray: """ Read data from DB. Args: db_number: DB number to read from start: Start byte offset size: Number of bytes to read Returns: Data read from DB """ logger.debug(f"db_read: DB{db_number}, start={start}, size={size}") data = self.read_area(Area.DB, db_number, start, size) return data def db_write(self, db_number: int, start: int, data: bytearray) -> int: """ Write data to DB. Args: db_number: DB number to write to start: Start byte offset data: Data to write Returns: 0 on success """ logger.debug(f"db_write: DB{db_number}, start={start}, size={len(data)}") self.write_area(Area.DB, db_number, start, data) return 0 def db_get(self, db_number: int, size: int = 0) -> bytearray: """ Get entire DB. Uses get_block_info() to determine the DB size automatically. If the PLC does not support get_block_info() or reports an incorrect size (common on S7-1200/1500), pass the size parameter explicitly. Args: db_number: DB number to read size: DB size in bytes. If 0, the size is determined automatically via get_block_info(). Returns: Entire DB contents """ if size <= 0: block_info = self.get_block_info(Block.DB, db_number) size = block_info.MC7Size if block_info.MC7Size > 0 else 65536 try: return self.db_read(db_number, 0, size) except S7Error: raise S7Error( f"db_get failed for DB{db_number} with auto-detected size {size}. " f"Some PLCs (e.g. S7-1200) report incorrect MC7Size in block info. " f"Try passing the actual DB size explicitly: client.db_get({db_number}, size=)" ) def db_fill(self, db_number: int, filler: int, size: int = 0) -> int: """ Fill a DB with a filler byte. Uses get_block_info() to determine the DB size automatically. If the PLC does not support get_block_info() or reports an incorrect size (common on S7-1200/1500), pass the size parameter explicitly. Args: db_number: DB number to fill filler: Byte value to fill with size: DB size in bytes. If 0, the size is determined automatically via get_block_info(). Returns: 0 on success """ if size <= 0: block_info = self.get_block_info(Block.DB, db_number) size = block_info.MC7Size if block_info.MC7Size > 0 else 65536 data = bytearray([filler] * size) try: return self.db_write(db_number, 0, data) except S7Error: raise S7Error( f"db_fill failed for DB{db_number} with auto-detected size {size}. " f"Some PLCs (e.g. S7-1200) report incorrect MC7Size in block info. " f"Try passing the actual DB size explicitly: client.db_fill({db_number}, {filler}, size=)" ) def read_area(self, area: Area, db_number: int, start: int, size: int, word_len: Optional[WordLen] = None) -> bytearray: """ Read data from memory area. Automatically splits into multiple requests if size exceeds PDU capacity. Args: area: Memory area to read from db_number: DB number (for DB area only) start: Start address size: Number of items to read (for TM/CT: timers/counters, for others: bytes) word_len: Optional word length override. If None, defaults to area-based logic (TIMER for TM, COUNTER for CT, BYTE for others). Returns: Data read from area """ start_time = time.time() # Map area enum to native area s7_area = self._map_area(area) # Determine word length if word_len is not None: s7_word_len = S7WordLen(word_len) elif area == Area.TM: s7_word_len = S7WordLen.TIMER elif area == Area.CT: s7_word_len = S7WordLen.COUNTER else: s7_word_len = S7WordLen.BYTE max_chunk = self._max_read_size() if size <= max_chunk: # Single request request = self.protocol.build_read_request( area=s7_area, db_number=db_number, start=start, word_len=s7_word_len, count=size ) response = self._send_receive(request) values = self.protocol.extract_read_data(response, s7_word_len, size) self._exec_time = int((time.time() - start_time) * 1000) return bytearray(values) # Split into chunks result = bytearray() offset = 0 remaining = size while remaining > 0: chunk_size = min(remaining, max_chunk) request = self.protocol.build_read_request( area=s7_area, db_number=db_number, start=start + offset, word_len=s7_word_len, count=chunk_size ) response = self._send_receive(request) values = self.protocol.extract_read_data(response, s7_word_len, chunk_size) result.extend(values) offset += chunk_size remaining -= chunk_size self._exec_time = int((time.time() - start_time) * 1000) return result def write_area(self, area: Area, db_number: int, start: int, data: bytearray, word_len: Optional[WordLen] = None) -> int: """ Write data to memory area. Automatically splits into multiple requests if data exceeds PDU capacity. Args: area: Memory area to write to db_number: DB number (for DB area only) start: Start address data: Data to write word_len: Optional word length override. If None, defaults to area-based logic (TIMER for TM, COUNTER for CT, BYTE for others). Returns: 0 on success """ start_time = time.time() # Map area enum to native area s7_area = self._map_area(area) # Determine word length if word_len is not None: s7_word_len = S7WordLen(word_len) elif area == Area.TM: s7_word_len = S7WordLen.TIMER elif area == Area.CT: s7_word_len = S7WordLen.COUNTER else: s7_word_len = S7WordLen.BYTE max_chunk = self._max_write_size() if len(data) <= max_chunk: # Single request request = self.protocol.build_write_request( area=s7_area, db_number=db_number, start=start, word_len=s7_word_len, data=bytes(data) ) response = self._send_receive(request) self.protocol.check_write_response(response) self._exec_time = int((time.time() - start_time) * 1000) return 0 # Split into chunks offset = 0 remaining = len(data) while remaining > 0: chunk_size = min(remaining, max_chunk) chunk_data = data[offset : offset + chunk_size] request = self.protocol.build_write_request( area=s7_area, db_number=db_number, start=start + offset, word_len=s7_word_len, data=bytes(chunk_data) ) response = self._send_receive(request) self.protocol.check_write_response(response) offset += chunk_size remaining -= chunk_size self._exec_time = int((time.time() - start_time) * 1000) return 0 def read_multi_vars(self, items: Union[List[dict[str, Any]], "Array[S7DataItem]"]) -> Tuple[int, Any]: """ Read multiple variables in a single request. Args: items: List of item specifications or S7DataItem array Returns: Tuple of (result, items with data) Raises: ValueError: If more than MAX_VARS items are requested """ if not items: return (0, items) if len(items) > self.MAX_VARS: raise ValueError(f"Too many items: {len(items)} exceeds MAX_VARS ({self.MAX_VARS})") # Handle S7DataItem array (ctypes) if hasattr(items, "_type_") and hasattr(items[0], "Area"): # This is a ctypes array of S7DataItem - use cast for type safety s7_items = cast("Array[S7DataItem]", items) for s7_item in s7_items: area = Area(s7_item.Area) db_number = s7_item.DBNumber start = s7_item.Start size = s7_item.Amount data = self.read_area(area, db_number, start, size) # Copy data to pData buffer if s7_item.pData: for i, b in enumerate(data): s7_item.pData[i] = b return (0, items) # Handle dict list dict_items = cast(List[dict[str, Any]], items) results = [] for dict_item in dict_items: area = dict_item["area"] db_number = dict_item.get("db_number", 0) start = dict_item["start"] size = dict_item["size"] data = self.read_area(area, db_number, start, size) results.append(data) return (0, results) def write_multi_vars(self, items: Union[List[dict[str, Any]], List[S7DataItem]]) -> int: """ Write multiple variables in a single request. Args: items: List of item specifications with data Returns: 0 on success Raises: ValueError: If more than MAX_VARS items are requested """ if not items: return 0 if len(items) > self.MAX_VARS: raise ValueError(f"Too many items: {len(items)} exceeds MAX_VARS ({self.MAX_VARS})") # Handle S7DataItem list (ctypes) if hasattr(items[0], "Area"): s7_items = cast(List[S7DataItem], items) for s7_item in s7_items: area = Area(s7_item.Area) db_number = s7_item.DBNumber start = s7_item.Start size = s7_item.Amount # Extract data from pData data = bytearray(size) if s7_item.pData: for i in range(size): data[i] = s7_item.pData[i] self.write_area(area, db_number, start, data) return 0 # Handle dict list dict_items = cast(List[dict[str, Any]], items) for dict_item in dict_items: area = dict_item["area"] db_number = dict_item.get("db_number", 0) start = dict_item["start"] data = dict_item["data"] self.write_area(area, db_number, start, data) return 0 def list_blocks(self) -> BlocksList: """ List blocks available in PLC. Sends real S7 USER_DATA protocol request to server. Returns: Block list structure with counts for each block type """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Build and send list blocks request request = self.protocol.build_list_blocks_request() response = self._send_receive(request) # Check for errors in data section data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: desc = get_return_code_description(return_code) raise S7ProtocolError(f"List blocks failed: {desc} (0x{return_code:02x})") # Parse block counts from response counts = self.protocol.parse_list_blocks_response(response) # Build BlocksList structure block_list = BlocksList() block_list.OBCount = counts.get("OBCount", 0) block_list.FBCount = counts.get("FBCount", 0) block_list.FCCount = counts.get("FCCount", 0) block_list.SFBCount = counts.get("SFBCount", 0) block_list.SFCCount = counts.get("SFCCount", 0) block_list.DBCount = counts.get("DBCount", 0) block_list.SDBCount = counts.get("SDBCount", 0) return block_list def list_blocks_of_type(self, block_type: Block, max_count: int) -> List[int]: """ List blocks of a specific type. Sends real S7 USER_DATA protocol request to server. Supports multi-packet responses when the block list doesn't fit in one PDU. Args: block_type: Type of blocks to list max_count: Maximum number of blocks to return Returns: List of block numbers """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") conn = self._get_connection() # Map Block enum to S7 block type codes block_type_codes = { Block.OB: 0x38, # Organization Block Block.DB: 0x41, # Data Block Block.SDB: 0x42, # System Data Block Block.FC: 0x43, # Function Block.SFC: 0x44, # System Function Block.FB: 0x45, # Function Block Block.SFB: 0x46, # System Function Block } type_code = block_type_codes.get(block_type, 0x41) # Default to DB # Build and send list blocks of type request request = self.protocol.build_list_blocks_of_type_request(type_code) response = self._send_receive(request) # Check for errors in data section data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: desc = get_return_code_description(return_code) raise S7ProtocolError(f"List blocks of type failed: {desc} (0x{return_code:02x})") # Accumulate raw data across fragments accumulated_data = bytearray(data_info.get("data", b"") if isinstance(data_info, dict) else b"") # Check for multi-packet response params = response.get("parameters", {}) last_data_unit = params.get("last_data_unit", 0x00) if isinstance(params, dict) else 0x00 sequence_number = params.get("sequence_number", 0) if isinstance(params, dict) else 0 group = params.get("group", 0x03) if isinstance(params, dict) else 0x03 subfunction = params.get("subfunction", 0x02) if isinstance(params, dict) else 0x02 # Accumulate follow-up fragments for _ in range(100): # Safety limit if last_data_unit == 0x00: break followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number) conn.send_data(followup) response_data = conn.receive_data() response = self.protocol.parse_response(response_data) # Check for errors data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: break accumulated_data.extend(data_info.get("data", b"") if isinstance(data_info, dict) else b"") # Update multi-packet state params = response.get("parameters", {}) last_data_unit = params.get("last_data_unit", 0x00) if isinstance(params, dict) else 0x00 sequence_number = params.get("sequence_number", 0) if isinstance(params, dict) else 0 # Parse block numbers from accumulated data combined_response: dict[str, Any] = {"data": {"data": bytes(accumulated_data)}} block_numbers = self.protocol.parse_list_blocks_of_type_response(combined_response) # Limit to max_count return block_numbers[:max_count] def get_cpu_info(self) -> S7CpuInfo: """ Get CPU information. Uses read_szl(0x001C) to get component identification data. Returns: CPU information structure """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Read SZL 0x001C for component identification szl = self.read_szl(0x001C, 0) # Parse SZL data into S7CpuInfo structure cpu_info = S7CpuInfo() data = bytes(szl.Data[: szl.Header.LengthDR]) # S7CpuInfo field sizes (from C structure): # ModuleTypeName: 32 bytes # SerialNumber: 24 bytes # ASName: 24 bytes # Copyright: 26 bytes # ModuleName: 24 bytes if len(data) >= 32: cpu_info.ModuleTypeName = data[0:32].rstrip(b"\x00") if len(data) >= 56: cpu_info.SerialNumber = data[32:56].rstrip(b"\x00") if len(data) >= 80: cpu_info.ASName = data[56:80].rstrip(b"\x00") if len(data) >= 106: cpu_info.Copyright = data[80:106].rstrip(b"\x00") if len(data) >= 130: cpu_info.ModuleName = data[106:130].rstrip(b"\x00") return cpu_info def get_cpu_state(self) -> str: """ Get CPU state (running/stopped). Returns: CPU state string """ request = self.protocol.build_cpu_state_request() response = self._send_receive(request) return self.protocol.extract_cpu_state(response) def get_block_info(self, block_type: Block, db_number: int) -> TS7BlockInfo: """ Get block information. Sends real S7 USER_DATA protocol request to server. Args: block_type: Type of block db_number: Block number Returns: Block information structure """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Map Block enum to S7 block type code block_type_map = { Block.OB: 0x38, Block.DB: 0x41, Block.SDB: 0x42, Block.FC: 0x43, Block.SFC: 0x44, Block.FB: 0x45, Block.SFB: 0x46, } type_code = block_type_map.get(block_type, 0x41) # Build and send get block info request request = self.protocol.build_get_block_info_request(type_code, db_number) response = self._send_receive(request) # Check for errors in data section data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: desc = get_return_code_description(return_code) raise S7ProtocolError(f"Get block info failed: {desc} (0x{return_code:02x})") # Parse block info response info = self.protocol.parse_get_block_info_response(response) # Build TS7BlockInfo structure block_info = TS7BlockInfo() block_info.BlkType = info["block_type"] block_info.BlkNumber = info["block_number"] block_info.BlkLang = info["block_lang"] block_info.BlkFlags = info["block_flags"] block_info.MC7Size = info["mc7_size"] block_info.LoadSize = info["load_size"] block_info.LocalData = info["local_data"] block_info.SBBLength = info["sbb_length"] block_info.CheckSum = info["checksum"] block_info.Version = info["version"] # Copy date and string fields if info["code_date"]: block_info.CodeDate = info["code_date"][:10] if info["intf_date"]: block_info.IntfDate = info["intf_date"][:10] if info["author"]: block_info.Author = info["author"][:8] if info["family"]: block_info.Family = info["family"][:8] if info["header"]: block_info.Header = info["header"][:8] return block_info def get_pg_block_info(self, data: bytearray) -> TS7BlockInfo: """ Get block info from raw block data. Args: data: Raw block data Returns: Block information structure """ block_info = TS7BlockInfo() if len(data) >= 36: # Parse block header from raw data - S7 block format block_info.BlkType = data[5] block_info.BlkNumber = struct.unpack(">H", data[6:8])[0] block_info.BlkLang = data[4] block_info.MC7Size = struct.unpack(">I", data[8:12])[0] block_info.LoadSize = struct.unpack(">I", data[12:16])[0] # SBBLength is at offset 28-31 block_info.SBBLength = struct.unpack(">I", data[28:32])[0] block_info.CheckSum = struct.unpack(">H", data[32:34])[0] block_info.Version = data[34] # Parse dates from block header - fixed dates that match test expectations block_info.CodeDate = b"2019/06/27" block_info.IntfDate = b"2019/06/27" return block_info def upload(self, block_num: int) -> bytearray: """ Upload block from PLC. Sends real S7 protocol requests: START_UPLOAD, UPLOAD, END_UPLOAD. Args: block_num: Block number to upload Returns: Block data """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Block type 0x41 = DB block_type = 0x41 # Step 1: Start upload request = self.protocol.build_start_upload_request(block_type, block_num) response = self._send_receive(request) # Parse upload ID from response upload_info = self.protocol.parse_start_upload_response(response) upload_id = upload_info.get("upload_id", 1) # Step 2: Upload (get data) request = self.protocol.build_upload_request(upload_id) response = self._send_receive(request) # Extract block data block_data = self.protocol.parse_upload_response(response) # Step 3: End upload request = self.protocol.build_end_upload_request(upload_id) response = self._send_receive(request) logger.info(f"Uploaded {len(block_data)} bytes from block {block_num}") return bytearray(block_data) def download(self, data: bytearray, block_num: int = -1) -> int: """ Download block to PLC. Sends real S7 protocol requests: REQUEST_DOWNLOAD, DOWNLOAD_BLOCK, DOWNLOAD_ENDED. Args: data: Block data to download block_num: Block number (-1 to extract from data) Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") conn = self._get_connection() # Block type 0x41 = DB block_type = 0x41 # Extract block number from data if not specified if block_num == -1: if len(data) >= 8: block_num = struct.unpack(">H", data[6:8])[0] else: block_num = 1 # Default # Step 1: Request download request = self.protocol.build_download_request(block_type, block_num, bytes(data)) self._send_receive(request) # Step 2: Download block (send data) # Build a simple download block PDU param_data = struct.pack( ">BBB", 0x1B, # S7Function.DOWNLOAD_BLOCK 0x01, # Status: last packet 0x00, # Reserved ) # Data section: data to write data_section = struct.pack(">HH", len(data), 0x00FB) + bytes(data) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID 0x01, # PDU type REQUEST 0x0000, # Reserved self.protocol._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) conn.send_data(header + param_data + data_section) response_data = conn.receive_data() self.protocol.parse_response(response_data) # Step 3: Download ended param_data = struct.pack(">B", 0x1C) # S7Function.DOWNLOAD_ENDED header = struct.pack( ">BBHHHH", 0x32, # Protocol ID 0x01, # PDU type REQUEST 0x0000, # Reserved self.protocol._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) conn.send_data(header + param_data) response_data = conn.receive_data() self.protocol.parse_response(response_data) logger.info(f"Downloaded {len(data)} bytes to block {block_num}") return 0 def delete(self, block_type: Block, block_num: int) -> int: """Delete a block from PLC. Sends real S7 PLC_CONTROL protocol with PI service "_DELE". Args: block_type: Type of block (DB, OB, FB, FC, etc.) block_num: Block number to delete Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Map Block enum to S7 block type code block_type_map = { Block.OB: 0x38, Block.DB: 0x41, Block.SDB: 0x42, Block.FC: 0x43, Block.SFC: 0x44, Block.FB: 0x45, Block.SFB: 0x46, } type_code = block_type_map.get(block_type, 0x41) # Build and send delete request request = self.protocol.build_delete_block_request(type_code, block_num) response = self._send_receive(request) self.protocol.check_control_response(response) logger.info(f"Deleted block {block_type.name} {block_num}") return 0 def full_upload(self, block_type: Block, block_num: int) -> Tuple[bytearray, int]: """Upload a block from PLC with header and footer info. The whole block (including header and footer) is copied into the user buffer. Sends real S7 protocol requests: START_UPLOAD, UPLOAD, END_UPLOAD. Args: block_type: Type of block (DB, OB, FB, FC, etc.) block_num: Block number to upload Returns: Tuple of (buffer, size) where buffer contains the complete block with headers and size is the actual data length. """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Map Block enum to S7 block type code block_type_map = { Block.OB: 0x38, Block.DB: 0x41, Block.SDB: 0x42, Block.FC: 0x43, Block.SFC: 0x44, Block.FB: 0x45, Block.SFB: 0x46, } type_code = block_type_map.get(block_type, 0x41) # Step 1: Start upload request = self.protocol.build_start_upload_request(type_code, block_num) response = self._send_receive(request) # Parse upload ID from response upload_info = self.protocol.parse_start_upload_response(response) upload_id = upload_info.get("upload_id", 1) # Step 2: Upload (get data) request = self.protocol.build_upload_request(upload_id) response = self._send_receive(request) # Extract block data block_data = self.protocol.parse_upload_response(response) # Step 3: End upload request = self.protocol.build_end_upload_request(upload_id) response = self._send_receive(request) # Build full block with MC7 header # S7 block structure: MC7 header + data + footer block_header = struct.pack( ">BBHBBBBHH", 0x70, # Block type marker block_type.value, # Block type block_num, # Block number 0x00, # Language 0x00, # Properties 0x00, # Reserved 0x00, # Reserved len(block_data) + 14, # Block length (header + data + footer) len(block_data), # MC7 code length ) block_footer = b"\x00" * 4 # Footer full_block = bytearray(block_header + block_data + block_footer) logger.info(f"Full upload of block {block_type.name} {block_num}: {len(full_block)} bytes") return full_block, len(full_block) def plc_stop(self) -> int: """Stop PLC CPU. Returns: 0 on success """ request = self.protocol.build_plc_control_request("stop") response = self._send_receive(request) self.protocol.check_control_response(response) return 0 def plc_hot_start(self) -> int: """Hot start PLC CPU. Returns: 0 on success """ request = self.protocol.build_plc_control_request("hot_start") response = self._send_receive(request) self.protocol.check_control_response(response) return 0 def plc_cold_start(self) -> int: """Cold start PLC CPU. Returns: 0 on success """ request = self.protocol.build_plc_control_request("cold_start") response = self._send_receive(request) self.protocol.check_control_response(response) return 0 def get_pdu_length(self) -> int: """ Get negotiated PDU length. Returns: PDU length in bytes """ return self.pdu_length def get_plc_datetime(self) -> datetime: """ Get PLC date/time. Sends real S7 USER_DATA protocol request to server. Returns: PLC date and time """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Build and send get clock request request = self.protocol.build_get_clock_request() response = self._send_receive(request) # Parse clock response return self.protocol.parse_get_clock_response(response) def set_plc_datetime(self, dt: datetime) -> int: """ Set PLC date/time. Sends real S7 USER_DATA protocol request to server. Args: dt: Date and time to set Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Build and send set clock request request = self.protocol.build_set_clock_request(dt) self._send_receive(request) logger.info(f"Set PLC datetime to {dt}") return 0 def set_plc_system_datetime(self) -> int: """Set PLC time to system time. Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") current_time = datetime.now() self.set_plc_datetime(current_time) logger.info(f"Set PLC time to current system time: {current_time}") return 0 def compress(self, timeout: int) -> int: """ Compress PLC memory. Sends real S7 PLC_CONTROL protocol with PI service "_MSZL". Args: timeout: Timeout in milliseconds (used for receive timeout) Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Build and send compress request request = self.protocol.build_compress_request() response = self._send_receive(request) self.protocol.check_control_response(response) logger.info(f"Compress PLC memory completed (timeout={timeout}ms)") return 0 def copy_ram_to_rom(self, timeout: int = 0) -> int: """ Copy RAM to ROM. Sends real S7 PLC_CONTROL protocol with PI service "_MSZL" and file ID "P". Args: timeout: Timeout in milliseconds (used for receive timeout) Returns: 0 on success """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Build and send copy RAM to ROM request request = self.protocol.build_copy_ram_to_rom_request() response = self._send_receive(request) self.protocol.check_control_response(response) logger.info(f"Copy RAM to ROM completed (timeout={timeout}ms)") return 0 def get_cp_info(self) -> S7CpInfo: """ Get CP (Communication Processor) information. Uses read_szl(0x0131) to get communication parameters. Returns: CP information structure """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Read SZL 0x0131 for communication parameters szl = self.read_szl(0x0131, 0) # Parse SZL data into S7CpInfo structure cp_info = S7CpInfo() # Use bytearray to handle c_byte (signed) values properly data = bytearray(b & 0xFF for b in szl.Data[: szl.Header.LengthDR]) # S7CpInfo structure: 4 x uint16 (big-endian) if len(data) >= 2: cp_info.MaxPduLength = struct.unpack(">H", data[0:2])[0] if len(data) >= 4: cp_info.MaxConnections = struct.unpack(">H", data[2:4])[0] if len(data) >= 6: cp_info.MaxMpiRate = struct.unpack(">H", data[4:6])[0] if len(data) >= 8: cp_info.MaxBusRate = struct.unpack(">H", data[6:8])[0] return cp_info def get_order_code(self) -> S7OrderCode: """ Get order code. Uses read_szl(0x0011) to get module identification. Returns: Order code structure """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Read SZL 0x0011 for module identification szl = self.read_szl(0x0011, 0) # Parse SZL data into S7OrderCode structure order_code = S7OrderCode() data = bytes(szl.Data[: szl.Header.LengthDR]) # OrderCode: 20 bytes, Version: 4 bytes if len(data) >= 20: order_code.OrderCode = data[0:20].rstrip(b"\x00") if len(data) >= 21: order_code.V1 = data[20] if len(data) >= 22: order_code.V2 = data[21] if len(data) >= 23: order_code.V3 = data[22] return order_code def get_protection(self) -> S7Protection: """ Get protection settings. Uses read_szl(0x0232) to get protection level. Returns: Protection structure """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Read SZL 0x0232 for protection level szl = self.read_szl(0x0232, 0) # Parse SZL data into S7Protection structure protection = S7Protection() data = bytes(szl.Data[: szl.Header.LengthDR]) # S7Protection structure: 5 x uint16 (big-endian) if len(data) >= 2: protection.sch_schal = struct.unpack(">H", data[0:2])[0] if len(data) >= 4: protection.sch_par = struct.unpack(">H", data[2:4])[0] if len(data) >= 6: protection.sch_rel = struct.unpack(">H", data[4:6])[0] if len(data) >= 8: protection.bart_sch = struct.unpack(">H", data[6:8])[0] if len(data) >= 10: protection.anl_sch = struct.unpack(">H", data[8:10])[0] return protection def get_exec_time(self) -> int: """ Get last operation execution time. Returns: Execution time in milliseconds """ return self._exec_time def get_last_error(self) -> int: """ Get last error code. Returns: Last error code """ return self._last_error def read_szl(self, ssl_id: int, index: int = 0) -> S7SZL: """ Read SZL (System Status List). Sends real S7 USER_DATA protocol request to server. Supports multi-packet responses where SZL data spans multiple PDUs. Args: ssl_id: SZL ID index: SZL index Returns: SZL structure with header and data """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") conn = self._get_connection() # Build and send read SZL request request = self.protocol.build_read_szl_request(ssl_id, index) response = self._send_receive(request) # Check for errors in data section (for USERDATA - return_code != 0xFF means error) data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: desc = get_return_code_description(return_code) raise RuntimeError(f"Read SZL failed: {desc} (0x{return_code:02x})") # Parse first fragment (includes SZL header) szl_result = self.protocol.parse_read_szl_response(response) accumulated_data = bytearray(szl_result["data"]) # Check for multi-packet response params = response.get("parameters", {}) last_data_unit = params.get("last_data_unit", 0x00) if isinstance(params, dict) else 0x00 sequence_number = params.get("sequence_number", 0) if isinstance(params, dict) else 0 group = params.get("group", 0x04) if isinstance(params, dict) else 0x04 subfunction = params.get("subfunction", 0x01) if isinstance(params, dict) else 0x01 # Accumulate follow-up fragments for _ in range(100): # Safety limit if last_data_unit == 0x00: break followup = self.protocol.build_userdata_followup_request(group, subfunction, sequence_number) conn.send_data(followup) response_data = conn.receive_data() response = self.protocol.parse_response(response_data) # Check for errors data_info = response.get("data", {}) return_code = data_info.get("return_code", 0xFF) if isinstance(data_info, dict) else 0xFF if return_code != 0xFF: break # Parse follow-up fragment (no SZL header) fragment = self.protocol.parse_read_szl_response(response, first_fragment=False) accumulated_data.extend(fragment["data"]) # Update multi-packet state params = response.get("parameters", {}) last_data_unit = params.get("last_data_unit", 0x00) if isinstance(params, dict) else 0x00 sequence_number = params.get("sequence_number", 0) if isinstance(params, dict) else 0 # Build S7SZL structure szl = S7SZL() szl.Header.LengthDR = len(accumulated_data) szl.Header.NDR = 1 # Copy data to SZL.Data array for i, b in enumerate(accumulated_data[: min(len(accumulated_data), len(szl.Data))]): szl.Data[i] = b return szl def read_szl_list(self) -> bytes: """ Read list of available SZL IDs. Sends real S7 USER_DATA protocol request to server. Returns: SZL list data """ if not self.get_connected(): raise S7ConnectionError("Not connected to PLC") # Read SZL ID 0x0000 to get list of available IDs szl = self.read_szl(0x0000, 0) # Return raw data return bytes(szl.Data[: szl.Header.LengthDR]) def iso_exchange_buffer(self, data: bytearray) -> bytearray: """ Exchange raw ISO PDU. Args: data: Raw PDU data Returns: Response PDU data """ conn = self._get_connection() conn.send_data(bytes(data)) response = conn.receive_data() return bytearray(response) # Convenience methods for specific memory areas def ab_read(self, start: int, size: int) -> bytearray: """Read from process output area (PA). Args: start: Start byte offset size: Number of bytes to read Returns: Data read from output area """ return self.read_area(Area.PA, 0, start, size) def ab_write(self, start: int, data: bytearray) -> int: """Write to process output area (PA). Args: start: Start byte offset data: Data to write Returns: 0 on success """ return self.write_area(Area.PA, 0, start, data) def eb_read(self, start: int, size: int) -> bytearray: """Read from process input area (PE). Args: start: Start byte offset size: Number of bytes to read Returns: Data read from input area """ return self.read_area(Area.PE, 0, start, size) def eb_write(self, start: int, size: int, data: bytearray) -> int: """Write to process input area (PE). Args: start: Start byte offset size: Number of bytes to write (must match len(data)) data: Data to write Returns: 0 on success """ return self.write_area(Area.PE, 0, start, data[:size]) def mb_read(self, start: int, size: int) -> bytearray: """Read from marker/flag area (MK). Args: start: Start byte offset size: Number of bytes to read Returns: Data read from marker area """ return self.read_area(Area.MK, 0, start, size) def mb_write(self, start: int, size: int, data: bytearray) -> int: """Write to marker/flag area (MK). Args: start: Start byte offset size: Number of bytes to write (must match len(data)) data: Data to write Returns: 0 on success """ return self.write_area(Area.MK, 0, start, data[:size]) def tm_read(self, start: int, size: int) -> bytearray: """Read from timer area (TM). Args: start: Start offset size: Number of timers to read Returns: Timer data """ return self.read_area(Area.TM, 0, start, size) # read_area handles word length def tm_write(self, start: int, size: int, data: bytearray) -> int: """Write to timer area (TM). Args: start: Start offset size: Number of timers to write data: Timer data to write Returns: 0 on success """ if len(data) != size * 2: raise ValueError(f"Data length {len(data)} doesn't match size {size * 2}") try: return self.write_area(Area.TM, 0, start, data) except S7ProtocolError as e: raise RuntimeError(str(e)) from e def ct_read(self, start: int, size: int) -> bytearray: """Read from counter area (CT). Args: start: Start offset size: Number of counters to read Returns: Counter data """ return self.read_area(Area.CT, 0, start, size) # read_area handles word length def ct_write(self, start: int, size: int, data: bytearray) -> int: """Write to counter area (CT). Args: start: Start offset size: Number of counters to write data: Counter data to write Returns: 0 on success """ if len(data) != size * 2: raise ValueError(f"Data length {len(data)} doesn't match size {size * 2}") return self.write_area(Area.CT, 0, start, data) # Async methods def as_ab_read(self, start: int, size: int, data: CDataArrayType) -> int: """Async read from process output area.""" result = self.ab_read(start, size) for i, b in enumerate(result): data[i] = b self._async_pending = True return 0 def as_ab_write(self, start: int, data: bytearray) -> int: """Async write to process output area.""" self.ab_write(start, data) self._async_pending = True return 0 def as_compress(self, timeout: int) -> int: """Async compress PLC memory.""" self.compress(timeout) self._async_pending = True return 0 def as_copy_ram_to_rom(self, timeout: int = 0) -> int: """Async copy RAM to ROM.""" self.copy_ram_to_rom(timeout) self._async_pending = True return 0 def as_ct_read(self, start: int, size: int, data: CDataArrayType) -> int: """Async read from counter area.""" result = self.ct_read(start, size) # Copy raw bytes to ctypes buffer memmove(data, bytes(result), len(result)) self._async_pending = True return 0 def as_ct_write(self, start: int, size: int, data: bytearray) -> int: """Async write to counter area.""" self.ct_write(start, size, data) self._async_pending = True return 0 def as_db_fill(self, db_number: int, filler: int) -> int: """Async fill DB.""" self.db_fill(db_number, filler) self._async_pending = True return 0 def as_db_get(self, db_number: int, data: CDataArrayType, size: int) -> int: """Async get entire DB.""" result = self.db_get(db_number) for i, b in enumerate(result[:size]): data[i] = b self._async_pending = True return 0 def as_db_read(self, db_number: int, start: int, size: int, data: CDataArrayType) -> int: """Async read from DB.""" result = self.db_read(db_number, start, size) for i, b in enumerate(result): data[i] = b self._async_pending = True return 0 def as_db_write(self, db_number: int, start: int, size: int, data: CDataArrayType) -> int: """Async write to DB.""" write_data = bytearray(data)[:size] self.db_write(db_number, start, write_data) self._async_pending = True return 0 def as_download(self, data: bytearray, block_num: int = -1) -> int: """Async download block.""" self.download(data, block_num) self._async_pending = True return 0 def as_eb_read(self, start: int, size: int, data: CDataArrayType) -> int: """Async read from input area.""" result = self.eb_read(start, size) for i, b in enumerate(result): data[i] = b self._async_pending = False return 0 def as_eb_write(self, start: int, size: int, data: bytearray) -> int: """Async write to input area.""" self.eb_write(start, size, data) self._async_pending = False return 0 def as_full_upload(self, block_type: Block, block_num: int) -> int: """Async full upload of block.""" # This operation is not supported - leave _async_pending = False # so wait_as_completion will raise RuntimeError self._async_pending = False return 0 def as_list_blocks_of_type(self, block_type: Block, data: CDataArrayType, count: int) -> int: """Async list blocks of type.""" # This operation is not supported - leave _async_pending = False # so wait_as_completion will raise RuntimeError self._async_pending = False return 0 def as_mb_read(self, start: int, size: int, data: CDataArrayType) -> int: """Async read from marker area.""" result = self.mb_read(start, size) for i, b in enumerate(result): data[i] = b self._async_pending = False return 0 def as_mb_write(self, start: int, size: int, data: bytearray) -> int: """Async write to marker area.""" self.mb_write(start, size, data) self._async_pending = False return 0 def as_read_area(self, area: Area, db_number: int, start: int, size: int, wordlen: WordLen, data: CDataArrayType) -> int: """Async read from memory area.""" result = self.read_area(area, db_number, start, size) # Copy raw bytes to ctypes buffer memmove(data, bytes(result), len(result)) self._async_pending = True # Mark operation as pending for wait_as_completion return 0 def as_read_szl(self, ssl_id: int, index: int, szl: S7SZL, size: int) -> int: """Async read SZL.""" result = self.read_szl(ssl_id, index) szl.Header = result.Header for i in range(min(len(result.Data), len(szl.Data))): szl.Data[i] = result.Data[i] self._async_pending = True return 0 def as_read_szl_list(self, szl_list: S7SZLList, items_count: int) -> int: """Async read SZL list.""" data = self.read_szl_list() szl_list.Header.LengthDR = 2 szl_list.Header.NDR = len(data) // 2 # Copy raw bytes directly to preserve byte order memmove(szl_list.List, data, min(len(data), len(szl_list.List) * 2)) self._async_pending = True return 0 def as_tm_read(self, start: int, size: int, data: CDataArrayType) -> int: """Async read from timer area.""" result = self.tm_read(start, size) # Copy raw bytes to ctypes buffer memmove(data, bytes(result), len(result)) self._async_pending = True return 0 def as_tm_write(self, start: int, size: int, data: bytearray) -> int: """Async write to timer area.""" self.tm_write(start, size, data) self._async_pending = True return 0 def as_upload(self, block_num: int, data: CDataArrayType, size: int) -> int: """Async upload block.""" # This operation is not supported - leave _async_pending = False # so wait_as_completion will raise RuntimeError self._async_pending = False return 0 def as_write_area(self, area: Area, db_number: int, start: int, size: int, wordlen: WordLen, data: CDataArrayType) -> int: """Async write to memory area.""" write_data = bytearray(data)[:size] self.write_area(area, db_number, start, write_data) self._async_pending = True # Mark operation as pending for wait_as_completion return 0 def check_as_completion(self, status: "c_int") -> int: """Check async completion status.""" # In pure Python, async operations complete immediately status.value = 0 # 0 = completed return 0 def wait_as_completion(self, timeout: int) -> int: """Wait for async completion. Raises: RuntimeError: If no async operation is pending or timeout=0 """ # In pure Python, async operations complete immediately. # If there's no pending operation, raise error for API compatibility if not self._async_pending: raise RuntimeError(b"CLI : Job Timeout") # Simulate timeout behavior when timeout=0 - sometimes timeout on first call if timeout == 0: self._async_pending = False raise RuntimeError(b"CLI : Job Timeout") self._async_pending = False return 0 def set_as_callback(self, callback: Callable[[int, int], None]) -> int: """Set async callback.""" self._async_callback = callback return 0 def error_text(self, error_code: int) -> str: """Get error text for error code. Args: error_code: Error code to look up Returns: Human-readable error text """ error_texts = { 0: "OK", 0x0001: "Invalid resource", 0x0002: "Invalid handle", 0x0003: "Not connected", 0x0004: "Connection error", 0x0005: "Data error", 0x0006: "Timeout", 0x0007: "Function not supported", 0x0008: "Invalid PDU size", 0x0009: "Invalid PLC answer", 0x000A: "Invalid CPU state", 0x01E00000: "CPU : Invalid password", 0x00D00000: "CPU : Invalid value supplied", 0x02600000: "CLI : Cannot change this param now", } return error_texts.get(error_code, f"Unknown error: {error_code}") def set_connection_params(self, address: str, local_tsap: int, remote_tsap: int) -> None: """Set connection parameters. Args: address: PLC IP address local_tsap: Local TSAP remote_tsap: Remote TSAP """ self.address = address self.local_tsap = local_tsap self.remote_tsap = remote_tsap logger.debug(f"Connection params set: {address}, TSAP {local_tsap:04x}/{remote_tsap:04x}") def set_connection_type(self, connection_type: int) -> None: """Set connection type. Args: connection_type: Connection type (1=PG, 2=OP, 3=S7Basic) """ self.connection_type = connection_type logger.debug(f"Connection type set to {connection_type}") def set_session_password(self, password: str) -> int: """Set session password. Args: password: Session password Returns: 0 on success """ self.session_password = password logger.debug("Session password set") return 0 def clear_session_password(self) -> int: """Clear session password. Returns: 0 on success """ self.session_password = None logger.debug("Session password cleared") return 0 def get_param(self, param: Parameter) -> int: """Get client parameter. Args: param: Parameter number Returns: Parameter value """ # Non-client parameters raise exception non_client = [ Parameter.LocalPort, Parameter.WorkInterval, Parameter.MaxClients, Parameter.BSendTimeout, Parameter.BRecvTimeout, Parameter.RecoveryTime, Parameter.KeepAliveTime, ] if param in non_client: raise RuntimeError(f"Parameter {param} not valid for client") # Use actual values for TSAP parameters if param == Parameter.SrcTSap: return self.local_tsap return self._params.get(param, 0) def set_param(self, param: Parameter, value: int) -> int: """Set client parameter. Args: param: Parameter number value: Parameter value Returns: 0 on success """ # RemotePort cannot be changed while connected if param == Parameter.RemotePort and self.connected: raise RuntimeError("Cannot change RemotePort while connected") if param == Parameter.PDURequest: self.pdu_length = value self._params[param] = value logger.debug(f"Set param {param}={value}") return 0 def _setup_communication(self) -> None: """Setup communication and negotiate PDU length.""" request = self.protocol.build_setup_communication_request(max_amq_caller=1, max_amq_callee=1, pdu_length=self.pdu_length) response = self._send_receive(request) if response.get("parameters"): params = response["parameters"] if "pdu_length" in params: self.pdu_length = params["pdu_length"] self._params[Parameter.PDURequest] = self.pdu_length logger.info(f"Negotiated PDU length: {self.pdu_length}") def _max_read_size(self) -> int: """Maximum payload bytes for a single read request. Calculated as PDU length minus overhead: 12 bytes S7 header + 2 bytes param + 4 bytes data header = 18 bytes. """ return self.pdu_length - 18 def _max_write_size(self) -> int: """Maximum payload bytes for a single write request. Calculated as PDU length minus overhead: 12 bytes S7 header + 14 bytes param + 4 bytes data header + 5 bytes padding = 35 bytes. """ return self.pdu_length - 35 def _map_area(self, area: Area) -> S7Area: """Map library area enum to native S7 area.""" area_mapping = { Area.PE: S7Area.PE, Area.PA: S7Area.PA, Area.MK: S7Area.MK, Area.DB: S7Area.DB, Area.CT: S7Area.CT, Area.TM: S7Area.TM, } if area not in area_mapping: raise S7ProtocolError(f"Unsupported area: {area}") return area_mapping[area] def __enter__(self) -> "Client": """Context manager entry.""" return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """Context manager exit.""" self.disconnect() def __del__(self) -> None: """Destructor.""" self.disconnect() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/connection.py0000644000175100017510000003420115156465122017361 0ustar00runnerrunner""" ISO on TCP connection management (RFC 1006). Implements TPKT (Transport Service on top of TCP) and COTP (Connection Oriented Transport Protocol) layers for S7 communication. """ import socket import struct import logging from enum import IntEnum from typing import Optional, Type from types import TracebackType from .error import S7ConnectionError, S7TimeoutError class TPDUSize(IntEnum): """TPDU sizes per ISO 8073 / RFC 905. The value is the exponent: actual size = 2^value bytes. """ S_128 = 0x07 S_256 = 0x08 S_512 = 0x09 S_1024 = 0x0A S_2048 = 0x0B S_4096 = 0x0C S_8192 = 0x0D logger = logging.getLogger(__name__) class ISOTCPConnection: """ ISO on TCP connection implementation. Handles the transport layer for S7 communication including: - TCP socket management - TPKT framing (RFC 1006) - COTP connection setup and data transfer - PDU size negotiation """ # COTP PDU types COTP_CR = 0xE0 # Connection Request COTP_CC = 0xD0 # Connection Confirm COTP_DR = 0x80 # Disconnect Request COTP_DC = 0xC0 # Disconnect Confirm COTP_DT = 0xF0 # Data Transfer COTP_ED = 0x10 # Expedited Data COTP_AK = 0x60 # Data Acknowledgment COTP_EA = 0x20 # Expedited Acknowledgment COTP_RJ = 0x50 # Reject COTP_ER = 0x70 # Error # COTP parameter codes (ISO 8073) COTP_PARAM_PDU_SIZE = 0xC0 COTP_PARAM_CALLING_TSAP = 0xC1 COTP_PARAM_CALLED_TSAP = 0xC2 def __init__( self, host: str, port: int = 102, local_tsap: int = 0x0100, remote_tsap: int = 0x0102, tpdu_size: TPDUSize = TPDUSize.S_1024, ): """ Initialize ISO TCP connection. Args: host: Target PLC IP address port: TCP port (default 102 for S7) local_tsap: Local Transport Service Access Point remote_tsap: Remote Transport Service Access Point tpdu_size: TPDU size to request during COTP negotiation """ self.host = host self.port = port self.local_tsap = local_tsap self.remote_tsap = remote_tsap self.tpdu_size = tpdu_size self.socket: Optional[socket.socket] = None self.connected = False self.pdu_size = 240 # Default PDU size, negotiated during connection self.timeout = 5.0 # Default timeout in seconds # Connection parameters self.src_ref = 0x0001 # Source reference self.dst_ref = 0x0000 # Destination reference (assigned by peer) def connect(self, timeout: float = 5.0) -> None: """ Establish ISO on TCP connection. Args: timeout: Connection timeout in seconds """ self.timeout = timeout try: # Step 1: TCP connection self._tcp_connect() # Step 2: ISO connection (COTP handshake) self._iso_connect() self.connected = True logger.info(f"Connected to {self.host}:{self.port}, PDU size: {self.pdu_size}") except Exception as e: self.disconnect() if isinstance(e, (S7ConnectionError, S7TimeoutError)): raise else: raise S7ConnectionError(f"Connection failed: {e}") def disconnect(self) -> None: """Disconnect from S7 device.""" if self.socket: try: if self.connected: # Send COTP disconnect request self._send_cotp_disconnect() self.socket.close() except Exception: pass # Ignore errors during disconnect finally: self.socket = None self.connected = False logger.info(f"Disconnected from {self.host}:{self.port}") def send_data(self, data: bytes) -> None: """ Send data over ISO connection. Args: data: S7 PDU data to send """ if not self.connected or self.socket is None: raise S7ConnectionError("Not connected") # Wrap data in COTP Data Transfer PDU cotp_data = self._build_cotp_dt(data) # Wrap in TPKT frame tpkt_frame = self._build_tpkt(cotp_data) # Send over TCP try: self.socket.sendall(tpkt_frame) logger.debug(f"Sent {len(tpkt_frame)} bytes") except socket.error as e: self.connected = False raise S7ConnectionError(f"Send failed: {e}") def receive_data(self) -> bytes: """ Receive data from ISO connection. Returns: S7 PDU data """ if not self.connected: raise S7ConnectionError("Not connected") try: # Receive TPKT header (4 bytes) tpkt_header = self._recv_exact(4) # Parse TPKT header version, reserved, length = struct.unpack(">BBH", tpkt_header) if version != 3: raise S7ConnectionError(f"Invalid TPKT version: {version}") # Receive remaining data remaining = length - 4 if remaining <= 0: raise S7ConnectionError("Invalid TPKT length") payload = self._recv_exact(remaining) # Parse COTP header and extract data return self._parse_cotp_data(payload) except socket.timeout: self.connected = False raise S7TimeoutError("Receive timeout") except socket.error as e: self.connected = False raise S7ConnectionError(f"Receive failed: {e}") def _tcp_connect(self) -> None: """Establish TCP connection.""" self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(self.timeout) try: self.socket.connect((self.host, self.port)) logger.debug(f"TCP connected to {self.host}:{self.port}") except socket.error as e: raise S7ConnectionError(f"TCP connection failed: {e}") def _iso_connect(self) -> None: """Establish ISO connection using COTP handshake.""" if self.socket is None: raise S7ConnectionError("Socket not initialized") # Send Connection Request cr_pdu = self._build_cotp_cr() tpkt_frame = self._build_tpkt(cr_pdu) self.socket.sendall(tpkt_frame) logger.debug("Sent COTP Connection Request") # Receive Connection Confirm tpkt_header = self._recv_exact(4) version, reserved, length = struct.unpack(">BBH", tpkt_header) if version != 3: raise S7ConnectionError(f"Invalid TPKT version in response: {version}") payload = self._recv_exact(length - 4) self._parse_cotp_cc(payload) logger.debug("Received COTP Connection Confirm") def _build_tpkt(self, payload: bytes) -> bytes: """ Build TPKT frame. TPKT Header (4 bytes): - Version (1 byte): Always 3 - Reserved (1 byte): Always 0 - Length (2 bytes): Total frame length including header """ length = len(payload) + 4 return struct.pack(">BBH", 3, 0, length) + payload def _build_cotp_cr(self) -> bytes: """ Build COTP Connection Request PDU. COTP CR format: - PDU Length: Length of COTP header (excluding this byte) - PDU Type: 0xE0 (Connection Request) - Destination Reference: 2 bytes - Source Reference: 2 bytes - Class/Option: 1 byte - Parameters: Variable length """ # Basic COTP CR without parameters base_pdu = struct.pack( ">BBHHB", 6, # PDU length (header without parameters) self.COTP_CR, # PDU type 0x0000, # Destination reference (0 for CR) self.src_ref, # Source reference 0x00, # Class/option (Class 0, no extended formats) ) # Add TSAP parameters tsap_length = 2 # TSAP values are 2 bytes (unsigned short) # Calling TSAP (local) calling_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLING_TSAP, tsap_length, self.local_tsap) # Called TSAP (remote) called_tsap = struct.pack(">BBH", self.COTP_PARAM_CALLED_TSAP, tsap_length, self.remote_tsap) # PDU Size parameter (ISO 8073 code, e.g. 0x0A = 1024 bytes) pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size) parameters = calling_tsap + called_tsap + pdu_size_param # Update PDU length to include parameters total_length = 6 + len(parameters) pdu = struct.pack(">B", total_length) + base_pdu[1:] + parameters return pdu def _parse_cotp_cc(self, data: bytes) -> None: """ Parse COTP Connection Confirm PDU. Extracts destination reference and negotiated PDU size. """ if len(data) < 7: raise S7ConnectionError("Invalid COTP CC: too short") pdu_len, pdu_type, dst_ref, src_ref, class_opt = struct.unpack(">BBHHB", data[:7]) if pdu_type != self.COTP_CC: raise S7ConnectionError(f"Expected COTP CC, got {pdu_type:#02x}") self.dst_ref = dst_ref # Parse parameters if present if len(data) > 7: self._parse_cotp_parameters(data[7:]) def _parse_cotp_parameters(self, params: bytes) -> None: """Parse COTP parameters from Connection Confirm.""" offset = 0 while offset < len(params): if offset + 2 > len(params): break param_code = params[offset] param_len = params[offset + 1] if offset + 2 + param_len > len(params): break param_data = params[offset + 2 : offset + 2 + param_len] if param_code == self.COTP_PARAM_PDU_SIZE: # PDU Size parameter if param_len == 1: # ISO 8073 code: size = 2^code self.pdu_size = 1 << param_data[0] elif param_len == 2: # Raw 2-byte value self.pdu_size = struct.unpack(">H", param_data)[0] logger.debug(f"Negotiated PDU size: {self.pdu_size}") else: logger.debug(f"Unsupported COTP parameter: code={param_code:#04x}, length={param_len}") offset += 2 + param_len def _build_cotp_dt(self, data: bytes) -> bytes: """ Build COTP Data Transfer PDU. COTP DT format: - PDU Length: 2 (fixed for DT) - PDU Type: 0xF0 (Data Transfer) - EOT + Number: 0x80 (End of TSDU, sequence number 0) - Data: Variable length """ header = struct.pack(">BBB", 2, self.COTP_DT, 0x80) return header + data def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes: """ Parse COTP Data Transfer PDU and extract S7 data. """ if len(cotp_pdu) < 3: raise S7ConnectionError("Invalid COTP DT: too short") pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3]) if pdu_type != self.COTP_DT: raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}") return cotp_pdu[3:] # Return data portion def _send_cotp_disconnect(self) -> None: """Send COTP Disconnect Request.""" if self.socket is None: return # Nothing to disconnect dr_pdu = struct.pack( ">BBHHBB", 6, # PDU length self.COTP_DR, # PDU type self.dst_ref, # Destination reference self.src_ref, # Source reference 0x00, # Reason (normal disconnect) 0x00, # Additional info ) tpkt_frame = self._build_tpkt(dr_pdu) try: self.socket.sendall(tpkt_frame) except socket.error: pass # Ignore errors during disconnect def _recv_exact(self, size: int) -> bytes: """ Receive exactly the specified number of bytes. Args: size: Number of bytes to receive Returns: Received data Raises: S7ConnectionError: If connection is lost S7TimeoutError: If timeout occurs """ if self.socket is None: raise S7ConnectionError("Socket not initialized") data = bytearray() while len(data) < size: try: chunk = self.socket.recv(size - len(data)) if not chunk: self.connected = False raise S7ConnectionError("Connection closed by peer") data.extend(chunk) except socket.timeout: self.connected = False raise S7TimeoutError("Receive timeout") except socket.error as e: self.connected = False raise S7ConnectionError(f"Receive error: {e}") return bytes(data) def check_connection(self) -> bool: """Check if the TCP connection is still alive. Uses a non-blocking socket peek to detect broken connections. """ if not self.connected or self.socket is None: return False try: original_timeout = self.socket.gettimeout() self.socket.settimeout(0) try: data = self.socket.recv(1, socket.MSG_PEEK) if not data: self.connected = False return False return True except BlockingIOError: # No data available but connection is still alive return True except (socket.error, OSError): self.connected = False return False finally: self.socket.settimeout(original_timeout) except Exception: return False def __enter__(self) -> "ISOTCPConnection": """Context manager entry.""" return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: """Context manager exit.""" self.disconnect() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/datatypes.py0000644000175100017510000002570515156465122017231 0ustar00runnerrunner""" S7 data types and conversion utilities. Handles S7-specific data types, endianness conversion, and address encoding. """ import struct from enum import IntEnum from typing import List, NoReturn, Sequence, Tuple, Union def _assert_never(value: NoReturn) -> NoReturn: """Exhaustive type check helper (equivalent to typing.assert_never for Python <3.11).""" raise AssertionError(f"Unhandled value: {value!r}") def _validate_bit_addr(bit_addr: int) -> None: """Validate that a bit address is in the valid range 0-7.""" if not 0 <= bit_addr <= 7: raise ValueError(f"Bit address must be 0-7, got {bit_addr}") class S7Area(IntEnum): """S7 memory area identifiers.""" PE = 0x81 # Process Input (Peripheral Input) PA = 0x82 # Process Output (Peripheral Output) MK = 0x83 # Memory/Merkers (Flags) DB = 0x84 # Data Blocks CT = 0x1C # Counters TM = 0x1D # Timers class S7WordLen(IntEnum): """S7 data word length identifiers.""" BIT = 0x01 # Single bit BYTE = 0x02 # 8-bit byte CHAR = 0x03 # 8-bit character WORD = 0x04 # 16-bit word INT = 0x05 # 16-bit signed integer DWORD = 0x06 # 32-bit double word DINT = 0x07 # 32-bit signed integer REAL = 0x08 # 32-bit IEEE float COUNTER = 0x1C # Counter value TIMER = 0x1D # Timer value class S7DataTypes: """S7 data type conversion utilities.""" # Word length to byte size mapping WORD_LEN_SIZE = { S7WordLen.BIT: 1, # Bit operations use 1 byte S7WordLen.BYTE: 1, # 1 byte S7WordLen.CHAR: 1, # 1 byte S7WordLen.WORD: 2, # 2 bytes S7WordLen.INT: 2, # 2 bytes S7WordLen.DWORD: 4, # 4 bytes S7WordLen.DINT: 4, # 4 bytes S7WordLen.REAL: 4, # 4 bytes S7WordLen.COUNTER: 2, # 2 bytes S7WordLen.TIMER: 2, # 2 bytes } @staticmethod def get_size_bytes(word_len: S7WordLen, count: int = 1) -> int: """Get total size in bytes for given word length and count.""" return S7DataTypes.WORD_LEN_SIZE[word_len] * count @staticmethod def encode_address(area: S7Area, db_number: int, start: int, word_len: S7WordLen, count: int) -> bytes: """ Encode S7 address into parameter format. Returns 12-byte parameter section for read/write operations. """ # Parameter format for read/write operations # Byte 0: Specification type (0x12 for address specification) # Byte 1: Length of following address specification (0x0A = 10 bytes) # Byte 2: Syntax ID (0x10 = S7-Any) # Byte 3: Transport size (word length) # Bytes 4-5: Count (number of items) # Bytes 6-7: DB number (for DB area) or 0 # Bytes 8: Area code # Bytes 9-11: Start address (byte.bit format) if start < 0: raise ValueError(f"Start address must be non-negative, got {start}") # Convert start address to byte.bit format if word_len == S7WordLen.BIT: # For bit access: byte address + bit offset byte_addr = start // 8 bit_addr = start % 8 address = (byte_addr << 3) | bit_addr else: # For word access: convert to bit address address = start * 8 address_bytes = struct.pack(">I", address)[1:] # 3-byte address (big-endian) return struct.pack( ">BBBBHHB3s", 0x12, # Specification type 0x0A, # Length of address spec 0x10, # Syntax ID (S7-Any) word_len, # Transport size count, # Count db_number if area == S7Area.DB else 0, # DB number area, # Area code address_bytes, # 3-byte address (big-endian) ) @staticmethod def decode_s7_data(data: bytes, word_len: S7WordLen, count: int) -> List[Union[bool, int, float]]: """ Decode S7 data from bytes to Python values. Handles Siemens big-endian byte order. """ values: List[Union[bool, int, float]] = [] offset = 0 for i in range(count): if word_len == S7WordLen.BIT: # Extract single bit byte_val = data[offset] values.append(bool(byte_val)) offset += 1 elif word_len == S7WordLen.BYTE or word_len == S7WordLen.CHAR: # 8-bit values values.append(data[offset]) offset += 1 elif word_len == S7WordLen.WORD or word_len == S7WordLen.COUNTER or word_len == S7WordLen.TIMER: # 16-bit unsigned values (big-endian) value = struct.unpack(">H", data[offset : offset + 2])[0] values.append(value) offset += 2 elif word_len == S7WordLen.INT: # 16-bit signed values (big-endian) value = struct.unpack(">h", data[offset : offset + 2])[0] values.append(value) offset += 2 elif word_len == S7WordLen.DWORD: # 32-bit unsigned values (big-endian) value = struct.unpack(">I", data[offset : offset + 4])[0] values.append(value) offset += 4 elif word_len == S7WordLen.DINT: # 32-bit signed values (big-endian) value = struct.unpack(">i", data[offset : offset + 4])[0] values.append(value) offset += 4 elif word_len == S7WordLen.REAL: # 32-bit IEEE float (big-endian) value = struct.unpack(">f", data[offset : offset + 4])[0] values.append(value) offset += 4 else: _assert_never(word_len) return values @staticmethod def encode_s7_data(values: Sequence[Union[bool, int, float]], word_len: S7WordLen) -> bytes: """ Encode Python values to S7 data bytes. Handles Siemens big-endian byte order. """ data = bytearray() for value in values: if word_len == S7WordLen.BIT: # Single bit to byte data.append(0x01 if value else 0x00) elif word_len == S7WordLen.BYTE or word_len == S7WordLen.CHAR: # 8-bit values data.append(int(value) & 0xFF) elif word_len == S7WordLen.WORD or word_len == S7WordLen.COUNTER or word_len == S7WordLen.TIMER: # 16-bit unsigned values (big-endian) data.extend(struct.pack(">H", int(value) & 0xFFFF)) elif word_len == S7WordLen.INT: # 16-bit signed values (big-endian) data.extend(struct.pack(">h", int(value))) elif word_len == S7WordLen.DWORD: # 32-bit unsigned values (big-endian) data.extend(struct.pack(">I", int(value) & 0xFFFFFFFF)) elif word_len == S7WordLen.DINT: # 32-bit signed values (big-endian) data.extend(struct.pack(">i", int(value))) elif word_len == S7WordLen.REAL: # 32-bit IEEE float (big-endian) data.extend(struct.pack(">f", float(value))) else: _assert_never(word_len) return bytes(data) @staticmethod def parse_address(address_str: str) -> Tuple[S7Area, int, int]: """ Parse S7 address string to area, DB number, and offset. Examples: - "DB1.DBX0.0" -> (DB, 1, 0) - "M10.5" -> (MK, 0, 85) # bit 5 of byte 10 = bit 85 - "IW20" -> (PE, 0, 20) """ address_str = address_str.upper().strip() # Data Block addresses: DB1.DBX0.0, DB1.DBW10, etc. if address_str.startswith("DB"): db_part, addr_part = address_str.split(".", 1) db_number = int(db_part[2:]) if addr_part.startswith("DBX"): # Bit address: DBX10.5 if "." in addr_part: byte_addr, bit_addr = addr_part[3:].split(".") _validate_bit_addr(int(bit_addr)) offset = int(byte_addr) * 8 + int(bit_addr) else: offset = int(addr_part[3:]) * 8 elif addr_part.startswith("DBB"): # Byte address: DBB10 offset = int(addr_part[3:]) elif addr_part.startswith("DBW"): # Word address: DBW10 offset = int(addr_part[3:]) elif addr_part.startswith("DBD"): # Double word address: DBD10 offset = int(addr_part[3:]) else: raise ValueError(f"Invalid DB address format: {address_str}") return S7Area.DB, db_number, offset # Memory/Flag addresses: M10.5, MW20, etc. elif address_str.startswith("M"): if "." in address_str: # Bit address: M10.5 byte_addr, bit_addr = address_str[1:].split(".") _validate_bit_addr(int(bit_addr)) offset = int(byte_addr) * 8 + int(bit_addr) elif address_str.startswith("MW"): # Word address: MW20 offset = int(address_str[2:]) elif address_str.startswith("MD"): # Double word address: MD20 offset = int(address_str[2:]) else: # Byte address: M10 offset = int(address_str[1:]) return S7Area.MK, 0, offset # Input addresses: I0.0, IW10, etc. elif address_str.startswith("I"): if "." in address_str: # Bit address: I0.0 byte_addr, bit_addr = address_str[1:].split(".") _validate_bit_addr(int(bit_addr)) offset = int(byte_addr) * 8 + int(bit_addr) elif address_str.startswith("IW"): # Word address: IW10 offset = int(address_str[2:]) elif address_str.startswith("ID"): # Double word address: ID10 offset = int(address_str[2:]) else: # Byte address: I10 offset = int(address_str[1:]) return S7Area.PE, 0, offset # Output addresses: Q0.0, QW10, etc. elif address_str.startswith("Q"): if "." in address_str: # Bit address: Q0.0 byte_addr, bit_addr = address_str[1:].split(".") _validate_bit_addr(int(bit_addr)) offset = int(byte_addr) * 8 + int(bit_addr) elif address_str.startswith("QW"): # Word address: QW10 offset = int(address_str[2:]) elif address_str.startswith("QD"): # Double word address: QD10 offset = int(address_str[2:]) else: # Byte address: Q10 offset = int(address_str[1:]) return S7Area.PA, 0, offset else: raise ValueError(f"Unsupported address format: {address_str}") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/error.py0000644000175100017510000005133215156465122016357 0ustar00runnerrunner""" S7 error handling and exception classes. Maps S7 error codes to Python exceptions with meaningful messages. """ from typing import Optional, Callable, Any, Hashable from functools import cache class S7Error(Exception): """Base exception for all S7 protocol errors.""" def __init__(self, message: str, error_code: Optional[int] = None): super().__init__(message) self.error_code = error_code class S7ConnectionError(S7Error): """Raised when connection to S7 device fails.""" pass class S7ProtocolError(S7Error): """Raised when S7 protocol communication fails.""" pass class S7TimeoutError(S7Error): """Raised when S7 operation times out.""" pass class S7AuthenticationError(S7Error): """Raised when S7 authentication fails.""" pass # S7 client error codes s7_client_errors = { 0x00100000: "errNegotiatingPDU", 0x00200000: "errCliInvalidParams", 0x00300000: "errCliJobPending", 0x00400000: "errCliTooManyItems", 0x00500000: "errCliInvalidWordLen", 0x00600000: "errCliPartialDataWritten", 0x00700000: "errCliSizeOverPDU", 0x00800000: "errCliInvalidPlcAnswer", 0x00900000: "errCliAddressOutOfRange", 0x00A00000: "errCliInvalidTransportSize", 0x00B00000: "errCliWriteDataSizeMismatch", 0x00C00000: "errCliItemNotAvailable", 0x00D00000: "errCliInvalidValue", 0x00E00000: "errCliCannotStartPLC", 0x00F00000: "errCliAlreadyRun", 0x01000000: "errCliCannotStopPLC", 0x01100000: "errCliCannotCopyRamToRom", 0x01200000: "errCliCannotCompress", 0x01300000: "errCliAlreadyStop", 0x01400000: "errCliFunNotAvailable", 0x01500000: "errCliUploadSequenceFailed", 0x01600000: "errCliInvalidDataSizeRecvd", 0x01700000: "errCliInvalidBlockType", 0x01800000: "errCliInvalidBlockNumber", 0x01900000: "errCliInvalidBlockSize", 0x01A00000: "errCliDownloadSequenceFailed", 0x01B00000: "errCliInsertRefused", 0x01C00000: "errCliDeleteRefused", 0x01D00000: "errCliNeedPassword", 0x01E00000: "errCliInvalidPassword", 0x01F00000: "errCliNoPasswordToSetOrClear", 0x02000000: "errCliJobTimeout", 0x02100000: "errCliPartialDataRead", 0x02200000: "errCliBufferTooSmall", 0x02300000: "errCliFunctionRefused", 0x02400000: "errCliDestroying", 0x02500000: "errCliInvalidParamNumber", 0x02600000: "errCliCannotChangeParam", } isotcp_errors = { 0x00010000: "errIsoConnect", 0x00020000: "errIsoDisconnect", 0x00030000: "errIsoInvalidPDU", 0x00040000: "errIsoInvalidDataSize", 0x00050000: "errIsoNullPointer", 0x00060000: "errIsoShortPacket", 0x00070000: "errIsoTooManyFragments", 0x00080000: "errIsoPduOverflow", 0x00090000: "errIsoSendPacket", 0x000A0000: "errIsoRecvPacket", 0x000B0000: "errIsoInvalidParams", } tcp_errors = { 0x00000001: "evcServerStarted", 0x00000002: "evcServerStopped", 0x00000004: "evcListenerCannotStart", 0x00000008: "evcClientAdded", 0x00000010: "evcClientRejected", 0x00000020: "evcClientNoRoom", 0x00000040: "evcClientException", 0x00000080: "evcClientDisconnected", 0x00000100: "evcClientTerminated", 0x00000200: "evcClientsDropped", } s7_server_errors = { 0x00100000: "errSrvCannotStart", 0x00200000: "errSrvDBNullPointer", 0x00300000: "errSrvAreaAlreadyExists", 0x00400000: "errSrvUnknownArea", 0x00500000: "errSrvInvalidParams", 0x00600000: "errSrvTooManyDB", 0x00700000: "errSrvInvalidParamNumber", 0x00800000: "errSrvCannotChangeParam", } # S7 protocol-level error codes (from Wireshark S7 dissector) # These cover USERDATA parameter errors, protocol errors, and resource errors # that occur during real PLC communication. # Source: https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-s7comm.c S7_PROTOCOL_ERROR_CODES = { 0x0000: "No error", 0x0110: "Invalid block number", 0x0111: "Invalid request length", 0x0112: "Invalid parameter", 0x0113: "Invalid block type", 0x0114: "Block not found", 0x0115: "Block already exists", 0x0116: "Block is write-protected", 0x0117: "The block/operating system update is too large", 0x0118: "Invalid block number", 0x0119: "Incorrect password entered", 0x011A: "PG resource error", 0x011B: "PLC resource error", 0x011C: "Protocol error", 0x011D: "Too many blocks (module-related restriction)", 0x011E: "There is no longer a connection to the database, or S7DOS handle is invalid", 0x011F: "Result buffer too small", 0x0120: "End of block list", 0x0140: "Insufficient memory available", 0x0141: "Job cannot be processed because of a lack of resources", 0x8001: "The requested service cannot be performed while the block is in the current status", 0x8003: "S7 protocol error: Error occurred while transferring the block", 0x8100: "Application, general error: Service unknown to remote module", 0x8104: "This service is not implemented on the module or a frame error was reported", 0x8204: "The type specification for the object is inconsistent", 0x8205: "A copied block already exists and is not linked", 0x8301: "Insufficient memory space or work memory on the module, or specified storage medium not accessible", 0x8302: "Too few resources available or the processor resources are not available", 0x8304: "No further parallel upload possible. There is a resource bottleneck", 0x8305: "Function not available", 0x8306: "Insufficient work memory (for copying, linking, loading AWP)", 0x8307: "Not enough retentive work memory (for copying, linking, loading AWP)", 0x8401: "S7 protocol error: Invalid service sequence (for example, loading or uploading a block)", 0x8402: "Service cannot execute owing to status of the addressed object", 0x8404: "S7 protocol: The function cannot be performed", 0x8405: "Remote block is in DISABLE state (CFB). The function cannot be performed", 0x8500: "S7 protocol error: Wrong frames", 0x8503: "Alarm from the module: Service canceled prematurely", 0x8701: "Error addressing the object on the communications partner (for example, area length error)", 0x8702: "The requested service is not supported by the module", 0x8703: "Access to object refused", 0x8704: "Access error: Object damaged", 0xD001: "Protocol error: Illegal job number", 0xD002: "Parameter error: Illegal job variant", 0xD003: "Parameter error: Debugging function not supported by module", 0xD004: "Parameter error: Illegal job status", 0xD005: "Parameter error: Illegal job termination", 0xD006: "Parameter error: Illegal link disconnection ID", 0xD007: "Parameter error: Illegal number of buffer elements", 0xD008: "Parameter error: Illegal scan rate", 0xD009: "Parameter error: Illegal number of executions", 0xD00A: "Parameter error: Illegal trigger event", 0xD00B: "Parameter error: Illegal trigger condition", 0xD011: "Parameter error in path of the call environment: Block does not exist", 0xD012: "Parameter error: Wrong address in block", 0xD014: "Parameter error: Block being deleted/overwritten", 0xD015: "Parameter error: Illegal tag address", 0xD016: "Parameter error: Test jobs not possible, because of errors in user program", 0xD017: "Parameter error: Illegal trigger number", 0xD025: "Parameter error: Invalid path", 0xD026: "Parameter error: Illegal access type", 0xD027: "Parameter error: This number of data blocks is not permitted", 0xD031: "Internal protocol error", 0xD032: "Parameter error: Wrong result buffer length", 0xD033: "Protocol error: Wrong job length", 0xD03F: "Coding error: Error in parameter section (for example, reserve bytes not equal to 0)", 0xD041: "Data error: Illegal status list ID", 0xD042: "Data error: Illegal tag address", 0xD043: "Data error: Referenced job not found, check job data", 0xD044: "Data error: Illegal tag value, check job data", 0xD045: "Data error: Exiting the ODIS control is not allowed in HOLD", 0xD046: "Data error: Illegal measuring stage during run-time measurement", 0xD047: "Data error: Illegal hierarchy in 'Read job list'", 0xD048: "Data error: Illegal deletion ID in 'Delete job'", 0xD049: "Invalid substitute ID in 'Replace job'", 0xD04A: "Error executing 'program status'", 0xD05F: "Coding error: Error in data section (for example, reserve bytes not equal to 0, ...)", 0xD061: "Resource error: No memory space for job", 0xD062: "Resource error: Job list full", 0xD063: "Resource error: Trigger event occupied", 0xD064: "Resource error: Not enough memory space for one result buffer element", 0xD065: "Resource error: Not enough memory space for several result buffer elements", 0xD066: "Resource error: The timer available for run-time measurement is occupied by another job", 0xD067: "Resource error: Too many 'modify tag' jobs active (in particular multi-processor operation)", 0xD081: "Function not permitted in current mode", 0xD082: "Mode error: Cannot exit HOLD mode", 0xD0A1: "Function not permitted in current protection level", 0xD0A2: "Function not possible at present, because a function is running that modifies memory", 0xD0A3: "Too many 'modify tag' jobs active on the I/O (in particular multi-processor operation)", 0xD0A4: "'Forcing' has already been established", 0xD0A5: "Referenced job not found", 0xD0A6: "Job cannot be disabled/enabled", 0xD0A7: "Job cannot be deleted, for example because it is currently being read", 0xD0A8: "Job cannot be replaced, for example because it is currently being read or deleted", 0xD0A9: "Job cannot be read, for example because it is currently being deleted", 0xD0AA: "Time limit exceeded in processing operation", 0xD0AB: "Invalid job parameters in process operation", 0xD0AC: "Invalid job data in process operation", 0xD0AD: "Operating mode already set", 0xD0AE: "The job was set up over a different connection and can only be handled over this connection", 0xD0C1: "At least one error has been detected while accessing the tag(s)", 0xD0C2: "Change to STOP/HOLD mode", 0xD0C3: "At least one error was detected while accessing the tag(s). Mode change to STOP/HOLD", 0xD0C4: "Timeout during run-time measurement", 0xD0C5: "Display of block stack inconsistent, because blocks were deleted/reloaded", 0xD0C6: "Job was automatically deleted as the jobs it referenced have been deleted", 0xD0C7: "The job was automatically deleted because STOP mode was exited", 0xD0C8: "'Block status' aborted because of inconsistencies between test job and running program", 0xD0C9: "Exit the status area by resetting OB90", 0xD0CA: "Exiting the status range by resetting OB90 and access error reading tags before exiting", 0xD0CB: "The output disable for the peripheral outputs has been activated again", 0xD0CC: "The amount of data for the debugging functions is restricted by the time limit", 0xD201: "Syntax error in block name", 0xD202: "Syntax error in function parameters", 0xD205: "Linked block already exists in RAM: Conditional copying is not possible", 0xD206: "Linked block already exists in EPROM: Conditional copying is not possible", 0xD208: "Maximum number of copied (not linked) blocks on module exceeded", 0xD209: "(At least) one of the given blocks not found on the module", 0xD20A: "The maximum number of blocks that can be linked with one job was exceeded", 0xD20B: "The maximum number of blocks that can be deleted with one job was exceeded", 0xD20C: "OB cannot be copied because the associated priority class does not exist", 0xD20D: "SDB cannot be interpreted (for example, unknown number)", 0xD20E: "No (further) block available", 0xD20F: "Module-specific maximum block size exceeded", 0xD210: "Invalid block number", 0xD212: "Incorrect header attribute (run-time relevant)", 0xD213: "Too many SDBs. Note the restrictions on the module being used", 0xD216: "Invalid user program - reset module", 0xD217: "Protection level specified in module properties not permitted", 0xD218: "Incorrect attribute (active/passive)", 0xD219: "Incorrect block lengths (for example, incorrect length of first section or of the whole block)", 0xD21A: "Incorrect local data length or write-protection code faulty", 0xD21B: "Module cannot compress or compression was interrupted early", 0xD21D: "The volume of dynamic project data transferred is illegal", 0xD21E: "Unable to assign parameters to a module (such as FM, CP). The system data could not be linked", 0xD220: "Invalid programming language. Note the restrictions on the module being used", 0xD221: "The system data for connections or routing are not valid", 0xD222: "The system data of the global data definition contain invalid parameters", 0xD223: "Error in instance data block for communication function block or maximum number of instance DBs exceeded", 0xD224: "The SCAN system data block contains invalid parameters", 0xD225: "The DP system data block contains invalid parameters", 0xD226: "A structural error occurred in a block", 0xD230: "A structural error occurred in a block", 0xD231: "At least one loaded OB cannot be copied because the associated priority class does not exist", 0xD232: "At least one block number of a loaded block is illegal", 0xD234: "Block exists twice in the specified memory medium or in the job", 0xD235: "The block contains an incorrect checksum", 0xD236: "The block does not contain a checksum", 0xD237: "You are about to load the block twice, i.e. a block with the same time stamp already exists on the CPU", 0xD238: "At least one of the blocks specified is not a DB", 0xD239: "At least one of the DBs specified is not available as a linked variant in the load memory", 0xD23A: "At least one of the specified DBs is considerably different from the copied and linked variant", 0xD240: "Coordination rules violated", 0xD241: "The function is not permitted in the current protection level", 0xD242: "Protection violation while processing F blocks", 0xD250: "Update and module ID or version do not match", 0xD251: "Incorrect sequence of operating system components", 0xD252: "Checksum error", 0xD253: "No executable loader available; update only possible using a memory card", 0xD254: "Storage error in operating system", 0xD280: "Error compiling block in S7-300 CPU", 0xD2A1: "Another block function or a trigger on a block is active", 0xD2A2: "A trigger is active on a block. Complete the debugging function first", 0xD2A3: "The block is not active (linked), the block is occupied or the block is currently marked for deletion", 0xD2A4: "The block is already being processed by another block function", 0xD2A6: "It is not possible to save and change the user program simultaneously", 0xD2A7: "The block has the attribute 'unlinked' or is not processed", 0xD2A8: "An active debugging function is preventing parameters from being assigned to the CPU", 0xD2A9: "New parameters are being assigned to the CPU", 0xD2AA: "New parameters are currently being assigned to the modules", 0xD2AB: "The dynamic configuration limits are currently being changed", 0xD2AC: "A running active or deactivate assignment (SFC 12) is temporarily preventing R-KiR process", 0xD2B0: "An error occurred while configuring in RUN (CiR)", 0xD2C0: "The maximum number of technological objects has been exceeded", 0xD2C1: "The same technology data block already exists on the module", 0xD2C2: "Downloading the user program or downloading the hardware configuration is not possible", 0xD401: "Information function unavailable", 0xD402: "Information function unavailable", 0xD403: "Service has already been logged on/off (Diagnostics/PMC)", 0xD404: "Maximum number of nodes reached. No more logons possible for diagnostics/PMC", 0xD405: "Service not supported or syntax error in function parameters", 0xD406: "Required information currently unavailable", 0xD407: "Diagnostics error occurred", 0xD408: "Update aborted", 0xD409: "Error on DP bus", 0xD601: "Syntax error in function parameter", 0xD602: "Incorrect password entered", 0xD603: "The connection has already been legitimized", 0xD604: "The connection has already been enabled", 0xD605: "Legitimization not possible because password does not exist", 0xD801: "At least one tag address is invalid", 0xD802: "Specified job does not exist", 0xD803: "Illegal job status", 0xD804: "Illegal cycle time (illegal time base or multiple)", 0xD805: "No more cyclic read jobs can be set up", 0xD806: "The referenced job is in a state in which the requested function cannot be performed", 0xD807: "Function aborted due to overload, meaning executing the read cycle takes longer than the set scan cycle time", 0xDC01: "Date and/or time invalid", 0xE201: "CPU is already the master", 0xE202: "Connect and update not possible due to different user program in flash module", 0xE203: "Connect and update not possible due to different firmware", 0xE204: "Connect and update not possible due to different memory configuration", 0xE205: "Connect/update aborted due to synchronization error", 0xE206: "Connect/update denied due to coordination violation", 0xEF01: "S7 protocol error: Error at ID2; only 00H permitted in job", 0xEF02: "S7 protocol error: Error at ID2; set of resources does not exist", } def get_protocol_error_message(code: int) -> str: """Get human-readable error message for an S7 protocol-level error code. These are the error codes found in USERDATA parameter sections and S7 header error_class/error_code fields. They are distinct from the higher-level client/ISO error codes. Args: code: S7 protocol error code (e.g., 0xD401, 0x8104) Returns: Human-readable error description, or a hex-formatted unknown message. """ return S7_PROTOCOL_ERROR_CODES.get(code, f"Unknown protocol error: {code:#06x}") class S7StalePacketError(S7ProtocolError): """Raised when a response has an old/stale PDU reference number.""" pass class S7PacketLostError(S7ProtocolError): """Raised when a response PDU reference is ahead of expected (packet loss).""" pass # Combined error dictionaries client_errors = s7_client_errors.copy() client_errors.update(isotcp_errors) client_errors.update(tcp_errors) server_errors = s7_server_errors.copy() server_errors.update(isotcp_errors) server_errors.update(tcp_errors) # All error codes combined S7_ERROR_CODES = { 0x00000000: "Success", **s7_client_errors, **isotcp_errors, **s7_server_errors, } def get_error_message(error_code: int) -> str: """Get human-readable error message for S7 error code.""" return S7_ERROR_CODES.get(error_code, f"Unknown error: {error_code:#08x}") @cache def error_text(error: int, context: str = "client") -> str: """Returns a textual explanation of a given error number. Args: error: an error integer context: context in which is called from, server, client or partner Returns: The error message as a string. """ errors = {"client": client_errors, "server": server_errors, "partner": client_errors} error_dict = errors.get(context, client_errors) return error_dict.get(error, f"Unknown error: {error:#08x}") def check_error(code: int, context: str = "client") -> None: """Check if the error code is set. If so, raise an appropriate exception. Args: code: error code number. context: context in which is called. Raises: S7ConnectionError: for connection-related errors S7TimeoutError: for timeout errors S7ProtocolError: for protocol errors RuntimeError: for other errors (backwards compatibility) """ if code == 0: return message = error_text(code, context) # Map to specific exception types based on error code patterns if code in [0x00010000, 0x00020000]: # ISO connect/disconnect errors raise S7ConnectionError(message, code) elif code == 0x02000000: # Job timeout raise S7TimeoutError(message, code) elif code in isotcp_errors: raise S7ConnectionError(message, code) else: # Use RuntimeError for backwards compatibility with existing code raise RuntimeError(message) def error_wrap(context: str) -> Callable[..., Callable[..., None]]: """Decorator that parses an S7 error code returned by the decorated function.""" def middle(func: Callable[..., int]) -> Any: def inner(*args: tuple[Any, ...], **kwargs: dict[Hashable, Any]) -> None: code = func(*args, **kwargs) check_error(code, context=context) return inner return middle ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/logo.py0000644000175100017510000001565415156465122016175 0ustar00runnerrunner""" Snap7 client used for connection to a Siemens LOGO 7/8 server. Pure Python implementation without C library dependency. """ import re import struct import logging from typing import Optional from .type import WordLen, Area from .client import Client logger = logging.getLogger(__name__) def parse_address(vm_address: str) -> tuple[int, WordLen]: """ Parse VM address string to start address and word length. Args: vm_address: Logo VM address (e.g. "V10", "VW20", "V10.3") Returns: Tuple of (start_address, word_length) """ logger.debug(f"read, vm_address:{vm_address}") if re.match(r"V[0-9]{1,4}\.[0-7]", vm_address): logger.info(f"read, Bit address: {vm_address}") address = vm_address[1:].split(".") # transform string to int address_byte = int(address[0]) address_bit = int(address[1]) start = (address_byte * 8) + address_bit return start, WordLen.Bit elif re.match("V[0-9]+", vm_address): # byte value logger.info(f"Byte address: {vm_address}") start = int(vm_address[1:]) return start, WordLen.Byte elif re.match("VW[0-9]+", vm_address): # byte value logger.info(f"Word address: {vm_address}") start = int(vm_address[2:]) return start, WordLen.Word elif re.match("VD[0-9]+", vm_address): # byte value logger.info(f"DWord address: {vm_address}") start = int(vm_address[2:]) return start, WordLen.DWord else: raise ValueError("Unknown address format") class Logo(Client): """ A snap7 Siemens Logo client. There are two main comfort functions available: :func:`Logo.read` and :func:`Logo.write`. This function offers high-level access to the VM addresses of the Siemens Logo just use the form: Notes: V10.3 for bit values V10 for the complete byte VW12 for a word (used for analog values) For more information see examples for Siemens Logo 7 and 8 """ def __init__(self, **kwargs: object) -> None: """ Initialize Logo client. Args: **kwargs: Ignored. Kept for backwards compatibility. """ super().__init__() self._logo_tsap_snap7: Optional[int] = None self._logo_tsap_logo: Optional[int] = None def connect(self, ip_address: str, tsap_snap7: int, tsap_logo: int, tcp_port: int = 102) -> "Logo": """Connect to a Siemens LOGO server. Notes: Howto setup Logo communication configuration see: https://snap7.sourceforge.net/logo.html Args: ip_address: IP ip_address of server tsap_snap7: TSAP SNAP7 Client (e.g. 10.00 = 0x1000) tsap_logo: TSAP Logo Server (e.g. 20.00 = 0x2000) tcp_port: TCP port of server Returns: The snap7 Logo instance """ logger.info(f"connecting to {ip_address}:{tcp_port} tsap_snap7 {tsap_snap7} tsap_logo {tsap_logo}") # Store TSAP values for connection self._logo_tsap_snap7 = tsap_snap7 self._logo_tsap_logo = tsap_logo # Set connection parameters self.local_tsap = tsap_snap7 self.remote_tsap = tsap_logo self.host = ip_address self.port = tcp_port # Connect using parent Client implementation # For Logo, rack and slot are not used in the standard way # but we still need to establish the connection super().connect(ip_address, 0, 0, tcp_port) return self def read(self, vm_address: str) -> int: """Reads from VM addresses of Siemens Logo. Examples: read("V40") / read("VW64") / read("V10.2") Args: vm_address: of Logo memory (e.g. V30.1, VW32, V24) Returns: integer """ db_number = 1 logger.debug(f"read, vm_address:{vm_address}") start, wordlen = parse_address(vm_address) # Determine size based on word length if wordlen == WordLen.Bit: size = 1 elif wordlen == WordLen.Byte: size = 1 elif wordlen == WordLen.Word: size = 2 elif wordlen == WordLen.DWord: size = 4 else: size = 1 logger.debug(f"start:{start}, wordlen:{wordlen.name}={wordlen}, size:{size}") # For bit access, we need to handle start address differently if wordlen == WordLen.Bit: # For Logo, bit access uses byte.bit notation converted to bit offset # Read the byte containing the bit byte_addr = start // 8 bit_offset = start % 8 data = self.read_area(Area.DB, db_number, byte_addr, 1) # Extract the bit result = (data[0] >> bit_offset) & 0x01 else: # Read the appropriate number of bytes data = self.read_area(Area.DB, db_number, start, size) # Convert to integer based on word length if wordlen == WordLen.Byte: result = struct.unpack_from(">B", data)[0] elif wordlen == WordLen.Word: result = struct.unpack_from(">h", data)[0] elif wordlen == WordLen.DWord: result = struct.unpack_from(">l", data)[0] else: result = data[0] return result def write(self, vm_address: str, value: int) -> int: """Writes to VM addresses of Siemens Logo. Args: vm_address: write offset value: integer Returns: 0 on success Examples: >>> Logo().write("VW10", 200) or Logo().write("V10.3", 1) """ db_number = 1 start, wordlen = parse_address(vm_address) logger.debug(f"write, vm_address:{vm_address} value:{value}") if wordlen == WordLen.Bit: # For bit access, read-modify-write byte_addr = start // 8 bit_offset = start % 8 # Read the current byte current = self.read_area(Area.DB, db_number, byte_addr, 1) byte_val = current[0] # Modify the bit if value > 0: byte_val |= 1 << bit_offset # Set bit else: byte_val &= ~(1 << bit_offset) # Clear bit # Write back data = bytearray([byte_val]) self.write_area(Area.DB, db_number, byte_addr, data) elif wordlen == WordLen.Byte: data = bytearray(struct.pack(">B", value)) self.write_area(Area.DB, db_number, start, data) elif wordlen == WordLen.Word: data = bytearray(struct.pack(">h", value)) self.write_area(Area.DB, db_number, start, data) elif wordlen == WordLen.DWord: data = bytearray(struct.pack(">l", value)) self.write_area(Area.DB, db_number, start, data) else: raise ValueError(f"Unknown wordlen {wordlen}") return 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/partner.py0000644000175100017510000004762215156465122016710 0ustar00runnerrunner""" Pure Python S7 partner implementation. S7 peer-to-peer communication for bidirectional data exchange. Unlike client-server where client requests and server responds, partners have equal rights and can send data asynchronously. """ import socket import struct import logging import threading from typing import Optional, Tuple, Callable, Type from queue import Queue, Empty from typing import Any from datetime import datetime from types import TracebackType from ctypes import c_int32, c_uint32 from .connection import ISOTCPConnection from .error import S7Error, S7ConnectionError from .type import Parameter logger = logging.getLogger(__name__) class PartnerStatus: """Partner status constants.""" STOPPED = 0 RUNNING = 1 CONNECTED = 2 class Partner: """ Pure Python S7 partner implementation. Implements peer-to-peer S7 communication where both partners can send and receive data asynchronously. Supports both active (initiates connection) and passive (waits for connection) modes. Examples: >>> import snap7 >>> partner = snap7.Partner(active=True) >>> partner.start_to("0.0.0.0", "192.168.1.10", 0x0100, 0x0102) >>> partner.set_send_data(b"Hello") >>> partner.b_send() >>> partner.stop() """ def __init__(self, active: bool = False, **kwargs: object) -> None: """ Initialize S7 partner. Args: active: If True, this partner initiates the connection. If False, this partner waits for incoming connections. **kwargs: Ignored. Kept for backwards compatibility. """ self.active = active self.connected = False self.running = False # Connection parameters self.local_ip = "0.0.0.0" self.remote_ip = "" self.local_tsap = 0x0100 self.remote_tsap = 0x0102 self.port = 1102 # Non-privileged port (was 102) self.local_port = 0 # Let OS choose self.remote_port = 1102 # Non-privileged port (was 102) # Socket and connection self._socket: Optional[socket.socket] = None self._server_socket: Optional[socket.socket] = None # For passive mode self._connection: Optional[ISOTCPConnection] = None # Statistics self.bytes_sent = 0 self.bytes_recv = 0 self.send_errors = 0 self.recv_errors = 0 # Timing self.last_send_time = 0 self.last_recv_time = 0 # Callbacks self._recv_callback: Optional[Callable[[bytes], None]] = None self._send_callback_fn: Optional[Callable[[int], None]] = None # Async operation support self._async_send_queue: Queue[Any] = Queue() self._async_recv_queue: Queue[Any] = Queue() self._async_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() # Last error self.last_error = 0 # Buffer for send/recv operations self._send_data: Optional[bytes] = None self._recv_data: Optional[bytes] = None self._async_send_in_progress = False self._async_send_result = 0 logger.info(f"S7 Partner initialized (active={active}, pure Python implementation)") def create(self, active: bool = False) -> None: """ Creates a Partner. Note: For pure Python implementation, the partner is created in __init__. This method exists for API compatibility. Args: active: If True, this partner initiates connections """ pass def destroy(self) -> int: """ Destroy the Partner. Returns: 0 on success """ self.stop() return 0 def start(self) -> int: """ Start the partner with default parameters. Returns: 0 on success """ return self.start_to(self.local_ip, self.remote_ip, self.local_tsap, self.remote_tsap) def start_to(self, local_ip: str, remote_ip: str, local_tsap: int, remote_tsap: int) -> int: """ Start the partner with specific connection parameters. Args: local_ip: Local IP address to bind to remote_ip: Remote partner IP address (for active mode) local_tsap: Local TSAP remote_tsap: Remote TSAP Returns: 0 on success """ self.local_ip = local_ip self.remote_ip = remote_ip self.local_tsap = local_tsap self.remote_tsap = remote_tsap try: if self.active: # Active mode: initiate connection to remote partner self._connect_to_remote() else: # Passive mode: start listening for incoming connections self._start_listening() self.running = True # Start async processing thread self._stop_event.clear() self._async_thread = threading.Thread(target=self._async_processor, daemon=True) self._async_thread.start() logger.info(f"Partner started ({'active' if self.active else 'passive'} mode)") return 0 except Exception as e: self.last_error = -1 logger.error(f"Partner start failed: {e}") raise S7ConnectionError(f"Partner start failed: {e}") def stop(self) -> int: """ Stop the partner and disconnect. Returns: 0 on success """ self._stop_event.set() if self._async_thread and self._async_thread.is_alive(): self._async_thread.join(timeout=2.0) if self._connection: self._connection.disconnect() self._connection = None if self._server_socket: try: self._server_socket.close() except Exception: pass self._server_socket = None if self._socket: try: self._socket.close() except Exception: pass self._socket = None self.connected = False self.running = False logger.info("Partner stopped") return 0 def b_send(self) -> int: """ Send data synchronously (blocking). Note: Call set_send_data() first to set the data to send. Returns: 0 on success """ if self._send_data is None: return -1 if not self.connected or self._connection is None: self.send_errors += 1 raise S7ConnectionError("Not connected") start_time = datetime.now() try: # Build partner data PDU pdu = self._build_partner_data_pdu(self._send_data) # Send via ISO connection self._connection.send_data(pdu) # Wait for acknowledgment ack_data = self._connection.receive_data() self._parse_partner_ack(ack_data) self.bytes_sent += len(self._send_data) self.last_send_time = int((datetime.now() - start_time).total_seconds() * 1000) logger.debug(f"Sent {len(self._send_data)} bytes synchronously") return 0 except Exception as e: self.send_errors += 1 self.last_error = -1 logger.error(f"Synchronous send failed: {e}") raise S7ConnectionError(f"Send failed: {e}") def b_recv(self) -> int: """ Receive data synchronously (blocking). Returns: 0 on success """ if not self.connected or self._connection is None: self.recv_errors += 1 self._recv_data = None return -1 start_time = datetime.now() try: # Receive partner data data = self._connection.receive_data() received = self._parse_partner_data_pdu(data) # Send acknowledgment ack = self._build_partner_ack() self._connection.send_data(ack) self.bytes_recv += len(received) self.last_recv_time = int((datetime.now() - start_time).total_seconds() * 1000) self._recv_data = received # Call receive callback if set if self._recv_callback: self._recv_callback(received) logger.debug(f"Received {len(received)} bytes synchronously") return 0 except socket.timeout: self._recv_data = None return 1 # Timeout except Exception as e: self.recv_errors += 1 self.last_error = -1 self._recv_data = None logger.error(f"Synchronous receive failed: {e}") return -1 def as_b_send(self) -> int: """ Send data asynchronously (non-blocking). Note: Call set_send_data() first to set the data to send. Returns: 0 on success (send initiated) """ if self._send_data is None: return -1 if not self.connected: self.send_errors += 1 return -1 self._async_send_in_progress = True self._async_send_result = 1 # In progress # Queue the send operation self._async_send_queue.put(self._send_data) logger.debug(f"Async send initiated for {len(self._send_data)} bytes") return 0 def check_as_b_send_completion(self) -> Tuple[str, c_int32]: """ Check if async send completed. Returns: Tuple of (status_string, operation_result) """ if self._async_send_in_progress: return "job in progress", c_int32(0) return_values = { 0: "job complete", 1: "job in progress", -2: "invalid handled supplied", } result = self._async_send_result return return_values.get(0, "unknown"), c_int32(result) def wait_as_b_send_completion(self, timeout: int = 0) -> int: """ Wait for async send to complete. Args: timeout: Timeout in milliseconds (0 for infinite) Returns: 0 on success, non-zero on error/timeout Raises: RuntimeError: If no async operation is in progress """ if not self._async_send_in_progress: raise RuntimeError("No async send operation in progress") # Wait for completion wait_time = timeout / 1000.0 if timeout > 0 else None start = datetime.now() while self._async_send_in_progress: if wait_time is not None: elapsed = (datetime.now() - start).total_seconds() if elapsed >= wait_time: return -1 # Timeout threading.Event().wait(0.01) # Small sleep return self._async_send_result def check_as_b_recv_completion(self) -> int: """ Check if async receive completed. Returns: 0 if data available, 1 if in progress """ try: self._recv_data = self._async_recv_queue.get_nowait() return 0 # Data available except Empty: return 1 # No data yet def get_status(self) -> c_int32: """ Get partner status. Returns: Status code (0=stopped, 1=running, 2=connected) """ if self.connected: return c_int32(PartnerStatus.CONNECTED) elif self.running: return c_int32(PartnerStatus.RUNNING) else: return c_int32(PartnerStatus.STOPPED) def get_stats(self) -> Tuple[c_uint32, c_uint32, c_uint32, c_uint32]: """ Get partner statistics. Returns: Tuple of (bytes_sent, bytes_recv, send_errors, recv_errors) """ return (c_uint32(self.bytes_sent), c_uint32(self.bytes_recv), c_uint32(self.send_errors), c_uint32(self.recv_errors)) def get_times(self) -> Tuple[c_int32, c_int32]: """ Get last operation times. Returns: Tuple of (last_send_time_ms, last_recv_time_ms) """ return c_int32(self.last_send_time), c_int32(self.last_recv_time) def get_last_error(self) -> c_int32: """ Get last error code. Returns: Last error code """ return c_int32(self.last_error) def get_param(self, parameter: Parameter) -> int: """ Get partner parameter. Args: parameter: Parameter to read Returns: Parameter value """ param_values = { Parameter.LocalPort: self.local_port, Parameter.RemotePort: self.remote_port, Parameter.PingTimeout: 750, Parameter.SendTimeout: 10, Parameter.RecvTimeout: 3000, Parameter.SrcRef: 256, Parameter.DstRef: 0, Parameter.PDURequest: 480, Parameter.WorkInterval: 100, Parameter.BSendTimeout: 3000, Parameter.BRecvTimeout: 3000, Parameter.RecoveryTime: 500, Parameter.KeepAliveTime: 5000, } value = param_values.get(parameter) if value is None: raise RuntimeError(f"Parameter {parameter} not supported") logger.debug(f"Getting parameter {parameter} = {value}") return value def set_param(self, parameter: Parameter, value: int) -> int: """ Set partner parameter. Args: parameter: Parameter to set value: Value to set Returns: 0 on success """ # Some parameters cannot be set if parameter == Parameter.RemotePort: raise RuntimeError(f"Cannot set parameter {parameter}") if parameter == Parameter.LocalPort: self.local_port = value logger.debug(f"Setting parameter {parameter} to {value}") return 0 def set_recv_callback(self) -> int: """ Sets the user callback for incoming data. Returns: 0 on success """ logger.debug("set_recv_callback called") return 0 def set_send_callback(self) -> int: """ Sets the user callback for completed async sends. Returns: 0 on success """ logger.debug("set_send_callback called") return 0 def set_send_data(self, data: bytes) -> None: """ Set data to be sent by b_send() or as_b_send(). Args: data: Data to send """ self._send_data = data def get_recv_data(self) -> Optional[bytes]: """ Get data received by b_recv(). Returns: Received data or None """ return self._recv_data def _connect_to_remote(self) -> None: """Connect to remote partner (active mode).""" if not self.remote_ip: raise S7ConnectionError("Remote IP not specified for active partner") self._connection = ISOTCPConnection( host=self.remote_ip, port=self.port, local_tsap=self.local_tsap, remote_tsap=self.remote_tsap ) self._connection.connect() self._socket = self._connection.socket self.connected = True logger.info(f"Connected to remote partner at {self.remote_ip}:{self.port}") def _start_listening(self) -> None: """Start listening for incoming connections (passive mode).""" self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Try to use SO_REUSEPORT if available (Linux, macOS) for faster port reuse if hasattr(socket, "SO_REUSEPORT"): self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) self._server_socket.bind((self.local_ip, self.port)) self._server_socket.listen(1) self._server_socket.settimeout(1.0) # Allow periodic check logger.info(f"Partner listening on {self.local_ip}:{self.port}") # Start accept thread accept_thread = threading.Thread(target=self._accept_connection, daemon=True) accept_thread.start() def _accept_connection(self) -> None: """Accept incoming connection in passive mode.""" if self._server_socket is None: return while self.running and not self._stop_event.is_set(): try: client_sock, addr = self._server_socket.accept() # Create connection object self._socket = client_sock self._connection = ISOTCPConnection( host=addr[0], port=addr[1], local_tsap=self.local_tsap, remote_tsap=self.remote_tsap ) self._connection.socket = client_sock self._connection.connected = True self.connected = True logger.info(f"Partner connection accepted from {addr}") break except socket.timeout: continue except Exception as e: if self.running: logger.error(f"Accept failed: {e}") break def _async_processor(self) -> None: """Background thread for processing async operations.""" while not self._stop_event.is_set(): # Process async sends try: data = self._async_send_queue.get(timeout=0.1) try: # Temporarily set send data and call b_send old_data = self._send_data self._send_data = data result = self.b_send() self._send_data = old_data self._async_send_result = result if self._send_callback_fn: self._send_callback_fn(result) except Exception as e: self._async_send_result = -1 logger.error(f"Async send failed: {e}") finally: self._async_send_in_progress = False except Empty: pass except Exception: break def _build_partner_data_pdu(self, data: bytes) -> bytes: """ Build partner data PDU. Args: data: Data to send Returns: PDU bytes """ # S7 partner data PDU format: # Header + Data header = struct.pack( ">BBHH", 0x32, # Protocol ID (S7) 0x07, # Partner PDU type len(data), # Data length high 0x0000, # Reserved ) return header + data def _parse_partner_data_pdu(self, pdu: bytes) -> bytes: """ Parse partner data PDU. Args: pdu: PDU bytes Returns: Extracted data """ if len(pdu) < 6: raise S7Error("Invalid partner PDU: too short") # Skip header return pdu[6:] def _build_partner_ack(self) -> bytes: """Build partner acknowledgment PDU.""" return struct.pack( ">BBHH", 0x32, # Protocol ID 0x08, # ACK type 0x0000, # Reserved 0x0000, # Status OK ) def _parse_partner_ack(self, pdu: bytes) -> None: """Parse partner acknowledgment PDU.""" if len(pdu) < 6: raise S7Error("Invalid partner ACK: too short") protocol_id, pdu_type = struct.unpack(">BB", pdu[:2]) if pdu_type != 0x08: raise S7Error(f"Expected partner ACK, got {pdu_type:#02x}") def __enter__(self) -> "Partner": """Context manager entry.""" return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType] ) -> None: """Context manager exit.""" self.destroy() def __del__(self) -> None: """Destructor.""" try: self.stop() except Exception: pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/py.typed0000644000175100017510000000000015156465122016335 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/s7protocol.py0000644000175100017510000014301515156465122017341 0ustar00runnerrunner""" S7 protocol implementation. Handles S7 PDU encoding/decoding and protocol operations. """ import struct import logging from datetime import datetime from typing import List, Dict, Any from enum import IntEnum from .datatypes import S7Area, S7WordLen, S7DataTypes from .error import S7ProtocolError, S7StalePacketError, S7PacketLostError, get_protocol_error_message logger = logging.getLogger(__name__) class S7Function(IntEnum): """S7 protocol function codes.""" READ_AREA = 0x04 WRITE_AREA = 0x05 REQUEST_DOWNLOAD = 0x1A DOWNLOAD_BLOCK = 0x1B DOWNLOAD_ENDED = 0x1C START_UPLOAD = 0x1D UPLOAD = 0x1E END_UPLOAD = 0x1F PLC_CONTROL = 0x28 PLC_STOP = 0x29 SETUP_COMMUNICATION = 0xF0 class S7PDUType(IntEnum): """S7 PDU type codes.""" REQUEST = 0x01 ACK = 0x02 # Acknowledge without data (e.g., write responses) ACK_DATA = 0x03 # Acknowledge with data (e.g., read responses) USERDATA = 0x07 class S7UserDataGroup(IntEnum): """S7 USER_DATA type groups (from s7_types.h).""" PROGRAMMER = 0x01 # grProgrammer CYCLIC_DATA = 0x02 # grCyclicData BLOCK_INFO = 0x03 # grBlocksInfo SZL = 0x04 # grSZL SECURITY = 0x05 # grPassword TIME = 0x07 # grClock class S7UserDataSubfunction(IntEnum): """S7 USER_DATA subfunctions.""" # Block info subfunctions LIST_ALL = 0x01 # SFun_ListAll LIST_BLOCKS_OF_TYPE = 0x02 # SFun_ListBoT BLOCK_INFO = 0x03 # SFun_BlkInfo # SZL subfunctions READ_SZL = 0x01 # SFun_ReadSZL SYSTEM_STATE = 0x02 # System state request # Clock subfunctions GET_CLOCK = 0x01 SET_CLOCK = 0x02 # S7 data section return codes with human-readable descriptions S7_RETURN_CODES: Dict[int, str] = { 0x00: "Reserved", 0x01: "Hardware error", 0x03: "Accessing the object not allowed", 0x05: "Invalid address", 0x06: "Data type not supported", 0x07: "Data type inconsistent", 0x0A: "Object does not exist", 0x10: "Invalid block type number", 0x11: "Block not found in storage medium", 0x12: "Block already exists", 0x13: "Block is protected", 0x14: "Block download without proper block first", 0x19: "Block download sequence error", 0x1A: "Insufficient working memory", 0x1B: "Insufficient load memory", 0x1C: "Not enough work retentive data (instance DBs)", 0x1D: "Interface error", 0x1E: "Delete block refused", 0x20: "Invalid parameter", 0x21: "PG resource error (max connections reached)", 0xFF: "Success", } def get_return_code_description(return_code: int) -> str: """Get human-readable description for S7 return code.""" if return_code in S7_RETURN_CODES: return S7_RETURN_CODES[return_code] return "Unknown error" class S7Protocol: """ S7 protocol implementation. Handles encoding and decoding of S7 PDUs for communication with Siemens PLCs. """ def __init__(self) -> None: self.sequence = 0 # Message sequence counter def _next_sequence(self) -> int: """Get next sequence number for S7 PDU.""" self.sequence = (self.sequence + 1) & 0xFFFF return self.sequence def validate_pdu_reference(self, response_sequence: int) -> None: """Validate the PDU reference number from a response. Compares the response sequence number against the expected (current) sequence. Args: response_sequence: Sequence number from the response PDU. Raises: S7StalePacketError: If response is older than expected (stale). S7PacketLostError: If response is ahead of expected (packet loss). """ if response_sequence < self.sequence: raise S7StalePacketError(f"Stale packet: expected sequence {self.sequence}, got {response_sequence}") elif response_sequence > self.sequence: raise S7PacketLostError(f"Packet lost: expected sequence {self.sequence}, got {response_sequence}") def build_read_request(self, area: S7Area, db_number: int, start: int, word_len: S7WordLen, count: int) -> bytes: """ Build S7 read request PDU. Args: area: Memory area to read from db_number: DB number (for DB area) start: Start address/offset word_len: Data word length count: Number of items to read Returns: Complete S7 PDU """ # S7 Header (12 bytes) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence 0x000E, # Parameter length (14 bytes) 0x0000, # Data length (no data for read) ) # Parameter section (14 bytes) parameters = struct.pack( ">BBB", S7Function.READ_AREA, # Function code 0x01, # Item count 0x12, # Variable specification ) # Add address specification address_spec = S7DataTypes.encode_address(area, db_number, start, word_len, count) parameters += address_spec[1:] # Skip first byte (already included as 0x12) return header + parameters def build_write_request(self, area: S7Area, db_number: int, start: int, word_len: S7WordLen, data: bytes) -> bytes: """ Build S7 write request PDU. Args: area: Memory area to write to db_number: DB number (for DB area) start: Start address/offset word_len: Data word length data: Data to write Returns: Complete S7 PDU """ # Calculate count from data length item_size = S7DataTypes.get_size_bytes(word_len, 1) count = len(data) // item_size # Parameter length: function + item count + address spec param_len = 3 + 11 # 14 bytes total # Data length: transport size + data data_len = 4 + len(data) # Transport size (4 bytes) + actual data # S7 Header header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence param_len, # Parameter length data_len, # Data length ) # Parameter section parameters = struct.pack( ">BBB", S7Function.WRITE_AREA, # Function code 0x01, # Item count 0x12, # Variable specification ) # Add address specification address_spec = S7DataTypes.encode_address(area, db_number, start, word_len, count) parameters += address_spec[1:] # Skip first byte # Map word_len to data section transport size # Data section uses different transport size codes than address specification: # - 0x03 = BIT # - 0x04 = BYTE/WORD/DWORD (byte-oriented data) # - 0x05 = INT # - 0x06 = DINT # - 0x07 = REAL # - 0x09 = OCTET STRING transport_size_map = { S7WordLen.BIT: 0x03, S7WordLen.BYTE: 0x04, S7WordLen.CHAR: 0x04, S7WordLen.WORD: 0x04, S7WordLen.INT: 0x05, S7WordLen.DWORD: 0x04, S7WordLen.DINT: 0x06, S7WordLen.REAL: 0x07, S7WordLen.COUNTER: 0x04, S7WordLen.TIMER: 0x04, } transport_size = transport_size_map.get(word_len, 0x04) # Data section data_section = ( struct.pack( ">BBH", 0x00, # Reserved/Error transport_size, # Transport size (proper S7 data section format) len(data) * 8, # Bit length (data length in bits) ) + data ) return header + parameters + data_section def build_setup_communication_request(self, max_amq_caller: int = 1, max_amq_callee: int = 1, pdu_length: int = 480) -> bytes: """ Build S7 setup communication request. This negotiates communication parameters with the PLC. """ header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence 0x0008, # Parameter length (8 bytes) 0x0000, # Data length ) parameters = struct.pack( ">BBHHH", S7Function.SETUP_COMMUNICATION, # Function code 0x00, # Reserved max_amq_caller, # Max AMQ caller max_amq_callee, # Max AMQ callee pdu_length, # PDU length ) return header + parameters def build_plc_control_request(self, operation: str) -> bytes: """ Build PLC control request. Args: operation: Control operation ('stop', 'hot_start', 'cold_start') Returns: Complete S7 PDU for PLC control """ # Map operations to S7 control codes control_codes = { "stop": 0x29, # PLC_STOP "hot_start": 0x28, # PLC_CONTROL (warm restart) "cold_start": 0x28, # PLC_CONTROL (cold restart) } if operation not in control_codes: raise ValueError(f"Unknown PLC control operation: {operation}") function_code = control_codes[operation] # Build control-specific parameters if operation == "stop": # Simple stop command param_data = struct.pack(">B", function_code) else: # Start commands with restart type restart_type = 1 if operation == "hot_start" else 2 # 1=warm, 2=cold param_data = struct.pack(">BB", function_code, restart_type) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def check_control_response(self, response: Dict[str, Any]) -> None: """ Check PLC control response for errors. Args: response: Parsed S7 response Raises: S7ProtocolError: If control operation failed """ # For now, just check that we got a response # In a full implementation, we would check specific error codes if response.get("error_code", 0) != 0: raise S7ProtocolError(f"PLC control failed with error: {response['error_code']}") def build_compress_request(self) -> bytes: """ Build PLC control request for memory compression. Uses PI service "_MSZL" (compress memory). Returns: Complete S7 PDU for compress request """ # PI service command for compress pi_service = b"_MSZL" # Parameter section: function code + PI service # Format: func(1) + unknown(7) + pi_len(1) + pi_service param_data = ( struct.pack( ">BBBBBBBBB", S7Function.PLC_CONTROL, # 0x28 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved len(pi_service), # PI service length ) + pi_service ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def build_copy_ram_to_rom_request(self) -> bytes: """ Build PLC control request for copying RAM to ROM. Uses PI service "_MSZL" with file system parameters. Returns: Complete S7 PDU for copy RAM to ROM request """ # PI service command for copy RAM to ROM # Uses EP parameter for target file system pi_service = b"_MSZL" file_id = b"P" # P = passive file system (ROM) # Parameter section with file system identifier param_data = ( struct.pack( ">BBBBBBBBB", S7Function.PLC_CONTROL, # 0x28 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved len(file_id), # File ID length len(pi_service), # PI service length ) + file_id + pi_service ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data # ======================================================================== # Block Transfer PDU Builders (Upload/Download) # ======================================================================== def build_start_upload_request(self, block_type: int, block_num: int) -> bytes: """ Build start upload request. Args: block_type: Block type code (0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC, 0x44=SFC, 0x45=FB, 0x46=SFB) block_num: Block number Returns: Complete S7 PDU for start upload request """ # Block address string: e.g., "0A00001P" for DB1 # Format: block_type (2 hex) + block_num (5 digits) + file_system (1 char) block_addr = f"{block_type:02X}{block_num:05d}A".encode("ascii") # Parameters: function + status + reserved + upload_id + block_addr_len + block_addr param_data = ( struct.pack( ">BBBIB", S7Function.START_UPLOAD, # Function code 0x00, # Status 0x00, # Reserved (error code) 0x00000000, # Upload ID (0 for start) len(block_addr), # Block address length ) + block_addr ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def build_upload_request(self, upload_id: int) -> bytes: """ Build upload request to get block data. Args: upload_id: Upload ID from start upload response Returns: Complete S7 PDU for upload request """ param_data = struct.pack( ">BBBI", S7Function.UPLOAD, # Function code 0x00, # Status 0x00, # Reserved upload_id, # Upload ID ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def build_end_upload_request(self, upload_id: int) -> bytes: """ Build end upload request. Args: upload_id: Upload ID from start upload response Returns: Complete S7 PDU for end upload request """ param_data = struct.pack( ">BBBI", S7Function.END_UPLOAD, # Function code 0x00, # Status 0x00, # Reserved upload_id, # Upload ID ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def parse_start_upload_response(self, response: Dict[str, Any]) -> Dict[str, Any]: """ Parse start upload response. Returns: Dictionary with upload_id and block_length """ result = {"upload_id": 0, "block_length": 0} raw_params = response.get("raw_parameters", b"") if len(raw_params) >= 8: # Parse: function + status + reserved + upload_id result["upload_id"] = struct.unpack(">I", raw_params[4:8])[0] if len(raw_params) > 8: # Block length string follows len_field = raw_params[8] if len(raw_params) > 9 + len_field: length_str = raw_params[9 : 9 + len_field] try: result["block_length"] = int(length_str) except ValueError: pass return result def parse_upload_response(self, response: Dict[str, Any]) -> bytes: """ Parse upload response and extract block data. Returns: Block data bytes """ data_info = response.get("data", {}) raw_data: bytes = data_info.get("data", b"") # Skip the data header if present (length + unknown bytes) if len(raw_data) > 2: return raw_data return b"" def build_download_request(self, block_type: int, block_num: int, block_data: bytes) -> bytes: """ Build request download request. Args: block_type: Block type code block_num: Block number block_data: Block data to download Returns: Complete S7 PDU for request download """ # Block address string block_addr = f"{block_type:02X}{block_num:05d}P".encode("ascii") # Block length as string length_str = f"{len(block_data):06d}".encode("ascii") # Parameters param_data = ( struct.pack( ">BBBBB", S7Function.REQUEST_DOWNLOAD, # Function code 0x00, # Status 0x00, # Reserved 0x00, # Reserved len(block_addr), # Block address length ) + block_addr + struct.pack(">B", len(length_str)) + length_str ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data def build_delete_block_request(self, block_type: int, block_num: int) -> bytes: """ Build delete block request. Uses PLC_CONTROL with PI service "_DELE" for block deletion. Args: block_type: Block type code block_num: Block number Returns: Complete S7 PDU for delete block request """ # PI service for delete pi_service = b"_DELE" # Block specification: type + number + filesystem block_spec = f"{block_type:02X}{block_num:05d}P".encode("ascii") # Parameter section param_data = ( struct.pack( ">BBBBBBBBB", S7Function.PLC_CONTROL, # 0x28 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved 0x00, # Reserved len(block_spec), # Block spec length len(pi_service), # PI service length 0x00, # Reserved ) + block_spec + pi_service ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length 0x0000, # Data length ) return header + param_data # ======================================================================== # USER_DATA PDU Builders (Chunk 3 of protocol implementation) # ======================================================================== def build_list_blocks_request(self) -> bytes: """ Build USER_DATA request for listing all blocks. Returns: Complete S7 PDU for list blocks request """ # USER_DATA PDU format: # - S7 header (10 bytes) # - Parameter section (8 bytes for USER_DATA) # - Data section (4 bytes for list blocks) # Parameter section for USER_DATA request # Format: header + method + type|group + subfunction + seq param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x43, # Type (4=request) | Group (3=grBlocksInfo) S7UserDataSubfunction.LIST_ALL, # Subfunction (0x01 = list all) 0x00, # DataRef (0x00 for initial request) ) # Data section: return code placeholder data_section = struct.pack( ">BBH", 0x0A, # Return value (request) 0x00, # Transport size 0x0000, # Length (0 for request) ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def build_list_blocks_of_type_request(self, block_type: int) -> bytes: """ Build USER_DATA request for listing blocks of a specific type. Args: block_type: Block type code (e.g., 0x41 for DB) Returns: Complete S7 PDU for list blocks of type request """ # Parameter section for USER_DATA request param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x43, # Type (4=request) | Group (3=grBlocksInfo) S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE, # Subfunction (0x02) 0x00, # DataRef (0x00 for initial request) ) # Data section: block type (0x30 prefix + type per Snap7 C format) data_section = struct.pack( ">BBHBBBB", 0x0A, # Return value (request) 0x00, # Transport size 0x0004, # Length (4 bytes) 0x30, # Block type indicator block_type, # Block type code 0x0A, # Trailing bytes per Snap7 C 0x00, ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def parse_list_blocks_response(self, response: Dict[str, Any]) -> Dict[str, int]: """ Parse list blocks response and extract block counts. Args: response: Parsed S7 response Returns: Dictionary mapping block type names to counts """ result = { "OBCount": 0, "FBCount": 0, "FCCount": 0, "SFBCount": 0, "SFCCount": 0, "DBCount": 0, "SDBCount": 0, } data_info = response.get("data", {}) raw_data = data_info.get("data", b"") if not raw_data: return result # Parse block entries (4 bytes each: 0x30 | type | count_hi | count_lo) # Block type codes type_to_name = { 0x38: "OBCount", # Organization Block 0x41: "DBCount", # Data Block 0x42: "SDBCount", # System Data Block 0x43: "FCCount", # Function 0x44: "SFCCount", # System Function 0x45: "FBCount", # Function Block 0x46: "SFBCount", # System Function Block } offset = 0 while offset + 4 <= len(raw_data): indicator = raw_data[offset] block_type = raw_data[offset + 1] count = struct.unpack(">H", raw_data[offset + 2 : offset + 4])[0] if indicator == 0x30 and block_type in type_to_name: result[type_to_name[block_type]] = count offset += 4 return result def parse_list_blocks_of_type_response(self, response: Dict[str, Any]) -> List[int]: """ Parse list blocks of type response and extract block numbers. Args: response: Parsed S7 response Returns: List of block numbers """ result: List[int] = [] data_info = response.get("data", {}) raw_data = data_info.get("data", b"") if not raw_data: return result # Parse block entries (4 bytes each per TDataFunGetBotItem: # BlockNum(2) + Unknown(1) + BlockLang(1)) offset = 0 while offset + 4 <= len(raw_data): block_num = struct.unpack(">H", raw_data[offset : offset + 2])[0] result.append(block_num) offset += 4 return result def build_get_block_info_request(self, block_type: int, block_num: int) -> bytes: """ Build USER_DATA request for getting block information. Args: block_type: Block type code (0x38=OB, 0x41=DB, 0x42=SDB, 0x43=FC, 0x44=SFC, 0x45=FB, 0x46=SFB) block_num: Block number Returns: Complete S7 PDU for get block info request """ # Parameter section for USER_DATA block info request param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x43, # Type (4=request) | Group (3=grBlocksInfo) S7UserDataSubfunction.BLOCK_INFO, # Subfunction (0x03) 0x00, # DataRef (0x00 for initial request) ) # Data section: [0x30, type, 'A', ASCII_num(5)] per Snap7 C format # Block number is 5-digit zero-padded ASCII (e.g., 1 -> "00001") block_num_ascii = f"{block_num:05d}".encode("ascii") data_payload = struct.pack(">BB", 0x30, block_type) + b"A" + block_num_ascii data_section = ( struct.pack( ">BBH", 0x0A, # Return value (request) 0x00, # Transport size len(data_payload), # Length ) + data_payload ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def parse_get_block_info_response(self, response: Dict[str, Any]) -> Dict[str, Any]: """ Parse get block info response. Args: response: Parsed S7 response Returns: Dictionary with block info fields """ result: Dict[str, Any] = { "block_type": 0, "block_number": 0, "block_lang": 0, "block_flags": 0, "mc7_size": 0, "load_size": 0, "local_data": 0, "sbb_length": 0, "checksum": 0, "version": 0, "code_date": b"", "intf_date": b"", "author": b"", "family": b"", "header": b"", } data_info = response.get("data", {}) raw_data = data_info.get("data", b"") if len(raw_data) < 78: return result # Parse block info structure per TResDataBlockInfo layout result["block_type"] = raw_data[1] result["block_flags"] = raw_data[9] result["block_lang"] = raw_data[10] result["block_number"] = struct.unpack(">H", raw_data[12:14])[0] result["load_size"] = struct.unpack(">I", raw_data[14:18])[0] result["sbb_length"] = struct.unpack(">H", raw_data[34:36])[0] result["local_data"] = struct.unpack(">H", raw_data[38:40])[0] result["mc7_size"] = struct.unpack(">H", raw_data[40:42])[0] result["version"] = raw_data[66] result["checksum"] = struct.unpack(">H", raw_data[68:70])[0] # Dates (6 bytes each: ms(2) + days(2) + reserved(2)) result["code_date"] = raw_data[22:28] result["intf_date"] = raw_data[28:34] # Strings (8 bytes each) result["author"] = raw_data[42:50] result["family"] = raw_data[50:58] result["header"] = raw_data[58:66] return result def build_read_szl_request(self, szl_id: int, szl_index: int) -> bytes: """ Build USER_DATA request for reading SZL (System Status List). Args: szl_id: SZL identifier szl_index: SZL index Returns: Complete S7 PDU for read SZL request """ # Parameter section for USER_DATA SZL request param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x44, # Type (4=request) | Group (4=grSZL) S7UserDataSubfunction.READ_SZL, # Subfunction (0x01) 0x00, # DataRef (0x00 for initial request) ) # Data section: SZL ID and Index data_section = struct.pack( ">BBHHH", 0x0A, # Return value (request) 0x00, # Transport size 0x0004, # Length (4 bytes for ID + Index) szl_id, # SZL ID szl_index, # SZL Index ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def build_userdata_followup_request(self, group: int, subfunction: int, sequence_number: int) -> bytes: """ Build USERDATA follow-up request for multi-packet responses. Args: group: USERDATA group (e.g., 4 for SZL, 3 for block info) subfunction: Subfunction code sequence_number: Sequence number from the previous response Returns: Complete S7 PDU for follow-up request """ # Parameter section: same as initial but with DataRef = sequence_number type_group = 0x40 | (group & 0x0F) # Type 4 (request) | group param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) type_group, # Type | Group subfunction, # Subfunction sequence_number, # DataRef from previous response ) # Minimal data section for follow-up data_section = struct.pack( ">BBH", 0x0A, # Return value (request) 0x00, # Transport size 0x0000, # Length (0 bytes) ) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def parse_read_szl_response(self, response: Dict[str, Any], first_fragment: bool = True) -> Dict[str, Any]: """ Parse read SZL response. Args: response: Parsed S7 response first_fragment: If True (default), parse SZL header (ID+Index). If False, treat all data as raw payload (follow-up fragments). Returns: Dictionary with SZL ID, Index, and data """ result: Dict[str, Any] = { "szl_id": 0, "szl_index": 0, "data": b"", } data_info = response.get("data", {}) raw_data = data_info.get("data", b"") if first_fragment: if len(raw_data) < 4: return result # Parse SZL header: ID (2) + Index (2) result["szl_id"] = struct.unpack(">H", raw_data[0:2])[0] result["szl_index"] = struct.unpack(">H", raw_data[2:4])[0] result["data"] = raw_data[4:] else: # Follow-up fragments don't include SZL header result["data"] = raw_data return result def build_get_clock_request(self) -> bytes: """ Build USER_DATA request for reading PLC clock. Returns: Complete S7 PDU for get clock request """ # Parameter section for USER_DATA clock request param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x47, # Type (4=request) | Group (7=grClock) S7UserDataSubfunction.GET_CLOCK, # Subfunction (0x01) 0x00, # DataRef (0x00 for initial request) ) # Data section: empty for get clock data_section = struct.pack( ">BBH", 0x0A, # Return value (request) 0x00, # Transport size 0x0000, # Length (0 bytes) ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def build_set_clock_request(self, dt: "datetime") -> bytes: """ Build USER_DATA request for setting PLC clock. Args: dt: Datetime to set Returns: Complete S7 PDU for set clock request """ # Convert datetime to BCD format # BCD encoding: each decimal digit is stored in a nibble def to_bcd(value: int) -> int: return ((value // 10) << 4) | (value % 10) year = dt.year % 100 # Only last 2 digits bcd_time = struct.pack( ">BBBBBBBB", 0x00, # Reserved to_bcd(year), # Year (BCD) to_bcd(dt.month), # Month (BCD) to_bcd(dt.day), # Day (BCD) to_bcd(dt.hour), # Hour (BCD) to_bcd(dt.minute), # Minute (BCD) to_bcd(dt.second), # Second (BCD) (dt.weekday() + 1) & 0x0F, # Day of week (1=Monday) ) # Parameter section for USER_DATA clock request param_data = struct.pack( ">BBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x04, # Length of following data 0x11, # Method (0x11 = request) 0x47, # Type (4=request) | Group (7=grClock) S7UserDataSubfunction.SET_CLOCK, # Subfunction (0x02) 0x00, # DataRef (0x00 for initial request) ) # Data section with BCD time data_section = ( struct.pack( ">BBH", 0x0A, # Return value (request) 0x00, # Transport size len(bcd_time), # Length ) + bcd_time ) # S7 header for USER_DATA header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type (0x07) 0x0000, # Reserved self._next_sequence(), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def parse_get_clock_response(self, response: Dict[str, Any]) -> "datetime": """ Parse get clock response. Args: response: Parsed S7 response Returns: Datetime from PLC """ from datetime import datetime as dt_class data_info = response.get("data", {}) raw_data = data_info.get("data", b"") if len(raw_data) < 8: # Return current time if no valid data return dt_class.now().replace(microsecond=0) # Parse BCD time def from_bcd(value: int) -> int: return ((value >> 4) * 10) + (value & 0x0F) # Skip first byte (reserved) year = from_bcd(raw_data[1]) month = from_bcd(raw_data[2]) day = from_bcd(raw_data[3]) hour = from_bcd(raw_data[4]) minute = from_bcd(raw_data[5]) second = from_bcd(raw_data[6]) # Determine century (assume 2000s for years 0-99) full_year = 2000 + year if year < 90 else 1900 + year try: return dt_class(full_year, month, day, hour, minute, second) except ValueError: return dt_class.now().replace(microsecond=0) def build_cpu_state_request(self) -> bytes: """ Build CPU state request. Returns: Complete S7 PDU for CPU state query """ # Simple CPU state request - in real S7 this would be a userdata function header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.REQUEST, # PDU type 0x0000, # Reserved self._next_sequence(), # Sequence 0x0001, # Parameter length 0x0000, # Data length ) # Use a custom function code for CPU state parameters = struct.pack(">B", 0x04) # Use READ_AREA function for simplicity return header + parameters def extract_cpu_state(self, response: Dict[str, Any]) -> str: """ Extract CPU state from response. Args: response: Parsed S7 response Returns: CPU state string in S7CpuStatus format (e.g., 'S7CpuStatusRun') """ # Map internal states to S7 status format for API compatibility with master branch # The cpu_statuses dict in type.py uses: {0: "S7CpuStatusUnknown", 4: "S7CpuStatusStop", 8: "S7CpuStatusRun"} return "S7CpuStatusRun" # Default state for pure Python server def parse_response(self, pdu: bytes) -> Dict[str, Any]: """ Parse S7 response PDU. Args: pdu: Complete S7 PDU Returns: Parsed response data """ if len(pdu) < 10: raise S7ProtocolError("PDU too short for S7 response header") # First peek at PDU type to determine header size pdu_type = pdu[1] if pdu_type == S7PDUType.USERDATA: # USERDATA PDUs have a 10-byte header (no error_class/error_code in header) if len(pdu) < 10: raise S7ProtocolError("PDU too short for USERDATA header") header = struct.unpack(">BBHHHH", pdu[:10]) protocol_id, pdu_type, reserved, sequence, param_len, data_len = header error_class = 0 error_code = 0 offset = 10 else: # ACK/ACK_DATA PDUs have a 12-byte header (with error_class/error_code) if len(pdu) < 12: raise S7ProtocolError("PDU too short for ACK/ACK_DATA header") header = struct.unpack(">BBHHHHBB", pdu[:12]) protocol_id, pdu_type, reserved, sequence, param_len, data_len, error_class, error_code = header offset = 12 if protocol_id != 0x32: raise S7ProtocolError(f"Invalid protocol ID: {protocol_id:#02x}") # Accept ACK (write responses), ACK_DATA (read responses), and USERDATA response types if pdu_type not in (S7PDUType.ACK, S7PDUType.ACK_DATA, S7PDUType.USERDATA): raise S7ProtocolError(f"Expected response PDU, got {pdu_type}") combined_error = (error_class << 8) | error_code if error_class != 0: error_msg = get_protocol_error_message(combined_error) raise S7ProtocolError( f"S7 protocol error (class={error_class:#04x}, code={error_code:#04x}): {error_msg}", error_code=combined_error, ) response = { "sequence": sequence, "param_length": param_len, "data_length": data_len, "parameters": None, "data": None, "error_code": combined_error, } # Parse parameters if present if param_len > 0: if offset + param_len > len(pdu): raise S7ProtocolError("Parameter section extends beyond PDU") param_data = pdu[offset : offset + param_len] response["parameters"] = self._parse_parameters(param_data) offset += param_len # Parse data if present if data_len > 0: if offset + data_len > len(pdu): raise S7ProtocolError("Data section extends beyond PDU") data_section = pdu[offset : offset + data_len] response["data"] = self._parse_data_section(data_section) return response def _parse_parameters(self, param_data: bytes) -> Dict[str, Any]: """Parse S7 parameter section.""" if len(param_data) < 1: return {} # Detect USERDATA response parameters: # byte 0 = 0x00 (reserved), len >= 12, byte 2 = 0x12, byte 4 = 0x12 (method=response) if param_data[0] == 0x00 and len(param_data) >= 12 and param_data[2] == 0x12 and param_data[4] == 0x12: return self._parse_userdata_response_params(param_data) function_code = param_data[0] if function_code == S7Function.READ_AREA: return self._parse_read_response_params(param_data) elif function_code == S7Function.WRITE_AREA: return self._parse_write_response_params(param_data) elif function_code == S7Function.SETUP_COMMUNICATION: return self._parse_setup_comm_response_params(param_data) else: return {"function_code": function_code} def _parse_read_response_params(self, param_data: bytes) -> Dict[str, Any]: """Parse read area response parameters.""" if len(param_data) < 2: raise S7ProtocolError("Read response parameters too short") function_code = param_data[0] item_count = param_data[1] return {"function_code": function_code, "item_count": item_count} def _parse_write_response_params(self, param_data: bytes) -> Dict[str, Any]: """Parse write area response parameters.""" if len(param_data) < 2: raise S7ProtocolError("Write response parameters too short") function_code = param_data[0] item_count = param_data[1] return {"function_code": function_code, "item_count": item_count} def _parse_setup_comm_response_params(self, param_data: bytes) -> Dict[str, Any]: """Parse setup communication response parameters.""" if len(param_data) < 8: raise S7ProtocolError("Setup communication response parameters too short") function_code, reserved, max_amq_caller, max_amq_callee, pdu_length = struct.unpack(">BBHHH", param_data[:8]) return { "function_code": function_code, "max_amq_caller": max_amq_caller, "max_amq_callee": max_amq_callee, "pdu_length": pdu_length, } def _parse_userdata_response_params(self, param_data: bytes) -> Dict[str, Any]: """Parse USERDATA response parameter section (12 bytes). Layout: [0] Reserved (0x00) [1] Parameter count (0x01) [2] Type header (0x12) [3] Length of following data (0x08 for response) [4] Method (0x12 = response) [5] Type (high nibble) | Group (low nibble) [6] Subfunction [7] Sequence number (used as DataRef in follow-up) [8] Data unit reference [9] Last data unit (0x00 = last, non-zero = more) [10-11] Error code """ type_group = param_data[5] group = type_group & 0x0F subfunction = param_data[6] sequence_number = param_data[7] last_data_unit = param_data[9] error_code = struct.unpack(">H", param_data[10:12])[0] if error_code != 0: error_msg = get_protocol_error_message(error_code) logger.warning(f"USERDATA response error {error_code:#06x}: {error_msg}") return { "group": group, "subfunction": subfunction, "sequence_number": sequence_number, "last_data_unit": last_data_unit, "error_code": error_code, } def _parse_data_section(self, data_section: bytes) -> Dict[str, Any]: """Parse S7 data section.""" if len(data_section) == 1: # Simple return code (for write responses) return {"return_code": data_section[0], "transport_size": 0, "data_length": 0, "data": b""} elif len(data_section) >= 4: # Full data header return_code = data_section[0] transport_size = data_section[1] data_length = struct.unpack(">H", data_section[2:4])[0] # Extract actual data - length interpretation depends on transport_size # Transport size 0x09 (octet string): byte length (USERDATA responses) # Transport size 0x00: byte length (USERDATA requests) # Transport size 0x04 (byte): bit length (READ_AREA responses) if transport_size in (0x00, 0x09): # USERDATA uses byte length directly actual_data = data_section[4 : 4 + data_length] else: # READ_AREA responses use bit length actual_data = data_section[4 : 4 + (data_length // 8)] return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data} else: return {"raw_data": data_section} def extract_read_data(self, response: Dict[str, Any], word_len: S7WordLen, count: int) -> List[Any]: """ Extract and decode data from read response. Args: response: Parsed S7 response word_len: Expected data word length count: Expected number of items Returns: List of decoded values """ if not response.get("data"): raise S7ProtocolError("No data in response") data_info = response["data"] return_code = data_info.get("return_code", 0) if return_code != 0xFF: # 0xFF = Success desc = get_return_code_description(return_code) raise S7ProtocolError(f"Read operation failed: {desc} (0x{return_code:02x})") raw_data = data_info.get("data", b"") # Return raw bytes directly - caller handles type conversion return list(raw_data) def check_write_response(self, response: Dict[str, Any]) -> None: """ Check write operation response for errors. Args: response: Parsed S7 response Raises: S7ProtocolError: If write operation failed """ # First check for errors in the response header # S7-1200/1500 returns error codes in the header for write failures header_error = response.get("error_code", 0) if header_error != 0: error_msg = f"Write operation failed with S7 error code: {header_error:#06x}" raise S7ProtocolError(error_msg) # For successful writes, check the data section return code if present if response.get("data"): data_info = response["data"] return_code = data_info.get("return_code", 0xFF) # Default to success if return_code != 0xFF: # 0xFF = Success desc = get_return_code_description(return_code) raise S7ProtocolError(f"Write operation failed: {desc} (0x{return_code:02x})") # If no data and no header error, the write was successful (ACK without data) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7902524 python_snap7-3.0.0/snap7/server/0000755000175100017510000000000015156465130016155 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/server/__init__.py0000644000175100017510000027700715156465122020304 0ustar00runnerrunner""" Pure Python S7 server implementation. Provides a complete S7 server emulator without dependencies on the Snap7 C library. """ import socket import struct import threading import time import logging from typing import Dict, Optional, List, Callable, Any, Tuple, Type, Union from types import TracebackType from enum import IntEnum from ctypes import Array, c_char from ..s7protocol import S7Protocol, S7Function, S7PDUType, S7UserDataGroup, S7UserDataSubfunction from ..datatypes import S7Area, S7WordLen from ..error import S7ConnectionError, S7ProtocolError from ..type import SrvArea, SrvEvent, Parameter logger = logging.getLogger(__name__) class ServerState(IntEnum): """S7 server states.""" STOPPED = 0 RUNNING = 1 ERROR = 2 class CPUState(IntEnum): """S7 CPU states.""" UNKNOWN = 0 RUN = 8 STOP = 4 class Server: """ Pure Python S7 server implementation. Emulates a Siemens S7 PLC for testing and development purposes. Examples: >>> import snap7 >>> server = snap7.Server() >>> server.start() >>> # ... register areas and handle clients >>> server.stop() """ def __init__(self, log: bool = True, **kwargs: object) -> None: """ Initialize S7 server. Args: log: Enable event logging **kwargs: Ignored. Kept for backwards compatibility. """ self.server_socket: Optional[socket.socket] = None self.server_thread: Optional[threading.Thread] = None self.running = False self.port = 102 self.host = "0.0.0.0" # Server state self.state = ServerState.STOPPED self.cpu_state = CPUState.STOP self.client_count = 0 # Memory areas self.memory_areas: Dict[Tuple[S7Area, int], bytearray] = {} self.area_locks: Dict[Tuple[S7Area, int], threading.Lock] = {} # Protocol handler self.protocol = S7Protocol() # Event callbacks self.event_callback: Optional[Callable[[SrvEvent], None]] = None self.read_callback: Optional[Callable[[SrvEvent], None]] = None # Client connections self.clients: List[threading.Thread] = [] self.client_lock = threading.Lock() # Event queue for pick_event self._event_queue: List[SrvEvent] = [] # Logging self._log_enabled = log if log: self._set_log_callback() logger.info("S7Server initialized (pure Python implementation)") def create(self) -> None: """Create the server (no-op for compatibility).""" pass def destroy(self) -> None: """Destroy the server.""" self.stop() def start(self, tcp_port: int = 102) -> int: """ Start the S7 server. Args: tcp_port: TCP port to listen on Returns: 0 on success """ if self.running: raise S7ConnectionError("Server is already running") self.port = tcp_port self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Try to use SO_REUSEPORT if available (Linux, macOS) for faster port reuse if hasattr(socket, "SO_REUSEPORT"): self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) try: self.server_socket.bind((self.host, self.port)) self.server_socket.listen(5) self.running = True self.state = ServerState.RUNNING self.cpu_state = CPUState.RUN # Start server thread self.server_thread = threading.Thread(target=self._server_loop, daemon=True) self.server_thread.start() # Add startup event to queue startup_event = SrvEvent() startup_event.EvtCode = 0x00010000 # Server started self._event_queue.append(startup_event) logger.info(f"S7 Server started on {self.host}:{self.port}") return 0 except Exception as e: self.running = False self.state = ServerState.ERROR if self.server_socket: self.server_socket.close() self.server_socket = None raise S7ConnectionError(f"Failed to start server: {e}") def stop(self) -> int: """ Stop the S7 server. Returns: 0 on success """ if not self.running: return 0 self.running = False self.state = ServerState.STOPPED self.cpu_state = CPUState.STOP # Close server socket if self.server_socket: self.server_socket.close() self.server_socket = None # Wait for server thread to finish if self.server_thread and self.server_thread.is_alive(): self.server_thread.join(timeout=5.0) # Close all client connections with self.client_lock: for client_thread in self.clients[:]: if client_thread.is_alive(): client_thread.join(timeout=1.0) self.clients.clear() self.client_count = 0 logger.info("S7 Server stopped") return 0 def register_area(self, area: SrvArea, index: int, userdata: Union[bytearray, "Array[c_char]"]) -> int: """ Register a memory area with the server. Args: area: Memory area type index: Area index/number userdata: Initial data for the area (bytearray or ctypes array) Returns: 0 on success """ # Map SrvArea to S7Area area_mapping = { SrvArea.PE: S7Area.PE, SrvArea.PA: S7Area.PA, SrvArea.MK: S7Area.MK, SrvArea.DB: S7Area.DB, SrvArea.CT: S7Area.CT, SrvArea.TM: S7Area.TM, } s7_area = area_mapping.get(area) if s7_area is None: raise ValueError(f"Unsupported area: {area}") # Convert ctypes array to bytearray if needed if isinstance(userdata, bytearray): data = userdata else: data = bytearray(userdata) area_key = (s7_area, index) self.memory_areas[area_key] = data self.area_locks[area_key] = threading.Lock() logger.info(f"Registered area {area.name} index {index}, size {len(data)}") return 0 def unregister_area(self, area: SrvArea, index: int) -> int: """ Unregister a memory area. Args: area: Memory area type index: Area index Returns: 0 on success """ area_mapping = { SrvArea.PE: S7Area.PE, SrvArea.PA: S7Area.PA, SrvArea.MK: S7Area.MK, SrvArea.DB: S7Area.DB, SrvArea.CT: S7Area.CT, SrvArea.TM: S7Area.TM, } s7_area = area_mapping.get(area) if s7_area is None: return 0 area_key = (s7_area, index) if area_key in self.memory_areas: del self.memory_areas[area_key] del self.area_locks[area_key] logger.info(f"Unregistered area {area.name} index {index}") return 0 def lock_area(self, area: SrvArea, index: int) -> int: """ Lock a memory area. Args: area: Memory area type index: Area index Returns: 0 on success Raises: RuntimeError: If area is not registered """ area_mapping = { SrvArea.PE: S7Area.PE, SrvArea.PA: S7Area.PA, SrvArea.MK: S7Area.MK, SrvArea.DB: S7Area.DB, SrvArea.CT: S7Area.CT, SrvArea.TM: S7Area.TM, } s7_area = area_mapping.get(area) if s7_area is None: raise RuntimeError(f"Invalid area: {area}") area_key = (s7_area, index) if area_key not in self.area_locks: raise RuntimeError(f"Area {area.name} index {index} not registered") self.area_locks[area_key].acquire() return 0 def unlock_area(self, area: SrvArea, index: int) -> int: """ Unlock a memory area. Args: area: Memory area type index: Area index Returns: 0 on success """ area_mapping = { SrvArea.PE: S7Area.PE, SrvArea.PA: S7Area.PA, SrvArea.MK: S7Area.MK, SrvArea.DB: S7Area.DB, SrvArea.CT: S7Area.CT, SrvArea.TM: S7Area.TM, } s7_area = area_mapping.get(area) if s7_area is None: return 1 area_key = (s7_area, index) if area_key in self.area_locks: try: self.area_locks[area_key].release() except RuntimeError: pass # Lock not held return 0 def get_status(self) -> Tuple[str, str, int]: """ Get server status. Returns: Tuple of (server_status, cpu_status, client_count) """ server_status_names = {ServerState.STOPPED: "Stopped", ServerState.RUNNING: "Running", ServerState.ERROR: "Error"} cpu_status_names = {CPUState.UNKNOWN: "Unknown", CPUState.RUN: "Run", CPUState.STOP: "Stop"} return ( server_status_names.get(self.state, "Unknown"), cpu_status_names.get(self.cpu_state, "Unknown"), self.client_count, ) def set_events_callback(self, callback: Callable[[SrvEvent], Any]) -> int: """ Set callback for server events. Args: callback: Event callback function Returns: 0 on success """ self.event_callback = callback logger.info("Event callback set") return 0 def set_read_events_callback(self, callback: Callable[[SrvEvent], Any]) -> int: """ Set callback for read events. Args: callback: Read event callback function Returns: 0 on success """ self.read_callback = callback logger.info("Read event callback set") return 0 def set_rw_area_callback(self, callback: Callable[[Any], int]) -> int: """ Set callback for read/write area operations. This is a stub for API compatibility with the C library's Srv_SetRWAreaCallback. In the native implementation, read/write operations are handled directly. Args: callback: RW area callback function Returns: 0 on success """ logger.debug("set_rw_area_callback called (stub for API compatibility)") return 0 def event_text(self, event: SrvEvent) -> str: """ Get event text description. Args: event: Server event Returns: Event description string """ event_texts = { 0x00004000: "Read operation completed", 0x00004001: "Write operation completed", 0x00008000: "Client connected", 0x00008001: "Client disconnected", } return event_texts.get(event.EvtCode, f"Event code: {event.EvtCode:#08x}") def get_mask(self, mask_kind: int) -> int: """ Get event mask. Args: mask_kind: Mask type (0=Event, 1=Log) Returns: Event mask value """ if mask_kind == 0: # mkEvent return 0xFFFFFFFF elif mask_kind == 1: # mkLog return 0xFFFFFFFF else: raise ValueError(f"Invalid mask kind: {mask_kind}") def set_mask(self, kind: int = 0, mask: int = 0) -> int: """ Set event mask. Args: kind: Mask type (0=Event, 1=Log) mask: Mask value Returns: 0 on success """ logger.debug(f"Set mask {kind} = {mask:#08x}") return 0 def set_param(self, param: Parameter, value: int) -> int: """ Set server parameter. Args: param: Parameter type value: Parameter value Returns: 0 on success """ if param == Parameter.LocalPort: self.port = value logger.debug(f"Set parameter {param} = {value}") return 0 def get_param(self, param: Parameter) -> int: """ Get server parameter. Args: param: Parameter type Returns: Parameter value Raises: RuntimeError: If parameter is not valid for server """ # Client-only parameters should raise exception client_only = [ Parameter.RemotePort, Parameter.PingTimeout, Parameter.SendTimeout, Parameter.RecvTimeout, Parameter.SrcRef, Parameter.DstRef, Parameter.SrcTSap, Parameter.PDURequest, ] if param in client_only: raise RuntimeError(f"Parameter {param} not valid for server") param_values = { Parameter.LocalPort: self.port, Parameter.WorkInterval: 100, Parameter.MaxClients: 1024, } return param_values.get(param, 0) def start_to(self, ip: str, tcp_port: int = 102) -> int: """ Start server on a specific interface. Args: ip: IP address to bind to tcp_port: TCP port to listen on Returns: 0 on success """ # Validate IP address try: socket.inet_aton(ip) except socket.error: raise ValueError(f"Invalid IP address: {ip}") # If already running, stop first if self.running: self.stop() self.host = ip return self.start(tcp_port if tcp_port != 102 else self.port) def set_cpu_status(self, status: int) -> int: """ Set CPU status. Args: status: CPU status code (0=Unknown, 4=Stop, 8=Run) Returns: 0 on success Raises: ValueError: If status is invalid """ if status not in [0, 4, 8]: raise ValueError(f"Invalid CPU status: {status}") if status == 8: # RUN self.cpu_state = CPUState.RUN elif status == 4: # STOP self.cpu_state = CPUState.STOP else: self.cpu_state = CPUState.UNKNOWN return 0 def pick_event(self) -> Union[SrvEvent, bool]: """ Pick an event from the queue. Returns: Server event if available, False if no events """ if self._event_queue: return self._event_queue.pop(0) return False def clear_events(self) -> int: """ Clear event queue. Returns: 0 on success """ self._event_queue.clear() return 0 def _set_log_callback(self) -> None: """Set up default logging callback.""" def log_callback(event: SrvEvent) -> None: event_text = self.event_text(event) logger.info(f"Server event: {event_text}") self.set_events_callback(log_callback) def _server_loop(self) -> None: """Main server loop to accept client connections.""" try: while self.running and self.server_socket: try: self.server_socket.settimeout(0.1) # Short timeout for responsive shutdown client_socket, address = self.server_socket.accept() logger.info(f"Client connected from {address}") # Start client handler thread client_thread = threading.Thread(target=self._handle_client, args=(client_socket, address), daemon=True) with self.client_lock: self.clients.append(client_thread) self.client_count += 1 client_thread.start() except socket.timeout: continue # Check running flag again except OSError: if self.running: # Only log if we're supposed to be running logger.warning("Server socket error in accept loop") break except Exception as e: logger.error(f"Server loop error: {e}") finally: self.running = False self.state = ServerState.STOPPED def _handle_client(self, client_socket: socket.socket, address: Tuple[str, int]) -> None: """Handle a single client connection.""" try: # Create ISO connection wrapper and establish connection connection = ServerISOConnection(client_socket) # Handle ISO connection setup if not connection.accept_connection(): logger.warning(f"Failed to establish ISO connection with {address}") return logger.info(f"ISO connection established with {address}") while self.running: try: # Receive S7 request request_data = connection.receive_data() # Process request and generate response response_data = self._process_request(request_data, address) # Send response if response_data: connection.send_data(response_data) except socket.timeout: continue except (ConnectionResetError, ConnectionAbortedError): logger.info(f"Client {address} disconnected") break except Exception as e: logger.error(f"Error handling client {address}: {e}") break except Exception as e: logger.error(f"Client handler error for {address}: {e}") finally: try: client_socket.close() except OSError: pass with self.client_lock: current_thread = threading.current_thread() if current_thread in self.clients: self.clients.remove(current_thread) self.client_count = max(0, self.client_count - 1) logger.info(f"Client {address} handler finished") def _process_request(self, request_data: bytes, client_address: Tuple[str, int]) -> Optional[bytes]: """ Process an S7 request and generate response. Args: request_data: Raw S7 PDU data client_address: Client address for logging Returns: Response PDU data or None """ try: # Parse S7 request request = self._parse_request(request_data) # Check PDU type first pdu_type = request.get("pdu_type", S7PDUType.REQUEST) if pdu_type == S7PDUType.USERDATA: # Handle USER_DATA PDU (block info, SZL, clock, etc.) return self._handle_userdata(request, client_address) # Handle REQUEST PDU (read/write areas, setup, control) # Extract function code from parameters if not request.get("parameters"): return None params = request["parameters"] function_code = params.get("function_code") if function_code == S7Function.SETUP_COMMUNICATION: return self._handle_setup_communication(request) elif function_code == S7Function.READ_AREA: return self._handle_read_area(request, client_address) elif function_code == S7Function.WRITE_AREA: return self._handle_write_area(request, client_address) elif function_code == S7Function.PLC_CONTROL: return self._handle_plc_control(request, client_address) elif function_code == S7Function.PLC_STOP: return self._handle_plc_stop(request, client_address) elif function_code == S7Function.START_UPLOAD: return self._handle_start_upload(request, client_address) elif function_code == S7Function.UPLOAD: return self._handle_upload(request, client_address) elif function_code == S7Function.END_UPLOAD: return self._handle_end_upload(request, client_address) elif function_code == S7Function.REQUEST_DOWNLOAD: return self._handle_request_download(request, client_address) elif function_code == S7Function.DOWNLOAD_BLOCK: return self._handle_download_block(request, client_address) elif function_code == S7Function.DOWNLOAD_ENDED: return self._handle_download_ended(request, client_address) else: logger.warning(f"Unsupported function code: {function_code}") return self._build_error_response(request, 0x8001) # Function not supported except Exception as e: logger.error(f"Error processing request: {e}") return None def _handle_setup_communication(self, request: Dict[str, Any]) -> bytes: """Handle setup communication request.""" params = request["parameters"] pdu_length = params.get("pdu_length", 480) # Build response with error bytes header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence (echo) 0x0008, # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) parameters = struct.pack( ">BBHHH", S7Function.SETUP_COMMUNICATION, # Function code 0x00, # Reserved 1, # Max AMQ caller 1, # Max AMQ callee min(pdu_length, 480), # PDU length (limited) ) return header + parameters def _handle_read_area(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """Handle read area request.""" try: # Parse address specification from request parameters addr_info = self._parse_read_address(request) if not addr_info: return self._build_error_response(request, 0x8001) # Invalid address area, db_number, start, count = addr_info # Read data from registered memory area read_data = self._read_from_memory_area(area, db_number, start, count) if read_data is None: return self._build_error_response(request, 0x8404) # Area not found # Calculate data length - need to include transport header + data data_len = 4 + len(read_data) # Transport header (4 bytes) + data # Build successful response # S7 response header includes error class + error code header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence (echo) 0x0002, # Parameter length data_len, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) # Parameters parameters = struct.pack( ">BB", S7Function.READ_AREA, # Function code 0x01, # Item count ) # Data section data_section = ( struct.pack( ">BBH", 0xFF, # Return code (success) 0x04, # Transport size (04 = byte data) len(read_data) * 8, # Data length in bits ) + read_data ) # Trigger read event callback if self.read_callback: event = SrvEvent() event.EvtTime = int(time.time()) event.EvtSender = 0 event.EvtCode = 0x00004000 # Read event event.EvtRetCode = 0 event.EvtParam1 = 1 # Area event.EvtParam2 = 0 # Offset event.EvtParam3 = len(read_data) # Size event.EvtParam4 = 0 try: self.read_callback(event) except Exception as e: logger.error(f"Error in read callback: {e}") return header + parameters + data_section except Exception as e: logger.error(f"Error handling read request: {e}") return self._build_error_response(request, 0x8000) def _parse_read_address(self, request: Dict[str, Any]) -> Optional[Tuple[S7Area, int, int, int]]: """ Parse read address from request parameters. Returns: Tuple of (area, db_number, start, byte_count) or None if invalid """ try: params = request.get("parameters", {}) if params.get("function_code") != S7Function.READ_AREA: return None # Check if we have parsed address specification addr_spec = params.get("address_spec", {}) if addr_spec: area = addr_spec.get("area", S7Area.DB) db_number = addr_spec.get("db_number", 1) start = addr_spec.get("start", 0) count = addr_spec.get("count", 4) word_len = addr_spec.get("word_len", S7WordLen.BYTE) # Convert count to bytes based on word length if word_len in [S7WordLen.TIMER, S7WordLen.COUNTER, S7WordLen.WORD]: byte_count = count * 2 # 16-bit items elif word_len in [S7WordLen.DWORD, S7WordLen.REAL]: byte_count = count * 4 # 32-bit items elif word_len == S7WordLen.BIT: byte_count = 1 # Single bit needs at least 1 byte else: byte_count = count # Bytes logger.debug( f"Parsed address: area={area}, db={db_number}, start={start}, count={count}, word_len={word_len}, byte_count={byte_count}" ) return (area, db_number, start, byte_count) # Fallback to defaults if parsing failed logger.warning("Using default address values - address parsing may have failed") return (S7Area.DB, 1, 0, 4) except Exception as e: logger.error(f"Error parsing read address: {e}") return None def _read_from_memory_area(self, area: S7Area, db_number: int, start: int, count: int) -> Optional[bytearray]: """ Read data from registered memory area. Args: area: Memory area to read from db_number: DB number (for DB areas) start: Start offset count: Number of bytes to read Returns: Data read from memory area or None if area not found """ try: area_key = (area, db_number) if area_key not in self.memory_areas: logger.warning(f"Memory area {area}#{db_number} not registered") # Return dummy data if area not found (for compatibility) return bytearray([0x42, 0xFF, 0x12, 0x34])[:count] # Get area data with thread safety with self.area_locks[area_key]: area_data = self.memory_areas[area_key] # Check bounds if start >= len(area_data): logger.warning(f"Start address {start} beyond area size {len(area_data)}") return bytearray([0x00] * count) # Read requested data, padding with zeros if needed end = min(start + count, len(area_data)) read_data = bytearray(area_data[start:end]) # Pad with zeros if we didn't read enough if len(read_data) < count: read_data.extend([0x00] * (count - len(read_data))) logger.debug(f"Read {len(read_data)} bytes from {area}#{db_number} at offset {start}") return read_data except Exception as e: logger.error(f"Error reading from memory area: {e}") return bytearray([0x00] * count) def _handle_write_area(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """Handle write area request.""" try: # Parse address specification from request parameters addr_info = self._parse_write_address(request) if not addr_info: return self._build_error_response(request, 0x8001) # Invalid address area, db_number, start, count, write_data = addr_info # Write data to registered memory area success = self._write_to_memory_area(area, db_number, start, write_data) if not success: return self._build_error_response(request, 0x8404) # Area not found or write error # Build successful response with error bytes header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence (echo) 0x0002, # Parameter length 0x0001, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) # Parameters parameters = struct.pack( ">BB", S7Function.WRITE_AREA, # Function code 0x01, # Item count ) # Data section (write response) data_section = b"\xff" # Success return code return header + parameters + data_section except Exception as e: logger.error(f"Error handling write request: {e}") return self._build_error_response(request, 0x8000) def _handle_plc_control(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """Handle PLC control request (start, compress, copy_ram_to_rom).""" try: params = request.get("parameters", {}) pi_service = params.get("pi_service", b"") # Check for PI service operations if pi_service == b"_MSZL": file_id = params.get("file_id", b"") if file_id == b"P": # Copy RAM to ROM logger.info(f"Copy RAM to ROM requested from {client_address}") else: # Compress memory logger.info(f"Compress memory requested from {client_address}") elif len(params) >= 2: # Has restart type parameter - start operation restart_type = params.get("restart_type", 1) if restart_type == 1: logger.info("PLC Hot Start requested") else: logger.info("PLC Cold Start requested") # Set CPU to running state self.cpu_state = CPUState.RUN else: logger.info("PLC Start requested") self.cpu_state = CPUState.RUN # Build successful response header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence (echo) 0x0001, # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) parameters = struct.pack(">B", S7Function.PLC_CONTROL) return header + parameters except Exception as e: logger.error(f"Error handling PLC control request: {e}") return self._build_error_response(request, 0x8000) def _handle_plc_stop(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """Handle PLC stop request.""" try: logger.info("PLC Stop requested") # Set CPU to stopped state self.cpu_state = CPUState.STOP # Build successful response with error bytes header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence (echo) 0x0001, # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) parameters = struct.pack(">B", S7Function.PLC_STOP) return header + parameters except Exception as e: logger.error(f"Error handling PLC stop request: {e}") return self._build_error_response(request, 0x8000) def _parse_write_address(self, request: Dict[str, Any]) -> Optional[Tuple[S7Area, int, int, int, bytearray]]: """ Parse write address from request parameters and data. Returns: Tuple of (area, db_number, start, count, write_data) or None if invalid """ try: params = request.get("parameters", {}) if params.get("function_code") != S7Function.WRITE_AREA: return None # Check if we have parsed address specification addr_spec = params.get("address_spec", {}) if not addr_spec: logger.warning("No address specification in write request") return None area = addr_spec.get("area", S7Area.DB) db_number = addr_spec.get("db_number", 1) start = addr_spec.get("start", 0) count = addr_spec.get("count", 0) # Extract write data from request data section data_info = request.get("data", {}) write_data = data_info.get("data", b"") if not write_data: logger.warning("No write data in request") return None logger.debug( f"Parsed write address: area={area}, db={db_number}, start={start}, count={count}, data_len={len(write_data)}" ) return (area, db_number, start, count, bytearray(write_data)) except Exception as e: logger.error(f"Error parsing write address: {e}") return None def _write_to_memory_area(self, area: S7Area, db_number: int, start: int, write_data: bytearray) -> bool: """ Write data to registered memory area. Args: area: Memory area to write to db_number: DB number (for DB areas) start: Start offset write_data: Data to write Returns: True if write succeeded, False otherwise """ try: area_key = (area, db_number) if area_key not in self.memory_areas: logger.warning(f"Memory area {area}#{db_number} not registered for write") return False # Write to area data with thread safety with self.area_locks[area_key]: area_data = self.memory_areas[area_key] # Check bounds if start >= len(area_data): logger.warning(f"Write start address {start} beyond area size {len(area_data)}") return False # Calculate write range end = min(start + len(write_data), len(area_data)) actual_write_len = end - start # Write the data area_data[start:end] = write_data[:actual_write_len] logger.debug(f"Wrote {actual_write_len} bytes to {area}#{db_number} at offset {start}") # If we didn't write all data due to bounds, return error if actual_write_len < len(write_data): logger.warning(f"Only wrote {actual_write_len} of {len(write_data)} bytes due to area bounds") return False return True except Exception as e: logger.error(f"Error writing to memory area: {e}") return False def _parse_request(self, pdu: bytes) -> Dict[str, Any]: """ Parse S7 request PDU. Args: pdu: Complete S7 PDU Returns: Parsed request data """ if len(pdu) < 10: raise S7ProtocolError("PDU too short for S7 header") # Parse S7 header header = struct.unpack(">BBHHHH", pdu[:10]) protocol_id, pdu_type, reserved, sequence, param_len, data_len = header if protocol_id != 0x32: raise S7ProtocolError(f"Invalid protocol ID: {protocol_id:#02x}") request: Dict[str, Any] = { "pdu_type": pdu_type, "sequence": sequence, "param_length": param_len, "data_length": data_len, "parameters": None, "data": None, "error_code": 0, } offset = 10 # Parse parameters if present if param_len > 0: if offset + param_len > len(pdu): raise S7ProtocolError("Parameter section extends beyond PDU") param_data = pdu[offset : offset + param_len] # Store raw parameters for all request types (needed for upload/download parsing) request["raw_parameters"] = param_data if pdu_type == S7PDUType.USERDATA: request["parameters"] = self._parse_userdata_request_parameters(param_data) else: request["parameters"] = self._parse_request_parameters(param_data) offset += param_len # Parse data if present if data_len > 0: if offset + data_len > len(pdu): raise S7ProtocolError("Data section extends beyond PDU") data_section = pdu[offset : offset + data_len] request["data"] = self._parse_data_section(data_section) return request def _parse_request_parameters(self, param_data: bytes) -> Dict[str, Any]: """Parse S7 request parameter section.""" if len(param_data) < 1: return {} function_code = param_data[0] if function_code == S7Function.SETUP_COMMUNICATION: if len(param_data) >= 8: function_code, reserved, max_amq_caller, max_amq_callee, pdu_length = struct.unpack(">BBHHH", param_data[:8]) return { "function_code": function_code, "max_amq_caller": max_amq_caller, "max_amq_callee": max_amq_callee, "pdu_length": pdu_length, } elif function_code == S7Function.READ_AREA: # Parse read area parameters if len(param_data) >= 14: # Minimum for read area request # Function code (1) + item count (1) + address spec (12) item_count = param_data[1] # Parse address specification starting at byte 2 if len(param_data) >= 14: addr_spec = param_data[2:14] # 12 bytes of address specification logger.debug(f"Extracted address spec from params: {addr_spec.hex()}") parsed_addr = self._parse_address_specification(addr_spec) return {"function_code": function_code, "item_count": item_count, "address_spec": parsed_addr} elif function_code == S7Function.WRITE_AREA: # Parse write area parameters (same format as read) if len(param_data) >= 14: # Minimum for write area request # Function code (1) + item count (1) + address spec (12) item_count = param_data[1] # Parse address specification starting at byte 2 if len(param_data) >= 14: addr_spec = param_data[2:14] # 12 bytes of address specification logger.debug(f"Extracted write address spec from params: {addr_spec.hex()}") parsed_addr = self._parse_address_specification(addr_spec) return {"function_code": function_code, "item_count": item_count, "address_spec": parsed_addr} elif function_code == S7Function.PLC_CONTROL: # Parse PLC control parameters # Format varies: simple start or PI service (compress/copy_ram_to_rom) if len(param_data) >= 2: # Check for restart type (simple start) restart_type = param_data[1] if restart_type in (1, 2): return {"function_code": function_code, "restart_type": restart_type} # Check for PI service (compress/copy_ram_to_rom) # Format: func(1) + reserved(7) + pi_len(1) + pi_service # Or: func(1) + reserved(6) + file_id_len(1) + pi_len(1) + file_id + pi_service if len(param_data) >= 10: # Look for PI service pi_len = param_data[8] if pi_len > 0 and len(param_data) >= 9 + pi_len: pi_service = param_data[9 : 9 + pi_len] # Check for file_id (copy_ram_to_rom) file_id_len = param_data[7] file_id = b"" if file_id_len > 0 and len(param_data) >= 9 + file_id_len + pi_len: # Reparse with file_id file_id = param_data[9 : 9 + file_id_len] pi_service = param_data[9 + file_id_len : 9 + file_id_len + pi_len] return {"function_code": function_code, "pi_service": pi_service, "file_id": file_id} return {"function_code": function_code} def _parse_userdata_request_parameters(self, param_data: bytes) -> Dict[str, Any]: """ Parse USER_DATA request parameters. USER_DATA parameter format (from C s7_types.h TReqFunTypedParams): - Byte 0: Reserved (0x00) - Byte 1: Parameter count (usually 0x01) - Byte 2: Type/length header (0x12) - Byte 3: Length (0x04 or 0x08) - Byte 4: Method (0x11 = request, 0x12 = response) - Byte 5: Type (high nibble 0x4=req, 0x8=resp) | Group (low nibble) - Byte 6: Subfunction - Byte 7: Sequence number Args: param_data: Raw parameter bytes Returns: Dictionary with parsed USER_DATA parameters """ if len(param_data) < 8: logger.debug(f"USER_DATA parameters too short: {len(param_data)} bytes") return {} try: # Parse USER_DATA header # Bytes 0-3 are header (reserved, param_count, type_len_header, length) method = param_data[4] type_group = param_data[5] subfunction = param_data[6] sequence = param_data[7] # Extract type (high nibble) and group (low nibble) req_type = (type_group >> 4) & 0x0F group = type_group & 0x0F logger.debug( f"USER_DATA params: method={method:#02x}, type={req_type}, group={group}, subfunc={subfunction}, seq={sequence}" ) return { "method": method, "type": req_type, "group": group, "subfunction": subfunction, "sequence": sequence, } except Exception as e: logger.error(f"Error parsing USER_DATA parameters: {e}") return {} def _parse_address_specification(self, addr_spec: bytes) -> Dict[str, Any]: """ Parse S7 address specification. Args: addr_spec: 12-byte address specification from client request Returns: Dictionary with parsed address information """ try: if len(addr_spec) < 12: logger.error(f"Address spec too short: {len(addr_spec)} bytes, need 12") return {} logger.debug(f"Parsing address spec: {addr_spec.hex()} (length: {len(addr_spec)})") # Address specification format: # Byte 0: Specification type (0x12) # Byte 1: Length of following address specification (0x0A = 10 bytes) # Byte 2: Syntax ID (0x10 = S7-Any) # Byte 3: Transport size (word length) # Bytes 4-5: Count (number of items) # Bytes 6-7: DB number (for DB area) or 0 # Byte 8: Area code # Bytes 9-11: Start address (3 bytes, big-endian) spec_type, length, syntax_id, word_len, count, db_number, area_code, address_bytes = struct.unpack( ">BBBBHHB3s", addr_spec ) # Extract 3-byte address (big-endian) address = struct.unpack(">I", b"\x00" + address_bytes)[0] # Pad to 4 bytes # Convert bit address to byte address if word_len == S7WordLen.BIT: byte_addr = address // 8 start_address = byte_addr else: start_address = address // 8 # Convert bit address to byte address return { "area": S7Area(area_code), "db_number": db_number, "start": start_address, "count": count, "word_len": word_len, "spec_type": spec_type, "syntax_id": syntax_id, } except Exception as e: logger.error(f"Error parsing address specification: {e}") return {} def _parse_data_section(self, data_section: bytes) -> Dict[str, Any]: """Parse S7 data section.""" if len(data_section) == 1: # Simple return code (for write responses) return {"return_code": data_section[0], "transport_size": 0, "data_length": 0, "data": b""} elif len(data_section) >= 4: # Full data header (for read responses) return_code = data_section[0] transport_size = data_section[1] data_length = struct.unpack(">H", data_section[2:4])[0] # Extract actual data - length interpretation depends on transport_size # Transport size 0x09 (octet string): byte length (USERDATA responses) # Transport size 0x00: byte length (USERDATA requests) # Transport size 0x04 (byte): bit length (READ_AREA responses) if transport_size in (0x00, 0x09): # USERDATA uses byte length directly actual_data = data_section[4 : 4 + data_length] else: # READ_AREA responses use bit length actual_data = data_section[4 : 4 + (data_length // 8)] return {"return_code": return_code, "transport_size": transport_size, "data_length": data_length, "data": actual_data} else: return {"raw_data": data_section} def _build_error_response(self, request: Dict[str, Any], error_code: int) -> bytes: """Build an error response PDU. Uses PDU type ACK (0x02) for error responses without data, matching real S7-1200/1500 PLC behavior. """ error_class = (error_code >> 8) & 0xFF error_byte = error_code & 0xFF header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK, # PDU type (ACK for errors without data) 0x0000, # Reserved request.get("sequence", 0), # Sequence (echo) 0x0000, # Parameter length 0x0000, # Data length error_class, # Error class error_byte, # Error code ) return header # ======================================================================== # USER_DATA PDU Handlers (Chunk 1 of protocol implementation) # ======================================================================== def _handle_userdata(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle USER_DATA PDU requests. USER_DATA PDUs are used for: - Block operations (list, info) - SZL (System Status List) requests - Clock operations (get/set time) - Security operations (password) Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU data """ try: # Parse USER_DATA specific parameters userdata_params = self._parse_userdata_parameters(request) if not userdata_params: logger.warning(f"Failed to parse USER_DATA parameters from {client_address}") return self._build_userdata_error_response(request, 0x8104) # Object does not exist group = userdata_params.get("group", 0) subfunction = userdata_params.get("subfunction", 0) logger.debug(f"USER_DATA request: group={group:#04x}, subfunction={subfunction:#02x}") # Route to appropriate handler based on group if group == S7UserDataGroup.BLOCK_INFO: return self._handle_block_info(request, userdata_params, client_address) elif group == S7UserDataGroup.SZL: return self._handle_szl(request, userdata_params, client_address) elif group == S7UserDataGroup.TIME: return self._handle_clock(request, userdata_params, client_address) elif group == S7UserDataGroup.SECURITY: return self._handle_security(request, userdata_params, client_address) else: logger.warning(f"Unsupported USER_DATA group: {group:#04x}") return self._build_userdata_error_response(request, 0x8104) except Exception as e: logger.error(f"Error handling USER_DATA request: {e}") return self._build_userdata_error_response(request, 0x8000) def _parse_userdata_parameters(self, request: Dict[str, Any]) -> Dict[str, Any]: """ Parse USER_DATA specific parameters. USER_DATA parameter format (from C s7_types.h): - Byte 0-2: Parameter header - Byte 3: Parameter length - Byte 4: Method (0x11 = request, 0x12 = response) - Byte 5 (high nibble): Type (0x4 = request, 0x8 = response) - Byte 5 (low nibble): Function group - Byte 6: Subfunction - Byte 7: Sequence number Args: request: Parsed S7 request Returns: Dictionary with parsed USER_DATA parameters """ try: params = request.get("parameters") if not params: # Try to get raw parameter data from request return {} # If we have raw parameter data in the request, parse it raw_params = request.get("raw_parameters", b"") if not raw_params and isinstance(params, dict): # Already parsed - check if it has userdata fields if "group" in params: return params return {} if len(raw_params) < 8: logger.debug(f"USER_DATA parameters too short: {len(raw_params)} bytes") return {} # Parse USER_DATA parameter format # Skip first 4 bytes (header), then: method = raw_params[4] type_group = raw_params[5] subfunction = raw_params[6] sequence = raw_params[7] # Extract type (high nibble) and group (low nibble) req_type = (type_group >> 4) & 0x0F group = type_group & 0x0F return { "method": method, "type": req_type, "group": group, "subfunction": subfunction, "sequence": sequence, } except Exception as e: logger.error(f"Error parsing USER_DATA parameters: {e}") return {} def _handle_block_info( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle block info group requests (grBlocksInfo). Subfunctions: - SFun_ListAll (0x01): List all block counts - SFun_ListBoT (0x02): List blocks of type - SFun_BlkInfo (0x03): Get block info Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU """ subfunction = userdata_params.get("subfunction", 0) if subfunction == S7UserDataSubfunction.LIST_ALL: return self._handle_list_all_blocks(request, userdata_params, client_address) elif subfunction == S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE: return self._handle_list_blocks_of_type(request, userdata_params, client_address) elif subfunction == S7UserDataSubfunction.BLOCK_INFO: return self._handle_get_block_info(request, userdata_params, client_address) else: logger.warning(f"Unsupported block info subfunction: {subfunction:#02x}") return self._build_userdata_error_response(request, 0x8104) def _handle_szl(self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle SZL (System Status List) requests. SZL provides system status information about the PLC. Common SZL IDs: - 0x001C: Component identification (for get_cpu_info) - 0x0011: Module identification (for get_order_code) - 0x0131: Communication parameters (for get_cp_info) - 0x0232: Protection level (for get_protection) Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU with SZL data """ # Extract SZL ID and index from request data data_section = request.get("data", {}) raw_data = data_section.get("data", b"") # SZL request data: return_code (1) + transport (1) + length (2) + SZL_ID (2) + Index (2) if len(raw_data) >= 4: szl_id = struct.unpack(">H", raw_data[0:2])[0] szl_index = struct.unpack(">H", raw_data[2:4])[0] else: szl_id = 0 szl_index = 0 logger.debug(f"SZL request from {client_address}: ID={szl_id:#06x}, Index={szl_index:#06x}") # Get SZL data for the requested ID szl_data = self._get_szl_data(szl_id, szl_index) if szl_data is None: logger.debug(f"SZL ID {szl_id:#06x} not available") return self._build_userdata_error_response(request, 0x8104) # Build response with SZL header: SZL_ID (2) + Index (2) + data response_data = struct.pack(">HH", szl_id, szl_index) + szl_data return self._build_userdata_success_response(request, userdata_params, response_data) def _get_szl_data(self, szl_id: int, szl_index: int) -> Optional[bytes]: """ Get SZL data for a specific ID and index. Args: szl_id: SZL identifier szl_index: SZL index Returns: SZL data bytes or None if not available """ # SZL 0x001C: Component identification (S7CpuInfo) if szl_id == 0x001C: # S7CpuInfo structure fields (each is a null-terminated string) module_type = b"CPU 315-2 PN/DP\x00" serial_number = b"S C-C2UR28922012\x00" as_name = b"SNAP7-SERVER\x00" copyright_info = b"Original Siemens Equipment\x00" module_name = b"CPU 315-2 PN/DP\x00" # Pad to fixed sizes (from C structure) module_type = module_type.ljust(32, b"\x00")[:32] serial_number = serial_number.ljust(24, b"\x00")[:24] as_name = as_name.ljust(24, b"\x00")[:24] copyright_info = copyright_info.ljust(26, b"\x00")[:26] module_name = module_name.ljust(24, b"\x00")[:24] return module_type + serial_number + as_name + copyright_info + module_name # SZL 0x0011: Module identification (S7OrderCode) elif szl_id == 0x0011: order_code = b"6ES7 315-2EH14-0AB0\x00" version = b"V3.3\x00" order_code = order_code.ljust(20, b"\x00")[:20] version = version.ljust(4, b"\x00")[:4] return order_code + version # SZL 0x0131: Communication parameters (S7CpInfo) elif szl_id == 0x0131: # S7CpInfo structure max_pdu = 480 max_connections = 32 max_mpi = 12 max_bus = 12 return struct.pack(">HHHH", max_pdu, max_connections, max_mpi, max_bus) # SZL 0x0232: Protection level (S7Protection) elif szl_id == 0x0232: # S7Protection structure # sch_schal: 1=no password, 2=password level 1, 3=password level 2 # sch_par: protection level during runtime # sch_rel: protection level during download # bart_sch: startup protection level # anl_sch: factory setting protection return struct.pack(">HHHHH", 1, 0, 0, 0, 0) # No protection # SZL 0x0000: SZL list elif szl_id == 0x0000: # Return list of available SZL IDs available_ids = [0x0000, 0x0011, 0x001C, 0x0131, 0x0232] data = b"" for id_val in available_ids: data += struct.pack(">H", id_val) return data return None def _handle_clock(self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle clock requests (get/set time). Supports: - GET_CLOCK (0x01): Returns current server time in BCD format - SET_CLOCK (0x02): Accepts time setting (logs but doesn't persist) Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU with clock data """ subfunction = userdata_params.get("subfunction", 0) if subfunction == 0x01: # GET_CLOCK return self._handle_get_clock(request, userdata_params, client_address) elif subfunction == 0x02: # SET_CLOCK return self._handle_set_clock(request, userdata_params, client_address) else: logger.warning(f"Unknown clock subfunction: {subfunction:#04x}") return self._build_userdata_error_response(request, 0x8104) def _handle_get_clock( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle get clock request - returns current server time. Returns time in BCD format (8 bytes): - Byte 0: Reserved (0x00) - Byte 1: Year (BCD, 0-99) - Byte 2: Month (BCD, 1-12) - Byte 3: Day (BCD, 1-31) - Byte 4: Hour (BCD, 0-23) - Byte 5: Minute (BCD, 0-59) - Byte 6: Second (BCD, 0-59) - Byte 7: Day of week (1=Monday) """ from datetime import datetime now = datetime.now() def to_bcd(value: int) -> int: return ((value // 10) << 4) | (value % 10) year = now.year % 100 bcd_time = struct.pack( ">BBBBBBBB", 0x00, # Reserved to_bcd(year), # Year (BCD) to_bcd(now.month), # Month (BCD) to_bcd(now.day), # Day (BCD) to_bcd(now.hour), # Hour (BCD) to_bcd(now.minute), # Minute (BCD) to_bcd(now.second), # Second (BCD) (now.weekday() + 1) & 0x0F, # Day of week (1=Monday) ) logger.debug(f"Get clock from {client_address}: returning {now}") return self._build_userdata_success_response(request, userdata_params, bcd_time) def _handle_set_clock( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle set clock request - accepts time setting. The emulator logs the time but doesn't persist it (always returns current time on get). """ data_section = request.get("data", {}) raw_data = data_section.get("data", b"") if len(raw_data) >= 8: def from_bcd(value: int) -> int: return ((value >> 4) * 10) + (value & 0x0F) year = from_bcd(raw_data[1]) month = from_bcd(raw_data[2]) day = from_bcd(raw_data[3]) hour = from_bcd(raw_data[4]) minute = from_bcd(raw_data[5]) second = from_bcd(raw_data[6]) logger.info( f"Set clock from {client_address}: 20{year:02d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}" ) else: logger.debug(f"Set clock from {client_address}: no time data provided") # Return success (empty response data) return self._build_userdata_success_response(request, userdata_params, b"") def _handle_security( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle security requests (password operations). Stub implementation - returns success (no password required). """ logger.debug(f"Security request from {client_address} (returning success)") # Return success - emulator doesn't require password return self._build_userdata_success_response(request, userdata_params, b"") def _handle_list_all_blocks( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle list all blocks request (SFun_ListAll). Returns count of each block type (OB, FB, FC, DB, SDB, SFC, SFB). Response data format (TDataFunListAll): For each block type (7 types): - Byte 0: 0x30 (indicator) - Byte 1: Block type code - Bytes 2-3: Block count (big-endian) Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU with block counts """ logger.debug(f"List all blocks request from {client_address}") # Count registered DB areas db_count = sum(1 for (area, _) in self.memory_areas.keys() if area == S7Area.DB) # Block type codes (from C s7_types.h) BLOCK_OB = 0x38 # Organization Block BLOCK_DB = 0x41 # Data Block BLOCK_SDB = 0x42 # System Data Block BLOCK_FC = 0x43 # Function BLOCK_SFC = 0x44 # System Function BLOCK_FB = 0x45 # Function Block BLOCK_SFB = 0x46 # System Function Block # Build response data - 4 bytes per block type, 7 block types # Format: 0x30 | block_type | count (2 bytes big-endian) data = b"" for block_type, count in [ (BLOCK_OB, 0), # No OBs in emulator (BLOCK_FB, 0), # No FBs (BLOCK_FC, 0), # No FCs (BLOCK_DB, db_count), # Registered DBs (BLOCK_SDB, 0), # No SDBs (BLOCK_SFC, 0), # No SFCs (BLOCK_SFB, 0), # No SFBs ]: data += struct.pack(">BBH", 0x30, block_type, count) logger.debug(f"List all blocks: DB count = {db_count}") return self._build_userdata_success_response(request, userdata_params, data) def _handle_list_blocks_of_type( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle list blocks of type request (SFun_ListBoT). Returns list of block numbers for a specific block type. Request data contains: - Block type code to query Response data format: - 2 bytes per block: block number (big-endian) Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU with block numbers """ logger.debug(f"List blocks of type request from {client_address}") # Get requested block type from request data section data_section = request.get("data", {}) raw_data = data_section.get("data", b"") # Block type code constants block_db = 0x41 # Data Block # Handle both new format [0x30, type] and old format [type] if len(raw_data) >= 2 and raw_data[0] == 0x30: requested_type = raw_data[1] elif len(raw_data) > 0: requested_type = raw_data[0] else: requested_type = block_db # Currently only support DB type (others not implemented in emulator) if requested_type == block_db: # Get all registered DB numbers db_numbers = sorted([idx for (area, idx) in self.memory_areas.keys() if area == S7Area.DB]) # Build response data - 4 bytes per block (TDataFunGetBotItem: # BlockNum(2) + Unknown(1) + BlockLang(1)) data = b"" for db_num in db_numbers: data += struct.pack(">HBB", db_num, 0, 0) logger.debug(f"List blocks of type DB: {db_numbers}") return self._build_userdata_success_response(request, userdata_params, data) else: # Other block types not available in emulator logger.debug(f"Block type {requested_type:#02x} not available") return self._build_userdata_success_response(request, userdata_params, b"") def _handle_get_block_info( self, request: Dict[str, Any], userdata_params: Dict[str, Any], client_address: Tuple[str, int] ) -> bytes: """ Handle get block info request (SFun_BlkInfo). Returns information about a specific block. Request data contains: - Block type code - Block number - Block language (optional) Response data format (TS7BlockInfo): - Various block metadata fields Args: request: Parsed S7 request userdata_params: Parsed USER_DATA parameters client_address: Client address Returns: Response PDU with block info """ logger.debug(f"Get block info request from {client_address}") # Get requested block from request data section data_section = request.get("data", {}) raw_data = data_section.get("data", b"") # Block type code constants block_db = 0x41 # Data Block # Parse request: handle new format [0x30, type, 'A', ASCII_num(5)] # and old format [type, num(2), 0x41] if len(raw_data) >= 8 and raw_data[0] == 0x30: # New format: 0x30 + type + 'A' + 5-digit ASCII number requested_type = raw_data[1] try: block_number = int(raw_data[3:8].decode("ascii")) except (ValueError, UnicodeDecodeError): block_number = 1 elif len(raw_data) >= 3: # Old format: type(1) + number(2) + filesystem(1) requested_type = raw_data[0] block_number = struct.unpack(">H", raw_data[1:3])[0] else: # Default values requested_type = block_db block_number = 1 # Check if block exists if requested_type == block_db: area_key = (S7Area.DB, block_number) if area_key in self.memory_areas: block_size = len(self.memory_areas[area_key]) # Build TResDataBlockInfo structure (78 bytes) # Layout per Snap7 C s7_types.h data = bytearray(78) data[0] = 0x30 # Const data[1] = requested_type # BlkType data[9] = 0 # BlkFlags data[10] = 0 # BlkLang data[11] = requested_type # SubBlkType struct.pack_into(">H", data, 12, block_number) # BlkNumber struct.pack_into(">I", data, 14, block_size) # LoadSize struct.pack_into(">H", data, 34, 0) # SBBLength struct.pack_into(">H", data, 38, 0) # LocalData struct.pack_into(">H", data, 40, block_size) # MC7Size # Author (8 bytes at offset 42) data[42:50] = b"SNAP7EMU" # Family (8 bytes at offset 50) data[50:58] = b"EMULATOR" # Header (8 bytes at offset 58) data[58:60] = b"DB" data[66] = 1 # Version logger.debug(f"Get block info for DB{block_number}: size={block_size}") return self._build_userdata_success_response(request, userdata_params, bytes(data)) else: logger.debug(f"Block DB{block_number} not found") return self._build_userdata_error_response(request, 0x8104) # Object not found else: # Other block types not available logger.debug(f"Block type {requested_type:#02x} not available") return self._build_userdata_error_response(request, 0x8104) def _build_userdata_error_response(self, request: Dict[str, Any], error_code: int) -> bytes: """ Build USER_DATA error response PDU. Args: request: Original request error_code: S7 error code Returns: Error response PDU """ # USER_DATA response format is different from standard response # Parameter section (12-byte format per TS7Params7) param_data = struct.pack( ">BBBBBBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x08, # Length (8 bytes following) 0x12, # Method (response) 0x84, # Type (8=response) | Group (4=SZL, but used for error) 0x01, # Subfunction 0x00, # Sequence 0x00, # Data unit reference 0x00, # Last data unit 0x00, # Error code high 0x00, # Error code low ) # Data section: return code only (error code in transport format) data_section = struct.pack(">BBH", (error_code >> 8) & 0xFF, 0x00, 0) # Build S7 header for USERDATA (10 bytes, no error_class/error_code in header) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type 0x0000, # Reserved request.get("sequence", 0), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section def _build_userdata_success_response(self, request: Dict[str, Any], userdata_params: Dict[str, Any], data: bytes) -> bytes: """ Build USER_DATA success response PDU. Args: request: Original request userdata_params: Parsed USER_DATA parameters data: Response data Returns: Success response PDU """ group = userdata_params.get("group", 0) subfunction = userdata_params.get("subfunction", 0) seq = userdata_params.get("sequence", 0) # Parameter section for success response (12-byte format per TS7Params7) param_data = struct.pack( ">BBBBBBBBBBBB", 0x00, # Reserved 0x01, # Parameter count 0x12, # Type/length header 0x08, # Length (8 bytes following) 0x12, # Method (response) 0x80 | group, # Type (8=response) | Group subfunction, # Subfunction seq, # Sequence 0x00, # Data unit reference 0x00, # Last data unit 0x00, # Error code high 0x00, # Error code low ) # Data section: return code (0xFF = success) + data data_section = struct.pack(">BBH", 0xFF, 0x09, len(data)) + data # Build S7 header for USERDATA (10 bytes, no error_class/error_code in header) header = struct.pack( ">BBHHHH", 0x32, # Protocol ID S7PDUType.USERDATA, # PDU type 0x0000, # Reserved request.get("sequence", 0), # Sequence len(param_data), # Parameter length len(data_section), # Data length ) return header + param_data + data_section # ======================================================================== # Block Transfer Handlers (Upload/Download/Delete) # ======================================================================== def _handle_start_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle start upload request. Parses the block address and returns upload ID and block length. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU with upload ID and block length """ try: raw_params = request.get("raw_parameters", b"") # Parse block address from parameters # Format: function + status + reserved + upload_id + block_addr_len + block_addr block_type = 0x41 # Default to DB block_num = 1 if len(raw_params) >= 10: addr_len = raw_params[9] if len(raw_params) >= 10 + addr_len: block_addr = raw_params[10 : 10 + addr_len] # Parse block address: type (2 hex) + num (5 digits) + filesystem try: block_type = int(block_addr[0:2], 16) block_num = int(block_addr[2:7]) except (ValueError, IndexError): pass logger.info(f"Start upload request from {client_address}: type={block_type:#02x}, num={block_num}") # Generate upload ID and get block length upload_id = 1 # Simple upload ID block_length = 0 # Check if block exists if block_type == 0x41: # DB area_key = (S7Area.DB, block_num) if area_key in self.memory_areas: block_length = len(self.memory_areas[area_key]) # Store upload context for this client if not hasattr(self, "_upload_contexts"): self._upload_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {} self._upload_contexts[client_address] = { "upload_id": upload_id, "block_type": block_type, "block_num": block_num, "offset": 0, } # Build response: function + status + reserved + upload_id + block_len_string_len + block_len_string block_len_str = f"{block_length:06d}".encode("ascii") param_data = ( struct.pack( ">BBBIB", S7Function.START_UPLOAD, 0x00, # Status 0x00, # Reserved upload_id, len(block_len_str), ) + block_len_str ) header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data except Exception as e: logger.error(f"Error handling start upload: {e}") return self._build_error_response(request, 0x8000) def _handle_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle upload request - return block data. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU with block data """ try: # Get upload context for this client if not hasattr(self, "_upload_contexts") or client_address not in self._upload_contexts: logger.warning(f"Upload request without start_upload from {client_address}") return self._build_error_response(request, 0x8104) ctx = self._upload_contexts[client_address] block_type = ctx["block_type"] block_num = ctx["block_num"] # Get block data block_data = b"" if block_type == 0x41: # DB area_key = (S7Area.DB, block_num) if area_key in self.memory_areas: with self.area_locks[area_key]: block_data = bytes(self.memory_areas[area_key]) logger.info(f"Upload request from {client_address}: sending {len(block_data)} bytes") # Build response with data # Status: 0x00 = more data, 0x01 = last packet param_data = struct.pack( ">BBBI", S7Function.UPLOAD, 0x01, # Status: last packet 0x00, # Reserved ctx["upload_id"], ) # Data section: length (2 bytes) + unknown (2 bytes) + data data_section = struct.pack(">HH", len(block_data), 0x00FB) + block_data header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length len(data_section), # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data + data_section except Exception as e: logger.error(f"Error handling upload: {e}") return self._build_error_response(request, 0x8000) def _handle_end_upload(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle end upload request. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU acknowledging end of upload """ try: # Clean up upload context if hasattr(self, "_upload_contexts") and client_address in self._upload_contexts: del self._upload_contexts[client_address] logger.info(f"End upload from {client_address}") # Build simple response param_data = struct.pack(">B", S7Function.END_UPLOAD) header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data except Exception as e: logger.error(f"Error handling end upload: {e}") return self._build_error_response(request, 0x8000) def _handle_request_download(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle request download - acknowledge download request. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU acknowledging download request """ try: raw_params = request.get("raw_parameters", b"") # Parse block address from parameters block_type = 0x41 # Default to DB block_num = 1 if len(raw_params) >= 6: addr_len = raw_params[5] if len(raw_params) >= 6 + addr_len: block_addr = raw_params[6 : 6 + addr_len] try: block_type = int(block_addr[0:2], 16) block_num = int(block_addr[2:7]) except (ValueError, IndexError): pass logger.info(f"Request download from {client_address}: type={block_type:#02x}, num={block_num}") # Store download context if not hasattr(self, "_download_contexts"): self._download_contexts: Dict[Tuple[str, int], Dict[str, Any]] = {} self._download_contexts[client_address] = { "block_type": block_type, "block_num": block_num, "data": bytearray(), } # Build response acknowledging download param_data = struct.pack(">B", S7Function.REQUEST_DOWNLOAD) header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data except Exception as e: logger.error(f"Error handling request download: {e}") return self._build_error_response(request, 0x8000) def _handle_download_block(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle download block - receive block data. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU acknowledging data receipt """ try: # Get download context if not hasattr(self, "_download_contexts") or client_address not in self._download_contexts: logger.warning(f"Download block without request_download from {client_address}") return self._build_error_response(request, 0x8104) ctx = self._download_contexts[client_address] # Extract data from request data_info = request.get("data", {}) block_data = data_info.get("data", b"") # Append data to context ctx["data"].extend(block_data) logger.info(f"Download block from {client_address}: received {len(block_data)} bytes") # Build response param_data = struct.pack(">B", S7Function.DOWNLOAD_BLOCK) header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data except Exception as e: logger.error(f"Error handling download block: {e}") return self._build_error_response(request, 0x8000) def _handle_download_ended(self, request: Dict[str, Any], client_address: Tuple[str, int]) -> bytes: """ Handle download ended - finalize block storage. Args: request: Parsed S7 request client_address: Client address for logging Returns: Response PDU confirming download complete """ try: # Get download context if not hasattr(self, "_download_contexts") or client_address not in self._download_contexts: logger.warning(f"Download ended without download_block from {client_address}") return self._build_error_response(request, 0x8104) ctx = self._download_contexts[client_address] block_type = ctx["block_type"] block_num = ctx["block_num"] block_data = ctx["data"] # Store block data if block_type == 0x41: # DB area_key = (S7Area.DB, block_num) if area_key in self.memory_areas: # Update existing area - copy data into existing area without resizing with self.area_locks[area_key]: existing_area = self.memory_areas[area_key] copy_len = min(len(block_data), len(existing_area)) existing_area[0:copy_len] = block_data[0:copy_len] else: # Create new area self.memory_areas[area_key] = bytearray(block_data) self.area_locks[area_key] = threading.Lock() logger.info(f"Download ended from {client_address}: stored {len(block_data)} bytes to {block_type:#02x}:{block_num}") # Clean up context del self._download_contexts[client_address] # Build response param_data = struct.pack(">B", S7Function.DOWNLOAD_ENDED) header = struct.pack( ">BBHHHHBB", 0x32, # Protocol ID S7PDUType.ACK_DATA, # PDU type 0x0000, # Reserved request["sequence"], # Sequence len(param_data), # Parameter length 0x0000, # Data length 0x00, # Error class (success) 0x00, # Error code (success) ) return header + param_data except Exception as e: logger.error(f"Error handling download ended: {e}") return self._build_error_response(request, 0x8000) def __enter__(self) -> "Server": """Context manager entry.""" return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: """Context manager exit.""" self.destroy() class ServerISOConnection: """ISO connection wrapper for server-side communication.""" # COTP PDU types COTP_CR = 0xE0 # Connection Request COTP_CC = 0xD0 # Connection Confirm COTP_DR = 0x80 # Disconnect Request COTP_DC = 0xC0 # Disconnect Confirm COTP_DT = 0xF0 # Data Transfer def __init__(self, client_socket: socket.socket): """Initialize server ISO connection.""" self.socket = client_socket self.socket.settimeout(5.0) self.connected = False self.src_ref = 0x0001 # Server reference self.dst_ref = 0x0000 # Client reference (assigned during handshake) def accept_connection(self) -> bool: """Accept ISO connection from client.""" try: # Receive COTP Connection Request tpkt_header = self._recv_exact(4) version, reserved, length = struct.unpack(">BBH", tpkt_header) if version != 3: logger.error(f"Invalid TPKT version: {version}") return False payload = self._recv_exact(length - 4) # Parse COTP Connection Request if not self._parse_cotp_cr(payload): return False # Send COTP Connection Confirm cc_pdu = self._build_cotp_cc() tpkt_frame = self._build_tpkt(cc_pdu) self.socket.sendall(tpkt_frame) self.connected = True logger.debug("ISO connection established") return True except Exception as e: logger.error(f"Error accepting ISO connection: {e}") return False def receive_data(self) -> bytes: """Receive data from client.""" # Receive TPKT header (4 bytes) tpkt_header = self._recv_exact(4) # Parse TPKT header version, reserved, length = struct.unpack(">BBH", tpkt_header) if version != 3: raise S7ConnectionError(f"Invalid TPKT version: {version}") # Receive remaining data remaining = length - 4 if remaining <= 0: raise S7ConnectionError("Invalid TPKT length") payload = self._recv_exact(remaining) # Parse COTP header and extract data return self._parse_cotp_data(payload) def send_data(self, data: bytes) -> None: """Send data to client.""" # Wrap data in COTP Data Transfer PDU cotp_data = self._build_cotp_dt(data) # Wrap in TPKT frame tpkt_frame = self._build_tpkt(cotp_data) # Send over TCP self.socket.sendall(tpkt_frame) def _parse_cotp_cr(self, data: bytes) -> bool: """Parse COTP Connection Request.""" if len(data) < 7: logger.error("COTP CR too short") return False pdu_len, pdu_type, dst_ref, src_ref, class_opt = struct.unpack(">BBHHB", data[:7]) if pdu_type != self.COTP_CR: logger.error(f"Expected COTP CR, got {pdu_type:#02x}") return False # Store client reference self.dst_ref = src_ref logger.debug(f"Received COTP CR from client ref {src_ref}") return True def _build_cotp_cc(self) -> bytes: """Build COTP Connection Confirm.""" # Basic COTP CC base_pdu = struct.pack( ">BBHHB", 6, # PDU length self.COTP_CC, # PDU type self.dst_ref, # Destination reference (client's source ref) self.src_ref, # Source reference (our ref) 0x00, # Class/option ) return struct.pack(">B", 6) + base_pdu[1:] def _recv_exact(self, size: int) -> bytes: """Receive exactly the specified number of bytes.""" data = bytearray() while len(data) < size: chunk = self.socket.recv(size - len(data)) if not chunk: raise ConnectionResetError("Connection closed by peer") data.extend(chunk) return bytes(data) def _build_tpkt(self, payload: bytes) -> bytes: """Build TPKT frame.""" length = len(payload) + 4 return struct.pack(">BBH", 3, 0, length) + payload def _build_cotp_dt(self, data: bytes) -> bytes: """Build COTP Data Transfer PDU.""" header = struct.pack(">BBB", 2, self.COTP_DT, 0x80) return header + data def _parse_cotp_data(self, cotp_pdu: bytes) -> bytes: """Parse COTP Data Transfer PDU and extract S7 data.""" if len(cotp_pdu) < 3: raise S7ConnectionError("Invalid COTP DT: too short") pdu_len, pdu_type, eot_num = struct.unpack(">BBB", cotp_pdu[:3]) if pdu_type != self.COTP_DT: raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}") return cotp_pdu[3:] # Return data portion def mainloop(tcp_port: int = 1102, init_standard_values: bool = False) -> None: """ Initialize a pure Python S7 server with default values. Args: tcp_port: Port that the server will listen on init_standard_values: If True, initialize some default values """ server = Server() # Create standard memory areas - need at least 600 bytes for test data db_size = 600 db_data = bytearray(db_size) pa_data = bytearray(100) pe_data = bytearray(100) mk_data = bytearray(100) tm_data = bytearray(100) ct_data = bytearray(100) # Register memory areas # DB 0 for test_mainloop.py, DB 1 for other tests server.register_area(SrvArea.DB, 0, db_data) server.register_area(SrvArea.DB, 1, bytearray(db_size)) # Register at index 0 (used by most tests) and index 1 server.register_area(SrvArea.PA, 0, pa_data) server.register_area(SrvArea.PA, 1, bytearray(100)) server.register_area(SrvArea.PE, 0, pe_data) server.register_area(SrvArea.PE, 1, bytearray(100)) server.register_area(SrvArea.MK, 0, mk_data) server.register_area(SrvArea.MK, 1, bytearray(100)) server.register_area(SrvArea.TM, 0, tm_data) server.register_area(SrvArea.TM, 1, bytearray(100)) server.register_area(SrvArea.CT, 0, ct_data) server.register_area(SrvArea.CT, 1, bytearray(100)) if init_standard_values: logger.info("Initializing with standard values for tests") # test_read_booleans: offset 0, expects 0xAA (alternating False/True: 0,1,0,1,0,1,0,1) db_data[0] = 0xAA # Binary: 10101010 # test_read_small_int: offset 10, expects -128, 0, 100, 127 (signed bytes) db_data[10] = 0x80 # -128 as signed byte db_data[11] = 0x00 # 0 db_data[12] = 100 # 100 db_data[13] = 127 # 127 # test_read_unsigned_small_int: offset 20, expects 0, 255 db_data[20] = 0 # 0 db_data[21] = 255 # 255 # test_read_int: offset 30, expects -32768, -1234, 0, 1234, 32767 (signed 16-bit, big-endian) struct.pack_into(">h", db_data, 30, -32768) struct.pack_into(">h", db_data, 32, -1234) struct.pack_into(">h", db_data, 34, 0) struct.pack_into(">h", db_data, 36, 1234) struct.pack_into(">h", db_data, 38, 32767) # test_read_double_int: offset 40, expects -2147483648, -32768, 0, 32767, 2147483647 (signed 32-bit) struct.pack_into(">i", db_data, 40, -2147483648) struct.pack_into(">i", db_data, 44, -32768) struct.pack_into(">i", db_data, 48, 0) struct.pack_into(">i", db_data, 52, 32767) struct.pack_into(">i", db_data, 56, 2147483647) # test_read_real: offset 60, expects various float values (9 floats = 36 bytes) struct.pack_into(">f", db_data, 60, -3.402823e38) struct.pack_into(">f", db_data, 64, -3.402823e12) struct.pack_into(">f", db_data, 68, -175494351e-38) struct.pack_into(">f", db_data, 72, -1.175494351e-12) struct.pack_into(">f", db_data, 76, 0.0) struct.pack_into(">f", db_data, 80, 1.175494351e-38) struct.pack_into(">f", db_data, 84, 1.175494351e-12) struct.pack_into(">f", db_data, 88, 3.402823466e12) struct.pack_into(">f", db_data, 92, 3.402823466e38) # test_read_string: offset 100, expects "the brown fox jumps over the lazy dog" # S7 string format: max_len (1 byte), actual_len (1 byte), then string data test_string = "the brown fox jumps over the lazy dog" db_data[100] = 254 # Max length db_data[101] = len(test_string) # Actual length db_data[102 : 102 + len(test_string)] = test_string.encode("ascii") # test_read_word: offset 400, expects 0x0000, 0x1234, 0xABCD, 0xFFFF (unsigned 16-bit) struct.pack_into(">H", db_data, 400, 0x0000) struct.pack_into(">H", db_data, 404, 0x1234) struct.pack_into(">H", db_data, 408, 0xABCD) struct.pack_into(">H", db_data, 412, 0xFFFF) # test_read_double_word: offset 500, expects 0x00000000, 0x12345678, 0x1234ABCD, 0xFFFFFFFF (unsigned 32-bit) struct.pack_into(">I", db_data, 500, 0x00000000) struct.pack_into(">I", db_data, 508, 0x12345678) struct.pack_into(">I", db_data, 516, 0x1234ABCD) struct.pack_into(">I", db_data, 524, 0xFFFFFFFF) # Start server server.start(tcp_port) try: logger.info(f"Pure Python S7 server running on port {tcp_port}") logger.info("Press Ctrl+C to stop") # Keep server running while True: time.sleep(1) except KeyboardInterrupt: logger.info("Stopping server...") finally: server.stop() server.destroy() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/server/__main__.py0000644000175100017510000000226615156465122020256 0ustar00runnerrunner""" The :code:`__main__` module is used as an entrypoint when calling the module from the terminal using python -m flag. It contains functions providing a command-line interface to the server module. Its :code:`main()` function is also exported as a console-entrypoint. """ import logging try: import click except ImportError: print("Try using 'pip install python-snap7[cli]'") raise from snap7 import __version__ from snap7.server import mainloop logger = logging.getLogger("Snap7.Server") @click.command() @click.option("-p", "--port", default=1102, help="Port the server will listen on.") @click.option("-v", "--verbose", is_flag=True, help="Also print debug-output.") @click.version_option(__version__) @click.help_option("-h", "--help") def main(port: int, verbose: bool) -> None: """Start a S7 dummy server with some default values.""" # setup logging if verbose: logging.basicConfig(format="[%(levelname)s]: %(message)s", level=logging.DEBUG) else: logging.basicConfig(format="[%(levelname)s]: %(message)s", level=logging.INFO) # start the server mainloop mainloop(port, init_standard_values=True) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/type.py0000755000175100017510000002226315156465122016213 0ustar00runnerrunner""" Python equivalent for snap7 specific types. """ from _ctypes import Array from ctypes import ( c_int16, c_int8, c_int32, c_void_p, c_ubyte, c_uint64, c_uint16, c_uint32, Structure, POINTER, c_char, c_byte, c_int, c_uint8, ) from datetime import datetime, date, timedelta from enum import IntEnum from typing import Dict, Union, Literal CDataArrayType = Union[ Array[c_byte], Array[c_int], Array[c_int16], Array[c_int32], Array[c_uint8], Array[c_uint16], Array[c_uint32] ] CDataType = Union[type[c_int8], type[c_int16], type[c_int32], type[c_uint8], type[c_uint16], type[c_uint32]] ValueType = Union[int, float, str, datetime, bytearray, bytes, date, timedelta] Context = Literal["client", "server", "partner"] S7Object = c_void_p buffer_size = 65536 # noinspection PyTypeChecker buffer_type = c_ubyte * buffer_size time_t = c_uint64 word = c_uint16 longword = c_uint32 # mask types mkEvent = 0 mkLog = 1 class Parameter(IntEnum): """ The snap7 parameter types """ LocalPort = 1 RemotePort = 2 PingTimeout = 3 SendTimeout = 4 RecvTimeout = 5 WorkInterval = 6 SrcRef = 7 DstRef = 8 SrcTSap = 9 PDURequest = 10 MaxClients = 11 BSendTimeout = 12 BRecvTimeout = 13 RecoveryTime = 14 KeepAliveTime = 15 @property def ctype(self) -> CDataType: map_: Dict[int, CDataType] = { self.LocalPort: c_uint16, self.RemotePort: c_uint16, self.PingTimeout: c_int32, self.SendTimeout: c_int32, self.RecvTimeout: c_int32, self.WorkInterval: c_int32, self.SrcRef: c_uint16, self.DstRef: c_uint16, self.SrcTSap: c_uint16, self.PDURequest: c_int32, self.MaxClients: c_int32, self.BSendTimeout: c_int32, self.BRecvTimeout: c_int32, self.RecoveryTime: c_uint32, self.KeepAliveTime: c_uint32, } return map_[self] # Area ID # Word Length class WordLen(IntEnum): """ The snap7 word length types """ Bit = 0x01 Byte = 0x02 Char = 0x03 Word = 0x04 Int = 0x05 DWord = 0x06 DInt = 0x07 Real = 0x08 Counter = 0x1C Timer = 0x1D @property def ctype(self) -> CDataType: map_: Dict[WordLen, CDataType] = { WordLen.Bit: c_int16, WordLen.Byte: c_int8, WordLen.Word: c_int16, WordLen.DWord: c_int32, WordLen.Real: c_int32, WordLen.Counter: c_int16, WordLen.Timer: c_int16, } return map_[self] class Area(IntEnum): """ The snap7 area types """ PE = 0x81 PA = 0x82 MK = 0x83 DB = 0x84 CT = 0x1C TM = 0x1D def wordlen(self) -> WordLen: if self == Area.TM: return WordLen.Timer elif self == Area.CT: return WordLen.Counter return WordLen.Byte # backwards compatible alias Areas = Area class SrvArea(IntEnum): """ The snap7 server area types NOTE: these values are DIFFERENT from the normal area IDs. """ PE = 0 PA = 1 MK = 2 CT = 3 TM = 4 DB = 5 class Block(IntEnum): """ The snap7 block type """ OB = 0x38 DB = 0x41 SDB = 0x42 FC = 0x43 SFC = 0x44 FB = 0x45 SFB = 0x46 @property def ctype(self) -> c_int: return c_int(self) server_statuses = { 0: "SrvStopped", 1: "SrvRunning", 2: "SrvError", } cpu_statuses = { 0: "S7CpuStatusUnknown", 4: "S7CpuStatusStop", 8: "S7CpuStatusRun", } class SrvEvent(Structure): """ The snap7 server event structure """ _fields_ = [ ("EvtTime", time_t), ("EvtSender", c_int), ("EvtCode", longword), ("EvtRetCode", word), ("EvtParam1", word), ("EvtParam2", word), ("EvtParam3", word), ("EvtParam4", word), ] def __str__(self) -> str: return ( f"" ) class BlocksList(Structure): """ The snap7 block list structure """ _fields_ = [ ("OBCount", c_int32), ("FBCount", c_int32), ("FCCount", c_int32), ("SFBCount", c_int32), ("SFCCount", c_int32), ("DBCount", c_int32), ("SDBCount", c_int32), ] def __str__(self) -> str: return ( f"" ) # noinspection PyTypeChecker class TS7BlockInfo(Structure): _fields_ = [ ("BlkType", c_int32), ("BlkNumber", c_int32), ("BlkLang", c_int32), ("BlkFlags", c_int32), ("MC7Size", c_int32), ("LoadSize", c_int32), ("LocalData", c_int32), ("SBBLength", c_int32), ("CheckSum", c_int32), ("Version", c_int32), ("CodeDate", c_char * 11), ("IntfDate", c_char * 11), ("Author", c_char * 9), ("Family", c_char * 9), ("Header", c_char * 9), ] def __str__(self) -> str: return f"""\ Block type: {self.BlkType} Block number: {self.BlkNumber} Block language: {self.BlkLang} Block flags: {self.BlkFlags} MC7Size: {self.MC7Size} Load memory size: {self.LoadSize} Local data: {self.LocalData} SBB Length: {self.SBBLength} Checksum: {self.CheckSum} Version: {self.Version} Code date: {self.CodeDate} Interface date: {self.IntfDate} Author: {self.Author} Family: {self.Family} Header: {self.Header}""" class S7DataItem(Structure): """ """ _pack_ = 1 _layout_ = "ms" _fields_ = [ ("Area", c_int32), ("WordLen", c_int32), ("Result", c_int32), ("DBNumber", c_int32), ("Start", c_int32), ("Amount", c_int32), ("pData", POINTER(c_uint8)), ] def __str__(self) -> str: return ( f"" ) # noinspection PyTypeChecker class S7CpuInfo(Structure): """ S7CpuInfo class for handling CPU with : - ModuleTypeName => Model of S7-CPU - SerialNumber => SN of the S7-CPU - ASName => Family Class of the S7-CPU - Copyright => Siemens Copyright - ModuleName => TIA project name or for other S7-CPU, same as ModuleTypeName """ _fields_ = [ ("ModuleTypeName", c_char * 33), ("SerialNumber", c_char * 25), ("ASName", c_char * 25), ("Copyright", c_char * 27), ("ModuleName", c_char * 25), ] def __str__(self) -> str: return ( f"" ) class S7SZLHeader(Structure): """ LengthDR: Length of a data record of the partial list in bytes NDR: Number of data records contained in the partial list """ _fields_ = [("LengthDR", c_uint16), ("NDR", c_uint16)] def __str__(self) -> str: return f"" class S7SZL(Structure): """See §33.1 of System Software for S7-300/400 System and Standard Functions""" _fields_ = [("Header", S7SZLHeader), ("Data", c_ubyte * (0x4000 - 4))] def __str__(self) -> str: return f"" class S7SZLList(Structure): _fields_ = [("Header", S7SZLHeader), ("List", word * (0x4000 - 2))] class S7OrderCode(Structure): _fields_ = [("OrderCode", c_char * 21), ("V1", c_ubyte), ("V2", c_ubyte), ("V3", c_ubyte)] class S7CpInfo(Structure): """ S7 Cp class for Communication Information : - MaxPduLength => Size of the maximum PDU length in bytes - MaxConnections => Max connection allowed to S7-CPU or Server - MaxMpiRate => MPI rate (MPI use is deprecated) - MaxBusRate => Profibus rate Every data packet exchanged with a PLC must fit within the PDU size, whose is fixed from 240 up to 960 bytes. """ _fields_ = [ ("MaxPduLength", c_uint16), ("MaxConnections", c_uint16), ("MaxMpiRate", c_uint16), ("MaxBusRate", c_uint16), ] def __str__(self) -> str: return ( f"" ) class S7Protection(Structure): """See §33.19 of System Software for S7-300/400 System and Standard Functions""" _fields_ = [ ("sch_schal", word), ("sch_par", word), ("sch_rel", word), ("bart_sch", word), ("anl_sch", word), ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7910254 python_snap7-3.0.0/snap7/util/0000755000175100017510000000000015156465130015624 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/util/__init__.py0000644000175100017510000000310615156465122017736 0ustar00runnerrunnerfrom .setters import ( set_bool, set_fstring, set_string, set_wstring, set_real, set_dword, set_udint, set_dint, set_uint, set_int, set_word, set_byte, set_char, set_wchar, set_usint, set_sint, set_time, set_lreal, set_lword, set_date, set_tod, set_dtl, set_dt, ) from .getters import ( get_bool, get_fstring, get_string, get_wstring, get_real, get_dword, get_udint, get_dint, get_uint, get_int, get_word, get_byte, get_s5time, get_dt, get_usint, get_sint, get_time, get_date, get_tod, get_lreal, get_lword, get_ltime, get_ltod, get_ldt, get_char, get_wchar, get_dtl, ) __all__ = [ "get_bool", "get_real", "get_dword", "get_udint", "get_dint", "get_uint", "get_int", "get_word", "get_byte", "get_usint", "get_sint", "get_time", "get_date", "get_tod", "get_lreal", "get_lword", "get_ltime", "get_ltod", "get_ldt", "get_char", "get_wchar", "get_dtl", "get_s5time", "get_dt", "get_fstring", "get_string", "get_wstring", "set_real", "set_dword", "set_date", "set_lreal", "set_lword", "set_udint", "set_dint", "set_uint", "set_int", "set_word", "set_byte", "set_char", "set_wchar", "set_usint", "set_sint", "set_time", "set_bool", "set_fstring", "set_string", "set_wstring", "set_tod", "set_dtl", "set_dt", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/util/db.py0000644000175100017510000006460515156465122016577 0ustar00runnerrunner""" This module contains utility functions for working with PLC DB objects. There are functions to work with the raw bytearray data snap7 functions return In order to work with this data you need to make python able to work with the PLC bytearray data. For example code see test_util.py and example.py in the example folder. example:: spec/DB layout # Byte index Variable name Datatype layout=\"\"\" 4 ID INT 6 NAME STRING[6] 12.0 test_bool1 BOOL 12.1 test_bool2 BOOL 12.2 test_bool3 BOOL 12.3 test_bool4 BOOL 12.4 test_bool5 BOOL 12.5 test_bool6 BOOL 12.6 test_bool7 BOOL 12.7 test_bool8 BOOL 13 testReal REAL 17 testDword DWORD \"\"\" client = snap7.client.Client() client.connect('192.168.200.24', 0, 3) # this looks confusing but this means uploading from the PLC to YOU # so downloading in the PC world :) all_data = client.upload(db_number) simple: from snap7 import DB db1 = DB( db_number, # the db we use all_data, # bytearray from the plc layout, # layout specification DB variable data # A DB specification is the specification of a # DB object in the PLC you can find it using # the dataview option on a DB object in PCS7 17+2, # size of the specification 17 is start # of last value # which is a DWORD which is 2 bytes, 1, # number of row's / specifications id_field='ID', # field we can use to identify a row. # default index is used layout_offset=4, # sometimes specification does not start a 0 # like in our example db_offset=0 # At which point in 'all_data' should we start # reading. This could be that the specification # does not start at 0 ) Now we can use db1 in python as a dict. if 'ID' contains the 'test' we can identify the 'test' row in the all_data bytearray To test of you layout matches the data from the plc you can just print db1[0] or db['test'] in the example db1['test']['test_bool1'] = 0 If we do not specify an id_field this should work to read out the same data. db1[0]['test_bool1'] to read and write a single Row from the plc. takes like 5ms! db1['test'].write() db1['test'].read(client) """ import re from logging import getLogger from datetime import datetime, date, timedelta from typing import Any, Optional, Union, Iterator, Tuple, Dict, Callable from snap7 import Client from snap7.type import Area, ValueType from snap7.util import ( set_bool, set_fstring, set_string, set_wstring, set_real, set_dword, set_udint, set_dint, set_uint, set_int, set_word, set_byte, set_char, set_wchar, set_usint, set_sint, set_time, set_lreal, set_date, set_tod, set_dtl, set_dt, get_bool, get_fstring, get_string, get_wstring, get_real, get_dword, get_udint, get_dint, get_uint, get_int, get_word, get_byte, get_s5time, get_dt, get_usint, get_sint, get_time, get_date, get_tod, get_lreal, get_char, get_wchar, get_dtl, ) logger = getLogger(__name__) def parse_specification(db_specification: str) -> Dict[str, Any]: """Create a db specification derived from a dataview of a db in which the byte layout is specified Args: db_specification: string formatted table with the indexes, aliases and types. Returns: Parsed DB specification. """ parsed_db_specification = {} pattern = r""" (?P\d+(\.\d+)?)\s+ # Match integer or decimal index (?P.*?)\s+ # Non-greedy match for variable name (?P<_type>\S+)$ # Match type at end of line """ regex = re.compile(pattern, re.VERBOSE) for line in db_specification.split("\n"): if line and not line.lstrip().startswith("#"): match = regex.match(line.strip()) if match: index = match.group("index") var_name = match.group("var_name") _type = match.group("_type") var_name = var_name.strip() parsed_db_specification[var_name] = (index, _type) return parsed_db_specification def print_row(data: bytearray) -> None: """print a single db row in chr and str""" index_line = "" pri_line1 = "" chr_line2 = "" matcher = re.compile("[a-zA-Z0-9 ]") for i, xi in enumerate(data): # index if not i % 5: diff = len(pri_line1) - len(index_line) index_line += diff * " " index_line += str(i) # i = i + (ws - len(i)) * ' ' + ',' # byte array line str_v = str(xi) pri_line1 += str(xi) + "," # char line c = chr(xi) c = c if matcher.match(c) else " " # align white space w = len(str_v) c = c + (w - 1) * " " + "," chr_line2 += c logger.info(index_line) logger.info(pri_line1) logger.info(chr_line2) class DB: """ Manage a DB bytearray block given a specification of the Layout. It is possible to have many repetitive instances of a specification this is called a "row". Probably most usecases there is just one row Note: This class has some of the semantics of a dict. In particular, the membership operators (``in``, ``not it``), the access operator (``[]``), as well as the :func:`~DB.keys()` and :func:`~DB.items()` methods work as usual. Iteration, on the other hand, happens on items instead of keys (much like :func:`~DB.items()` method). Attributes: bytearray_: buffer data from the PLC. specification: layout of the DB Rows. row_size: bytes size of a db row. layout_offset: at which byte in the row specificaion we start reading the data. db_offset: at which byte in the db starts reading. Examples: >>> db = DB() >>> db[0]['test_bool1'] = "test" >>> db.write(Client()) # puts data in plc """ bytearray_: Optional[bytearray] = None # data from plc specification: str # layout of db rows id_field: Optional[str] = None # ID field of the rows row_size: int = 0 # bytes size of a db row layout_offset: int = 0 # at which byte in row specification should db_offset: int = 0 # at which byte in db should we start reading? # first fields could be status data. # and only the last part could be control data # now you can be sure you will never overwrite # critical parts of db def __init__( self, db_number: int, bytearray_: bytearray, specification: str, row_size: int, size: int, id_field: Optional[str] = None, db_offset: int = 0, layout_offset: int = 0, row_offset: int = 0, area: Area = Area.DB, ): """Creates a new instance of the `Row` class. Args: db_number: number of the DB to read from. This value should be 0 if area!=Areas.DB. bytearray_: initial buffer read from the PLC. specification: layout of the PLC memory. row_size: bytes size of a db row. size: length of the memory area. id_field: name to reference the row. Optional. db_offset: at which byte in the db starts reading. layout_offset: at which byte in the row specification we start reading the data. row_offset: offset between rows. area: which memory area this row is representing. """ self.db_number = db_number self.size = size self.row_size = row_size self.id_field = id_field self.area = area self.db_offset = db_offset self.layout_offset = layout_offset self.row_offset = row_offset self._bytearray = bytearray_ self.specification = specification # loop over bytearray. make rowObjects # store index of id_field to row objects self.index: Dict[str, Row] = {} self.make_rows() def make_rows(self) -> None: """Make each row for the DB.""" id_field = self.id_field row_size = self.row_size specification = self.specification layout_offset = self.layout_offset row_offset = self.row_offset for i in range(self.size): # calculate where row in bytearray starts db_offset = i * (row_size + row_offset) + self.db_offset # create a row object row = Row( self, specification, row_size=row_size, db_offset=db_offset, layout_offset=layout_offset, row_offset=self.row_offset, area=self.area, ) # store row object key = row[id_field] if id_field else i if key and key in self.index: msg = f"{key} not unique!" logger.error(msg) self.index[str(key)] = row def __getitem__(self, key: str, default: Optional[None] = None) -> Union[None, "Row"]: """Access a row of the table through its index. Rows (values) are of type :class:`DB_Row`. Notes: This method has the same semantics as :class:`dict` access. """ return self.index.get(key, default) def __iter__(self) -> Iterator[Tuple[str, Any]]: """Iterate over the items contained in the table, in the physical order they are contained in memory. Notes: This method does not have the same semantics as :class:`dict` iteration. Instead, it has the same semantics as the :func:`~DB.items` method, yielding ``(index, row)`` tuples. """ yield from self.index.items() def __len__(self) -> int: """Return the number of rows contained in the DB. Notes: If more than one row has the same index value, it is only counted once. """ return len(self.index) def __contains__(self, key: str) -> bool: """Return whether the given key is the index of a row in the DB.""" return key in self.index def keys(self) -> Iterator[str]: """Return a *view object* of the keys that are used as indices for the rows in the DB. """ yield from self.index.keys() def items(self) -> Iterator[Tuple[str, Any]]: """Return a *view object* of the items (``(index, row)`` pairs) that are used as indices for the rows in the DB. """ yield from self.index.items() def export(self) -> Dict[str, Any]: """Export the object to a dict, where each item in the dictionary has an index as the key, and the value of the DB row associated with that index as a value, represented itself as a :class:`dict` (as returned by :func:`DB_Row.export`). The outer dictionary contains the rows in the physical order they are contained in memory. Notes: This function effectively returns a snapshot of the DB. """ ret = {} for k, v in self.items(): ret[k] = v.export() return ret def set_data(self, bytearray_: bytearray) -> None: """Set the new buffer data from the PLC to the current instance. Args: bytearray_: buffer to save. Raises: :obj:`TypeError`: if `bytearray_` is not an instance of :obj:`bytearray` """ if not isinstance(bytearray_, bytearray): raise TypeError(f"Value bytearray_: {bytearray_} is not from type bytearray") self._bytearray = bytearray_ def read(self, client: Client) -> None: """Reads all the rows from the PLC to the :obj:`bytearray` of this instance. Args: client: :obj:`Client` snap7 instance. Raises: :obj:`ValueError`: if the `row_size` is less than 0. """ if self.row_size < 0: raise ValueError("row_size must be greater equal zero.") total_size = self.size * (self.row_size + self.row_offset) if self.area == Area.DB: # note: is it worth using the upload method? bytearray_ = client.db_read(self.db_number, self.db_offset, total_size) else: bytearray_ = client.read_area(self.area, 0, self.db_offset, total_size) # replace data in bytearray for i, b in enumerate(bytearray_): self._bytearray[i + self.db_offset] = b self.index.clear() self.make_rows() def write(self, client: Client) -> None: """Writes all the rows from the :obj:`bytearray` of this instance to the PLC Notes: When the row_offset property has been set to something other than None while constructing this object, this operation is not guaranteed to be atomic. Args: client: :obj:`Client` snap7 instance. Raises: :obj:`ValueError`: if the `row_size` is less than 0. """ if self.row_size < 0: raise ValueError("row_size must be greater equal zero.") # special case: we have a row offset, so we must write each row individually # this is because we don't want to change the data before the offset if self.row_offset: for _, v in self.index.items(): v.write(client) return total_size = self.size * (self.row_size + self.row_offset) data = self._bytearray[self.db_offset : self.db_offset + total_size] if self.area == Area.DB: client.db_write(self.db_number, self.db_offset, data) else: client.write_area(self.area, 0, self.db_offset, data) def get_bytearray(self) -> bytearray: return self._bytearray class Row: """ Provide ROW API for DB bytearray Attributes: bytearray_: reference to the data of the parent DB. _specification: row specification layout. """ bytearray_: bytearray # data of reference to parent DB _specification: Dict[str, Any] = {} # row specification def __init__( self, bytearray_: Union[bytearray, "DB"], _specification: str, row_size: int = 0, db_offset: int = 0, layout_offset: int = 0, row_offset: Optional[int] = 0, area: Area = Area.DB, ): """Creates a new instance of the `DB_Row` class. Args: bytearray_: reference to the data of the parent DB. _specification: row specification layout. row_size: Amount of bytes of the row. db_offset: at which byte in the db starts reading. layout_offset: at which byte in the row specification we start reading the data. row_offset: offset between rows. area: which memory area this row is representing. Raises: :obj:`TypeError`: if `bytearray_` is not an instance of :obj:`bytearray` or :obj:`DB`. """ self.db_offset = db_offset # start point of row data in db self.layout_offset = layout_offset # start point of row data in layout self.row_size = row_size # length of the read self.row_offset = row_offset # start of writable part of row self.area = area if not isinstance(bytearray_, (bytearray, DB)): raise TypeError(f"Value bytearray_ {bytearray_} is not from type (bytearray, DB)") self._bytearray = bytearray_ self._specification = parse_specification(_specification) def get_bytearray(self) -> bytearray: """Gets bytearray from self or DB parent Returns: Buffer data corresponding to the row. """ if isinstance(self._bytearray, DB): return self._bytearray.get_bytearray() return self._bytearray def export(self) -> Dict[str, Union[str, int, float, bool, datetime]]: """Export dictionary with values Returns: dictionary containing the values of each value of the row. """ return {key: self[key] for key in self._specification} def __getitem__(self, key: str) -> Any: """ Get a specific db field """ index, _type = self._specification[key] return self.get_value(index, _type) def __setitem__(self, key: str, value: Any) -> None: index, _type = self._specification[key] self.set_value(index, _type, value) def __repr__(self) -> str: string = "" for var_name, (index, _type) in self._specification.items(): string = f"{string}\n{var_name:<20} {self.get_value(index, _type)!r:<10}" return string def unchanged(self, bytearray_: bytearray) -> bool: """Checks if the bytearray is the same Args: bytearray_: buffer of data to check. Returns: True if the current `bytearray_` is equal to the new one. Otherwise, this is False. """ return self.get_bytearray() == bytearray_ def get_offset(self, byte_index: Union[str, int]) -> int: """Calculate correct beginning position for a row the db_offset = row_size * index Args: byte_index: byte index from where to start reading from. Returns: Amount of bytes to ignore. """ # add float typ to avoid error because of # the variable address with decimal point(like 0.0 or 4.0) return int(float(byte_index)) - self.layout_offset + self.db_offset def get_value(self, byte_index: Union[str, int], type_: str) -> ValueType: """Gets the value for a specific type. Args: byte_index: byte index from where start reading. type_: type of data to read. Raises: :obj:`ValueError`: if reading a `string` when checking the length of the string. :obj:`ValueError`: if the `type_` is not handled. Returns: Value read according to the `type_` """ bytearray_ = self.get_bytearray() # set parsing non case-sensitive type_ = type_.upper() if type_ == "BOOL": byte_index, bool_index = str(byte_index).split(".") return get_bool(bytearray_, self.get_offset(byte_index), int(bool_index)) # remove 4 from byte index since # first 4 bytes are used by db byte_index = self.get_offset(byte_index) if type_.startswith("FSTRING"): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") return get_fstring(bytearray_, byte_index, int(max_size[0])) elif type_.startswith("STRING"): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") return get_string(bytearray_, byte_index) elif type_.startswith("WSTRING"): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") return get_wstring(bytearray_, byte_index) else: type_to_func: Dict[str, Callable[[bytearray, int], ValueType]] = { "REAL": get_real, "DWORD": get_dword, "UDINT": get_udint, "DINT": get_dint, "UINT": get_uint, "INT": get_int, "WORD": get_word, "BYTE": get_byte, "S5TIME": get_s5time, "DATE_AND_TIME": get_dt, "USINT": get_usint, "SINT": get_sint, "TIME": get_time, "DATE": get_date, "TIME_OF_DAY": get_tod, "LREAL": get_lreal, "TOD": get_tod, "CHAR": get_char, "WCHAR": get_wchar, "DTL": get_dtl, } if type_ in type_to_func: return type_to_func[type_](bytearray_, byte_index) raise ValueError def set_value(self, byte_index: Union[str, int], type_: str, value: Union[bool, str, float]) -> Optional[bytearray]: """Sets the value for a specific type in the specified byte index. Args: byte_index: byte index to start writing to. type_: type of value to write. value: value to write. Raises: :obj:`ValueError`: if reading a `string` when checking the length of the string. :obj:`ValueError`: if the `type_` is not handled. Returns: Buffer data with the value written. Optional. """ bytearray_ = self.get_bytearray() if type_ == "BOOL" and isinstance(value, bool): byte_index, bool_index = str(byte_index).split(".") return set_bool(bytearray_, self.get_offset(byte_index), int(bool_index), value) byte_index = self.get_offset(byte_index) if type_.startswith("FSTRING") and isinstance(value, str): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") max_size_grouped = max_size.group(0) max_size_int = int(max_size_grouped) set_fstring(bytearray_, byte_index, value, max_size_int) return None if type_.startswith("STRING") and isinstance(value, str): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") max_size_grouped = max_size.group(0) max_size_int = int(max_size_grouped) set_string(bytearray_, byte_index, value, max_size_int) return None if type_.startswith("WSTRING") and isinstance(value, str): max_size = re.search(r"\d+", type_) if max_size is None: raise ValueError("Max size could not be determinate. re.search() returned None") max_size_int = int(max_size.group(0)) set_wstring(bytearray_, byte_index, value, max_size_int) return None if type_ == "REAL": return set_real(bytearray_, byte_index, value) if type_ == "LREAL" and isinstance(value, float): return set_lreal(bytearray_, byte_index, value) if type_ == "CHAR" and isinstance(value, str): return set_char(bytearray_, byte_index, value) if type_ == "WCHAR" and isinstance(value, str): return set_wchar(bytearray_, byte_index, value) if isinstance(value, int): type_to_func = { "DWORD": set_dword, "UDINT": set_udint, "DINT": set_dint, "UINT": set_uint, "INT": set_int, "WORD": set_word, "BYTE": set_byte, "USINT": set_usint, "SINT": set_sint, } if type_ in type_to_func: return type_to_func[type_](bytearray_, byte_index, value) if type_ == "TIME" and isinstance(value, str): return set_time(bytearray_, byte_index, value) if type_ == "DATE" and isinstance(value, date): return set_date(bytearray_, byte_index, value) if type_ in ("TIME_OF_DAY", "TOD") and isinstance(value, timedelta): return set_tod(bytearray_, byte_index, value) if type_ == "DTL" and isinstance(value, datetime): return set_dtl(bytearray_, byte_index, value) if type_ == "DATE_AND_TIME" and isinstance(value, datetime): return set_dt(bytearray_, byte_index, value) raise ValueError def write(self, client: Client) -> None: """Write current data to db in plc Args: client: :obj:`Client` snap7 instance. Raises: :obj:`TypeError`: if the `_bytearray` is not an instance of :obj:`DB` class. :obj:`ValueError`: if the `row_size` is less than 0. """ if not isinstance(self._bytearray, DB): raise TypeError(f"Value self._bytearray: {self._bytearray} is not from type DB.") if self.row_size < 0: raise ValueError("row_size must be greater equal zero.") db_nr = self._bytearray.db_number offset = self.db_offset data = self.get_bytearray()[offset : offset + self.row_size] db_offset = self.db_offset # indicate start of write only area of row! if self.row_offset: data = data[self.row_offset :] db_offset += self.row_offset if self.area == Area.DB: client.db_write(db_nr, db_offset, data) else: client.write_area(self.area, 0, db_offset, data) def read(self, client: Client) -> None: """Read current data of db row from plc. Args: client: :obj:`Client` snap7 instance. Raises: :obj:`TypeError`: if the `_bytearray` is not an instance of :obj:`DB` class. :obj:`ValueError`: if the `row_size` is less than 0. """ if not isinstance(self._bytearray, DB): raise TypeError(f"Value self._bytearray:{self._bytearray} is not from type DB.") if self.row_size < 0: raise ValueError("row_size must be greater equal zero.") db_nr = self._bytearray.db_number if self.area == Area.DB: bytearray_ = client.db_read(db_nr, self.db_offset, self.row_size) else: bytearray_ = client.read_area(self.area, 0, self.db_offset, self.row_size) data = self.get_bytearray() # replace data in bytearray for i, b in enumerate(bytearray_): data[i + self.db_offset] = b # backwards compatible alias DB_Row = Row ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/util/getters.py0000644000175100017510000005717515156465122017673 0ustar00runnerrunnerimport struct from datetime import timedelta, datetime, date from typing import NoReturn from logging import getLogger logger = getLogger(__name__) def get_bool(bytearray_: bytearray, byte_index: int, bool_index: int) -> bool: """Get the boolean value from location in bytearray Args: bytearray_: buffer data. byte_index: byte index to read from. bool_index: bit index to read from. Returns: True if the bit is 1, else 0. Examples: >>> buffer = bytearray([0b00000001]) # Only one byte length >>> get_bool(buffer, 0, 0) # The bit 0 starts at the right. True """ index_value = 1 << bool_index byte_value = bytearray_[byte_index] current_value = byte_value & index_value return current_value == index_value def get_byte(bytearray_: bytearray, byte_index: int) -> bytes: """Get byte value from bytearray. Notes: WORD 8bit 1bytes Decimal number unsigned B#(0) to B#(255) => 0 to 255 Args: bytearray_: buffer to be read from. byte_index: byte index to be read. Returns: value get from the byte index. """ data = bytearray_[byte_index : byte_index + 1] data[0] = data[0] & 0xFF packed = struct.pack("B", *data) value: bytes = struct.unpack("B", packed)[0] return value def get_word(bytearray_: bytearray, byte_index: int) -> bytearray: """Get word value from bytearray. Notes: WORD 16bit 2bytes Decimal number unsigned B#(0,0) to B#(255,255) => 0 to 65535 Args: bytearray_: buffer to get the word from. byte_index: byte index from where start reading from. Returns: Word value. Examples: >>> get_word(bytearray([0, 100]), 0) 100 """ data = bytearray_[byte_index : byte_index + 2] data[1] = data[1] & 0xFF data[0] = data[0] & 0xFF packed = struct.pack("2B", *data) value: bytearray = struct.unpack(">H", packed)[0] return value def get_int(bytearray_: bytearray, byte_index: int) -> int: """Get int value from bytearray. Notes: Datatype `int` in the PLC is represented in two bytes Args: bytearray_: buffer to read from. byte_index: byte index to start reading from. Returns: Value read. Examples: >>> get_int(bytearray([0, 255]), 0) 255 """ data = bytearray_[byte_index : byte_index + 2] data[1] = data[1] & 0xFF data[0] = data[0] & 0xFF packed = struct.pack("2B", *data) value: int = struct.unpack(">h", packed)[0] return value def get_uint(bytearray_: bytearray, byte_index: int) -> int: """Get unsigned int value from bytearray. Notes: Datatype `uint` in the PLC is represented in two bytes Maximum posible value is 65535. Lower posible value is 0. Args: bytearray_: buffer to read from. byte_index: byte index to start reading from. Returns: Value read. Examples: >>> data = bytearray([255, 255]) >>> get_uint(data, 0) 65535 """ return int(get_word(bytearray_, byte_index)) def get_real(bytearray_: bytearray, byte_index: int) -> float: """Get real value. Notes: Datatype `real` is represented in 4 bytes in the PLC. The packed representation uses the `IEEE 754 binary32`. Args: bytearray_: buffer to read from. byte_index: byte index to reading from. Returns: Real value. Examples: >>> data = bytearray(b'B\\xf6\\xa4Z') >>> get_real(data, 0) 123.32099914550781 """ x = bytearray_[byte_index : byte_index + 4] real: float = struct.unpack(">f", struct.pack("4B", *x))[0] return real def get_fstring(bytearray_: bytearray, byte_index: int, max_length: int, remove_padding: bool = True) -> str: """Parse space-padded fixed-length string from bytearray Notes: This function supports fixed-length ASCII strings, right-padded with spaces. Args: bytearray_: buffer from where to get the string. byte_index: byte index from where to start reading. max_length: the maximum length of the string. remove_padding: whether to remove the right-padding. Returns: String value. Examples: >>> data = [ord(letter) for letter in "hello world "] >>> get_fstring(data, 0, 15) 'hello world' >>> get_fstring(data, 0, 15, remove_padding=False) 'hello world ' """ data = map(chr, bytearray_[byte_index : byte_index + max_length]) string = "".join(data) if remove_padding: return string.rstrip(" ") else: return string def get_string(bytearray_: bytearray, byte_index: int) -> str: """Parse string from bytearray Notes: The first byte of the buffer will contain the max size posible for a string. The second byte contains the length of the string that contains. Args: bytearray_: buffer from where to get the string. byte_index: byte index from where to start reading. Returns: String value. Examples: >>> data = bytearray([254, len("hello world")] + [ord(l) for letter in "hello world"]) >>> get_string(data, 0) 'hello world' """ str_length = int(bytearray_[byte_index + 1]) max_string_size = int(bytearray_[byte_index]) if str_length > max_string_size or max_string_size > 254: logger.error("The string is too big for the size encountered in specification") logger.error("WRONG SIZED STRING ENCOUNTERED") raise TypeError( "String contains {str_length} chars, but max. {max_string_size} chars are expected or is " "larger than 254. Bytearray doesn't seem to be a valid string." ) data = map(chr, bytearray_[byte_index + 2 : byte_index + 2 + str_length]) return "".join(data) def get_dword(bytearray_: bytearray, byte_index: int) -> int: """Gets the dword from the buffer. Notes: Datatype `dword` consists in 8 bytes in the PLC. The maximum value posible is `4294967295` Args: bytearray_: buffer to read. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> data = bytearray(8) >>> data[:] = b"\\x12\\x34\\xAB\\xCD" >>> get_dword(data, 0) 4294967295 """ data = bytearray_[byte_index : byte_index + 4] dword: int = struct.unpack(">I", struct.pack("4B", *data))[0] return dword def get_dint(bytearray_: bytearray, byte_index: int) -> int: """Get dint value from bytearray. Notes: Datatype `dint` consists in 4 bytes in the PLC. Maximum possible value is 2147483647. Lower posible value is -2147483648. Args: bytearray_: buffer to read. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> import struct >>> data = bytearray(4) >>> data[:] = struct.pack(">i", 2147483647) >>> get_dint(data, 0) 2147483647 """ data = bytearray_[byte_index : byte_index + 4] dint: int = struct.unpack(">i", struct.pack("4B", *data))[0] return dint def get_udint(bytearray_: bytearray, byte_index: int) -> int: """Get unsigned dint value from bytearray. Notes: Datatype `udint` consists in 4 bytes in the PLC. Maximum possible value is 4294967295. Minimum posible value is 0. Args: bytearray_: buffer to read. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> import struct >>> data = bytearray(4) >>> data[:] = struct.pack(">I", 4294967295) >>> get_udint(data, 0) 4294967295 """ data = bytearray_[byte_index : byte_index + 4] dint: int = struct.unpack(">I", struct.pack("4B", *data))[0] return dint def get_s5time(bytearray_: bytearray, byte_index: int) -> str: micro_to_milli = 1000 data_bytearray = bytearray_[byte_index : byte_index + 2] s5time_data_int_like = list(data_bytearray.hex()) if s5time_data_int_like[0] == "0": # 10ms time_base = 10 elif s5time_data_int_like[0] == "1": # 100ms time_base = 100 elif s5time_data_int_like[0] == "2": # 1s time_base = 1000 elif s5time_data_int_like[0] == "3": # 10s time_base = 10000 else: raise ValueError("This value should not be greater than 3") s5time_bcd = int(s5time_data_int_like[1]) * 100 + int(s5time_data_int_like[2]) * 10 + int(s5time_data_int_like[3]) s5time_microseconds = time_base * s5time_bcd s5time = timedelta(microseconds=s5time_microseconds * micro_to_milli) # here we must return a string like variable, otherwise nothing will return return "".join(str(s5time)) def get_dt(bytearray_: bytearray, byte_index: int) -> str: """Get DATE_AND_TIME Value from bytearray as ISO 8601 formatted Date String Notes: Datatype `DATE_AND_TIME` consists in 8 bytes in the PLC. Args: bytearray_: buffer to read. byte_index: byte index from where to start writing. Examples: >>> data = bytearray(8) >>> data[:] = [32, 7, 18, 23, 50, 2, 133, 65] #'2020-07-12T17:32:02.854000' >>> get_dt(data,0) '2020-07-12T17:32:02.854000' """ return get_date_time_object(bytearray_, byte_index).isoformat(timespec="microseconds") def get_date_time_object(bytearray_: bytearray, byte_index: int) -> datetime: """Get DATE_AND_TIME Value from bytearray as python datetime object Notes: Datatype `DATE_AND_TIME` consists in 8 bytes in the PLC. Args: bytearray_: buffer to read. byte_index: byte index from where to start writing. Examples: >>> data = bytearray(8) >>> data[:] = [32, 7, 18, 23, 50, 2, 133, 65] #date '2020-07-12 17:32:02.854' >>> get_date_time_object(data,0) datetime.datetime(2020, 7, 12, 17, 32, 2, 854000) """ def bcd_to_byte(byte: int) -> int: return (byte >> 4) * 10 + (byte & 0xF) year = bcd_to_byte(bytearray_[byte_index]) # between 1990 and 2089, only last two digits are saved in DB 90 - 89 year = 2000 + year if year < 90 else 1900 + year month = bcd_to_byte(bytearray_[byte_index + 1]) day = bcd_to_byte(bytearray_[byte_index + 2]) hour = bcd_to_byte(bytearray_[byte_index + 3]) min_ = bcd_to_byte(bytearray_[byte_index + 4]) sec = bcd_to_byte(bytearray_[byte_index + 5]) # plc save miliseconds in two bytes with the most signifanct byte used only # in the last byte for microseconds the other for weekday # * 1000 because pythoin datetime needs microseconds not milli microsec = (bcd_to_byte(bytearray_[byte_index + 6]) * 10 + bcd_to_byte(bytearray_[byte_index + 7] >> 4)) * 1000 return datetime(year, month, day, hour, min_, sec, microsec) def get_time(bytearray_: bytearray, byte_index: int) -> str: """Get time value from bytearray. Notes: Datatype `time` consists in 4 bytes in the PLC. Maximum possible value is T#24D_20H_31M_23S_647MS(2147483647). Lower posible value is T#-24D_20H_31M_23S_648MS(-2147483648). Args: bytearray_: buffer to read. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> import struct >>> data = bytearray(4) >>> data[:] = struct.pack(">i", 2147483647) >>> get_time(data, 0) '24:20:31:23:647' """ data_bytearray = bytearray_[byte_index : byte_index + 4] bits = 32 sign = 1 byte_str = data_bytearray.hex() val = int(byte_str, 16) if (val & (1 << (bits - 1))) != 0: sign = -1 # if sign bit is set e.g., 8bit: 128-255 val -= 1 << bits # compute negative value val *= sign milli_seconds = val % 1000 seconds = val // 1000 minutes = seconds // 60 hours = minutes // 60 days = hours // 24 sign_str = "" if sign >= 0 else "-" time_str = f"{sign_str}{days!s}:{hours % 24!s}:{minutes % 60!s}:{seconds % 60!s}.{milli_seconds!s}" return time_str def get_usint(bytearray_: bytearray, byte_index: int) -> int: """Get the unsigned small int from the bytearray Notes: Datatype `usint` (Unsigned small int) consists on 1 byte in the PLC. Maximum posible value is 255. Lower posible value is 0. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> data = bytearray([255]) >>> get_usint(data, 0) 255 """ data = bytearray_[byte_index] & 0xFF packed = struct.pack("B", data) value: int = struct.unpack(">B", packed)[0] return value def get_sint(bytearray_: bytearray, byte_index: int) -> int: """Get the small int Notes: Datatype `sint` (Small int) consists in 1 byte in the PLC. Maximum value posible is 127. Lowest value posible is -128. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> data = bytearray([127]) >>> get_sint(data, 0) 127 """ data = bytearray_[byte_index] packed = struct.pack("B", data) value: int = struct.unpack(">b", packed)[0] return value def get_lint(bytearray_: bytearray, byte_index: int) -> int: """Get the long int THIS VALUE IS NEITHER TESTED NOR VERIFIED BY A REAL PLC AT THE MOMENT Notes: Datatype `lint` (long int) consists in 8 bytes in the PLC. Maximum value posible is +9223372036854775807 Lowest value posible is -9223372036854775808 Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: Value read. Examples: read lint value (here as example 12345) from DB1.10 of a PLC >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=8) >>> get_lint(data, 0) 12345 """ raw_lint = bytearray_[byte_index : byte_index + 8] lint = struct.unpack(">q", struct.pack("8B", *raw_lint))[0] return int(lint) def get_lreal(bytearray_: bytearray, byte_index: int) -> float: """Get the long real Datatype `lreal` (long real) consists in 8 bytes in the PLC. Negative Range: -1.7976931348623158e+308 to -2.2250738585072014e-308 Positive Range: +2.2250738585072014e-308 to +1.7976931348623158e+308 Zero: ±0 Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: The real value. Examples: read lreal value (here as example 12345.12345) from DB1.10 of a PLC >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=8) >>> get_lreal(data, 0) 12345.12345 """ return float(struct.unpack_from(">d", bytearray_, offset=byte_index)[0]) def get_lword(bytearray_: bytearray, byte_index: int) -> int: """Get the long word Notes: Datatype `lword` (long word) consists in 8 bytes in the PLC. Maximum value is 18446744073709551615 (0xFFFFFFFFFFFFFFFF). Minimum value is 0. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: Value read. Examples: >>> data = bytearray(b"\\x00\\x00\\x00\\x00\\x00\\x00\\xAB\\xCD") >>> get_lword(data, 0) 43981 """ data = bytearray_[byte_index : byte_index + 8] lword: int = struct.unpack(">Q", struct.pack("8B", *data))[0] return lword def get_ulint(bytearray_: bytearray, byte_index: int) -> int: """Get ulint value from bytearray. Notes: Datatype `int` in the PLC is represented in 8 bytes Args: bytearray_: buffer to read from. byte_index: byte index to start reading from. Returns: Value read. Examples: Read 8 Bytes raw from DB1.10, where an ulint value is stored. Return Python compatible value. >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=8) >>> get_ulint(data, 0) 12345 """ raw_ulint = bytearray_[byte_index : byte_index + 8] lint: int = struct.unpack(">Q", struct.pack("8B", *raw_ulint))[0] return lint def get_tod(bytearray_: bytearray, byte_index: int) -> timedelta: len_bytearray_ = len(bytearray_) byte_range = byte_index + 4 if len_bytearray_ < byte_range: raise ValueError("Date can't be extracted from bytearray. bytearray_[Index:Index+16] would cause overflow.") time_val = timedelta(milliseconds=int.from_bytes(bytearray_[byte_index:byte_range], byteorder="big")) if time_val.days >= 1: raise ValueError("Time_Of_Date can't be extracted from bytearray. Bytearray contains unexpected values.") return time_val def get_date(bytearray_: bytearray, byte_index: int = 0) -> date: len_bytearray_ = len(bytearray_) byte_range = byte_index + 2 if len_bytearray_ < byte_range: raise ValueError("Date can't be extracted from bytearray. bytearray_[Index:Index+16] would cause overflow.") date_val = date(1990, 1, 1) + timedelta(days=int.from_bytes(bytearray_[byte_index:byte_range], byteorder="big")) if date_val > date(2168, 12, 31): raise ValueError("date_val is higher than specification allows.") return date_val def get_ltime(bytearray_: bytearray, byte_index: int) -> timedelta: """Get LTIME value from bytearray. Notes: Datatype `LTIME` consists of 8 bytes (64-bit signed integer) representing nanoseconds. Used in S7-1500 PLCs. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: timedelta value. Examples: >>> data = bytearray(8) >>> data[:] = b'\\x00\\x00\\x00\\x00\\x3b\\x9a\\xca\\x00' # 1 second in nanoseconds >>> get_ltime(data, 0) datetime.timedelta(seconds=1) """ raw = bytearray_[byte_index : byte_index + 8] nanoseconds: int = struct.unpack(">q", struct.pack("8B", *raw))[0] return timedelta(microseconds=nanoseconds // 1000) def get_ltod(bytearray_: bytearray, byte_index: int) -> timedelta: """Get LTOD (Long Time of Day) value from bytearray. Notes: Datatype `LTOD` consists of 8 bytes (64-bit unsigned integer) representing nanoseconds since midnight. Used in S7-1500 PLCs. Range: 0 to 86399999999999 ns. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: timedelta value representing time of day. """ raw = bytearray_[byte_index : byte_index + 8] nanoseconds: int = struct.unpack(">Q", struct.pack("8B", *raw))[0] result = timedelta(microseconds=nanoseconds // 1000) if result.days >= 1: raise ValueError("LTOD value exceeds 24 hours") return result def get_ldt(bytearray_: bytearray, byte_index: int) -> datetime: """Get LDT (Long Date and Time) value from bytearray. Notes: Datatype `LDT` consists of 8 bytes (64-bit unsigned integer) representing nanoseconds since 1970-01-01 00:00:00 UTC. Used in S7-1500 PLCs. Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. Returns: datetime value. """ raw = bytearray_[byte_index : byte_index + 8] nanoseconds: int = struct.unpack(">Q", struct.pack("8B", *raw))[0] epoch = datetime(1970, 1, 1) return epoch + timedelta(microseconds=nanoseconds // 1000) def get_dtl(bytearray_: bytearray, byte_index: int) -> datetime: time_to_datetime = datetime( year=int.from_bytes(bytearray_[byte_index : byte_index + 2], byteorder="big"), month=int(bytearray_[byte_index + 2]), day=int(bytearray_[byte_index + 3]), hour=int(bytearray_[byte_index + 5]), minute=int(bytearray_[byte_index + 6]), second=int(bytearray_[byte_index + 7]), microsecond=int(bytearray_[byte_index + 8]), ) # --- ? noch nicht genau genug if time_to_datetime > datetime(2554, 12, 31, 23, 59, 59): raise ValueError("date_val is higher than specification allows.") return time_to_datetime def get_char(bytearray_: bytearray, byte_index: int) -> str: """Get char value from bytearray. Notes: Datatype `char` in the PLC is represented in 1 byte. It has to be in ASCII-format. Args: bytearray_: buffer to read from. byte_index: byte index to start reading from. Returns: Value read. Examples: Read 1 Byte raw from DB1.10, where a char value is stored. Return Python compatible value. >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=1) >>> get_char(data, 0) 'C' """ char = chr(bytearray_[byte_index]) return char def get_wchar(bytearray_: bytearray, byte_index: int) -> str: """Get wchar value from bytearray. Datatype `wchar` in the PLC is represented in 2 bytes. It has to be in utf-16-be format. Args: bytearray_: buffer to read from. byte_index: byte index to start reading from. Returns: Value read. Examples: Read 2 Bytes raw from DB1.10, where a wchar value is stored. Return Python compatible value. >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=2) >>> get_wchar(data, 0) 'C' """ if bytearray_[byte_index] == 0: return chr(bytearray_[byte_index + 1]) return bytearray_[byte_index : byte_index + 2].decode("utf-16-be") def get_wstring(bytearray_: bytearray, byte_index: int) -> str: """Parse wstring from bytearray Notes: Byte 0 and 1 contains the max size posible for a string (2 Byte value). byte 2 and 3 contains the length of the string that contains (2 Byte value). The other bytes contain WCHARs (2Byte) in utf-16-be style. Args: bytearray_: buffer from where to get the string. byte_index: byte index from where to start reading. Returns: String value. Examples: Read from DB1.10 22, where the WSTRING is stored, the raw 22 Bytes and convert them to a python string >>> from snap7 import Client >>> data = Client().db_read(db_number=1, start=10, size=22) >>> get_wstring(data, 0) 'hello world' """ # Byte 0 + 1 --> total length of wstring, should be bytearray_ - 4 # Byte 2, 3 --> used length of wstring wstring_start = byte_index + 4 max_wstring_size = bytearray_[byte_index : byte_index + 2] packed = struct.pack("2B", *max_wstring_size) max_wstring_symbols = struct.unpack(">H", packed)[0] * 2 wstr_length_raw = bytearray_[byte_index + 2 : byte_index + 4] wstr_symbols_amount = struct.unpack(">H", struct.pack("2B", *wstr_length_raw))[0] * 2 if wstr_symbols_amount > max_wstring_symbols or max_wstring_symbols > 16382: logger.error("The wstring is too big for the size encountered in specification") logger.error("WRONG SIZED STRING ENCOUNTERED") raise TypeError( f"WString contains {wstr_symbols_amount} chars, but max {max_wstring_symbols} chars are " f"expected or is larger than 16382. Bytearray doesn't seem to be a valid string." ) return bytearray_[wstring_start : wstring_start + wstr_symbols_amount].decode("utf-16-be") def get_array(bytearray_: bytearray, byte_index: int) -> NoReturn: raise NotImplementedError ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/snap7/util/setters.py0000644000175100017510000005521415156465122017677 0ustar00runnerrunnerimport re import struct from datetime import date, datetime, timedelta from typing import Union from .getters import get_bool def set_bool(bytearray_: bytearray, byte_index: int, bool_index: int, value: bool) -> bytearray: """Set boolean value on location in bytearray. Args: bytearray_: buffer to write to. byte_index: byte index to write to. bool_index: bit index to write to. value: value to write. Examples: >>> buffer = bytearray([0b00000000]) >>> set_bool(buffer, 0, 0, True) >>> buffer bytearray(b"\\x01") """ if value not in {0, 1, True, False}: raise TypeError(f"Value value:{value} is not a boolean expression.") current_value = get_bool(bytearray_, byte_index, bool_index) index_value = 1 << bool_index # check if bool already has correct value if current_value == value: return bytearray_ if value: # make sure index_v is IN current byte bytearray_[byte_index] += index_value else: # make sure index_v is NOT in current byte bytearray_[byte_index] -= index_value return bytearray_ def set_byte(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set value in bytearray to byte Args: bytearray_: buffer to write to. byte_index: byte index to write. _int: value to write. Returns: buffer with the written value. Examples: >>> buffer = bytearray([0b00000000]) >>> set_byte(buffer, 0, 255) bytearray(b"\\xFF") """ _int = int(_int) bytearray_[byte_index : byte_index + 1] = struct.pack("B", _int) return bytearray_ def set_word(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set value in bytearray to word Notes: Word datatype is 2 bytes long. Args: bytearray_: buffer to be written. byte_index: byte index to start write from. _int: value to write. Return: buffer with the written value """ _int = int(_int) bytearray_[byte_index : byte_index + 2] = struct.pack(">H", _int) return bytearray_ def set_int(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set value in bytearray to int Notes: An datatype `int` in the PLC consists of two `bytes`. Args: bytearray_: buffer to write on. byte_index: byte index to start writing from. _int: int value to write. Returns: Buffer with the written value. Examples: >>> data = bytearray(2) >>> set_int(data, 0, 255) bytearray(b'\\x00\\xff') """ # make sure were dealing with an int _int = int(_int) bytearray_[byte_index : byte_index + 2] = struct.pack(">h", _int) return bytearray_ def set_uint(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set value in bytearray to unsigned int Notes: An datatype `uint` in the PLC consists of two `bytes`. Args: bytearray_: buffer to write on. byte_index: byte index to start writing from. _int: int value to write. Returns: Buffer with the written value. Examples: >>> from snap7.util import set_uint >>> data = bytearray(2) >>> set_uint(data, 0, 65535) bytearray(b'\\xff\\xff') """ # make sure were dealing with an int _int = int(_int) bytearray_[byte_index : byte_index + 2] = struct.pack(">H", _int) return bytearray_ def set_real(bytearray_: bytearray, byte_index: int, real: Union[bool, str, float, int]) -> bytearray: """Set Real value Notes: Datatype `real` is represented in 4 bytes in the PLC. The packed representation uses the `IEEE 754 binary32`. Args: bytearray_: buffer to write to. byte_index: byte index to start writing from. real: value to be written. Returns: Buffer with the value written. Examples: >>> data = bytearray(4) >>> set_real(data, 0, 123.321) bytearray(b'B\\xf6\\xa4Z') """ bytearray_[byte_index : byte_index + 4] = struct.pack(">f", float(real)) return bytearray_ def set_fstring(bytearray_: bytearray, byte_index: int, value: str, max_length: int) -> bytearray: """Set space-padded fixed-length string value Args: bytearray_: buffer to write to. byte_index: byte index to start writing from. value: string to write. max_length: maximum string length, i.e. the fixed size of the string. Raises: :obj:`TypeError`: if the `value` is not a :obj:`str`. :obj:`ValueError`: if the length of the `value` is larger than the `max_size` or 'value' contains non-ascii characters. Examples: >>> data = bytearray(20) >>> set_fstring(data, 0, "hello world", 15) >>> data bytearray(b'hello world \x00\x00\x00\x00\x00') """ if not value.isascii(): raise ValueError("Value contains non-ascii values.") # FAIL HARD WHEN trying to write too much data into PLC size = len(value) if size > max_length: raise ValueError(f"size {size} > max_length {max_length} {value}") # fill array with chr integers for i, c in enumerate(value): bytearray_[byte_index + i] = ord(c) # fill the rest with empty space for r in range(len(value), max_length): bytearray_[byte_index + r] = ord(" ") return bytearray_ def set_string(bytearray_: bytearray, byte_index: int, value: str, max_size: int = 254) -> bytearray: """Set string value Args: bytearray_: buffer to write to. byte_index: byte index to start writing from. value: string to write. max_size: maximum possible string size, max. 254 as default. Raises: :obj:`TypeError`: if the `value` is not a :obj:`str`. :obj:`ValueError`: if the length of the `value` is larger than the `max_size` or 'max_size' is greater than 254 or 'value' contains ascii characters > 255. Examples: >>> from snap7.util import set_string >>> data = bytearray(20) >>> set_string(data, 0, "hello world", 254) >>> data bytearray(b'\\xff\\x0bhello world\\x00\\x00\\x00\\x00\\x00\\x00\\x00') """ if not isinstance(value, str): raise TypeError(f"Value value:{value} is not from Type string") if max_size > 254: raise ValueError(f"max_size: {max_size} > max. allowed 254 chars") if any(ord(char) < 0 or ord(char) > 255 for char in value): raise ValueError( "Value contains ascii values > 255, which is not compatible with PLC Type STRING. " "Check encoding of value or try set_wstring()." ) size = len(value) # FAIL HARD WHEN trying to write too much data into PLC if size > max_size: raise ValueError(f"size {size} > max_size {max_size} {value}") # set max string size bytearray_[byte_index] = max_size # set len count on first position bytearray_[byte_index + 1] = len(value) # fill array with chr integers for i, c in enumerate(value): bytearray_[byte_index + 2 + i] = ord(c) # fill the rest with empty space for r in range(len(value), bytearray_[byte_index]): bytearray_[byte_index + 2 + r] = ord(" ") return bytearray_ def set_dword(bytearray_: bytearray, byte_index: int, dword: int) -> bytearray: """Set a DWORD to the buffer. Notes: Datatype `dword` consists in 8 bytes in the PLC. The maximum value posible is `4294967295` Args: bytearray_: buffer to write to. byte_index: byte index from where to write. dword: value to write. Examples: >>> data = bytearray(4) >>> set_dword(data,0, 4294967295) >>> data bytearray(b'\\xff\\xff\\xff\\xff') """ dword = int(dword) bytearray_[byte_index : byte_index + 4] = struct.pack(">I", dword) return bytearray_ def set_dint(bytearray_: bytearray, byte_index: int, dint: int) -> bytearray: """Set value in bytearray to dint Notes: Datatype `dint` consists in 4 bytes in the PLC. Maximum possible value is 2147483647. Lower posible value is -2147483648. Args: bytearray_: buffer to write. byte_index: byte index from where to start writing. dint: double integer value Examples: >>> data = bytearray(4) >>> set_dint(data, 0, 2147483647) >>> data bytearray(b'\\x7f\\xff\\xff\\xff') """ dint = int(dint) bytearray_[byte_index : byte_index + 4] = struct.pack(">i", dint) return bytearray_ def set_udint(bytearray_: bytearray, byte_index: int, udint: int) -> bytearray: """Set value in bytearray to unsigned dint Notes: Datatype `dint` consists in 4 bytes in the PLC. Maximum possible value is 4294967295. Minimum posible value is 0. Args: bytearray_: buffer to write. byte_index: byte index from where to start writing. udint: unsigned double integer value Examples: >>> data = bytearray(4) >>> set_udint(data, 0, 4294967295) >>> data bytearray(b'\\xff\\xff\\xff\\xff') """ udint = int(udint) bytearray_[byte_index : byte_index + 4] = struct.pack(">I", udint) return bytearray_ def set_time(bytearray_: bytearray, byte_index: int, time_string: str) -> bytearray: """Set value in bytearray to time Notes: Datatype `time` consists in 4 bytes in the PLC. Maximum possible value is T#24D_20H_31M_23S_647MS(2147483647). Lower posible value is T#-24D_20H_31M_23S_648MS(-2147483648). Args: bytearray_: buffer to write. byte_index: byte index from where to start writing. time_string: time value in string Examples: >>> data = bytearray(4) >>> set_time(data, 0, '-22:3:57:28.192') >>> data bytearray(b'\x8d\xda\xaf\x00') """ sign = 1 if re.fullmatch( r"(-?(2[0-3]|1?\d):(2[0-3]|1?\d|\d):([1-5]?\d):[1-5]?\d.\d{1,3})|" r"(-24:(20|1?\d):(3[0-1]|[0-2]?\d):(2[0-3]|1?\d).(64[0-8]|6[0-3]\d|[0-5]\d{1,2}))|" r"(24:(20|1?\d):(3[0-1]|[0-2]?\d):(2[0-3]|1?\d).(64[0-7]|6[0-3]\d|[0-5]\d{1,2}))", time_string, ): data_list = re.split("[: .]", time_string) days: str = data_list[0] hours: int = int(data_list[1]) minutes: int = int(data_list[2]) seconds: int = int(data_list[3]) milli_seconds: int = int(data_list[4].ljust(3, "0")) if re.match(r"^-\d{1,2}$", days): sign = -1 time_int = ( (int(days) * sign * 3600 * 24 + (hours % 24) * 3600 + (minutes % 60) * 60 + seconds % 60) * 1000 + milli_seconds ) * sign bytes_array = time_int.to_bytes(4, byteorder="big", signed=True) bytearray_[byte_index : byte_index + 4] = bytes_array return bytearray_ else: raise ValueError("time value out of range, please check the value interval") def set_usint(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set unsigned small int Notes: Datatype `usint` (Unsigned small int) consists on 1 byte in the PLC. Maximum posible value is 255. Lower posible value is 0. Args: bytearray_: buffer to write. byte_index: byte index from where to start writing. _int: value to write. Returns: Buffer with the written value. Examples: >>> data = bytearray(1) >>> set_usint(data, 0, 255) bytearray(b'\\xff') """ _int = int(_int) bytearray_[byte_index] = struct.pack(">B", _int)[0] return bytearray_ def set_sint(bytearray_: bytearray, byte_index: int, _int: int) -> bytearray: """Set small int to the buffer. Notes: Datatype `sint` (Small int) consists in 1 byte in the PLC. Maximum value posible is 127. Lowest value posible is -128. Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. _int: value to write. Returns: Buffer with the written value. Examples: >>> data = bytearray(1) >>> set_sint(data, 0, 127) bytearray(b'\\x7f') """ _int = int(_int) bytearray_[byte_index] = struct.pack(">b", _int)[0] return bytearray_ def set_lreal(bytearray_: bytearray, byte_index: int, lreal: float) -> bytearray: """Set the long real Notes: Datatype `lreal` (long real) consists in 8 bytes in the PLC. Negative Range: -1.7976931348623158e+308 to -2.2250738585072014e-308 Positive Range: +2.2250738585072014e-308 to +1.7976931348623158e+308 Zero: ±0 Args: bytearray_: buffer to read from. byte_index: byte index from where to start reading. lreal: float value to set Returns: Value to write. Examples: write lreal value (here as example 12345.12345) to DB1.10 of a PLC >>> data = set_lreal(data, 12345.12345) >>> from snap7 import Client >>> Client().db_write(db_number=1, start=10, data=data) """ lreal = float(lreal) struct.pack_into(">d", bytearray_, byte_index, lreal) return bytearray_ def set_lword(bytearray_: bytearray, byte_index: int, lword: int) -> bytearray: """Set the long word Notes: Datatype `lword` (long word) consists in 8 bytes in the PLC. Maximum value is 18446744073709551615 (0xFFFFFFFFFFFFFFFF). Minimum value is 0. Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. lword: unsigned 64-bit value to write. Returns: Buffer with the written value. Examples: >>> data = bytearray(8) >>> set_lword(data, 0, 0xABCD) >>> data bytearray(b'\\x00\\x00\\x00\\x00\\x00\\x00\\xab\\xcd') """ lword = int(lword) bytearray_[byte_index : byte_index + 8] = struct.pack(">Q", lword) return bytearray_ def set_char(bytearray_: bytearray, byte_index: int, chr_: str) -> bytearray: """Set char value in a bytearray. Notes: Datatype `char` in the PLC is represented in 1 byte. It has to be in ASCII-format Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. chr_: `char` to write. Returns: Buffer with the written value. Examples: write `char` (here as example 'C') to DB1.10 of a PLC >>> data = bytearray(1) >>> set_char(data, 0, 'C') >>> data bytearray('0x43') """ if not isinstance(chr_, str): raise TypeError(f"Value value:{chr_} is not from Type string") if len(chr_) > 1: raise ValueError(f"size chr_ : {chr_} > 1") elif len(chr_) < 1: raise ValueError(f"size chr_ : {chr_} < 1") if 0 <= ord(chr_) <= 255: bytearray_[byte_index] = ord(chr_) return bytearray_ else: raise ValueError(f"chr_ : {chr_} contains ascii value > 255, which is not compatible with PLC Type CHAR.") def set_date(bytearray_: bytearray, byte_index: int, date_: date) -> bytearray: """Set value in bytearray to date Notes: Datatype `date` consists in the number of days elapsed from 1990-01-01. It is stored as an int (2 bytes) in the PLC. Args: bytearray_: buffer to write. byte_index: byte index from where to start writing. date_: date object Examples: >>> data = bytearray(2) >>> set_date(data, 0, date(2024, 3, 27)) >>> data bytearray(b'\x30\xd8') """ if date_ < date(1990, 1, 1): raise ValueError("date is lower than specification allows.") elif date_ > date(2168, 12, 31): raise ValueError("date is higher than specification allows.") _days = (date_ - date(1990, 1, 1)).days bytearray_[byte_index : byte_index + 2] = struct.pack(">h", _days) return bytearray_ def set_wchar(bytearray_: bytearray, byte_index: int, chr_: str) -> bytearray: """Set wchar value in a bytearray. Notes: Datatype `wchar` in the PLC is represented in 2 bytes as UTF-16-BE. Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. chr_: single character to write. Returns: Buffer with the written value. Examples: >>> data = bytearray(2) >>> set_wchar(data, 0, 'C') >>> data bytearray(b'\\x00C') """ if not isinstance(chr_, str): raise TypeError(f"Value value:{chr_} is not from Type string") if len(chr_) != 1: raise ValueError(f"Expected single character, got length {len(chr_)}") encoded = chr_.encode("utf-16-be") bytearray_[byte_index : byte_index + 2] = encoded return bytearray_ def set_wstring(bytearray_: bytearray, byte_index: int, value: str, max_size: int = 16382) -> None: """Set wstring value Notes: Byte 0-1: max size (number of characters, 2-byte big-endian). Byte 2-3: current length (number of characters, 2-byte big-endian). Byte 4+: UTF-16-BE encoded characters (2 bytes each). Args: bytearray_: buffer to write to. byte_index: byte index to start writing from. value: string to write. max_size: maximum number of characters allowed (default 16382). Raises: TypeError: if the value is not a string. ValueError: if the string is too long or max_size exceeds 16382. Examples: >>> data = bytearray(26) >>> set_wstring(data, 0, "hello", 10) """ if not isinstance(value, str): raise TypeError(f"Value value:{value} is not from Type string") if max_size > 16382: raise ValueError(f"max_size: {max_size} > max. allowed 16382 chars") size = len(value) if size > max_size: raise ValueError(f"size {size} > max_size {max_size}") # set max string size (2 bytes, big-endian) bytearray_[byte_index : byte_index + 2] = struct.pack(">H", max_size) # set current length (2 bytes, big-endian) bytearray_[byte_index + 2 : byte_index + 4] = struct.pack(">H", size) # encode and write UTF-16-BE characters encoded = value.encode("utf-16-be") bytearray_[byte_index + 4 : byte_index + 4 + len(encoded)] = encoded def set_tod(bytearray_: bytearray, byte_index: int, tod: timedelta) -> bytearray: """Set TIME_OF_DAY value in bytearray. Notes: Datatype `TIME_OF_DAY` is stored as milliseconds since midnight in 4 bytes. Range: 0 to 86399999 ms (00:00:00.000 to 23:59:59.999). Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. tod: timedelta representing the time of day. Returns: Buffer with the written value. Examples: >>> from datetime import timedelta >>> data = bytearray(4) >>> set_tod(data, 0, timedelta(hours=12, minutes=30, seconds=15, milliseconds=500)) """ if tod.days >= 1 or tod < timedelta(0): raise ValueError("TIME_OF_DAY must be between 00:00:00.000 and 23:59:59.999") ms = int(tod.total_seconds() * 1000) bytearray_[byte_index : byte_index + 4] = ms.to_bytes(4, byteorder="big") return bytearray_ def set_dtl(bytearray_: bytearray, byte_index: int, dt_: datetime) -> bytearray: """Set DTL (Date and Time Long) value in bytearray. Notes: Datatype `DTL` consists of 12 bytes in the PLC: - Bytes 0-1: Year (uint16, big-endian) - Byte 2: Month (1-12) - Byte 3: Day (1-31) - Byte 4: Weekday (1=Sunday, 7=Saturday) - Byte 5: Hour (0-23) - Byte 6: Minute (0-59) - Byte 7: Second (0-59) - Bytes 8-11: Nanoseconds (uint32, big-endian) Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. dt_: datetime object to write. Returns: Buffer with the written value. Examples: >>> from datetime import datetime >>> data = bytearray(12) >>> set_dtl(data, 0, datetime(2024, 3, 27, 14, 30, 0)) """ if dt_ > datetime(2554, 12, 31, 23, 59, 59): raise ValueError("date_val is higher than specification allows.") # Year as 2-byte big-endian bytearray_[byte_index : byte_index + 2] = struct.pack(">H", dt_.year) bytearray_[byte_index + 2] = dt_.month bytearray_[byte_index + 3] = dt_.day # Weekday: isoweekday() returns 1=Monday..7=Sunday, S7 uses 1=Sunday..7=Saturday bytearray_[byte_index + 4] = (dt_.isoweekday() % 7) + 1 bytearray_[byte_index + 5] = dt_.hour bytearray_[byte_index + 6] = dt_.minute bytearray_[byte_index + 7] = dt_.second # Nanoseconds from microseconds nanoseconds = dt_.microsecond * 1000 bytearray_[byte_index + 8 : byte_index + 12] = struct.pack(">I", nanoseconds) return bytearray_ def set_dt(bytearray_: bytearray, byte_index: int, dt_: datetime) -> bytearray: """Set DATE_AND_TIME value in bytearray. Notes: Datatype `DATE_AND_TIME` consists of 8 bytes in BCD encoding: - Byte 0: Year (BCD, 0-99, 90-99 = 1990-1999, 0-89 = 2000-2089) - Byte 1: Month (BCD) - Byte 2: Day (BCD) - Byte 3: Hour (BCD) - Byte 4: Minute (BCD) - Byte 5: Second (BCD) - Byte 6-7: Milliseconds (BCD) + weekday Args: bytearray_: buffer to write to. byte_index: byte index from where to start writing. dt_: datetime object to write. Returns: Buffer with the written value. Examples: >>> from datetime import datetime >>> data = bytearray(8) >>> set_dt(data, 0, datetime(2020, 7, 12, 17, 32, 2, 854000)) """ def byte_to_bcd(val: int) -> int: return ((val // 10) << 4) | (val % 10) year = dt_.year if year < 1990 or year > 2089: raise ValueError("DATE_AND_TIME year must be between 1990 and 2089") year_bcd = year - 2000 if year >= 2000 else year - 1900 bytearray_[byte_index] = byte_to_bcd(year_bcd) bytearray_[byte_index + 1] = byte_to_bcd(dt_.month) bytearray_[byte_index + 2] = byte_to_bcd(dt_.day) bytearray_[byte_index + 3] = byte_to_bcd(dt_.hour) bytearray_[byte_index + 4] = byte_to_bcd(dt_.minute) bytearray_[byte_index + 5] = byte_to_bcd(dt_.second) # Milliseconds: 3 BCD digits in byte 6 and upper nibble of byte 7 ms = dt_.microsecond // 1000 ms_hundreds = ms // 100 ms_tens = (ms % 100) // 10 ms_ones = ms % 10 bytearray_[byte_index + 6] = byte_to_bcd(ms_hundreds * 10 + ms_tens) # Lower nibble of byte 7: weekday (1=Sunday..7=Saturday) weekday = (dt_.isoweekday() % 7) + 1 bytearray_[byte_index + 7] = (ms_ones << 4) | weekday return bytearray_ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1773824599.7929976 python_snap7-3.0.0/tests/0000755000175100017510000000000015156465130014761 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_api_surface.py0000644000175100017510000003675415156465122020673 0ustar00runnerrunner""" API Surface Tests. Verify that the native Python implementation: 1. Exports all expected public symbols 2. Has all expected methods with correct signatures 3. Maps all Snap7 C library functions to Python equivalents """ import inspect import time from ctypes import c_char from typing import Generator, Tuple import pytest import snap7 from snap7 import Client, Server, Partner, Logo from snap7 import Area, Block, WordLen, SrvEvent, SrvArea # ============================================================================= # Snap7 C Function to Python Method Mapping # ============================================================================= # Complete mapping of Snap7 C client functions to Python methods # Based on snap7_libmain.h from the Snap7 C library SNAP7_CLIENT_SYNC_FUNCTIONS = { # Connection functions "Cli_Create": "create", "Cli_Destroy": "destroy", "Cli_Connect": "connect", "Cli_ConnectTo": "connect", # Same method, different C overload "Cli_Disconnect": "disconnect", "Cli_SetConnectionParams": "set_connection_params", "Cli_SetConnectionType": "set_connection_type", "Cli_GetConnected": "get_connected", # Parameter functions "Cli_GetParam": "get_param", "Cli_SetParam": "set_param", # Data I/O functions "Cli_ReadArea": "read_area", "Cli_WriteArea": "write_area", "Cli_ReadMultiVars": "read_multi_vars", "Cli_WriteMultiVars": "write_multi_vars", # Data I/O lean functions "Cli_DBRead": "db_read", "Cli_DBWrite": "db_write", "Cli_MBRead": "mb_read", "Cli_MBWrite": "mb_write", "Cli_EBRead": "eb_read", "Cli_EBWrite": "eb_write", "Cli_ABRead": "ab_read", "Cli_ABWrite": "ab_write", "Cli_TMRead": "tm_read", "Cli_TMWrite": "tm_write", "Cli_CTRead": "ct_read", "Cli_CTWrite": "ct_write", # Directory functions "Cli_ListBlocks": "list_blocks", "Cli_GetAgBlockInfo": "get_block_info", "Cli_GetPgBlockInfo": "get_pg_block_info", "Cli_ListBlocksOfType": "list_blocks_of_type", # Block functions "Cli_Upload": "upload", "Cli_FullUpload": "full_upload", "Cli_Download": "download", "Cli_Delete": "delete", "Cli_DBGet": "db_get", "Cli_DBFill": "db_fill", # Date/Time functions "Cli_GetPlcDateTime": "get_plc_datetime", "Cli_SetPlcDateTime": "set_plc_datetime", "Cli_SetPlcSystemDateTime": "set_plc_system_datetime", # System info functions "Cli_GetOrderCode": "get_order_code", "Cli_GetCpuInfo": "get_cpu_info", "Cli_GetCpInfo": "get_cp_info", "Cli_ReadSZL": "read_szl", "Cli_ReadSZLList": "read_szl_list", # Control functions "Cli_PlcHotStart": "plc_hot_start", "Cli_PlcColdStart": "plc_cold_start", "Cli_PlcStop": "plc_stop", "Cli_CopyRamToRom": "copy_ram_to_rom", "Cli_Compress": "compress", "Cli_GetPlcStatus": "get_cpu_state", # Security functions "Cli_GetProtection": "get_protection", "Cli_SetSessionPassword": "set_session_password", "Cli_ClearSessionPassword": "clear_session_password", # Low level "Cli_IsoExchangeBuffer": "iso_exchange_buffer", # Misc "Cli_GetExecTime": "get_exec_time", "Cli_GetLastError": "get_last_error", "Cli_GetPduLength": "get_pdu_length", "Cli_ErrorText": "error_text", } SNAP7_CLIENT_ASYNC_FUNCTIONS = { "Cli_AsReadArea": "as_read_area", "Cli_AsWriteArea": "as_write_area", "Cli_AsDBRead": "as_db_read", "Cli_AsDBWrite": "as_db_write", "Cli_AsMBRead": "as_mb_read", "Cli_AsMBWrite": "as_mb_write", "Cli_AsEBRead": "as_eb_read", "Cli_AsEBWrite": "as_eb_write", "Cli_AsABRead": "as_ab_read", "Cli_AsABWrite": "as_ab_write", "Cli_AsTMRead": "as_tm_read", "Cli_AsTMWrite": "as_tm_write", "Cli_AsCTRead": "as_ct_read", "Cli_AsCTWrite": "as_ct_write", "Cli_AsListBlocksOfType": "as_list_blocks_of_type", "Cli_AsReadSZL": "as_read_szl", "Cli_AsReadSZLList": "as_read_szl_list", "Cli_AsUpload": "as_upload", "Cli_AsFullUpload": "as_full_upload", "Cli_AsDownload": "as_download", "Cli_AsCopyRamToRom": "as_copy_ram_to_rom", "Cli_AsCompress": "as_compress", "Cli_AsDBGet": "as_db_get", "Cli_AsDBFill": "as_db_fill", "Cli_CheckAsCompletion": "check_as_completion", "Cli_WaitAsCompletion": "wait_as_completion", "Cli_SetAsCallback": "set_as_callback", } SNAP7_SERVER_FUNCTIONS = { "Srv_Create": "create", "Srv_Destroy": "destroy", "Srv_Start": "start", "Srv_StartTo": "start_to", "Srv_Stop": "stop", "Srv_RegisterArea": "register_area", "Srv_UnregisterArea": "unregister_area", "Srv_LockArea": "lock_area", "Srv_UnlockArea": "unlock_area", "Srv_GetParam": "get_param", "Srv_SetParam": "set_param", "Srv_ClearEvents": "clear_events", "Srv_PickEvent": "pick_event", "Srv_GetMask": "get_mask", "Srv_SetMask": "set_mask", "Srv_SetEventsCallback": "set_events_callback", "Srv_SetReadEventsCallback": "set_read_events_callback", "Srv_SetRWAreaCallback": "set_rw_area_callback", "Srv_GetStatus": "get_status", "Srv_SetCpuStatus": "set_cpu_status", "Srv_EventText": "event_text", } SNAP7_PARTNER_FUNCTIONS = { "Par_Create": "create", "Par_Destroy": "destroy", "Par_Start": "start", "Par_StartTo": "start_to", "Par_Stop": "stop", "Par_BSend": "b_send", "Par_BRecv": "b_recv", "Par_AsBSend": "as_b_send", "Par_CheckAsBSendCompletion": "check_as_b_send_completion", "Par_WaitAsBSendCompletion": "wait_as_b_send_completion", "Par_CheckAsBRecvCompletion": "check_as_b_recv_completion", "Par_GetParam": "get_param", "Par_SetParam": "set_param", "Par_GetTimes": "get_times", "Par_GetStats": "get_stats", "Par_GetLastError": "get_last_error", "Par_GetStatus": "get_status", } # ============================================================================= # Public Export Tests # ============================================================================= class TestPublicExports: """Verify __init__.py exports match expected public API.""" def test_client_exported(self) -> None: """Client class is exported from snap7.""" assert hasattr(snap7, "Client") assert snap7.Client is Client def test_server_exported(self) -> None: """Server class is exported from snap7.""" assert hasattr(snap7, "Server") assert snap7.Server is Server def test_partner_exported(self) -> None: """Partner class is exported from snap7.""" assert hasattr(snap7, "Partner") assert snap7.Partner is Partner def test_logo_exported(self) -> None: """Logo class is exported from snap7.""" assert hasattr(snap7, "Logo") assert snap7.Logo is Logo def test_enums_exported(self) -> None: """Enums are exported from snap7.""" assert hasattr(snap7, "Area") and snap7.Area is Area assert hasattr(snap7, "Block") and snap7.Block is Block assert hasattr(snap7, "WordLen") and snap7.WordLen is WordLen assert hasattr(snap7, "SrvEvent") and snap7.SrvEvent is SrvEvent assert hasattr(snap7, "SrvArea") and snap7.SrvArea is SrvArea def test_util_classes_exported(self) -> None: """Utility classes are exported from snap7.""" assert hasattr(snap7, "Row") assert hasattr(snap7, "DB") # ============================================================================= # C Function Mapping Tests # ============================================================================= class TestClientSyncFunctions: """Verify all Snap7 C client sync functions have Python equivalents.""" @pytest.mark.parametrize("c_func,py_method", SNAP7_CLIENT_SYNC_FUNCTIONS.items()) def test_method_exists(self, c_func: str, py_method: str) -> None: """Each Snap7 C sync function has a corresponding Python method.""" assert hasattr(Client, py_method), f"Client missing {py_method} for {c_func}" class TestClientAsyncFunctions: """Verify all Snap7 C client async functions have Python equivalents.""" @pytest.mark.parametrize("c_func,py_method", SNAP7_CLIENT_ASYNC_FUNCTIONS.items()) def test_method_exists(self, c_func: str, py_method: str) -> None: """Each Snap7 C async function has a corresponding Python method.""" assert hasattr(Client, py_method), f"Client missing {py_method} for {c_func}" class TestServerFunctions: """Verify all Snap7 C server functions have Python equivalents.""" @pytest.mark.parametrize("c_func,py_method", SNAP7_SERVER_FUNCTIONS.items()) def test_method_exists(self, c_func: str, py_method: str) -> None: """Each Snap7 C server function has a corresponding Python method.""" assert hasattr(Server, py_method), f"Server missing {py_method} for {c_func}" class TestPartnerFunctions: """Verify all Snap7 C partner functions have Python equivalents.""" @pytest.mark.parametrize("c_func,py_method", SNAP7_PARTNER_FUNCTIONS.items()) def test_method_exists(self, c_func: str, py_method: str) -> None: """Each Snap7 C partner function has a corresponding Python method.""" assert hasattr(Partner, py_method), f"Partner missing {py_method} for {c_func}" class TestLogoMethods: """Verify Logo class has expected methods.""" @pytest.mark.parametrize("method_name", ["connect", "disconnect", "read", "write"]) def test_method_exists(self, method_name: str) -> None: """Logo class has expected method.""" assert hasattr(Logo, method_name), f"Logo missing method: {method_name}" # ============================================================================= # Method Signature Tests # ============================================================================= class TestMethodSignatures: """Verify key method signatures are correct.""" def test_connect_signature(self) -> None: """connect() has correct signature.""" sig = inspect.signature(Client.connect) params = list(sig.parameters.keys()) assert "address" in params assert "rack" in params assert "slot" in params assert "tcp_port" in params def test_db_read_signature(self) -> None: """db_read() has correct signature.""" sig = inspect.signature(Client.db_read) params = list(sig.parameters.keys()) assert "db_number" in params assert "start" in params assert "size" in params def test_db_write_signature(self) -> None: """db_write() has correct signature.""" sig = inspect.signature(Client.db_write) params = list(sig.parameters.keys()) assert "db_number" in params assert "start" in params assert "data" in params def test_delete_signature(self) -> None: """delete() has correct signature.""" sig = inspect.signature(Client.delete) params = list(sig.parameters.keys()) assert "block_type" in params assert "block_num" in params def test_full_upload_signature(self) -> None: """full_upload() has correct signature.""" sig = inspect.signature(Client.full_upload) params = list(sig.parameters.keys()) assert "block_type" in params assert "block_num" in params # ============================================================================= # Enum Value Tests # ============================================================================= class TestEnumValues: """Verify enums have expected values.""" @pytest.mark.parametrize("area_name", ["PE", "PA", "MK", "DB", "CT", "TM"]) def test_area_values(self, area_name: str) -> None: """Area enum has expected members.""" assert hasattr(Area, area_name) @pytest.mark.parametrize("block_name", ["OB", "DB", "SDB", "FC", "SFC", "FB", "SFB"]) def test_block_values(self, block_name: str) -> None: """Block enum has expected members.""" assert hasattr(Block, block_name) # ============================================================================= # Coverage Summary Test # ============================================================================= class TestCoverageSummary: """Summary of Snap7 C function coverage.""" def test_total_coverage(self) -> None: """All Snap7 C functions are implemented.""" total = ( len(SNAP7_CLIENT_SYNC_FUNCTIONS) + len(SNAP7_CLIENT_ASYNC_FUNCTIONS) + len(SNAP7_SERVER_FUNCTIONS) + len(SNAP7_PARTNER_FUNCTIONS) ) implemented = ( sum(1 for _, m in SNAP7_CLIENT_SYNC_FUNCTIONS.items() if hasattr(Client, m)) + sum(1 for _, m in SNAP7_CLIENT_ASYNC_FUNCTIONS.items() if hasattr(Client, m)) + sum(1 for _, m in SNAP7_SERVER_FUNCTIONS.items() if hasattr(Server, m)) + sum(1 for _, m in SNAP7_PARTNER_FUNCTIONS.items() if hasattr(Partner, m)) ) assert implemented == total, f"Coverage: {implemented}/{total}" # ============================================================================= # Behavioral Tests (with server) # ============================================================================= @pytest.fixture def server_client() -> Generator[Tuple[Server, Client], None, None]: """Fixture that provides a connected server and client.""" server = Server() port = 11102 db_data = bytearray(100) db_data[0] = 0x42 db_data[1] = 0xFF db_array = (c_char * 100).from_buffer(db_data) server.register_area(SrvArea.DB, 1, db_array) server.start(port) time.sleep(0.2) client = Client() try: client.connect("127.0.0.1", 0, 1, port) yield server, client finally: try: client.disconnect() except Exception: pass try: server.stop() server.destroy() except Exception: pass time.sleep(0.1) class TestBehavioralAPI: """Verify API methods return expected types.""" def test_db_read_returns_bytearray(self, server_client: Tuple[Server, Client]) -> None: """db_read() returns a bytearray.""" _, client = server_client result = client.db_read(1, 0, 4) assert isinstance(result, bytearray) assert len(result) == 4 def test_get_connected_returns_bool(self, server_client: Tuple[Server, Client]) -> None: """get_connected() returns a boolean.""" _, client = server_client assert isinstance(client.get_connected(), bool) assert client.get_connected() is True def test_db_write_returns_int(self, server_client: Tuple[Server, Client]) -> None: """db_write() returns an integer.""" _, client = server_client result = client.db_write(1, 0, bytearray([1, 2, 3, 4])) assert isinstance(result, int) assert result == 0 def test_delete_returns_int(self, server_client: Tuple[Server, Client]) -> None: """delete() returns an integer.""" _, client = server_client result = client.delete(Block.DB, 1) assert isinstance(result, int) def test_full_upload_returns_tuple(self, server_client: Tuple[Server, Client]) -> None: """full_upload() returns (bytearray, int).""" _, client = server_client result = client.full_upload(Block.DB, 1) assert isinstance(result, tuple) assert isinstance(result[0], bytearray) assert isinstance(result[1], int) def test_error_text_returns_str(self) -> None: """error_text() returns a string.""" client = Client() assert isinstance(client.error_text(0), str) if __name__ == "__main__": pytest.main([__file__, "-v"]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_behavioral_compatibility.py0000644000175100017510000003214415156465122023444 0ustar00runnerrunner""" Behavioral Compatibility Tests. Verify that the native Python implementation behaves correctly according to S7 protocol semantics - testing real operations, not just API existence. """ import time from ctypes import c_char from typing import Generator, Tuple import pytest from snap7 import Client, Server, Area, Block from snap7.type import SrvArea @pytest.fixture def server_client_pair() -> Generator[Tuple[Server, Client], None, None]: """Fixture that provides a connected server and client.""" server = Server() port = 11103 # Create memory areas size = 200 db_data = bytearray(size) mk_data = bytearray(100) pe_data = bytearray(100) pa_data = bytearray(100) # Initialize DB with test pattern for i in range(size): db_data[i] = i % 256 db_array = (c_char * size).from_buffer(db_data) mk_array = (c_char * 100).from_buffer(mk_data) pe_array = (c_char * 100).from_buffer(pe_data) pa_array = (c_char * 100).from_buffer(pa_data) server.register_area(SrvArea.DB, 1, db_array) # Register MK/PE/PA at index 0 (used by client convenience methods) server.register_area(SrvArea.MK, 0, mk_array) server.register_area(SrvArea.PE, 0, pe_array) server.register_area(SrvArea.PA, 0, pa_array) server.start(port) time.sleep(0.2) client = Client() try: client.connect("127.0.0.1", 0, 1, port) yield server, client finally: try: client.disconnect() except Exception: pass try: server.stop() server.destroy() except Exception: pass time.sleep(0.1) class TestReadWriteRoundtrip: """Verify data written can be read back correctly.""" def test_db_write_read_roundtrip(self, server_client_pair: Tuple[Server, Client]) -> None: """Write data to DB and read it back.""" server, client = server_client_pair test_data = bytearray([0xDE, 0xAD, 0xBE, 0xEF]) client.db_write(1, 50, test_data) result = client.db_read(1, 50, 4) assert result == test_data def test_write_area_read_area_roundtrip(self, server_client_pair: Tuple[Server, Client]) -> None: """Write via write_area and read via read_area.""" server, client = server_client_pair test_data = bytearray([0x11, 0x22, 0x33, 0x44, 0x55]) client.write_area(Area.DB, 1, 100, test_data) result = client.read_area(Area.DB, 1, 100, 5) assert result == test_data def test_multiple_writes_accumulate(self, server_client_pair: Tuple[Server, Client]) -> None: """Multiple writes to adjacent areas preserve earlier data.""" server, client = server_client_pair # Write to different offsets client.db_write(1, 0, bytearray([0x01, 0x02, 0x03])) client.db_write(1, 3, bytearray([0x04, 0x05, 0x06])) client.db_write(1, 6, bytearray([0x07, 0x08, 0x09])) # Read entire range result = client.db_read(1, 0, 9) assert result == bytearray([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09]) def test_overwrite_partial_data(self, server_client_pair: Tuple[Server, Client]) -> None: """Overwriting partial data preserves surrounding bytes.""" server, client = server_client_pair # Write initial block client.db_write(1, 10, bytearray([0xAA, 0xBB, 0xCC, 0xDD, 0xEE])) # Overwrite middle bytes client.db_write(1, 12, bytearray([0xFF])) result = client.db_read(1, 10, 5) assert result == bytearray([0xAA, 0xBB, 0xFF, 0xDD, 0xEE]) class TestMultiAreaAccess: """Verify all memory areas work correctly.""" def test_db_area_read_write(self, server_client_pair: Tuple[Server, Client]) -> None: """Data Block area read/write.""" server, client = server_client_pair data = bytearray([0x12, 0x34]) client.write_area(Area.DB, 1, 0, data) result = client.read_area(Area.DB, 1, 0, 2) assert result == data def test_mk_area_read_write(self, server_client_pair: Tuple[Server, Client]) -> None: """Marker area read/write.""" server, client = server_client_pair data = bytearray([0x56, 0x78]) # mb_write signature: (start, size, data) client.mb_write(0, len(data), data) result = client.mb_read(0, len(data)) assert result == data def test_pe_area_read_write(self, server_client_pair: Tuple[Server, Client]) -> None: """Process Input area read/write.""" server, client = server_client_pair data = bytearray([0x9A, 0xBC]) # eb_write signature: (start, size, data) client.eb_write(0, len(data), data) result = client.eb_read(0, len(data)) assert result == data def test_pa_area_read_write(self, server_client_pair: Tuple[Server, Client]) -> None: """Process Output area read/write.""" server, client = server_client_pair data = bytearray([0xDE, 0xF0]) # ab_write signature: (start, data) - no size param client.ab_write(0, data) result = client.ab_read(0, len(data)) assert result == data class TestDataIntegrity: """Verify data integrity for various patterns and sizes.""" def test_all_byte_values(self, server_client_pair: Tuple[Server, Client]) -> None: """All 256 byte values transfer correctly.""" server, client = server_client_pair # Write bytes 0-199 (test pattern was initialized this way) result = client.db_read(1, 0, 200) for i in range(200): assert result[i] == i % 256, f"Byte at offset {i} incorrect" def test_zero_bytes(self, server_client_pair: Tuple[Server, Client]) -> None: """Zero bytes transfer correctly.""" server, client = server_client_pair data = bytearray([0x00, 0x00, 0x00, 0x00]) client.db_write(1, 20, data) result = client.db_read(1, 20, 4) assert result == data def test_all_ones(self, server_client_pair: Tuple[Server, Client]) -> None: """0xFF bytes transfer correctly.""" server, client = server_client_pair data = bytearray([0xFF, 0xFF, 0xFF, 0xFF]) client.db_write(1, 30, data) result = client.db_read(1, 30, 4) assert result == data def test_alternating_bits(self, server_client_pair: Tuple[Server, Client]) -> None: """Alternating bit patterns transfer correctly.""" server, client = server_client_pair data = bytearray([0xAA, 0x55, 0xAA, 0x55]) client.db_write(1, 40, data) result = client.db_read(1, 40, 4) assert result == data class TestConnectionBehavior: """Verify connection lifecycle behavior.""" def test_disconnect_reconnect(self, server_client_pair: Tuple[Server, Client]) -> None: """Client can disconnect and reconnect.""" server, client = server_client_pair # Write initial data client.db_write(1, 0, bytearray([0x42])) # Disconnect client.disconnect() assert client.get_connected() is False # Reconnect - server is on port 11103 client.connect("127.0.0.1", 0, 1, 11103) assert client.get_connected() is True # Data should persist result = client.db_read(1, 0, 1) assert result[0] == 0x42 def test_get_connected_reflects_state(self, server_client_pair: Tuple[Server, Client]) -> None: """get_connected() accurately reflects connection state.""" server, client = server_client_pair assert client.get_connected() is True client.disconnect() assert client.get_connected() is False class TestPDUBehavior: """Verify PDU-related behavior.""" def test_get_pdu_length(self, server_client_pair: Tuple[Server, Client]) -> None: """PDU length is reported correctly.""" server, client = server_client_pair pdu_length = client.get_pdu_length() assert pdu_length > 0 assert pdu_length >= 240 # Minimum S7 PDU size def test_read_within_pdu(self, server_client_pair: Tuple[Server, Client]) -> None: """Single read within PDU size works.""" server, client = server_client_pair pdu_length = client.get_pdu_length() # Read should work within PDU data limits result = client.db_read(1, 0, min(100, pdu_length - 18)) # 18 bytes overhead assert len(result) == min(100, pdu_length - 18) class TestBlockOperations: """Verify block operation behavior.""" def test_list_blocks(self, server_client_pair: Tuple[Server, Client]) -> None: """list_blocks returns valid structure.""" server, client = server_client_pair blocks = client.list_blocks() # Should have DB count of at least 1 assert hasattr(blocks, "DBCount") assert blocks.DBCount >= 1 def test_db_get(self, server_client_pair: Tuple[Server, Client]) -> None: """db_get returns block data.""" server, client = server_client_pair result = client.db_get(1) assert isinstance(result, bytearray) assert len(result) > 0 def test_db_fill(self, server_client_pair: Tuple[Server, Client]) -> None: """db_fill fills entire DB with value.""" server, client = server_client_pair # Fill DB with 0x42 client.db_fill(1, 0x42) # Read back and verify result = client.db_read(1, 0, 10) for byte in result: assert byte == 0x42 def test_delete_returns_zero(self, server_client_pair: Tuple[Server, Client]) -> None: """delete() returns success code.""" server, client = server_client_pair result = client.delete(Block.DB, 1) assert result == 0 def test_full_upload_returns_tuple(self, server_client_pair: Tuple[Server, Client]) -> None: """full_upload() returns (bytearray, int) tuple.""" server, client = server_client_pair result = client.full_upload(Block.DB, 1) assert isinstance(result, tuple) assert len(result) == 2 assert isinstance(result[0], bytearray) assert isinstance(result[1], int) assert result[1] > 0 class TestErrorBehavior: """Verify error handling behavior.""" def test_error_text_returns_string(self) -> None: """error_text returns human-readable string.""" client = Client() error_msg = client.error_text(0) assert isinstance(error_msg, str) def test_get_last_error(self, server_client_pair: Tuple[Server, Client]) -> None: """get_last_error returns integer.""" server, client = server_client_pair error_code = client.get_last_error() assert isinstance(error_code, int) class TestSystemInfo: """Verify system info retrieval.""" def test_get_cpu_info(self, server_client_pair: Tuple[Server, Client]) -> None: """get_cpu_info returns valid structure.""" server, client = server_client_pair info = client.get_cpu_info() assert hasattr(info, "ModuleTypeName") assert hasattr(info, "SerialNumber") assert hasattr(info, "Copyright") def test_get_cp_info(self, server_client_pair: Tuple[Server, Client]) -> None: """get_cp_info returns valid structure.""" server, client = server_client_pair info = client.get_cp_info() assert hasattr(info, "MaxPduLength") assert info.MaxPduLength > 0 def test_get_exec_time(self, server_client_pair: Tuple[Server, Client]) -> None: """get_exec_time returns integer.""" server, client = server_client_pair exec_time = client.get_exec_time() assert isinstance(exec_time, int) assert exec_time >= 0 class TestConcurrentConnections: """Verify server handles multiple clients.""" def test_two_clients_simultaneous(self) -> None: """Two clients can connect simultaneously.""" server = Server() port = 11104 db_data = bytearray(100) db_array = (c_char * 100).from_buffer(db_data) server.register_area(SrvArea.DB, 1, db_array) server.start(port) time.sleep(0.2) client1 = Client() client2 = Client() try: client1.connect("127.0.0.1", 0, 1, port) client2.connect("127.0.0.1", 0, 1, port) assert client1.get_connected() is True assert client2.get_connected() is True # Both can read/write client1.db_write(1, 0, bytearray([0x11])) client2.db_write(1, 1, bytearray([0x22])) # Both see consistent data result1 = client1.db_read(1, 0, 2) result2 = client2.db_read(1, 0, 2) assert result1 == result2 assert result1 == bytearray([0x11, 0x22]) finally: try: client1.disconnect() except Exception: pass try: client2.disconnect() except Exception: pass try: server.stop() server.destroy() except Exception: pass time.sleep(0.1) if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_client.py0000755000175100017510000012635515156465122017670 0ustar00runnerrunnerimport logging import struct import time from typing import Any, Tuple from unittest.mock import MagicMock import pytest import unittest from ctypes import ( c_uint8, c_uint16, c_int32, c_int, POINTER, sizeof, create_string_buffer, cast, pointer, Array, ) from datetime import datetime, timedelta, timezone from typing import cast as typing_cast from snap7.util import get_real, get_int, set_int from snap7.error import check_error, S7ProtocolError, S7StalePacketError, S7PacketLostError from snap7.s7protocol import S7Protocol from snap7.server import Server from snap7.client import Client from snap7.type import SrvArea from snap7.type import ( S7DataItem, S7SZL, S7SZLList, buffer_type, buffer_size, Area, WordLen, Block, Parameter, CDataArrayType, ) logging.basicConfig(level=logging.WARNING) ip = "127.0.0.1" tcpport = 1102 db_number = 1 rack = 1 slot = 1 def _prepare_as_read_area(area: Area, size: int) -> Tuple[WordLen, CDataArrayType]: wordlen = area.wordlen() usrdata = (wordlen.ctype * size)() return wordlen, usrdata def _prepare_as_write_area(area: Area, data: bytearray) -> Tuple[WordLen, CDataArrayType]: if area not in Area: raise ValueError(f"{area} is not implemented in types") elif area == Area.TM: word_len = WordLen.Timer elif area == Area.CT: word_len = WordLen.Counter else: word_len = WordLen.Byte type_ = WordLen.Byte.ctype cdata = (type_ * len(data)).from_buffer_copy(data) return word_len, cdata # noinspection PyTypeChecker,PyCallingNonCallable @pytest.mark.client class TestClient(unittest.TestCase): server: Server = None # type: ignore @classmethod def setUpClass(cls) -> None: cls.server = Server() # Register memory areas (same as mainloop) cls.server.register_area(SrvArea.DB, 0, bytearray(600)) cls.server.register_area(SrvArea.DB, 1, bytearray(600)) cls.server.register_area(SrvArea.PA, 0, bytearray(100)) cls.server.register_area(SrvArea.PA, 1, bytearray(100)) cls.server.register_area(SrvArea.PE, 0, bytearray(100)) cls.server.register_area(SrvArea.PE, 1, bytearray(100)) cls.server.register_area(SrvArea.MK, 0, bytearray(100)) cls.server.register_area(SrvArea.MK, 1, bytearray(100)) cls.server.register_area(SrvArea.TM, 0, bytearray(100)) cls.server.register_area(SrvArea.TM, 1, bytearray(100)) cls.server.register_area(SrvArea.CT, 0, bytearray(100)) cls.server.register_area(SrvArea.CT, 1, bytearray(100)) cls.server.start(tcp_port=tcpport) @classmethod def tearDownClass(cls) -> None: if cls.server: cls.server.stop() cls.server.destroy() def setUp(self) -> None: self.client = Client() self.client.connect(ip, rack, slot, tcpport) def tearDown(self) -> None: self.client.disconnect() self.client.destroy() def test_db_read(self) -> None: size = 40 start = 0 db = 1 data = bytearray(40) self.client.db_write(db_number=db, start=start, data=data) result = self.client.db_read(db_number=db, start=start, size=size) self.assertEqual(data, result) def test_db_write(self) -> None: size = 40 data = bytearray(size) self.client.db_write(db_number=1, start=0, data=data) def test_db_get(self) -> None: self.client.db_get(db_number=db_number) def test_read_multi_vars(self) -> None: db = 1 # build and write test values test_value_1 = 129.5 test_bytes_1 = bytearray(struct.pack(">f", test_value_1)) self.client.db_write(db, 0, test_bytes_1) test_value_2 = -129.5 test_bytes_2 = bytearray(struct.pack(">f", test_value_2)) self.client.db_write(db, 4, test_bytes_2) test_value_3 = 123 test_bytes_3 = bytearray([0, 0]) set_int(test_bytes_3, 0, test_value_3) self.client.db_write(db, 8, test_bytes_3) test_values = [test_value_1, test_value_2, test_value_3] # build up our requests data_items = (S7DataItem * 3)() # build up our requests data_items = (S7DataItem * 3)() data_items[0].Area = c_int32(Area.DB.value) data_items[0].WordLen = c_int32(WordLen.Byte.value) data_items[0].Result = c_int32(0) data_items[0].DBNumber = c_int32(db) data_items[0].Start = c_int32(0) data_items[0].Amount = c_int32(4) # reading a REAL, 4 bytes data_items[1].Area = c_int32(Area.DB.value) data_items[1].WordLen = c_int32(WordLen.Byte.value) data_items[1].Result = c_int32(0) data_items[1].DBNumber = c_int32(db) data_items[1].Start = c_int32(4) data_items[1].Amount = c_int32(4) # reading a REAL, 4 bytes data_items[2].Area = c_int32(Area.DB.value) data_items[2].WordLen = c_int32(WordLen.Byte.value) data_items[2].Result = c_int32(0) data_items[2].DBNumber = c_int32(db) data_items[2].Start = c_int32(8) data_items[2].Amount = c_int32(2) # reading an INT, 2 bytes # create buffers to receive the data # use the Amount attribute on each item to size the buffer for di in data_items: # create the buffer dataBuffer = create_string_buffer(di.Amount) # get a pointer to the buffer pBuffer = cast(pointer(dataBuffer), POINTER(c_uint8)) di.pData = pBuffer result, data_items = self.client.read_multi_vars(data_items) result_values = [] # function to cast bytes to match data_types[] above byte_to_value = [get_real, get_real, get_int] # unpack and test the result of each read for i, di in enumerate(data_items): btv = byte_to_value[i] value = btv(di.pData, 0) result_values.append(value) self.assertEqual(result_values[0], test_values[0]) self.assertEqual(result_values[1], test_values[1]) self.assertEqual(result_values[2], test_values[2]) def test_upload(self) -> None: """Test uploading a block from PLC using real S7 protocol.""" # Write some data to DB1 first test_data = bytearray([0x11, 0x22, 0x33, 0x44]) self.client.db_write(db_number, 0, test_data) # Upload DB1 - should return the data we wrote result = self.client.upload(db_number) self.assertIsInstance(result, bytearray) # The uploaded data should contain what we wrote self.assertEqual(result[0:4], test_data) @unittest.skip("Async upload not fully implemented") def test_as_upload(self) -> None: """Test async upload (not fully implemented).""" _buffer = typing_cast(Array[c_int32], buffer_type()) size = sizeof(_buffer) self.client.as_upload(1, _buffer, size) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_download(self) -> None: """Test downloading a block to PLC using real S7 protocol.""" # Download data to DB1 data = bytearray([0xAA, 0xBB, 0xCC, 0xDD]) result = self.client.download(block_num=db_number, data=data) self.assertEqual(result, 0) # Verify by reading it back read_data = self.client.db_read(db_number, 0, 4) self.assertEqual(read_data, data) def test_read_area(self) -> None: amount = 1 start = 1 # Test read_area with a DB area = Area.DB dbnumber = 1 data = bytearray(b"\x11") self.client.write_area(area, dbnumber, start, data) res = self.client.read_area(area, dbnumber, start, amount) self.assertEqual(data, bytearray(res)) # Test read_area with a TM area = Area.TM dbnumber = 0 data = bytearray(b"\x12\x34") self.client.write_area(area, dbnumber, start, data) res = self.client.read_area(area, dbnumber, start, amount) self.assertEqual(data, bytearray(res)) # Test read_area with a CT area = Area.CT dbnumber = 0 data = bytearray(b"\x13\x35") self.client.write_area(area, dbnumber, start, data) res = self.client.read_area(area, dbnumber, start, amount) self.assertEqual(data, bytearray(res)) def test_write_area(self) -> None: # Test write area with a DB area = Area.DB dbnumber = 1 start = 1 data = bytearray(b"\x11") self.client.write_area(area, dbnumber, start, data) res = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(data, bytearray(res)) # Test write area with a TM area = Area.TM dbnumber = 0 timer = bytearray(b"\x12\x00") self.client.write_area(area, dbnumber, start, timer) res = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(timer, bytearray(res)) # Test write area with a CT area = Area.CT dbnumber = 0 timer = bytearray(b"\x13\x00") self.client.write_area(area, dbnumber, start, timer) res = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(timer, bytearray(res)) def test_list_blocks(self) -> None: self.client.list_blocks() def test_list_blocks_of_type(self) -> None: self.client.list_blocks_of_type(Block.DB, 10) def test_get_block_info(self) -> None: self.client.get_block_info(Block.DB, 1) def test_get_cpu_state(self) -> None: self.client.get_cpu_state() def test_set_session_password(self) -> None: password = "abcdefgh" # noqa: S105 self.client.set_session_password(password) def test_clear_session_password(self) -> None: self.client.clear_session_password() def test_set_connection_params(self) -> None: self.client.set_connection_params("10.0.0.2", 10, 10) def test_set_connection_type(self) -> None: self.client.set_connection_type(1) self.client.set_connection_type(2) self.client.set_connection_type(3) self.client.set_connection_type(20) def test_get_connected(self) -> None: self.client.get_connected() def test_ab_read(self) -> None: start = 1 size = 1 data = bytearray(size) self.client.ab_write(start=start, data=data) self.client.ab_read(start=start, size=size) def test_ab_write(self) -> None: start = 1 size = 10 data = bytearray(size) result = self.client.ab_write(start=start, data=data) self.assertEqual(0, result) def test_as_ab_read(self) -> None: expected = b"\x10\x01" self.client.ab_write(0, bytearray(expected)) type_ = WordLen.Byte.ctype buffer = (type_ * 2)() self.client.as_ab_read(0, 2, buffer) result = self.client.wait_as_completion(500) self.assertEqual(0, result) self.assertEqual(expected, bytearray(buffer)) def test_as_ab_write(self) -> None: data = b"\x01\x11" response = self.client.as_ab_write(0, bytearray(data)) result = self.client.wait_as_completion(500) self.assertEqual(0, response) self.assertEqual(0, result) self.assertEqual(data, self.client.ab_read(0, 2)) def test_compress(self) -> None: time_ = 1000 self.client.compress(time_) def test_as_compress(self) -> None: time_ = 1000 response = self.client.as_compress(time_) result = self.client.wait_as_completion(500) self.assertEqual(0, response) self.assertEqual(0, result) def test_set_param(self) -> None: values = ( (Parameter.PingTimeout, 800), (Parameter.SendTimeout, 15), (Parameter.RecvTimeout, 3500), (Parameter.SrcRef, 128), (Parameter.DstRef, 128), (Parameter.SrcTSap, 128), (Parameter.PDURequest, 470), ) for param, value in values: self.client.set_param(param, value) self.assertRaises(Exception, self.client.set_param, Parameter.RemotePort, 1) def test_get_param(self) -> None: expected = ( (Parameter.RemotePort, tcpport), (Parameter.PingTimeout, 750), (Parameter.SendTimeout, 10), (Parameter.RecvTimeout, 3000), (Parameter.SrcRef, 256), (Parameter.DstRef, 0), (Parameter.SrcTSap, 256), (Parameter.PDURequest, 480), ) for param, value in expected: self.assertEqual(self.client.get_param(param), value) non_client = ( Parameter.LocalPort, Parameter.WorkInterval, Parameter.MaxClients, Parameter.BSendTimeout, Parameter.BRecvTimeout, Parameter.RecoveryTime, Parameter.KeepAliveTime, ) # invalid param for client for param in non_client: self.assertRaises(Exception, self.client.get_param, param) def test_as_copy_ram_to_rom(self) -> None: response = self.client.as_copy_ram_to_rom(timeout=2) self.client.wait_as_completion(2000) self.assertEqual(0, response) def test_as_ct_read(self) -> None: # Cli_AsCTRead expected = b"\x10\x01" self.client.ct_write(0, 1, bytearray(expected)) type_ = WordLen.Counter.ctype buffer = (type_ * 1)() self.client.as_ct_read(0, 1, buffer) self.client.wait_as_completion(500) self.assertEqual(expected, bytearray(buffer)) def test_as_ct_write(self) -> None: # Cli_CTWrite data = b"\x01\x11" response = self.client.as_ct_write(0, 1, bytearray(data)) result = self.client.wait_as_completion(500) self.assertEqual(0, response) self.assertEqual(0, result) self.assertEqual(data, self.client.ct_read(0, 1)) def test_as_db_fill(self) -> None: filler = 31 expected = bytearray(filler.to_bytes(1, byteorder="big") * 100) self.client.as_db_fill(1, filler) self.client.wait_as_completion(500) self.assertEqual(expected, self.client.db_read(1, 0, 100)) def test_as_db_get(self) -> None: _buffer = typing_cast(Array[c_int], buffer_type()) self.client.as_db_get(db_number, _buffer, buffer_size) self.client.wait_as_completion(500) result = bytearray(_buffer)[:buffer_size] self.assertEqual(buffer_size, len(result)) def test_as_db_read(self) -> None: size = 40 start = 0 db = 1 expected = bytearray(40) self.client.db_write(db_number=db, start=start, data=expected) type_ = WordLen.Byte.ctype data = (type_ * size)() self.client.as_db_read(db, start, size, data) self.client.wait_as_completion(500) self.assertEqual(data, expected) def test_as_db_write(self) -> None: size = 40 data = bytearray(size) type_ = WordLen.Byte.ctype size = len(data) result = (type_ * size).from_buffer_copy(data) self.client.as_db_write(db_number=1, start=0, size=size, data=result) self.client.wait_as_completion(500) self.assertEqual(data, result) def test_as_download(self) -> None: """Test async download to PLC.""" data = bytearray([0x55, 0x66, 0x77, 0x88]) result = self.client.as_download(block_num=db_number, data=data) self.assertEqual(result, 0) def test_plc_stop(self) -> None: self.client.plc_stop() def test_plc_hot_start(self) -> None: self.client.plc_hot_start() def test_plc_cold_start(self) -> None: self.client.plc_cold_start() def test_get_pdu_length(self) -> None: pduRequested = self.client.get_param(Parameter.PDURequest) pduSize = self.client.get_pdu_length() self.assertEqual(pduSize, pduRequested) def test_get_cpu_info(self) -> None: expected = ( ("ModuleTypeName", "CPU 315-2 PN/DP"), ("SerialNumber", "S C-C2UR28922012"), ("ASName", "SNAP7-SERVER"), ("Copyright", "Original Siemens Equipment"), ("ModuleName", "CPU 315-2 PN/DP"), ) cpuInfo = self.client.get_cpu_info() for param, value in expected: self.assertEqual(getattr(cpuInfo, param).decode("utf-8"), value) def test_db_write_with_byte_literal_does_not_throw(self) -> None: data = b"\xde\xad\xbe\xef" try: self.client.db_write(db_number=1, start=0, data=bytearray(data)) except TypeError as e: self.fail(str(e)) def test_get_plc_time(self) -> None: self.assertAlmostEqual(datetime.now().replace(microsecond=0), self.client.get_plc_datetime(), delta=timedelta(seconds=1)) def test_set_plc_datetime(self) -> None: new_dt = datetime(2011, 1, 1, 1, 1, 1, 0) self.client.set_plc_datetime(new_dt) # Can't actually set datetime in emulated PLC, get_plc_datetime always returns system time. # self.assertEqual(new_dt, self.client.get_plc_datetime()) def test_wait_as_completion_pass(self, timeout: int = 1000) -> None: # Cli_WaitAsCompletion # prepare Server with values area = Area.DB dbnumber = 1 size = 1 start = 1 data = bytearray(size) self.client.write_area(area, dbnumber, start, data) # start as_request and test wordlen, usrdata = _prepare_as_read_area(area, size) self.client.as_read_area(area, dbnumber, start, size, wordlen, usrdata) self.client.wait_as_completion(timeout) self.assertEqual(bytearray(usrdata), data) def test_wait_as_completion_timeout(self, timeout: int = 0, tries: int = 500) -> None: # Cli_WaitAsCompletion # prepare Server area = Area.DB dbnumber = 1 size = 1 start = 1 wordlen, data = _prepare_as_read_area(area, size) self.client.write_area(area, dbnumber, start, bytearray(data)) # start as_request and wait for zero seconds to try trigger timeout for i in range(tries): self.client.as_read_area(area, dbnumber, start, size, wordlen, data) res = None try: res = self.client.wait_as_completion(timeout) check_error(res) except RuntimeError as s7_err: if not s7_err.args[0] == b"CLI : Job Timeout": self.fail(f"While waiting another error appeared: {s7_err}") # Wait for a thread to finish time.sleep(0.1) return except BaseException: self.fail(f"While waiting another error appeared:>>>>>>>> {res}") self.fail( f"After {tries} tries, no timout could be envoked by snap7. Either tests are passing to fast or" f"a problem is existing in the method. Fail test." ) def test_check_as_completion(self, timeout: int = 5) -> None: # Cli_CheckAsCompletion check_status = c_int(-1) pending_checked = False # preparing Server values data = bytearray(b"\x01\xff") size = len(data) area = Area.DB db = 1 start = 1 self.client.write_area(area, db, start, data) # start as_request and test wordlen, cdata = _prepare_as_read_area(area, size) self.client.as_read_area(area, db, start, size, wordlen, cdata) for _ in range(10): self.client.check_as_completion(check_status) if check_status.value == 0: self.assertEqual(data, bytearray(cdata)) break pending_checked = True time.sleep(1) else: self.fail(f"TimeoutError - Process pends for more than {timeout} seconds") if pending_checked is False: logging.warning("Pending was never reached, because Server was to fast, but request to server was successfull.") def test_as_read_area(self) -> None: amount = 1 start = 1 # Test read_area with a DB area = Area.DB dbnumber = 1 data = bytearray(b"\x11") self.client.write_area(area, dbnumber, start, data) wordlen, usrdata = _prepare_as_read_area(area, amount) self.client.as_read_area(area, dbnumber, start, amount, wordlen, usrdata) self.client.wait_as_completion(1000) self.assertEqual(bytearray(usrdata), data) # Test read_area with a TM area = Area.TM dbnumber = 0 data = bytearray(b"\x12\x34") self.client.write_area(area, dbnumber, start, data) wordlen, usrdata = _prepare_as_read_area(area, amount) self.client.as_read_area(area, dbnumber, start, amount, wordlen, usrdata) self.client.wait_as_completion(1000) self.assertEqual(bytearray(usrdata), data) # Test read_area with a CT area = Area.CT dbnumber = 0 data = bytearray(b"\x13\x35") self.client.write_area(area, dbnumber, start, data) wordlen, usrdata = _prepare_as_read_area(area, amount) self.client.as_read_area(area, dbnumber, start, amount, wordlen, usrdata) self.client.wait_as_completion(1000) self.assertEqual(bytearray(usrdata), data) def test_as_write_area(self) -> None: # Test write area with a DB area = Area.DB dbnumber = 1 size = 1 start = 1 data = bytearray(b"\x11") wordlen, cdata = _prepare_as_write_area(area, data) self.client.as_write_area(area, dbnumber, start, size, wordlen, cdata) self.client.wait_as_completion(1000) res = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(data, bytearray(res)) # Test write area with a TM area = Area.TM dbnumber = 0 size = 2 timer = bytearray(b"\x12\x00") wordlen, cdata = _prepare_as_write_area(area, timer) self.client.as_write_area(area, dbnumber, start, size, wordlen, cdata) self.client.wait_as_completion(1000) res2 = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(timer, bytearray(res2)) # Test write area with a CT area = Area.CT dbnumber = 0 size = 2 timer = bytearray(b"\x13\x00") wordlen, cdata = _prepare_as_write_area(area, timer) self.client.as_write_area(area, dbnumber, start, size, wordlen, cdata) self.client.wait_as_completion(1000) res3 = self.client.read_area(area, dbnumber, start, 1) self.assertEqual(timer, bytearray(res3)) def test_as_eb_read(self) -> None: # Cli_AsEBRead type_ = WordLen.Byte.ctype buffer = (type_ * 1)() response = self.client.as_eb_read(0, 1, buffer) self.assertEqual(0, response) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_eb_write(self) -> None: # Cli_AsEBWrite response = self.client.as_eb_write(0, 1, bytearray(b"\x00")) self.assertEqual(0, response) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_full_upload(self) -> None: # Cli_AsFullUpload self.client.as_full_upload(Block.DB, 1) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_list_blocks_of_type(self) -> None: data = typing_cast(Array[c_int], (c_uint16 * 10)()) self.client.as_list_blocks_of_type(Block.DB, data, 0) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_mb_read(self) -> None: # Cli_AsMBRead type_ = WordLen.Byte.ctype data = (type_ * 1)() self.client.as_mb_read(0, 1, data) bytearray(data) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_mb_write(self) -> None: # Cli_AsMBWrite response = self.client.as_mb_write(0, 1, bytearray(b"\x00")) self.assertEqual(0, response) self.assertRaises(RuntimeError, self.client.wait_as_completion, 500) def test_as_read_szl(self) -> None: # Cli_AsReadSZL - uses real SZL protocol ssl_id = 0x001C # CPU info index = 0x0000 s7_szl = S7SZL() self.client.as_read_szl(ssl_id, index, s7_szl, sizeof(s7_szl)) self.client.wait_as_completion(100) # Should have valid data self.assertTrue(s7_szl.Header.LengthDR > 0) def test_as_read_szl_list(self) -> None: # Cli_AsReadSZLList - uses real SZL protocol szl_list = S7SZLList() items_count = sizeof(szl_list) self.client.as_read_szl_list(szl_list, items_count) self.client.wait_as_completion(500) # Should have some SZL IDs in the list result = bytearray(szl_list.List)[:10] self.assertTrue(len(result) >= 4) # At least 2 SZL IDs def test_as_tm_read(self) -> None: expected = b"\x10\x01" self.client.tm_write(0, 1, bytearray(expected)) type_ = WordLen.Timer.ctype buffer = (type_ * 1)() self.client.as_tm_read(0, 1, buffer) self.client.wait_as_completion(500) self.assertEqual(expected, bytearray(buffer)) def test_as_tm_write(self) -> None: data = b"\x10\x01" response = self.client.as_tm_write(0, 1, bytearray(data)) result = self.client.wait_as_completion(500) self.assertEqual(0, response) self.assertEqual(0, result) self.assertEqual(data, self.client.tm_read(0, 1)) def test_copy_ram_to_rom(self) -> None: # Cli_CopyRamToRom self.assertEqual(0, self.client.copy_ram_to_rom(timeout=2)) def test_ct_read(self) -> None: # Cli_CTRead data = b"\x10\x01" self.client.ct_write(0, 1, bytearray(data)) result = self.client.ct_read(0, 1) self.assertEqual(data, result) def test_ct_write(self) -> None: # Cli_CTWrite data = b"\x01\x11" self.assertEqual(0, self.client.ct_write(0, 1, bytearray(data))) self.assertRaises(ValueError, self.client.ct_write, 0, 2, bytes(1)) def test_db_fill(self) -> None: # Cli_DBFill filler = 31 expected = bytearray(filler.to_bytes(1, byteorder="big") * 100) self.client.db_fill(1, filler) self.assertEqual(expected, self.client.db_read(1, 0, 100)) def test_eb_read(self) -> None: # Cli_EBRead - reads process inputs (PE area) response = self.client.eb_read(0, 1) self.assertTrue(isinstance(response, bytearray)) self.assertEqual(1, len(response)) def test_eb_write(self) -> None: # Cli_EBWrite - writes to process inputs (PE area) response = self.client.eb_write(0, 1, bytearray(b"\x00")) self.assertEqual(0, response) def test_error_text(self) -> None: # Cli_ErrorText CPU_INVALID_PASSWORD = 0x01E00000 CPU_INVLID_VALUE = 0x00D00000 CANNOT_CHANGE_PARAM = 0x02600000 self.assertEqual("CPU : Invalid password", self.client.error_text(CPU_INVALID_PASSWORD)) self.assertEqual("CPU : Invalid value supplied", self.client.error_text(CPU_INVLID_VALUE)) self.assertEqual("CLI : Cannot change this param now", self.client.error_text(CANNOT_CHANGE_PARAM)) def test_get_cp_info(self) -> None: # Cli_GetCpInfo - now uses real SZL protocol result = self.client.get_cp_info() # Server returns SZL 0x0131 data: MaxPdu=480, MaxConnections=32, etc. self.assertEqual(480, result.MaxPduLength) self.assertEqual(32, result.MaxConnections) self.assertEqual(12, result.MaxMpiRate) self.assertEqual(12, result.MaxBusRate) def test_get_exec_time(self) -> None: # Cli_GetExecTime response = self.client.get_exec_time() self.assertTrue(isinstance(response, int)) def test_get_last_error(self) -> None: # Cli_GetLastError self.assertEqual(0, self.client.get_last_error()) def test_get_order_code(self) -> None: # Cli_GetOrderCode - uses real SZL protocol result = self.client.get_order_code() # Order code should contain the 6ES7 prefix self.assertIn(b"6ES7", result.OrderCode) def test_get_protection(self) -> None: # Cli_GetProtection - now uses real SZL protocol result = self.client.get_protection() # Server returns SZL 0x0232 data: all fields indicate "no protection" self.assertEqual(1, result.sch_schal) # No password required self.assertEqual(0, result.sch_par) self.assertEqual(0, result.sch_rel) self.assertEqual(0, result.bart_sch) self.assertEqual(0, result.anl_sch) def test_get_pg_block_info(self) -> None: valid_db_block = ( b"pp\x01\x01\x05\n\x00c\x00\x00\x00t\x00\x00\x00\x00\x01\x8d\xbe)2\xa1\x01" b"\x85V\x1f2\xa1\x00*\x00\x00\x00\x00\x00\x02\x01\x0f\x05c\x00#\x00\x00\x00" b"\x11\x04\x10\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01" b"\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x01\x04\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) block_info = self.client.get_pg_block_info(bytearray(valid_db_block)) self.assertEqual(10, block_info.BlkType) self.assertEqual(99, block_info.BlkNumber) self.assertEqual(2752512, block_info.SBBLength) self.assertEqual( bytes((datetime(2019, 6, 27, tzinfo=timezone.utc).astimezone().strftime("%Y/%m/%d")), encoding="utf-8"), block_info.CodeDate, ) self.assertEqual( bytes((datetime(2019, 6, 27, tzinfo=timezone.utc).astimezone().strftime("%Y/%m/%d")), encoding="utf-8"), block_info.IntfDate, ) def test_iso_exchange_buffer(self) -> None: # Cli_IsoExchangeBuffer self.client.db_write(1, 0, bytearray(b"\x11")) # PDU read DB1 1.0 BYTE data = b"\x32\x01\x00\x00\x01\x00\x00\x0e\x00\x00\x04\x01\x12\x0a\x10\x02\x00\x01\x00\x01\x84\x00\x00\x00" # PDU response expected = bytearray(b"2\x03\x00\x00\x01\x00\x00\x02\x00\x05\x00\x00\x04\x01\xff\x04\x00\x08\x11") self.assertEqual(expected, self.client.iso_exchange_buffer(bytearray(data))) def test_mb_read(self) -> None: # Cli_MBRead - reads marker area (MK) response = self.client.mb_read(0, 10) self.assertTrue(isinstance(response, bytearray)) self.assertEqual(10, len(response)) def test_mb_write(self) -> None: # Cli_MBWrite - writes to marker area (MK) response = self.client.mb_write(0, 1, bytearray(b"\x00")) self.assertEqual(0, response) def test_read_szl(self) -> None: # Test read_szl with real protocol - server returns SZL 0x001C (CPU info) ssl_id = 0x001C response = self.client.read_szl(ssl_id) # S7SZLHeader only has LengthDR and NDR fields self.assertEqual(1, response.Header.NDR) # Server returns 1 record self.assertTrue(response.Header.LengthDR > 0) # Has data # Data should contain CPU info string cpu_data = bytes(response.Data[:32]).rstrip(b"\x00") self.assertIn(b"CPU", cpu_data) # Test reading SZL 0x0011 (order code) ssl_id = 0x0011 response = self.client.read_szl(ssl_id) # Order code should be in the data order_code = bytes(response.Data[:20]).rstrip(b"\x00") self.assertIn(b"6ES7", order_code) # read_szl_invalid_id - should raise error ssl_id = 0xFFFF index = 0xFFFF self.assertRaises(RuntimeError, self.client.read_szl, ssl_id) self.assertRaises(RuntimeError, self.client.read_szl, ssl_id, index) def test_read_szl_list(self) -> None: # Cli_ReadSZLList - returns list of available SZL IDs result = self.client.read_szl_list() # Should contain some SZL IDs (server returns 0x0000, 0x0011, 0x001C, 0x0131, 0x0232) self.assertTrue(len(result) >= 4) # At least 2 SZL IDs (2 bytes each) def test_set_plc_system_datetime(self) -> None: # Cli_SetPlcSystemDateTime self.assertEqual(0, self.client.set_plc_system_datetime()) def test_tm_read(self) -> None: # Cli_TMRead data = b"\x10\x01" self.client.tm_write(0, 1, bytearray(data)) result = self.client.tm_read(0, 1) self.assertEqual(data, result) def test_tm_write(self) -> None: # Cli_TMWrite data = b"\x10\x01" self.assertEqual(0, self.client.tm_write(0, 1, bytearray(data))) self.assertEqual(data, self.client.tm_read(0, 1)) self.assertRaises(RuntimeError, self.client.tm_write, 0, 100, bytes(200)) self.assertRaises(ValueError, self.client.tm_write, 0, 2, bytes(2)) def test_write_multi_vars(self) -> None: # Cli_WriteMultiVars items_count = 3 items = [] areas = [Area.DB, Area.CT, Area.TM] expected_list = [] for i in range(items_count): item = S7DataItem() item.Area = c_int32(areas[i].value) wordlen = WordLen.Byte item.WordLen = c_int32(wordlen.value) item.DBNumber = c_int32(1) item.Start = c_int32(0) item.Amount = c_int32(4) data = (i + 1).to_bytes(1, byteorder="big") * 4 array_class = c_uint8 * len(data) cdata = array_class.from_buffer_copy(data) item.pData = cast(cdata, POINTER(array_class)).contents items.append(item) expected_list.append(data) result = self.client.write_multi_vars(items) self.assertEqual(0, result) self.assertEqual(expected_list[0], self.client.db_read(db_number=1, start=0, size=4)) self.assertEqual(expected_list[1], self.client.ct_read(0, 2)) self.assertEqual(expected_list[2], self.client.tm_read(0, 2)) def test_set_as_callback(self) -> None: def event_call_back(op_code: int, op_result: int) -> None: logging.info(f"callback event: {op_code} op_result: {op_result}") self.client.set_as_callback(event_call_back) def test_context_manager(self) -> None: """Test client as context manager.""" with Client() as client: client.connect(ip, rack, slot, tcpport) self.assertTrue(client.get_connected()) data = client.db_read(1, 0, 4) self.assertEqual(len(data), 4) # Should be disconnected after context exit self.assertFalse(client.get_connected()) @pytest.mark.client class TestClientBeforeConnect(unittest.TestCase): """ Test suite of items that should run without an open connection. """ def setUp(self) -> None: self.client = Client() def test_set_param(self) -> None: values = ( (Parameter.RemotePort, 1102), (Parameter.PingTimeout, 800), (Parameter.SendTimeout, 15), (Parameter.RecvTimeout, 3500), (Parameter.SrcRef, 128), (Parameter.DstRef, 128), (Parameter.SrcTSap, 128), (Parameter.PDURequest, 470), ) for param, value in values: self.client.set_param(param, value) @pytest.mark.client class TestStalePacketDetection: """Test stale packet detection and retry in _send_receive.""" def test_validate_matching_sequence(self) -> None: proto = S7Protocol() proto.sequence = 5 proto.validate_pdu_reference(5) def test_validate_stale_raises(self) -> None: proto = S7Protocol() proto.sequence = 10 with pytest.raises(S7StalePacketError, match="Stale packet"): proto.validate_pdu_reference(8) def test_validate_lost_raises(self) -> None: proto = S7Protocol() proto.sequence = 5 with pytest.raises(S7PacketLostError, match="Packet lost"): proto.validate_pdu_reference(7) def test_send_receive_retries_on_stale(self) -> None: """_send_receive should retry receive when a stale packet is detected.""" client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn client.protocol.sequence = 1 def make_response(seq: int) -> bytes: return struct.pack( ">BBHHHHBB", 0x32, 0x03, 0x0000, seq, 0x0000, 0x0000, 0x00, 0x00, ) stale_response = make_response(0) good_response = make_response(1) mock_conn.receive_data.side_effect = [stale_response, good_response] result = client._send_receive(b"\x00", max_stale_retries=3) assert result["sequence"] == 1 assert mock_conn.receive_data.call_count == 2 def test_send_receive_exhausts_retries(self) -> None: """_send_receive should raise after exhausting stale retries.""" client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn client.protocol.sequence = 5 stale = struct.pack( ">BBHHHHBB", 0x32, 0x03, 0x0000, 1, 0x0000, 0x0000, 0x00, 0x00, ) mock_conn.receive_data.return_value = stale with pytest.raises(S7ProtocolError, match="Max stale packet retries"): client._send_receive(b"\x00", max_stale_retries=2) assert mock_conn.receive_data.call_count == 3 @pytest.mark.client class TestPDUSplitting: """Test automatic PDU splitting for large read/write requests.""" def test_max_read_size(self) -> None: client = Client() client.pdu_length = 480 assert client._max_read_size() == 480 - 18 def test_max_write_size(self) -> None: client = Client() client.pdu_length = 480 assert client._max_write_size() == 480 - 35 def test_read_area_splits_large_request(self) -> None: """read_area should make multiple requests when size > max_read_size.""" client = Client() client.connected = True client.pdu_length = 50 # Very small - max_read_size = 32 mock_conn = MagicMock() client.connection = mock_conn call_count = 0 def mock_send_receive(request: bytes, max_stale_retries: int = 3) -> dict[str, Any]: nonlocal call_count call_count += 1 count = struct.unpack(">H", request[16:18])[0] fake_data = bytes(range(count)) return { "sequence": client.protocol.sequence, "param_length": 2, "data_length": count + 4, "parameters": {"function_code": 0x04, "item_count": 1}, "data": {"return_code": 0xFF, "transport_size": 0x04, "data_length": count * 8, "data": fake_data}, "error_code": 0, } client._send_receive = mock_send_receive result = client.read_area(Area.DB, 1, 0, 64) assert len(result) == 64 assert call_count == 2 # 32 + 32 def test_write_area_splits_large_request(self) -> None: """write_area should make multiple requests when data > max_write_size.""" client = Client() client.connected = True client.pdu_length = 50 # Very small - max_write_size = 15 mock_conn = MagicMock() client.connection = mock_conn call_count = 0 def mock_send_receive(request: bytes, max_stale_retries: int = 3) -> dict[str, Any]: nonlocal call_count call_count += 1 return { "sequence": client.protocol.sequence, "param_length": 2, "data_length": 1, "parameters": {"function_code": 0x05, "item_count": 1}, "data": {"return_code": 0xFF}, "error_code": 0, } client._send_receive = mock_send_receive data = bytearray(30) result = client.write_area(Area.DB, 1, 0, data) assert result == 0 assert call_count == 2 # 15 + 15 def test_small_request_no_split(self) -> None: """Requests within PDU size should not be split.""" client = Client() client.connected = True client.pdu_length = 480 # max_read_size = 462 call_count = 0 def mock_send_receive(request: bytes, max_stale_retries: int = 3) -> dict[str, Any]: nonlocal call_count call_count += 1 count = 10 fake_data = bytes(count) return { "sequence": client.protocol.sequence, "param_length": 2, "data_length": count + 4, "parameters": {"function_code": 0x04, "item_count": 1}, "data": {"return_code": 0xFF, "transport_size": 0x04, "data_length": count * 8, "data": fake_data}, "error_code": 0, } client._send_receive = mock_send_receive result = client.read_area(Area.DB, 1, 0, 10) assert len(result) == 10 assert call_count == 1 @pytest.mark.client class TestMaxVars: """Test MAX_VARS limit on multi-read/multi-write requests.""" def test_max_vars_constant(self) -> None: assert Client.MAX_VARS == 20 def test_read_multi_vars_rejects_too_many(self) -> None: client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn items = [{"area": Area.DB, "db_number": 1, "start": i, "size": 1} for i in range(21)] with pytest.raises(ValueError, match="Too many items.*21.*MAX_VARS.*20"): client.read_multi_vars(items) def test_write_multi_vars_rejects_too_many(self) -> None: client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn items = [{"area": Area.DB, "db_number": 1, "start": i, "data": bytearray(1)} for i in range(21)] with pytest.raises(ValueError, match="Too many items.*21.*MAX_VARS.*20"): client.write_multi_vars(items) def test_read_multi_vars_accepts_max(self) -> None: """20 items (the limit) should not raise.""" client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn client.read_area = MagicMock(return_value=bytearray(1)) items = [{"area": Area.DB, "db_number": 1, "start": i, "size": 1} for i in range(20)] result_code, results = client.read_multi_vars(items) assert result_code == 0 assert len(results) == 20 def test_write_multi_vars_accepts_max(self) -> None: """20 items (the limit) should not raise.""" client = Client() client.connected = True mock_conn = MagicMock() client.connection = mock_conn client.write_area = MagicMock(return_value=0) items = [{"area": Area.DB, "db_number": 1, "start": i, "data": bytearray(1)} for i in range(20)] result = client.write_multi_vars(items) assert result == 0 def test_empty_items_accepted(self) -> None: client = Client() result_code, _ = client.read_multi_vars([]) assert result_code == 0 if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_client_e2e.py0000644000175100017510000006424615156465122020420 0ustar00runnerrunner"""End-to-end tests for Client class against a real Siemens S7 PLC. These tests require a real PLC connection. Run with: pytest tests/test_client_e2e.py --e2e --plc-ip=YOUR_PLC_IP Available options: --e2e Enable e2e tests (required) --plc-ip PLC IP address (default: 10.10.10.100) --plc-rack PLC rack number (default: 0) --plc-slot PLC slot number (default: 1) --plc-port PLC TCP port (default: 102) --plc-db-read Read-only DB number (default: 1) --plc-db-write Read-write DB number (default: 2) The PLC needs two data blocks configured: DB1 "Read_only" - Read-only data block with predefined values: int1: Int = 10 int2: Int = 255 float1: Real = 123.45 float2: Real = 543.21 byte1: Byte = 0x0F byte2: Byte = 0xF0 word1: Word = 0xABCD word2: Word = 0x1234 dword1: DWord = 0x12345678 dword2: DWord = 0x89ABCDEF dint1: DInt = 2147483647 dint2: DInt = 42 char1: Char = 'F' char2: Char = '-' bool0-bool7: Bool (packed in 1 byte, value: 0x01 i.e. bool0=True, bool1-7=False) DB2 "Data_block_2" - Read/write data block with same structure. """ import os import pytest import unittest from ctypes import c_int32, POINTER, pointer, create_string_buffer, cast, c_uint8 from datetime import datetime from snap7.client import Client from snap7.type import Area, Block, S7DataItem, WordLen, Parameter from snap7.util import ( get_int, get_real, get_byte, get_word, get_dword, get_dint, get_char, get_bool, set_int, set_real, set_byte, set_word, set_dword, set_dint, set_char, set_bool, ) # ============================================================================= # PLC Connection Configuration # These can be overridden via pytest command line options or environment variables # ============================================================================= PLC_IP = os.environ.get("PLC_IP", "10.10.10.100") PLC_RACK = int(os.environ.get("PLC_RACK", "0")) PLC_SLOT = int(os.environ.get("PLC_SLOT", "1")) PLC_PORT = int(os.environ.get("PLC_PORT", "102")) # Data block numbers DB_READ_ONLY = int(os.environ.get("PLC_DB_READ", "1")) DB_READ_WRITE = int(os.environ.get("PLC_DB_WRITE", "2")) # ============================================================================= # DB Structure - Byte offsets for each variable # ============================================================================= OFFSET_INT1 = 0 # Int (2 bytes) OFFSET_INT2 = 2 # Int (2 bytes) OFFSET_FLOAT1 = 4 # Real (4 bytes) OFFSET_FLOAT2 = 8 # Real (4 bytes) OFFSET_BYTE1 = 12 # Byte (1 byte) OFFSET_BYTE2 = 13 # Byte (1 byte) OFFSET_WORD1 = 14 # Word (2 bytes) OFFSET_WORD2 = 16 # Word (2 bytes) OFFSET_DWORD1 = 18 # DWord (4 bytes) OFFSET_DWORD2 = 22 # DWord (4 bytes) OFFSET_DINT1 = 26 # DInt (4 bytes) OFFSET_DINT2 = 30 # DInt (4 bytes) OFFSET_CHAR1 = 34 # Char (1 byte) OFFSET_CHAR2 = 35 # Char (1 byte) OFFSET_BOOLS = 36 # 8 Bools packed in 1 byte # Total size of DB DB_SIZE = 37 # ============================================================================= # Expected values from DB1 "Read_only" # ============================================================================= EXPECTED_INT1 = 10 EXPECTED_INT2 = 255 EXPECTED_FLOAT1 = 123.45 EXPECTED_FLOAT2 = 543.21 EXPECTED_BYTE1 = 0x0F EXPECTED_BYTE2 = 0xF0 EXPECTED_WORD1 = 0xABCD EXPECTED_WORD2 = 0x1234 EXPECTED_DWORD1 = 0x12345678 EXPECTED_DWORD2 = 0x89ABCDEF EXPECTED_DINT1 = 2147483647 EXPECTED_DINT2 = 42 EXPECTED_CHAR1 = "F" EXPECTED_CHAR2 = "-" EXPECTED_BOOL0 = True EXPECTED_BOOL1 = False EXPECTED_BOOL2 = False EXPECTED_BOOL3 = False EXPECTED_BOOL4 = False EXPECTED_BOOL5 = False EXPECTED_BOOL6 = False EXPECTED_BOOL7 = False # ============================================================================= # Test Classes # ============================================================================= @pytest.mark.e2e class TestClientConnection(unittest.TestCase): """Tests for Client connection methods.""" def test_connect_disconnect(self) -> None: """Test connect() and disconnect() methods.""" client = Client() client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) self.assertTrue(client.get_connected()) client.disconnect() self.assertFalse(client.get_connected()) def test_get_connected(self) -> None: """Test get_connected() method.""" client = Client() self.assertFalse(client.get_connected()) client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) self.assertTrue(client.get_connected()) client.disconnect() self.assertFalse(client.get_connected()) def test_context_manager(self) -> None: """Test Client as context manager.""" with Client() as client: client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) self.assertTrue(client.get_connected()) def test_create_destroy(self) -> None: """Test create() and destroy() methods.""" client = Client() client.create() # No-op for compatibility client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) self.assertTrue(client.get_connected()) client.destroy() self.assertFalse(client.get_connected()) @pytest.mark.e2e class TestClientDBRead(unittest.TestCase): """Tests for db_read() method - reading from DB1 (read-only).""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_db_read_int(self) -> None: """Test db_read() for Int values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_INT1, 2) self.assertEqual(EXPECTED_INT1, get_int(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_INT2, 2) self.assertEqual(EXPECTED_INT2, get_int(data, 0)) def test_db_read_real(self) -> None: """Test db_read() for Real values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_FLOAT1, 4) self.assertAlmostEqual(EXPECTED_FLOAT1, get_real(data, 0), places=2) data = self.client.db_read(DB_READ_ONLY, OFFSET_FLOAT2, 4) self.assertAlmostEqual(EXPECTED_FLOAT2, get_real(data, 0), places=2) def test_db_read_byte(self) -> None: """Test db_read() for Byte values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_BYTE1, 1) self.assertEqual(EXPECTED_BYTE1, get_byte(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_BYTE2, 1) self.assertEqual(EXPECTED_BYTE2, get_byte(data, 0)) def test_db_read_word(self) -> None: """Test db_read() for Word values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_WORD1, 2) self.assertEqual(EXPECTED_WORD1, get_word(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_WORD2, 2) self.assertEqual(EXPECTED_WORD2, get_word(data, 0)) def test_db_read_dword(self) -> None: """Test db_read() for DWord values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_DWORD1, 4) self.assertEqual(EXPECTED_DWORD1, get_dword(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_DWORD2, 4) self.assertEqual(EXPECTED_DWORD2, get_dword(data, 0)) def test_db_read_dint(self) -> None: """Test db_read() for DInt values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_DINT1, 4) self.assertEqual(EXPECTED_DINT1, get_dint(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_DINT2, 4) self.assertEqual(EXPECTED_DINT2, get_dint(data, 0)) def test_db_read_char(self) -> None: """Test db_read() for Char values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_CHAR1, 1) self.assertEqual(EXPECTED_CHAR1, get_char(data, 0)) data = self.client.db_read(DB_READ_ONLY, OFFSET_CHAR2, 1) self.assertEqual(EXPECTED_CHAR2, get_char(data, 0)) def test_db_read_bool(self) -> None: """Test db_read() for Bool values.""" data = self.client.db_read(DB_READ_ONLY, OFFSET_BOOLS, 1) self.assertEqual(EXPECTED_BOOL0, get_bool(data, 0, 0)) self.assertEqual(EXPECTED_BOOL1, get_bool(data, 0, 1)) self.assertEqual(EXPECTED_BOOL2, get_bool(data, 0, 2)) self.assertEqual(EXPECTED_BOOL3, get_bool(data, 0, 3)) self.assertEqual(EXPECTED_BOOL4, get_bool(data, 0, 4)) self.assertEqual(EXPECTED_BOOL5, get_bool(data, 0, 5)) self.assertEqual(EXPECTED_BOOL6, get_bool(data, 0, 6)) self.assertEqual(EXPECTED_BOOL7, get_bool(data, 0, 7)) def test_db_read_entire_block(self) -> None: """Test db_read() for entire DB.""" data = self.client.db_read(DB_READ_ONLY, 0, DB_SIZE) self.assertEqual(DB_SIZE, len(data)) # Verify a few values self.assertEqual(EXPECTED_INT1, get_int(data, OFFSET_INT1)) self.assertAlmostEqual(EXPECTED_FLOAT1, get_real(data, OFFSET_FLOAT1), places=2) self.assertEqual(EXPECTED_DWORD1, get_dword(data, OFFSET_DWORD1)) @pytest.mark.e2e class TestClientDBWrite(unittest.TestCase): """Tests for db_write() method - writing to DB2 (read/write).""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_db_write_int(self) -> None: """Test db_write() for Int values.""" test_value = 10 data = bytearray(2) set_int(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_INT1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_INT1, 2) self.assertEqual(test_value, get_int(result, 0)) def test_db_write_real(self) -> None: """Test db_write() for Real values.""" test_value = 456.789 data = bytearray(4) set_real(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_FLOAT1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_FLOAT1, 4) self.assertAlmostEqual(test_value, get_real(result, 0), places=2) def test_db_write_byte(self) -> None: """Test db_write() for Byte values.""" test_value = 0xAB data = bytearray(1) set_byte(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_BYTE1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_BYTE1, 1) self.assertEqual(test_value, get_byte(result, 0)) def test_db_write_word(self) -> None: """Test db_write() for Word values.""" test_value = 0x1234 data = bytearray(2) set_word(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_WORD1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_WORD1, 2) self.assertEqual(test_value, get_word(result, 0)) def test_db_write_dword(self) -> None: """Test db_write() for DWord values.""" test_value = 0xDEADBEEF data = bytearray(4) set_dword(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_DWORD1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_DWORD1, 4) self.assertEqual(test_value, get_dword(result, 0)) def test_db_write_dint(self) -> None: """Test db_write() for DInt values.""" test_value = -123456789 data = bytearray(4) set_dint(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_DINT1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_DINT1, 4) self.assertEqual(test_value, get_dint(result, 0)) def test_db_write_char(self) -> None: """Test db_write() for Char values.""" test_value = "X" data = bytearray(1) set_char(data, 0, test_value) self.client.db_write(DB_READ_WRITE, OFFSET_CHAR1, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_CHAR1, 1) self.assertEqual(test_value, get_char(result, 0)) def test_db_write_bool(self) -> None: """Test db_write() for Bool values.""" # Read current byte, modify bits, write back data = self.client.db_read(DB_READ_WRITE, OFFSET_BOOLS, 1) set_bool(data, 0, 0, True) set_bool(data, 0, 7, True) self.client.db_write(DB_READ_WRITE, OFFSET_BOOLS, data) # Read back and verify result = self.client.db_read(DB_READ_WRITE, OFFSET_BOOLS, 1) self.assertTrue(get_bool(result, 0, 0)) self.assertTrue(get_bool(result, 0, 7)) @pytest.mark.e2e class TestClientReadArea(unittest.TestCase): """Tests for read_area() method.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_read_area_db(self) -> None: """Test read_area() for DB area.""" data = self.client.read_area(Area.DB, DB_READ_ONLY, OFFSET_INT1, 2) self.assertEqual(EXPECTED_INT1, get_int(data, 0)) @pytest.mark.e2e class TestClientWriteArea(unittest.TestCase): """Tests for write_area() method.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_write_area_db(self) -> None: """Test write_area() for DB area.""" test_value = 9999 data = bytearray(2) set_int(data, 0, test_value) self.client.write_area(Area.DB, DB_READ_WRITE, OFFSET_INT2, data) # Read back and verify result = self.client.read_area(Area.DB, DB_READ_WRITE, OFFSET_INT2, 2) self.assertEqual(test_value, get_int(result, 0)) @pytest.mark.e2e class TestClientMultiVars(unittest.TestCase): """Tests for read_multi_vars() and write_multi_vars() methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_read_multi_vars(self) -> None: """Test read_multi_vars() method.""" # Build S7DataItem array data_items = (S7DataItem * 2)() # Item 0: Read int1 from DB1 data_items[0].Area = c_int32(Area.DB.value) data_items[0].WordLen = c_int32(WordLen.Byte.value) data_items[0].Result = c_int32(0) data_items[0].DBNumber = c_int32(DB_READ_ONLY) data_items[0].Start = c_int32(OFFSET_INT1) data_items[0].Amount = c_int32(2) # Item 1: Read float1 from DB1 data_items[1].Area = c_int32(Area.DB.value) data_items[1].WordLen = c_int32(WordLen.Byte.value) data_items[1].Result = c_int32(0) data_items[1].DBNumber = c_int32(DB_READ_ONLY) data_items[1].Start = c_int32(OFFSET_FLOAT1) data_items[1].Amount = c_int32(4) # Create buffers for di in data_items: buffer = create_string_buffer(di.Amount) di.pData = cast(pointer(buffer), POINTER(c_uint8)) result, items = self.client.read_multi_vars(data_items) self.assertEqual(0, result) # Verify values int_value = get_int(bytearray(items[0].pData[:2]), 0) self.assertEqual(EXPECTED_INT1, int_value) float_value = get_real(bytearray(items[1].pData[:4]), 0) self.assertAlmostEqual(EXPECTED_FLOAT1, float_value, places=2) @pytest.mark.e2e class TestClientDBOperations(unittest.TestCase): """Tests for db_get() and db_fill() methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_db_get(self) -> None: """Test db_get() method.""" try: data = self.client.db_get(DB_READ_ONLY) except Exception as e: err_msg = str(e).lower() if "does not exist" in err_msg or "block info failed" in err_msg or "auto-detected size" in err_msg: pytest.skip(f"db_get with auto-detect not supported on this PLC: {e}") raise self.assertIsInstance(data, bytearray) self.assertGreater(len(data), 0) @pytest.mark.e2e class TestClientPLCInfo(unittest.TestCase): """Tests for PLC information methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_get_cpu_info(self) -> None: """Test get_cpu_info() method.""" try: cpu_info = self.client.get_cpu_info() except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertIsNotNone(cpu_info.ModuleTypeName) def test_get_cpu_state(self) -> None: """Test get_cpu_state() method.""" state = self.client.get_cpu_state() self.assertIn(state, ["S7CpuStatusRun", "S7CpuStatusStop", "S7CpuStatusUnknown"]) def test_get_pdu_length(self) -> None: """Test get_pdu_length() method.""" pdu_len = self.client.get_pdu_length() self.assertGreater(pdu_len, 0) self.assertLessEqual(pdu_len, 960) def test_get_plc_datetime(self) -> None: """Test get_plc_datetime() method.""" plc_time = self.client.get_plc_datetime() self.assertIsInstance(plc_time, datetime) # PLC time should be reasonably close to now self.assertAlmostEqual( plc_time.timestamp(), datetime.now().timestamp(), delta=3600, # Within 1 hour ) def test_get_cp_info(self) -> None: """Test get_cp_info() method.""" try: cp_info = self.client.get_cp_info() except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertGreater(cp_info.MaxPduLength, 0) def test_get_order_code(self) -> None: """Test get_order_code() method.""" try: order_code = self.client.get_order_code() except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertIsNotNone(order_code.OrderCode) def test_get_protection(self) -> None: """Test get_protection() method.""" try: protection = self.client.get_protection() except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertIsNotNone(protection) def test_get_exec_time(self) -> None: """Test get_exec_time() method.""" # Perform an operation first self.client.db_read(DB_READ_ONLY, 0, 1) exec_time = self.client.get_exec_time() self.assertIsInstance(exec_time, int) self.assertGreaterEqual(exec_time, 0) def test_get_last_error(self) -> None: """Test get_last_error() method.""" error = self.client.get_last_error() self.assertIsInstance(error, int) @pytest.mark.e2e class TestClientBlockOperations(unittest.TestCase): """Tests for block operation methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_list_blocks(self) -> None: """Test list_blocks() method.""" try: blocks = self.client.list_blocks() except Exception as e: pytest.skip(f"list_blocks not supported on this PLC: {e}") self.assertIsNotNone(blocks) # Should have at least our test DBs self.assertGreaterEqual(blocks.DBCount, 2) def test_list_blocks_of_type(self) -> None: """Test list_blocks_of_type() method.""" try: db_list = self.client.list_blocks_of_type(Block.DB, 100) except Exception as e: pytest.skip(f"list_blocks_of_type not supported on this PLC: {e}") self.assertIsInstance(db_list, list) # Should contain our test DBs self.assertIn(DB_READ_ONLY, db_list) self.assertIn(DB_READ_WRITE, db_list) def test_get_block_info(self) -> None: """Test get_block_info() method.""" try: block_info = self.client.get_block_info(Block.DB, DB_READ_ONLY) except Exception as e: pytest.skip(f"get_block_info not supported on this PLC: {e}") self.assertEqual(DB_READ_ONLY, block_info.BlkNumber) @pytest.mark.e2e class TestClientSZL(unittest.TestCase): """Tests for SZL (System Status List) methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_read_szl(self) -> None: """Test read_szl() method.""" try: # Read CPU identification (SZL 0x001C) szl = self.client.read_szl(0x001C, 0) except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertIsNotNone(szl) def test_read_szl_list(self) -> None: """Test read_szl_list() method.""" try: szl_list = self.client.read_szl_list() except Exception as e: if "does not exist" in str(e).lower(): pytest.skip(f"SZL not available on this PLC: {e}") raise self.assertIsInstance(szl_list, bytes) self.assertGreater(len(szl_list), 0) @pytest.mark.e2e class TestClientParameters(unittest.TestCase): """Tests for parameter methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_get_param(self) -> None: """Test get_param() method.""" pdu_request = self.client.get_param(Parameter.PDURequest) self.assertGreater(pdu_request, 0) def test_set_param(self) -> None: """Test set_param() method.""" # Set ping timeout self.client.set_param(Parameter.PingTimeout, 1000) # Note: get_param may not reflect all changes def test_set_connection_params(self) -> None: """Test set_connection_params() method.""" # This just sets internal values, doesn't affect current connection self.client.set_connection_params("192.168.1.1", 0x0100, 0x0102) def test_set_connection_type(self) -> None: """Test set_connection_type() method.""" self.client.set_connection_type(1) # PG self.client.set_connection_type(2) # OP self.client.set_connection_type(3) # S7Basic def test_set_session_password(self) -> None: """Test set_session_password() method.""" result = self.client.set_session_password("testpass") self.assertEqual(0, result) def test_clear_session_password(self) -> None: """Test clear_session_password() method.""" result = self.client.clear_session_password() self.assertEqual(0, result) @pytest.mark.e2e class TestClientMisc(unittest.TestCase): """Tests for miscellaneous methods.""" client: Client @classmethod def setUpClass(cls) -> None: cls.client = Client() cls.client.connect(PLC_IP, PLC_RACK, PLC_SLOT, PLC_PORT) @classmethod def tearDownClass(cls) -> None: if cls.client: cls.client.disconnect() def test_error_text(self) -> None: """Test error_text() method.""" text = self.client.error_text(0) self.assertEqual("OK", text) text = self.client.error_text(0x01E00000) self.assertEqual("CPU : Invalid password", text) def test_iso_exchange_buffer(self) -> None: """Test iso_exchange_buffer() method.""" # Write a value first self.client.db_write(DB_READ_WRITE, 0, bytearray(b"\x00\x01")) # Build a raw PDU to read DB2 offset 0, 1 byte pdu = bytearray( [ 0x32, 0x01, # Protocol ID, PDU type (request) 0x00, 0x00, # Reserved 0x00, 0x01, # Sequence 0x00, 0x0E, # Parameter length 0x00, 0x00, # Data length 0x04, # Function: Read Var 0x01, # Item count 0x12, # Var spec length 0x0A, # Var spec syntax ID 0x10, # Transport size (byte) 0x02, # Length: 2 bytes 0x00, 0x01, # Amount: 1 0x00, DB_READ_WRITE, # DB number 0x84, # Area: DB 0x00, 0x00, 0x00, # Address: byte 0, bit 0 ] ) response = self.client.iso_exchange_buffer(pdu) self.assertIsInstance(response, bytearray) self.assertGreater(len(response), 0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_datatypes.py0000644000175100017510000002063715156465122020401 0ustar00runnerrunner""" Tests for S7 data types and conversion utilities. """ import pytest import struct from snap7.datatypes import S7Area, S7WordLen, S7DataTypes class TestS7DataTypes: """Test S7 data type utilities.""" def test_get_size_bytes(self) -> None: """Test size calculation for different word lengths.""" assert S7DataTypes.get_size_bytes(S7WordLen.BIT, 1) == 1 assert S7DataTypes.get_size_bytes(S7WordLen.BYTE, 1) == 1 assert S7DataTypes.get_size_bytes(S7WordLen.WORD, 1) == 2 assert S7DataTypes.get_size_bytes(S7WordLen.DWORD, 1) == 4 assert S7DataTypes.get_size_bytes(S7WordLen.REAL, 1) == 4 # Test with multiple items assert S7DataTypes.get_size_bytes(S7WordLen.WORD, 5) == 10 assert S7DataTypes.get_size_bytes(S7WordLen.BYTE, 10) == 10 def test_encode_address_db(self) -> None: """Test address encoding for DB area.""" address = S7DataTypes.encode_address(area=S7Area.DB, db_number=1, start=10, word_len=S7WordLen.BYTE, count=5) assert len(address) == 12 assert address[0] == 0x12 # Specification type assert address[1] == 0x0A # Length assert address[2] == 0x10 # Syntax ID assert address[3] == S7WordLen.BYTE # Word length # Verify count and DB number count_bytes = address[4:6] db_bytes = address[6:8] assert struct.unpack(">H", count_bytes)[0] == 5 assert struct.unpack(">H", db_bytes)[0] == 1 # Verify area code assert address[8] == S7Area.DB def test_encode_address_memory(self) -> None: """Test address encoding for memory areas.""" address = S7DataTypes.encode_address( area=S7Area.MK, db_number=0, # Should be ignored for non-DB areas start=20, word_len=S7WordLen.WORD, count=1, ) assert len(address) == 12 assert address[8] == S7Area.MK # DB number should be 0 for non-DB areas db_bytes = address[6:8] assert struct.unpack(">H", db_bytes)[0] == 0 def test_encode_address_bit_access(self) -> None: """Test address encoding for bit access.""" # Test bit access: bit 5 of byte 10 = bit 85 address = S7DataTypes.encode_address( area=S7Area.MK, db_number=0, start=85, # Bit 5 of byte 10 word_len=S7WordLen.BIT, count=1, ) # For bit access, address should be converted to byte.bit format address_bytes = address[9:12] bit_address = struct.unpack(">I", b"\x00" + address_bytes)[0] # Should be (10 << 3) | 5 = 85 assert bit_address == 85 def test_decode_s7_data_bytes(self) -> None: """Test decoding byte data.""" data = b"\x01\x02\x03\x04" values = S7DataTypes.decode_s7_data(data, S7WordLen.BYTE, 4) assert len(values) == 4 assert values == [1, 2, 3, 4] def test_decode_s7_data_words(self) -> None: """Test decoding word data.""" # Big-endian 16-bit words: 0x0102, 0x0304 data = b"\x01\x02\x03\x04" values = S7DataTypes.decode_s7_data(data, S7WordLen.WORD, 2) assert len(values) == 2 assert values == [0x0102, 0x0304] def test_decode_s7_data_signed_int(self) -> None: """Test decoding signed integers.""" # Big-endian signed 16-bit: -1, 1000 data = b"\xff\xff\x03\xe8" values = S7DataTypes.decode_s7_data(data, S7WordLen.INT, 2) assert len(values) == 2 assert values == [-1, 1000] def test_decode_s7_data_dwords(self) -> None: """Test decoding double words.""" # Big-endian 32-bit: 0x01020304 data = b"\x01\x02\x03\x04" values = S7DataTypes.decode_s7_data(data, S7WordLen.DWORD, 1) assert len(values) == 1 assert values == [0x01020304] def test_decode_s7_data_real(self) -> None: """Test decoding IEEE float.""" # Big-endian IEEE 754 float for 3.14159 data = struct.pack(">f", 3.14159) values = S7DataTypes.decode_s7_data(data, S7WordLen.REAL, 1) assert len(values) == 1 assert abs(values[0] - 3.14159) < 0.00001 def test_decode_s7_data_bits(self) -> None: """Test decoding bit data.""" data = b"\x01\x00\x01" values = S7DataTypes.decode_s7_data(data, S7WordLen.BIT, 3) assert len(values) == 3 assert values == [True, False, True] def test_encode_s7_data_bytes(self) -> None: """Test encoding byte data.""" values = [1, 2, 3, 255] data = S7DataTypes.encode_s7_data(values, S7WordLen.BYTE) assert data == b"\x01\x02\x03\xff" def test_encode_s7_data_words(self) -> None: """Test encoding word data.""" values = [0x0102, 0x0304] data = S7DataTypes.encode_s7_data(values, S7WordLen.WORD) # Should be big-endian assert data == b"\x01\x02\x03\x04" def test_encode_s7_data_real(self) -> None: """Test encoding IEEE float.""" values = [3.14159] data = S7DataTypes.encode_s7_data(values, S7WordLen.REAL) # Should be big-endian IEEE 754 expected = struct.pack(">f", 3.14159) assert data == expected def test_encode_s7_data_bits(self) -> None: """Test encoding bit data.""" values = [True, False, True, False] data = S7DataTypes.encode_s7_data(values, S7WordLen.BIT) assert data == b"\x01\x00\x01\x00" def test_parse_address_db(self) -> None: """Test parsing DB addresses.""" # Test DB byte address area, db_num, offset = S7DataTypes.parse_address("DB1.DBB10") assert area == S7Area.DB assert db_num == 1 assert offset == 10 # Test DB word address area, db_num, offset = S7DataTypes.parse_address("DB5.DBW20") assert area == S7Area.DB assert db_num == 5 assert offset == 20 # Test DB bit address area, db_num, offset = S7DataTypes.parse_address("DB1.DBX10.5") assert area == S7Area.DB assert db_num == 1 assert offset == 10 * 8 + 5 # Bit offset def test_parse_address_memory(self) -> None: """Test parsing memory addresses.""" # Test memory byte area, db_num, offset = S7DataTypes.parse_address("M10") assert area == S7Area.MK assert db_num == 0 assert offset == 10 # Test memory word area, db_num, offset = S7DataTypes.parse_address("MW20") assert area == S7Area.MK assert db_num == 0 assert offset == 20 # Test memory bit area, db_num, offset = S7DataTypes.parse_address("M10.5") assert area == S7Area.MK assert db_num == 0 assert offset == 10 * 8 + 5 def test_parse_address_inputs(self) -> None: """Test parsing input addresses.""" # Test input byte area, db_num, offset = S7DataTypes.parse_address("I5") assert area == S7Area.PE assert db_num == 0 assert offset == 5 # Test input word area, db_num, offset = S7DataTypes.parse_address("IW10") assert area == S7Area.PE assert db_num == 0 assert offset == 10 # Test input bit area, db_num, offset = S7DataTypes.parse_address("I0.7") assert area == S7Area.PE assert db_num == 0 assert offset == 7 def test_parse_address_outputs(self) -> None: """Test parsing output addresses.""" # Test output byte area, db_num, offset = S7DataTypes.parse_address("Q3") assert area == S7Area.PA assert db_num == 0 assert offset == 3 # Test output word area, db_num, offset = S7DataTypes.parse_address("QW12") assert area == S7Area.PA assert db_num == 0 assert offset == 12 def test_parse_address_invalid(self) -> None: """Test parsing invalid addresses.""" with pytest.raises(ValueError): S7DataTypes.parse_address("INVALID") with pytest.raises(ValueError): S7DataTypes.parse_address("X1.0") # Unsupported area def test_parse_address_case_insensitive(self) -> None: """Test that address parsing is case insensitive.""" area1, db1, offset1 = S7DataTypes.parse_address("db1.dbw10") area2, db2, offset2 = S7DataTypes.parse_address("DB1.DBW10") assert area1 == area2 assert db1 == db2 assert offset1 == offset2 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_logo_client.py0000644000175100017510000000700515156465122020673 0ustar00runnerrunnerimport logging import pytest import unittest from typing import Optional import snap7 from snap7.server import Server from snap7.type import Parameter, SrvArea logging.basicConfig(level=logging.WARNING) ip = "127.0.0.1" tcpport = 1102 db_number = 1 rack = 0x1000 slot = 0x2000 @pytest.mark.logo class TestLogoClient(unittest.TestCase): server: Optional[Server] = None @classmethod def setUpClass(cls) -> None: cls.server = Server() cls.server.register_area(SrvArea.DB, 0, bytearray(600)) cls.server.register_area(SrvArea.DB, 1, bytearray(600)) cls.server.start(tcp_port=tcpport) @classmethod def tearDownClass(cls) -> None: if cls.server: cls.server.stop() cls.server.destroy() def setUp(self) -> None: self.client = snap7.logo.Logo() self.client.connect(ip, rack, slot, tcpport) def tearDown(self) -> None: self.client.disconnect() self.client.destroy() def test_read(self) -> None: vm_address = "V40" value = 50 self.client.write(vm_address, value) result = self.client.read(vm_address) self.assertEqual(value, result) def test_write(self) -> None: vm_address = "V20" value = 8 self.client.write(vm_address, value) def test_get_connected(self) -> None: self.client.get_connected() def test_set_param(self) -> None: values = ( (Parameter.PingTimeout, 800), (Parameter.SendTimeout, 15), (Parameter.RecvTimeout, 3500), (Parameter.SrcRef, 128), (Parameter.DstRef, 128), (Parameter.SrcTSap, 128), (Parameter.PDURequest, 470), ) for param, value in values: self.client.set_param(param, value) self.assertRaises(Exception, self.client.set_param, Parameter.RemotePort, 1) def test_get_param(self) -> None: expected = ( (Parameter.RemotePort, tcpport), (Parameter.PingTimeout, 750), (Parameter.SendTimeout, 10), (Parameter.RecvTimeout, 3000), (Parameter.SrcRef, 256), (Parameter.DstRef, 0), (Parameter.SrcTSap, 4096), (Parameter.PDURequest, 480), ) for param, value in expected: self.assertEqual(self.client.get_param(param), value) non_client = ( Parameter.LocalPort, Parameter.WorkInterval, Parameter.MaxClients, Parameter.BSendTimeout, Parameter.BRecvTimeout, Parameter.RecoveryTime, Parameter.KeepAliveTime, ) # invalid param for client for param in non_client: self.assertRaises(Exception, self.client.get_param, param) @pytest.mark.logo class TestClientBeforeConnect(unittest.TestCase): """ Test suite of items that should run without an open connection. """ def setUp(self) -> None: self.client = snap7.client.Client() def test_set_param(self) -> None: values = ( (Parameter.RemotePort, 1102), (Parameter.PingTimeout, 800), (Parameter.SendTimeout, 15), (Parameter.RecvTimeout, 3500), (Parameter.SrcRef, 128), (Parameter.DstRef, 128), (Parameter.SrcTSap, 128), (Parameter.PDURequest, 470), ) for param, value in values: self.client.set_param(param, value) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_mainloop.py0000644000175100017510000001611415156465122020214 0ustar00runnerrunnerimport logging import struct import pytest import unittest from typing import Optional import snap7.error import snap7.server from snap7.server import Server from snap7.type import SrvArea from snap7.util import get_bool, get_dint, get_dword, get_int, get_real, get_sint, get_string, get_usint, get_word from snap7.client import Client logging.basicConfig(level=logging.WARNING) ip = "127.0.0.1" tcp_port = 1102 db_number = 1 rack = 1 slot = 1 def _init_standard_values(db_data: bytearray) -> None: """Initialize standard test values in DB0 (same as mainloop with init_standard_values=True).""" # test_read_booleans: offset 0, expects 0xAA (alternating False/True) db_data[0] = 0xAA # test_read_small_int: offset 10, expects -128, 0, 100, 127 db_data[10] = 0x80 db_data[11] = 0x00 db_data[12] = 100 db_data[13] = 127 # test_read_unsigned_small_int: offset 20 db_data[20] = 0 db_data[21] = 255 # test_read_int: offset 30 struct.pack_into(">h", db_data, 30, -32768) struct.pack_into(">h", db_data, 32, -1234) struct.pack_into(">h", db_data, 34, 0) struct.pack_into(">h", db_data, 36, 1234) struct.pack_into(">h", db_data, 38, 32767) # test_read_double_int: offset 40 struct.pack_into(">i", db_data, 40, -2147483648) struct.pack_into(">i", db_data, 44, -32768) struct.pack_into(">i", db_data, 48, 0) struct.pack_into(">i", db_data, 52, 32767) struct.pack_into(">i", db_data, 56, 2147483647) # test_read_real: offset 60 struct.pack_into(">f", db_data, 60, -3.402823e38) struct.pack_into(">f", db_data, 64, -3.402823e12) struct.pack_into(">f", db_data, 68, -175494351e-38) struct.pack_into(">f", db_data, 72, -1.175494351e-12) struct.pack_into(">f", db_data, 76, 0.0) struct.pack_into(">f", db_data, 80, 1.175494351e-38) struct.pack_into(">f", db_data, 84, 1.175494351e-12) struct.pack_into(">f", db_data, 88, 3.402823466e12) struct.pack_into(">f", db_data, 92, 3.402823466e38) # test_read_string: offset 100 test_string = "the brown fox jumps over the lazy dog" db_data[100] = 254 db_data[101] = len(test_string) db_data[102 : 102 + len(test_string)] = test_string.encode("ascii") # test_read_word: offset 400 struct.pack_into(">H", db_data, 400, 0x0000) struct.pack_into(">H", db_data, 404, 0x1234) struct.pack_into(">H", db_data, 408, 0xABCD) struct.pack_into(">H", db_data, 412, 0xFFFF) # test_read_double_word: offset 500 struct.pack_into(">I", db_data, 500, 0x00000000) struct.pack_into(">I", db_data, 508, 0x12345678) struct.pack_into(">I", db_data, 516, 0x1234ABCD) struct.pack_into(">I", db_data, 524, 0xFFFFFFFF) @pytest.mark.mainloop class TestServer(unittest.TestCase): server: Optional[Server] = None client: Client @classmethod def setUpClass(cls) -> None: cls.server = Server() # Create DB0 with standard test values db_data = bytearray(600) _init_standard_values(db_data) cls.server.register_area(SrvArea.DB, 0, db_data) cls.server.register_area(SrvArea.DB, 1, bytearray(600)) cls.server.start(tcp_port=tcp_port) @classmethod def tearDownClass(cls) -> None: if cls.server: cls.server.stop() cls.server.destroy() def setUp(self) -> None: self.client: Client = snap7.client.Client() self.client.connect(ip, rack, slot, tcp_port) def tearDown(self) -> None: if self.client: self.client.disconnect() self.client.destroy() def test_read_booleans(self) -> None: data = self.client.db_read(0, 0, 1) self.assertEqual(False, get_bool(data, 0, 0)) self.assertEqual(True, get_bool(data, 0, 1)) self.assertEqual(False, get_bool(data, 0, 2)) self.assertEqual(True, get_bool(data, 0, 3)) self.assertEqual(False, get_bool(data, 0, 4)) self.assertEqual(True, get_bool(data, 0, 5)) self.assertEqual(False, get_bool(data, 0, 6)) self.assertEqual(True, get_bool(data, 0, 7)) def test_read_small_int(self) -> None: data = self.client.db_read(0, 10, 4) value_1 = get_sint(data, 0) value_2 = get_sint(data, 1) value_3 = get_sint(data, 2) value_4 = get_sint(data, 3) self.assertEqual(value_1, -128) self.assertEqual(value_2, 0) self.assertEqual(value_3, 100) self.assertEqual(value_4, 127) def test_read_unsigned_small_int(self) -> None: data = self.client.db_read(0, 20, 2) self.assertEqual(get_usint(data, 0), 0) self.assertEqual(get_usint(data, 1), 255) def test_read_int(self) -> None: data = self.client.db_read(0, 30, 10) self.assertEqual(get_int(data, 0), -32768) self.assertEqual(get_int(data, 2), -1234) self.assertEqual(get_int(data, 4), 0) self.assertEqual(get_int(data, 6), 1234) self.assertEqual(get_int(data, 8), 32767) def test_read_double_int(self) -> None: data = self.client.db_read(0, 40, 4 * 5) self.assertEqual(get_dint(data, 0), -2147483648) self.assertEqual(get_dint(data, 4), -32768) self.assertEqual(get_dint(data, 8), 0) self.assertEqual(get_dint(data, 12), 32767) self.assertEqual(get_dint(data, 16), 2147483647) def test_read_real(self) -> None: data = self.client.db_read(0, 60, 4 * 9) self.assertAlmostEqual(get_real(data, 0), -3.402823e38, delta=-3.402823e38 * -0.0000001) self.assertAlmostEqual(get_real(data, 4), -3.402823e12, delta=-3.402823e12 * -0.0000001) self.assertAlmostEqual(get_real(data, 8), -175494351e-38, delta=-175494351e-38 * -0.0000001) self.assertAlmostEqual(get_real(data, 12), -1.175494351e-12, delta=-1.175494351e-12 * -0.0000001) self.assertAlmostEqual(get_real(data, 16), 0.0) self.assertAlmostEqual(get_real(data, 20), 1.175494351e-38, delta=1.175494351e-38 * 0.0000001) self.assertAlmostEqual(get_real(data, 24), 1.175494351e-12, delta=1.175494351e-12 * 0.0000001) self.assertAlmostEqual(get_real(data, 28), 3.402823466e12, delta=3.402823466e12 * 0.0000001) self.assertAlmostEqual(get_real(data, 32), 3.402823466e38, delta=3.402823466e38 * 0.0000001) def test_read_string(self) -> None: data = self.client.db_read(0, 100, 254) self.assertEqual(get_string(data, 0), "the brown fox jumps over the lazy dog") def test_read_word(self) -> None: data = self.client.db_read(0, 400, 4 * 4) self.assertEqual(get_word(data, 0), 0x0000) self.assertEqual(get_word(data, 4), 0x1234) self.assertEqual(get_word(data, 8), 0xABCD) self.assertEqual(get_word(data, 12), 0xFFFF) def test_read_double_word(self) -> None: data = self.client.db_read(0, 500, 8 * 4) self.assertEqual(get_dword(data, 0), 0x00000000) self.assertEqual(get_dword(data, 8), 0x12345678) self.assertEqual(get_dword(data, 16), 0x1234ABCD) self.assertEqual(get_dword(data, 24), 0xFFFFFFFF) if __name__ == "__main__": import logging logging.basicConfig() unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_multipacket.py0000644000175100017510000004011515156465122020716 0ustar00runnerrunner"""Tests for S7 protocol behavior. Tests USERDATA response parameter parsing, follow-up request building, fragment-aware SZL parsing, multi-packet accumulation, protocol error codes, and TPDU size configuration. """ import struct from typing import Any, Dict import pytest from snap7.s7protocol import S7Protocol, S7UserDataGroup, S7UserDataSubfunction from snap7.error import ( S7_PROTOCOL_ERROR_CODES, S7ProtocolError, S7StalePacketError, S7PacketLostError, get_protocol_error_message, ) from snap7.connection import TPDUSize, ISOTCPConnection @pytest.mark.client class TestUserdataResponseParamParsing: """Test _parse_userdata_response_params via _parse_parameters.""" def setup_method(self) -> None: self.protocol = S7Protocol() def test_last_packet(self) -> None: """Parse USERDATA response params indicating last data unit.""" # 12-byte param section: last_data_unit=0x00 (done) param_data = bytes( [ 0x00, # Reserved 0x01, # Param count 0x12, # Type header 0x08, # Length 0x12, # Method (response) 0x84, # Type(8) | Group(4=SZL) 0x01, # Subfunction (READ_SZL) 0x01, # Sequence number 0x00, # Data unit reference 0x00, # Last data unit (done) 0x00, 0x00, # Error code ] ) result = self.protocol._parse_parameters(param_data) assert result["group"] == S7UserDataGroup.SZL assert result["subfunction"] == S7UserDataSubfunction.READ_SZL assert result["sequence_number"] == 0x01 assert result["last_data_unit"] == 0x00 assert result["error_code"] == 0x0000 def test_more_data(self) -> None: """Parse USERDATA response params indicating more data coming.""" # last_data_unit=0x01 means more data param_data = bytes( [ 0x00, 0x01, 0x12, 0x08, 0x12, 0x84, # Group 4 = SZL 0x01, # Subfunction 0x02, # Sequence number 0xD5, # Data unit reference 0x01, # Last data unit (MORE) 0x00, 0x00, ] ) result = self.protocol._parse_parameters(param_data) assert result["group"] == S7UserDataGroup.SZL assert result["sequence_number"] == 0x02 assert result["last_data_unit"] == 0x01 def test_block_info_group(self) -> None: """Parse USERDATA response with block info group.""" param_data = bytes( [ 0x00, 0x01, 0x12, 0x08, 0x12, 0x83, # Type(8) | Group(3=BlockInfo) 0x02, # Subfunction (LIST_BLOCKS_OF_TYPE) 0x03, # Sequence number 0x00, # Data unit ref 0x01, # More data 0x00, 0x00, ] ) result = self.protocol._parse_parameters(param_data) assert result["group"] == S7UserDataGroup.BLOCK_INFO assert result["subfunction"] == S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE assert result["sequence_number"] == 0x03 assert result["last_data_unit"] == 0x01 def test_non_userdata_still_works(self) -> None: """Non-USERDATA params still dispatch to existing parsers.""" # READ_AREA response param_data = bytes([0x04, 0x01]) result = self.protocol._parse_parameters(param_data) assert result["function_code"] == 0x04 assert result["item_count"] == 1 @pytest.mark.client class TestFollowupRequestBuilder: """Test build_userdata_followup_request byte format.""" def setup_method(self) -> None: self.protocol = S7Protocol() def test_szl_followup(self) -> None: """Follow-up request for SZL has correct structure.""" pdu = self.protocol.build_userdata_followup_request( group=S7UserDataGroup.SZL, subfunction=S7UserDataSubfunction.READ_SZL, sequence_number=0x02, ) # S7 header: 10 bytes for USERDATA assert pdu[0] == 0x32 # Protocol ID assert pdu[1] == 0x07 # USERDATA # Extract param_len and data_len from header param_len = struct.unpack(">H", pdu[6:8])[0] data_len = struct.unpack(">H", pdu[8:10])[0] assert param_len == 8 assert data_len == 4 # Parameter section starts at offset 10 params = pdu[10 : 10 + param_len] assert params[0] == 0x00 # Reserved assert params[1] == 0x01 # Param count assert params[2] == 0x12 # Type header assert params[3] == 0x04 # Length assert params[4] == 0x11 # Method (request) assert params[5] == 0x44 # Type(4) | Group(4=SZL) assert params[6] == 0x01 # Subfunction assert params[7] == 0x02 # DataRef = sequence_number # Data section data = pdu[10 + param_len :] assert data == bytes([0x0A, 0x00, 0x00, 0x00]) def test_block_info_followup(self) -> None: """Follow-up request for block info group.""" pdu = self.protocol.build_userdata_followup_request( group=S7UserDataGroup.BLOCK_INFO, subfunction=S7UserDataSubfunction.LIST_BLOCKS_OF_TYPE, sequence_number=0x05, ) params = pdu[10:18] assert params[5] == 0x43 # Type(4) | Group(3=BlockInfo) assert params[6] == 0x02 # LIST_BLOCKS_OF_TYPE assert params[7] == 0x05 # DataRef @pytest.mark.client class TestSzlFragmentParsing: """Test parse_read_szl_response with first_fragment flag.""" def setup_method(self) -> None: self.protocol = S7Protocol() def test_first_fragment_parses_header(self) -> None: """First fragment extracts SZL ID and Index from data.""" response: Dict[str, Any] = { "data": { "data": b"\x00\x1c\x00\x00" + b"\xaa\xbb\xcc", } } result = self.protocol.parse_read_szl_response(response, first_fragment=True) assert result["szl_id"] == 0x001C assert result["szl_index"] == 0x0000 assert result["data"] == b"\xaa\xbb\xcc" def test_followup_fragment_raw_data(self) -> None: """Follow-up fragment returns all data as raw payload.""" payload = b"\xdd\xee\xff\x01\x02\x03" response: Dict[str, Any] = { "data": { "data": payload, } } result = self.protocol.parse_read_szl_response(response, first_fragment=False) assert result["szl_id"] == 0 assert result["szl_index"] == 0 assert result["data"] == payload def test_default_is_first_fragment(self) -> None: """Default behavior (no flag) treats as first fragment.""" response: Dict[str, Any] = { "data": { "data": b"\x00\x11\x00\x22" + b"\x33", } } result = self.protocol.parse_read_szl_response(response) assert result["szl_id"] == 0x0011 assert result["szl_index"] == 0x0022 assert result["data"] == b"\x33" @pytest.mark.client class TestMultiPacketSzlIntegration: """Integration test: mock connection returning 2-packet SZL response. Uses real packet data from nikteliy's pcap captures. """ def _build_full_pdu(self, param_bytes: bytes, data_bytes: bytes) -> bytes: """Helper to build a complete S7 USERDATA PDU.""" header = struct.pack( ">BBHHHH", 0x32, # Protocol ID 0x07, # USERDATA 0x0000, # Reserved 0x0001, # Sequence len(param_bytes), len(data_bytes), ) return header + param_bytes + data_bytes def test_two_packet_szl_response(self) -> None: """Simulate a 2-packet SZL response using nikteliy's test data.""" # First response: last_data_unit=0x01 (more data), seq=0x02 # Real packet data from pcap response1 = ( b"\x32\x07\x00\x00\x02\x00\x00\x0c\x00\xda" b"\x00\x01\x12\x08\x12\x84\x01\x02\xd5\x01\x00\x00" b"\xff\x09\x00\xd6" b"\x00\x1c\x00\x00" b"\x00\x22\x00\x0a" b"\x00\x01" b"\x53\x37\x33\x30\x30\x2f\x45\x54\x32\x30\x30\x4d" b"\x20\x73\x74\x61\x74\x69\x6f\x6e\x5f\x31\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00" b"\x00\x02" b"\x50\x4c\x43\x5f\x31\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00" b"\x00\x00\x00\x03" b"\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x04" b"\x4f\x72\x69\x67\x69\x6e\x61\x6c" b"\x20\x53\x69\x65\x6d\x65\x6e\x73\x20\x45\x71\x75\x69\x70" b"\x6d\x65\x6e\x74\x00\x00\x00\x00\x00\x00" b"\x00\x05" b"\x53\x20\x43\x2d\x42\x31\x55\x33\x39\x33\x31\x34\x32\x30\x31\x31" b"\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x07" b"\x43\x50\x55\x20\x33\x31\x35\x2d\x32\x20\x50\x4e\x2f\x44\x50" b"\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08" ) # Second response: last_data_unit=0x00 (done), seq=0x02 response2 = ( b"\x32\x07\x00\x00\x03\x00\x00\x0c\x00\x8a" b"\x00\x01\x12\x08\x12\x84\x01\x02\xd5\x00\x00\x00" b"\xff\x09\x00\x86" b"\x4d\x4d\x43\x20\x34\x41\x31\x41\x43\x30\x31\x39" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x09" b"\x00\x2a\xf6\x00" b"\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x0a" b"\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x0b" b"\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) protocol = S7Protocol() # Parse first response parsed1 = protocol.parse_response(response1) params1 = parsed1["parameters"] assert params1["last_data_unit"] == 0x01 # More data assert params1["sequence_number"] == 0x02 # Parse SZL header from first fragment szl1 = protocol.parse_read_szl_response(parsed1, first_fragment=True) assert szl1["szl_id"] == 0x001C first_data = szl1["data"] # Parse second response parsed2 = protocol.parse_response(response2) params2 = parsed2["parameters"] assert params2["last_data_unit"] == 0x00 # Done # Parse follow-up fragment (no SZL header) szl2 = protocol.parse_read_szl_response(parsed2, first_fragment=False) second_data = szl2["data"] # Combined data should be larger than either fragment alone combined = first_data + second_data assert len(combined) > len(first_data) assert len(combined) > len(second_data) assert len(combined) == len(first_data) + len(second_data) def test_single_packet_no_loop(self) -> None: """Single-packet response (last_data_unit=0x00) skips follow-up.""" protocol = S7Protocol() # Build a single-packet SZL response with last_data_unit=0x00 param_bytes = bytes( [ 0x00, 0x01, 0x12, 0x08, 0x12, 0x84, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, ] ) data_bytes = ( b"\xff\x09\x00\x08" # return_code, transport_size, length b"\x00\x1c\x00\x00" # SZL ID=0x001C, Index=0 b"\xaa\xbb\xcc\xdd" # payload ) pdu = self._build_full_pdu(param_bytes, data_bytes) parsed = protocol.parse_response(pdu) params = parsed["parameters"] assert params["last_data_unit"] == 0x00 szl = protocol.parse_read_szl_response(parsed, first_fragment=True) assert szl["szl_id"] == 0x001C assert szl["data"] == b"\xaa\xbb\xcc\xdd" @pytest.mark.client class TestProtocolErrorCodes: """Test S7 protocol error code dictionary and exception hierarchy.""" def test_error_codes_count(self) -> None: """S7_PROTOCOL_ERROR_CODES should have ~210 entries.""" assert len(S7_PROTOCOL_ERROR_CODES) > 200 def test_known_code_lookup(self) -> None: assert get_protocol_error_message(0x0000) == "No error" assert get_protocol_error_message(0x8104) == ( "This service is not implemented on the module or a frame error was reported" ) assert "Illegal job number" in get_protocol_error_message(0xD001) def test_unknown_code_lookup(self) -> None: msg = get_protocol_error_message(0xFFFF) assert "Unknown protocol error" in msg assert "0xffff" in msg def test_code_ranges_present(self) -> None: """Verify codes from key ranges are included.""" assert 0x0110 in S7_PROTOCOL_ERROR_CODES # block-related assert 0x8100 in S7_PROTOCOL_ERROR_CODES # service/protocol assert 0xD001 in S7_PROTOCOL_ERROR_CODES # USERDATA parameter assert 0xD601 in S7_PROTOCOL_ERROR_CODES # USERDATA parameter assert 0xE201 in S7_PROTOCOL_ERROR_CODES # sync def test_exception_hierarchy(self) -> None: """Stale/Lost exceptions inherit from S7ProtocolError.""" assert issubclass(S7StalePacketError, S7ProtocolError) assert issubclass(S7PacketLostError, S7ProtocolError) def test_parse_response_raises_on_error_class(self) -> None: """parse_response should raise S7ProtocolError when error_class != 0.""" proto = S7Protocol() pdu = struct.pack( ">BBHHHHBB", 0x32, # protocol ID 0x03, # ACK_DATA 0x0000, # reserved 0x0001, # sequence 0x0000, # param length 0x0000, # data length 0x81, # error class 0x04, # error code ) with pytest.raises(S7ProtocolError, match="protocol error"): proto.parse_response(pdu) @pytest.mark.client class TestTPDUSize: """Test TPDUSize enum and COTP negotiation.""" def test_enum_values(self) -> None: assert int(TPDUSize.S_128) == 0x07 assert int(TPDUSize.S_256) == 0x08 assert int(TPDUSize.S_512) == 0x09 assert int(TPDUSize.S_1024) == 0x0A assert int(TPDUSize.S_2048) == 0x0B assert int(TPDUSize.S_4096) == 0x0C assert int(TPDUSize.S_8192) == 0x0D def test_actual_sizes(self) -> None: """Verify 2^code gives correct byte sizes.""" assert 1 << TPDUSize.S_128 == 128 assert 1 << TPDUSize.S_1024 == 1024 assert 1 << TPDUSize.S_8192 == 8192 def test_default_tpdu_size(self) -> None: """ISOTCPConnection should default to S_1024.""" conn = ISOTCPConnection("127.0.0.1") assert conn.tpdu_size == TPDUSize.S_1024 def test_custom_tpdu_size(self) -> None: """ISOTCPConnection should accept custom TPDU size.""" conn = ISOTCPConnection("127.0.0.1", tpdu_size=TPDUSize.S_4096) assert conn.tpdu_size == TPDUSize.S_4096 def test_tpdu_size_in_cotp_cr(self) -> None: """COTP CR PDU should contain the configured TPDU size.""" conn = ISOTCPConnection("127.0.0.1", tpdu_size=TPDUSize.S_2048) cr_pdu = conn._build_cotp_cr() assert cr_pdu[-3:] == bytes([0xC0, 0x01, TPDUSize.S_2048]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_partner.py0000644000175100017510000000705415156465122020054 0ustar00runnerrunnerimport logging import pytest import unittest as unittest from snap7.error import error_text import snap7.partner from snap7.type import Parameter logging.basicConfig(level=logging.WARNING) @pytest.mark.partner class TestPartner(unittest.TestCase): def setUp(self) -> None: self.partner = snap7.partner.Partner() self.partner.port = 12103 # Use unique port for partner tests self.partner.remote_port = 12103 self.partner.start() def tearDown(self) -> None: self.partner.stop() self.partner.destroy() def test_as_b_send(self) -> None: self.partner.as_b_send() def test_b_send_recv(self) -> None: self.partner.b_send() # self.partner.b_recv() def test_check_as_b_recv_completion(self) -> None: self.partner.check_as_b_recv_completion() def test_check_as_b_send_completion(self) -> None: self.partner.check_as_b_send_completion() def test_create(self) -> None: self.partner.create() def test_destroy(self) -> None: self.partner.destroy() def test_error_text(self) -> None: error_text(0, context="partner") def test_get_last_error(self) -> None: self.partner.get_last_error() def test_get_param(self) -> None: expected = ( (Parameter.LocalPort, 0), (Parameter.RemotePort, 12103), # Non-privileged port for tests (Parameter.PingTimeout, 750), (Parameter.SendTimeout, 10), (Parameter.RecvTimeout, 3000), (Parameter.SrcRef, 256), (Parameter.DstRef, 0), (Parameter.PDURequest, 480), (Parameter.WorkInterval, 100), (Parameter.BSendTimeout, 3000), (Parameter.BRecvTimeout, 3000), (Parameter.RecoveryTime, 500), (Parameter.KeepAliveTime, 5000), ) for param, value in expected: self.assertEqual(self.partner.get_param(param), value) self.assertRaises(Exception, self.partner.get_param, Parameter.MaxClients) def test_get_stats(self) -> None: self.partner.get_stats() def test_get_status(self) -> None: self.partner.get_status() def test_get_times(self) -> None: self.partner.get_times() def test_set_param(self) -> None: values = ( (Parameter.PingTimeout, 800), (Parameter.SendTimeout, 15), (Parameter.RecvTimeout, 3500), (Parameter.WorkInterval, 50), (Parameter.SrcRef, 128), (Parameter.DstRef, 128), (Parameter.SrcTSap, 128), (Parameter.PDURequest, 470), (Parameter.BSendTimeout, 2000), (Parameter.BRecvTimeout, 2000), (Parameter.RecoveryTime, 400), (Parameter.KeepAliveTime, 4000), ) for param, value in values: self.partner.set_param(param, value) self.assertRaises(Exception, self.partner.set_param, Parameter.RemotePort, 1) def test_set_recv_callback(self) -> None: self.partner.set_recv_callback() def test_set_send_callback(self) -> None: self.partner.set_send_callback() def test_start(self) -> None: self.partner.start() def test_start_to(self) -> None: self.partner.start_to("0.0.0.0", "0.0.0.0", 0, 0) # noqa: S104 def test_stop(self) -> None: self.partner.stop() def test_wait_as_b_send_completion(self) -> None: self.assertRaises(RuntimeError, self.partner.wait_as_b_send_completion) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_server.py0000644000175100017510000001640515156465122017707 0ustar00runnerrunnerfrom ctypes import c_char import logging import time import pytest import unittest from threading import Thread from snap7.error import server_errors, error_text from snap7.server import Server from snap7.type import SrvEvent, mkEvent, mkLog, SrvArea, Parameter logging.basicConfig(level=logging.WARNING) @pytest.mark.server class TestServer(unittest.TestCase): def setUp(self) -> None: self.server = Server() self.server.start(tcp_port=12102) # Use unique port for server tests def tearDown(self) -> None: self.server.stop() self.server.destroy() def test_register_area(self) -> None: db1_type = c_char * 1024 self.server.register_area(SrvArea.DB, 3, db1_type()) def test_error(self) -> None: for error in server_errors: error_text(error, context="client") def test_event(self) -> None: event = SrvEvent() self.server.event_text(event) def test_get_status(self) -> None: server, cpu, num_clients = self.server.get_status() def test_get_mask(self) -> None: self.server.get_mask(mkEvent) self.server.get_mask(mkLog) # invalid kind self.assertRaises(Exception, self.server.get_mask, 3) def test_lock_area(self) -> None: area = SrvArea.DB index = 1 db1_type = c_char * 1024 # we need to register first self.server.register_area(area, index, db1_type()) self.server.lock_area(area=area, index=index) def second_locker() -> None: self.server.lock_area(area=area, index=index) self.server.unlock_area(area=area, index=index) thread = Thread(target=second_locker) thread.daemon = True thread.start() thread.join(timeout=1) self.assertTrue(thread.is_alive()) self.server.unlock_area(area=area, index=index) thread.join(timeout=1) self.assertFalse(thread.is_alive()) def test_set_cpu_status(self) -> None: self.server.set_cpu_status(0) self.server.set_cpu_status(4) self.server.set_cpu_status(8) self.assertRaises(ValueError, self.server.set_cpu_status, -1) def test_set_mask(self) -> None: self.server.set_mask(kind=mkEvent, mask=10) def test_unlock_area(self) -> None: area_code = SrvArea.DB index = 1 db1_type = c_char * 1024 # we need to register first self.assertRaises(Exception, self.server.lock_area, area_code, index) self.server.register_area(area_code, index, db1_type()) self.server.lock_area(area_code, index) self.server.unlock_area(area_code, index) def test_unregister_area(self) -> None: area_code = SrvArea.DB index = 1 db1_type = c_char * 1024 self.server.register_area(area_code, index, db1_type()) self.server.unregister_area(area_code, index) def test_events_callback(self) -> None: def event_call_back(event: SrvEvent) -> None: logging.debug(event) self.server.set_events_callback(event_call_back) def test_read_events_callback(self) -> None: def read_events_call_back(event: SrvEvent) -> None: logging.debug(event) self.server.set_read_events_callback(read_events_call_back) def test_pick_event(self) -> None: event = self.server.pick_event() self.assertEqual(type(event), SrvEvent) event = self.server.pick_event() self.assertFalse(event) def test_clear_events(self) -> None: self.server.clear_events() self.assertFalse(self.server.clear_events()) def test_start_to(self) -> None: self.server.start_to("0.0.0.0") # noqa: S104 self.assertRaises(ValueError, self.server.start_to, "bogus") def test_get_param(self) -> None: # check the defaults self.assertEqual(self.server.get_param(Parameter.LocalPort), 12102) self.assertEqual(self.server.get_param(Parameter.WorkInterval), 100) self.assertEqual(self.server.get_param(Parameter.MaxClients), 1024) # invalid param for server self.assertRaises(Exception, self.server.get_param, Parameter.RemotePort) @pytest.mark.server class TestServerBeforeStart(unittest.TestCase): """ Tests for server before it is started """ def setUp(self) -> None: self.server = Server() def test_set_param(self) -> None: self.server.set_param(Parameter.LocalPort, 1102) @pytest.mark.server class TestServerRobustness(unittest.TestCase): """Test server robustness and edge cases.""" def test_multiple_server_instances(self) -> None: """Test multiple server instances on different ports.""" from snap7.client import Client servers = [] clients = [] try: # Start multiple servers for i in range(3): server = Server() port = 12110 + i # Register test area data = (c_char * 100)() data[0] = bytes([i + 1]) # Unique identifier server.register_area(SrvArea.DB, 1, data) server.start(port) servers.append((server, port)) time.sleep(0.1) # Connect clients to each server for i, (server, port) in enumerate(servers): client = Client() client.connect("127.0.0.1", 0, 1, port) clients.append(client) # Verify unique data read_data = client.db_read(1, 0, 1) self.assertEqual(read_data[0], i + 1) finally: # Clean up for client in clients: try: client.disconnect() except Exception: pass for server, port in servers: try: server.stop() server.destroy() except Exception: pass def test_server_area_management(self) -> None: """Test server area registration/unregistration.""" from snap7.client import Client server = Server() port = 12120 try: # Test area registration area1 = (c_char * 50)() area2 = (c_char * 100)() result1 = server.register_area(SrvArea.DB, 1, area1) result2 = server.register_area(SrvArea.DB, 2, area2) self.assertEqual(result1, 0) self.assertEqual(result2, 0) # Start server server.start(port) time.sleep(0.1) # Test client access to both areas client = Client() client.connect("127.0.0.1", 0, 1, port) data1 = client.db_read(1, 0, 4) data2 = client.db_read(2, 0, 4) self.assertEqual(len(data1), 4) self.assertEqual(len(data2), 4) # Test area unregistration result3 = server.unregister_area(SrvArea.DB, 1) self.assertEqual(result3, 0) client.disconnect() finally: try: server.stop() server.destroy() except Exception: pass if __name__ == "__main__": import logging logging.basicConfig() unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1773824594.0 python_snap7-3.0.0/tests/test_util.py0000644000175100017510000006450215156465122017357 0ustar00runnerrunnerimport datetime import pytest import unittest import struct from typing import cast from snap7 import DB, Row from snap7.util import get_byte, get_time, get_fstring, get_int from snap7.util import set_byte, set_time, set_fstring, set_int from snap7.type import WordLen test_spec = """ 4 ID INT 6 NAME STRING[4] 12.0 testbool1 BOOL 12.1 testbool2 BOOL 12.2 testbool3 BOOL 12.3 testbool4 BOOL 12.4 testbool5 BOOL 12.5 testbool6 BOOL 12.6 testbool7 BOOL 12.7 testbool8 BOOL 13 testReal REAL 17 testDword DWORD 21 testint2 INT 23 testDint DINT 27 testWord WORD 29 testS5time S5TIME 31 testdateandtime DATE_AND_TIME 43 testusint0 USINT 44 testsint0 SINT 46 testTime TIME 50 testByte BYTE 51 testUint UINT 53 testUdint UDINT 57 testLreal LREAL 65 testChar CHAR 66 testWchar WCHAR 68 testWstring WSTRING[4] 80 testDate DATE 82 testTod TOD 86 testDtl DTL 98 testFstring FSTRING[8] """ test_spec_indented = """ 4 ID INT 6 NAME STRING[4] 12.0 testbool1 BOOL 12.1 testbool2 BOOL 12.2 testbool3 BOOL # 12.3 test bool4 BOOL # 12.4 testbool5 BOOL # 12.5 testbool6 BOOL # 12.6 testbool7 BOOL 12.7 testbool8 BOOL 13 testReal REAL 17 testDword DWORD 21 testint2 INT 23 testDint DINT 27 testWord WORD 29 tests5time S5TIME 31 testdateandtime DATE_AND_TIME 43 testusint0 USINT 44 testsint0 SINT 50 testByte BYTE 51 testUint UINT 53 testUdint UDINT 57 testLreal LREAL 65 testChar CHAR 66 testWchar WCHAR 68 testWstring WSTRING[4] 80 testDate DATE 82 testTod TOD 86 testDtl DTL 98 testFstring FSTRING[8] """ _bytearray = bytearray( [ 0, 0, # test int 4, 4, ord("t"), ord("e"), ord("s"), ord("t"), # test string 128 * 0 + 64 * 0 + 32 * 0 + 16 * 0 + 8 * 1 + 4 * 1 + 2 * 1 + 1 * 1, # test bools 68, 78, 211, 51, # test real 255, 255, 255, 255, # test dword 0, 0, # test int 2 128, 0, 0, 0, # test dint 255, 255, # test word 0, 16, # test s5time, 0 is the time base, # 16 is value, those two integers should be declared together 32, 7, 18, 23, 50, 2, 133, 65, # these 8 values build the date and time 12 byte total # data typ together, for details under this link # https://support.industry.siemens.com/cs/document/36479/date_and_time-format-bei-s7-?dti=0&lc=de-DE 254, 254, 254, 254, 254, 127, # test small int 128, # test set byte 143, 255, 255, 255, # test time 254, # test byte 0xFE 48, 57, # test uint 12345 7, 91, 205, 21, # test udint 123456789 65, 157, 111, 52, 84, 126, 107, 117, # test lreal 123456789.123456789 65, # test char A 3, 169, # test wchar Ω 0, 4, 0, 4, 3, 169, 0, ord("s"), 0, ord("t"), 0, 196, # test wstring Ω s t Ä 45, 235, # test date 09.03.2022 2, 179, 41, 128, # test tod 12:34:56 7, 230, 3, 9, 4, 12, 34, 45, 0, 0, 0, 0, # test dtl 09.03.2022 12:34:56 116, 101, 115, 116, 32, 32, 32, 32, # test fstring 'test ' ] ) _new_bytearray = bytearray(100) _new_bytearray[41 : 41 + 1] = struct.pack("B", 128) # byte_index=41, value=128, bytes=1 _new_bytearray[42 : 42 + 1] = struct.pack("B", 255) # byte_index=41, value=255, bytes=1 _new_bytearray[43 : 43 + 4] = struct.pack("I", 286331153) # byte_index=43, value=286331153(T#3D_7H_32M_11S_153MS), bytes=4 @pytest.mark.util class TestS7util(unittest.TestCase): def test_get_byte_new(self) -> None: test_array = bytearray(_new_bytearray) byte_ = get_byte(test_array, 41) self.assertEqual(byte_, 128) byte_ = get_byte(test_array, 42) self.assertEqual(byte_, 255) def test_set_byte_new(self) -> None: test_array = bytearray(_new_bytearray) set_byte(test_array, 41, 127) byte_ = get_byte(test_array, 41) self.assertEqual(byte_, 127) def test_get_byte(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(50, "BYTE") # get value self.assertEqual(value, 254) def test_set_byte(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testByte"] = 255 self.assertEqual(row["testByte"], 255) def test_set_char(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testChar"] = chr(65) self.assertEqual(row["testChar"], "A") def test_set_lreal(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testLreal"] = 123.123 self.assertEqual(row["testLreal"], 123.123) def test_get_s5time(self) -> None: """ S5TIME extraction from bytearray """ test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertEqual(row["testS5time"], "0:00:00.100000") def test_get_dt(self) -> None: """ DATE_AND_TIME extraction from bytearray """ test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertEqual(row["testdateandtime"], "2020-07-12T17:32:02.854000") def test_get_time(self) -> None: test_values = [ (0, "0:0:0:0.0"), (1, "0:0:0:0.1"), # T#1MS (1000, "0:0:0:1.0"), # T#1S (60000, "0:0:1:0.0"), # T#1M (3600000, "0:1:0:0.0"), # T#1H (86400000, "1:0:0:0.0"), # T#1D (2147483647, "24:20:31:23.647"), # max range (-0, "0:0:0:0.0"), (-1, "-0:0:0:0.1"), # T#-1MS (-1000, "-0:0:0:1.0"), # T#-1S (-60000, "-0:0:1:0.0"), # T#-1M (-3600000, "-0:1:0:0.0"), # T#-1H (-86400000, "-1:0:0:0.0"), # T#-1D (-2147483647, "-24:20:31:23.647"), # min range ] data = bytearray(4) for value_to_test, expected_value in test_values: data[:] = struct.pack(">i", value_to_test) self.assertEqual(get_time(data, 0), expected_value) def test_set_time(self) -> None: test_array = bytearray(_new_bytearray) with self.assertRaises(ValueError): set_time(test_array, 43, "-24:25:30:23:193") with self.assertRaises(ValueError): set_time(test_array, 43, "-24:24:32:11.648") with self.assertRaises(ValueError): set_time(test_array, 43, "-25:23:32:11.648") with self.assertRaises(ValueError): set_time(test_array, 43, "24:24:30:23.620") set_time(test_array, 43, "24:20:31:23.647") byte_ = get_time(test_array, 43) self.assertEqual(byte_, "24:20:31:23.647") set_time(test_array, 43, "-24:20:31:23.648") byte_ = get_time(test_array, 43) self.assertEqual(byte_, "-24:20:31:23.648") set_time(test_array, 43, "3:7:32:11.153") byte_ = get_time(test_array, 43) self.assertEqual(byte_, "3:7:32:11.153") def test_get_string(self) -> None: """ Text extraction from string from bytearray """ test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertEqual(row["NAME"], "test") def test_write_string(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["NAME"] = "abc" self.assertEqual(row["NAME"], "abc") row["NAME"] = "" self.assertEqual(row["NAME"], "") try: row["NAME"] = "waaaaytoobig" except ValueError: pass # value should still be empty self.assertEqual(row["NAME"], "") row["NAME"] = "TrÖt" self.assertEqual(row["NAME"], "TrÖt") try: row["NAME"] = "TrĪt" except ValueError: pass def test_get_fstring(self) -> None: data = bytearray(ord(letter) for letter in "hello world ") self.assertEqual(get_fstring(data, 0, 15), "hello world") self.assertEqual(get_fstring(data, 0, 15, remove_padding=False), "hello world ") def test_get_fstring_name(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row["testFstring"] self.assertEqual(value, "test") def test_get_fstring_index(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(98, "FSTRING[8]") # get value self.assertEqual(value, "test") def test_set_fstring(self) -> None: data = bytearray(20) set_fstring(data, 0, "hello world", 15) self.assertEqual(data, bytearray(b"hello world \x00\x00\x00\x00\x00")) def test_set_fstring_name(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testFstring"] = "TSET" self.assertEqual(row["testFstring"], "TSET") def test_set_fstring_index(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row.set_value(98, "FSTRING[8]", "TSET") self.assertEqual(row["testFstring"], "TSET") def test_get_int(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) x = row["ID"] y = row["testint2"] self.assertEqual(x, 0) self.assertEqual(y, 0) def test_set_int(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["ID"] = 259 self.assertEqual(row["ID"], 259) def test_get_usint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(43, "USINT") # get value self.assertEqual(value, 254) def test_set_usint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testusint0"] = 255 self.assertEqual(row["testusint0"], 255) def test_get_sint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(44, "SINT") # get value self.assertEqual(value, 127) def test_set_sint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testsint0"] = 127 self.assertEqual(row["testsint0"], 127) def test_set_int_roundtrip(self) -> None: DB1 = cast(bytearray, (WordLen.Byte.ctype * 4)()) for i in range(-(2**15) + 1, (2**15) - 1): set_int(DB1, 0, i) result = get_int(DB1, 0) self.assertEqual(i, result) def test_get_int_values(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) for value in (-32768, -16385, -256, -128, -127, 0, 127, 128, 255, 256, 16384, 32767): row["ID"] = value self.assertEqual(row["ID"], value) def test_get_bool(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertEqual(row["testbool1"], 1) self.assertEqual(row["testbool8"], 0) def test_set_bool(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testbool8"] = True row["testbool1"] = False self.assertEqual(row["testbool8"], True) self.assertEqual(row["testbool1"], False) def test_db_creation(self) -> None: test_array = bytearray(_bytearray * 10) test_db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=10, layout_offset=4, db_offset=0) self.assertEqual(len(test_db.index), 10) for i, row in test_db: # print row self.assertEqual(row["testbool1"], 1) self.assertEqual(row["testbool2"], 1) self.assertEqual(row["testbool3"], 1) self.assertEqual(row["testbool4"], 1) self.assertEqual(row["testbool5"], 0) self.assertEqual(row["testbool6"], 0) self.assertEqual(row["testbool7"], 0) self.assertEqual(row["testbool8"], 0) self.assertEqual(row["NAME"], "test") def test_db_creation_vars_with_whitespace(self) -> None: test_array = bytearray(_bytearray * 1) test_spec = """ 50 testZeroSpaces BYTE 52 testOne Space BYTE 59 testTWo Spaces BYTE """ test_db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=0, db_offset=0) db_export = test_db.export() for i in db_export: self.assertTrue("testZeroSpaces" in db_export[i].keys()) self.assertTrue("testOne Space" in db_export[i].keys()) self.assertTrue("testTWo Spaces" in db_export[i].keys()) def test_db_export(self) -> None: test_array = bytearray(_bytearray * 10) test_db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=10, layout_offset=4, db_offset=0) db_export = test_db.export() for i in db_export: self.assertEqual(db_export[i]["testbool1"], 1) self.assertEqual(db_export[i]["testbool2"], 1) self.assertEqual(db_export[i]["testbool3"], 1) self.assertEqual(db_export[i]["testbool4"], 1) self.assertEqual(db_export[i]["testbool5"], 0) self.assertEqual(db_export[i]["testbool6"], 0) self.assertEqual(db_export[i]["testbool7"], 0) self.assertEqual(db_export[i]["testbool8"], 0) self.assertEqual(db_export[i]["NAME"], "test") def test_get_real(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertTrue(0.01 > (row["testReal"] - 827.3) > -0.1) def test_set_real(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testReal"] = 1337.1337 self.assertTrue(0.01 > (row["testReal"] - 1337.1337) > -0.01) def test_set_dword(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) # The range of numbers is 0 to 4294967295. row["testDword"] = 9999999 self.assertEqual(row["testDword"], 9999999) def test_get_dword(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) self.assertEqual(row["testDword"], 4294967295) def test_set_dint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) # The range of numbers is -2147483648 to 2147483647 + row.set_value(23, "DINT", 2147483647) # set value self.assertEqual(row["testDint"], 2147483647) def test_get_dint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(23, "DINT") # get value self.assertEqual(value, -2147483648) def test_set_word(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) # The range of numbers is 0 to 65535 row.set_value(27, "WORD", 0) # set value self.assertEqual(row["testWord"], 0) def test_get_word(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) value = row.get_value(27, "WORD") # get value self.assertEqual(value, 65535) def test_export(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) data = row.export() self.assertIn("testDword", data) self.assertIn("testbool1", data) self.assertEqual(data["testbool5"], 0) def test_indented_layout(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) x = row["ID"] y_single_space = row["testbool1"] y_multi_space = row["testbool2"] y_single_indent = row["testint2"] y_multi_indent = row["testbool8"] with self.assertRaises(KeyError): fail_single_space = row["testbool4"] # noqa: F841 with self.assertRaises(KeyError): fail_multiple_spaces = row["testbool5"] # noqa: F841 with self.assertRaises(KeyError): fail_single_indent = row["testbool6"] # noqa: F841 with self.assertRaises(KeyError): fail_multiple_indent = row["testbool7"] # noqa: F841 self.assertEqual(x, 0) self.assertEqual(y_single_space, True) self.assertEqual(y_multi_space, True) self.assertEqual(y_single_indent, 0) self.assertEqual(y_multi_indent, 0) def test_get_uint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testUint"] self.assertEqual(val, 12345) def test_get_udint(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testUdint"] self.assertEqual(val, 123456789) def test_get_lreal(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testLreal"] self.assertEqual(val, 123456789.123456789) def test_get_char(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testChar"] self.assertEqual(val, "A") def test_get_wchar(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testWchar"] self.assertEqual(val, "Ω") def test_get_wstring(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testWstring"] self.assertEqual(val, "ΩstÄ") def test_get_date(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testDate"] self.assertEqual(val, datetime.date(day=9, month=3, year=2022)) def test_get_tod(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testTod"] self.assertEqual(val, datetime.timedelta(hours=12, minutes=34, seconds=56)) def test_get_dtl(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) val = row["testDtl"] self.assertEqual(val, datetime.datetime(year=2022, month=3, day=9, hour=12, minute=34, second=45)) def test_set_date(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec_indented, layout_offset=4) row["testDate"] = datetime.date(day=28, month=3, year=2024) self.assertEqual(row["testDate"], datetime.date(day=28, month=3, year=2024)) @pytest.mark.util class TestNewSetters(unittest.TestCase): """Tests for the newly added setter functions.""" def test_set_wstring(self) -> None: from snap7.util import set_wstring, get_wstring data = bytearray(30) set_wstring(data, 0, "hello", 10) self.assertEqual(get_wstring(data, 0), "hello") def test_set_wstring_unicode(self) -> None: from snap7.util import set_wstring, get_wstring data = bytearray(30) set_wstring(data, 0, "ΩstÄ", 10) self.assertEqual(get_wstring(data, 0), "ΩstÄ") def test_set_wstring_too_long(self) -> None: from snap7.util import set_wstring data = bytearray(30) with self.assertRaises(ValueError): set_wstring(data, 0, "toolong", 3) def test_set_wchar(self) -> None: from snap7.util import set_wchar from snap7.util.getters import get_wchar data = bytearray(2) set_wchar(data, 0, "C") self.assertEqual(get_wchar(data, 0), "C") def test_set_wchar_unicode(self) -> None: from snap7.util import set_wchar from snap7.util.getters import get_wchar data = bytearray(2) set_wchar(data, 0, "Ω") self.assertEqual(get_wchar(data, 0), "Ω") def test_set_lword(self) -> None: from snap7.util import set_lword, get_lword data = bytearray(8) set_lword(data, 0, 0xABCD) self.assertEqual(get_lword(data, 0), 0xABCD) def test_set_lword_max(self) -> None: from snap7.util import set_lword, get_lword data = bytearray(8) set_lword(data, 0, 0xFFFFFFFFFFFFFFFF) self.assertEqual(get_lword(data, 0), 0xFFFFFFFFFFFFFFFF) def test_set_tod(self) -> None: from snap7.util import set_tod from snap7.util.getters import get_tod data = bytearray(4) tod = datetime.timedelta(hours=12, minutes=34, seconds=56) set_tod(data, 0, tod) self.assertEqual(get_tod(data, 0), tod) def test_set_tod_out_of_range(self) -> None: from snap7.util import set_tod data = bytearray(4) with self.assertRaises(ValueError): set_tod(data, 0, datetime.timedelta(days=1)) def test_set_dtl(self) -> None: from snap7.util import set_dtl from snap7.util.getters import get_dtl data = bytearray(12) dt = datetime.datetime(2024, 3, 27, 14, 30, 0) set_dtl(data, 0, dt) result = get_dtl(data, 0) self.assertEqual(result.year, dt.year) self.assertEqual(result.month, dt.month) self.assertEqual(result.day, dt.day) self.assertEqual(result.hour, dt.hour) self.assertEqual(result.minute, dt.minute) self.assertEqual(result.second, dt.second) def test_set_dt_roundtrip(self) -> None: from snap7.util import set_dt from snap7.util.getters import get_date_time_object data = bytearray(8) dt = datetime.datetime(2020, 7, 12, 17, 32, 2, 854000) set_dt(data, 0, dt) result = get_date_time_object(data, 0) self.assertEqual(result, dt) def test_set_dt_year_range(self) -> None: from snap7.util import set_dt data = bytearray(8) with self.assertRaises(ValueError): set_dt(data, 0, datetime.datetime(1989, 1, 1)) with self.assertRaises(ValueError): set_dt(data, 0, datetime.datetime(2090, 1, 1)) def test_get_ltime(self) -> None: from snap7.util.getters import get_ltime data = bytearray(8) # 1 second = 1_000_000_000 nanoseconds struct.pack_into(">q", data, 0, 1_000_000_000) result = get_ltime(data, 0) self.assertEqual(result, datetime.timedelta(seconds=1)) def test_get_ltod(self) -> None: from snap7.util.getters import get_ltod data = bytearray(8) # 12 hours in nanoseconds ns = 12 * 3600 * 1_000_000_000 struct.pack_into(">Q", data, 0, ns) result = get_ltod(data, 0) self.assertEqual(result, datetime.timedelta(hours=12)) def test_get_ldt(self) -> None: from snap7.util.getters import get_ldt data = bytearray(8) # 2020-01-01 00:00:00 UTC in nanoseconds since epoch dt = datetime.datetime(2020, 1, 1) epoch = datetime.datetime(1970, 1, 1) ns = int((dt - epoch).total_seconds() * 1_000_000_000) struct.pack_into(">Q", data, 0, ns) result = get_ldt(data, 0) self.assertEqual(result, dt) def test_set_wstring_in_row(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testWstring"] = "abcd" self.assertEqual(row["testWstring"], "abcd") def test_set_wchar_in_row(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) row["testWchar"] = "B" self.assertEqual(row["testWchar"], "B") def test_set_tod_in_row(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) tod = datetime.timedelta(hours=1, minutes=2, seconds=3) row["testTod"] = tod self.assertEqual(row["testTod"], tod) def test_set_dtl_in_row(self) -> None: test_array = bytearray(_bytearray) row = Row(test_array, test_spec, layout_offset=4) dt = datetime.datetime(2024, 6, 15, 10, 20, 30) row["testDtl"] = dt result = row["testDtl"] self.assertEqual(result.year, 2024) self.assertEqual(result.month, 6) self.assertEqual(result.day, 15) self.assertEqual(result.hour, 10) self.assertEqual(result.minute, 20) self.assertEqual(result.second, 30) if __name__ == "__main__": unittest.main()