From 4819ae046bc865a9963ec93d8de7497e1f074012 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Fri, 20 Mar 2026 13:07:46 +0200 Subject: [PATCH 1/4] Cleanup: consolidate tests, fix docs, remove README async section README: - Remove async support section (unnecessary on landing page) Documentation: - Add S7CommPlus API docs with experimental warning - Add experimental warning to AsyncClient docs - Update PLC support matrix for S7CommPlus V1/V2 status Test consolidation (no test logic changed): - Merge test_server_coverage.py into test_server.py - Merge test_partner_coverage.py into test_partner.py - Merge test_logo_coverage.py into test_logo_client.py - Merge test_db_coverage.py into test_util.py - Rename test_s7protocol_coverage.py to test_s7protocol.py Mypy fixes: - Widen Row.set_value type to accept date/datetime/timedelta - Add type annotations in test_s7protocol.py, test_partner.py, test_connection.py, test_async_client.py Co-Authored-By: Claude Opus 4.6 --- README.rst | 17 - doc/API/async_client.rst | 6 + doc/API/s7commplus.rst | 70 ++ doc/index.rst | 1 + doc/plc-support.rst | 8 +- snap7/util/db.py | 4 +- tests/test_async_client.py | 6 +- tests/test_connection.py | 6 +- tests/test_db_coverage.py | 546 --------------- tests/test_logo_client.py | 245 ++++++- tests/test_logo_coverage.py | 260 -------- tests/test_partner.py | 617 ++++++++++++++++- tests/test_partner_coverage.py | 625 ------------------ ...rotocol_coverage.py => test_s7protocol.py} | 14 +- tests/test_server.py | 360 +++++++++- tests/test_server_coverage.py | 375 ----------- tests/test_util.py | 554 +++++++++++++++- 17 files changed, 1866 insertions(+), 1848 deletions(-) create mode 100644 doc/API/s7commplus.rst delete mode 100644 tests/test_db_coverage.py delete mode 100644 tests/test_logo_coverage.py delete mode 100644 tests/test_partner_coverage.py rename tests/{test_s7protocol_coverage.py => test_s7protocol.py} (98%) delete mode 100644 tests/test_server_coverage.py diff --git a/README.rst b/README.rst index 2b7cf5a3..f748fb5f 100644 --- a/README.rst +++ b/README.rst @@ -68,20 +68,3 @@ 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. - - -Async support -============= - -An ``AsyncClient`` is available for use with ``asyncio``:: - - import asyncio - import snap7 - - async def main(): - async with snap7.AsyncClient() as client: - await client.connect("192.168.1.10", 0, 1) - data = await client.db_read(1, 0, 4) - print(data) - - asyncio.run(main()) diff --git a/doc/API/async_client.rst b/doc/API/async_client.rst index 34e70b8a..0cf130fb 100644 --- a/doc/API/async_client.rst +++ b/doc/API/async_client.rst @@ -1,6 +1,12 @@ AsyncClient =========== +.. warning:: + + The ``AsyncClient`` is **experimental**. The API may change in future + releases. If you encounter problems, please `open an issue + `_. + The :class:`~snap7.async_client.AsyncClient` provides a native ``asyncio`` interface for communicating with Siemens S7 PLCs. It has feature parity with the synchronous :class:`~snap7.client.Client` and is safe for concurrent use diff --git a/doc/API/s7commplus.rst b/doc/API/s7commplus.rst new file mode 100644 index 00000000..4314bb4e --- /dev/null +++ b/doc/API/s7commplus.rst @@ -0,0 +1,70 @@ +S7CommPlus (S7-1200/1500) +========================= + +.. warning:: + + S7CommPlus support is **experimental**. The API may change in future + releases. If you encounter problems, please `open an issue + `_. + +The :mod:`snap7.s7commplus` package provides support for Siemens S7-1200 and +S7-1500 PLCs, which use the S7CommPlus protocol instead of the classic S7 +protocol used by S7-300/400. + +Both synchronous and asynchronous clients are available. When a PLC does not +support S7CommPlus data operations, the clients automatically fall back to the +legacy S7 protocol transparently. + +Synchronous client +------------------ + +.. code-block:: python + + from snap7.s7commplus.client import S7CommPlusClient + + client = S7CommPlusClient() + client.connect("192.168.1.10") + data = client.db_read(1, 0, 4) + client.disconnect() + +Asynchronous client +------------------- + +.. code-block:: python + + import asyncio + from snap7.s7commplus.async_client import S7CommPlusAsyncClient + + async def main(): + client = S7CommPlusAsyncClient() + await client.connect("192.168.1.10") + data = await client.db_read(1, 0, 4) + await client.disconnect() + + asyncio.run(main()) + +Legacy fallback +--------------- + +If the PLC returns an error for S7CommPlus data operations (common with some +firmware versions), the client automatically falls back to the classic S7 +protocol. You can check whether fallback is active: + +.. code-block:: python + + client.connect("192.168.1.10") + if client.using_legacy_fallback: + print("Using legacy S7 protocol") + +API reference +------------- + +.. automodule:: snap7.s7commplus.client + :members: + +.. automodule:: snap7.s7commplus.async_client + :members: + +.. automodule:: snap7.s7commplus.connection + :members: + :exclude-members: S7CommPlusConnection diff --git a/doc/index.rst b/doc/index.rst index 927d0c57..8066c63d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -15,6 +15,7 @@ Contents: API/client API/async_client + API/s7commplus API/server API/partner API/logo diff --git a/doc/plc-support.rst b/doc/plc-support.rst index b468eeac..dfc1cda6 100644 --- a/doc/plc-support.rst +++ b/doc/plc-support.rst @@ -51,22 +51,22 @@ Supported PLCs - PUT/GET only - Yes - No - - **Full** - - Enable PUT/GET access in TIA Portal. + - **Full** (experimental S7CommPlus) + - S7CommPlus V1 session + legacy S7 fallback for data. * - S7-1500 (FW 2.x) - ~2016 - PUT/GET only - No - V2 - **PUT/GET only** - - S7CommPlus V2 is encrypted; not supported by any open-source library. + - S7CommPlus V2 support is in development. * - S7-1500 (FW 3.x+) - ~2022 - PUT/GET only - No - V3 - **PUT/GET only** - - S7CommPlus V3 uses TLS; not supported by any open-source library. + - S7CommPlus V3 uses proprietary crypto; not yet supported. * - S7-1500R/H - ~2019 - No diff --git a/snap7/util/db.py b/snap7/util/db.py index b3aaa2d9..48834898 100644 --- a/snap7/util/db.py +++ b/snap7/util/db.py @@ -636,7 +636,7 @@ def get_value(self, byte_index: Union[str, int], type_: str) -> ValueType: raise ValueError def set_value( - self, byte_index: Union[str, int], type_: str, value: Union[bool, str, float] + self, byte_index: Union[str, int], type_: str, value: Union[bool, str, float, date, datetime, timedelta] ) -> Optional[Union[bytearray, memoryview]]: """Sets the value for a specific type in the specified byte index. @@ -687,7 +687,7 @@ def set_value( set_wstring(bytearray_, byte_index, value, max_size_int) return None - if type_ == "REAL": + if type_ == "REAL" and isinstance(value, (bool, str, float, int)): return set_real(bytearray_, byte_index, value) if type_ == "LREAL" and isinstance(value, float): diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 09c8b4a4..86f55617 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -5,7 +5,7 @@ import asyncio import logging -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator import pytest import pytest_asyncio @@ -45,10 +45,10 @@ def server() -> Generator[Server]: @pytest_asyncio.fixture -async def client(server: Server) -> AsyncClient: +async def client(server: Server) -> AsyncGenerator[AsyncClient]: c = AsyncClient() await c.connect(ip, rack, slot, tcpport) - yield c # type: ignore[misc] + yield c await c.disconnect() diff --git a/tests/test_connection.py b/tests/test_connection.py index 124956b0..ed784e67 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -13,9 +13,9 @@ class TestTPDUSize: """Test TPDUSize enum values.""" def test_sizes(self) -> None: - assert TPDUSize.S_128 == 0x07 - assert TPDUSize.S_1024 == 0x0A - assert TPDUSize.S_8192 == 0x0D + assert TPDUSize.S_128.value == 0x07 + assert TPDUSize.S_1024.value == 0x0A + assert TPDUSize.S_8192.value == 0x0D class TestISOTCPConnectionInit: diff --git a/tests/test_db_coverage.py b/tests/test_db_coverage.py deleted file mode 100644 index 660133fb..00000000 --- a/tests/test_db_coverage.py +++ /dev/null @@ -1,546 +0,0 @@ -"""Tests for snap7.util.db — DB/Row dict-like interface, read/write with mocked client, type conversions.""" - -import datetime -import logging -import struct -import pytest -from unittest.mock import MagicMock - -from snap7 import DB, Row -from snap7.type import Area -from snap7.util.db import print_row - -# Reuse the test spec and bytearray from test_util.py -test_spec = """ -4 ID INT -6 NAME STRING[4] - -12.0 testbool1 BOOL -12.1 testbool2 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] -""" - -_bytearray = bytearray( - [ - 0, - 0, # test int - 4, - 4, - ord("t"), - ord("e"), - ord("s"), - ord("t"), # test string - 0x0F, # 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 - 32, - 7, - 18, - 23, - 50, - 2, - 133, - 65, # date_and_time (8 bytes) - 254, - 254, - 254, - 254, - 254, # padding - 127, # usint - 128, # sint - 143, - 255, - 255, - 255, # time - 254, # byte - 48, - 57, # uint - 7, - 91, - 205, - 21, # udint - 65, - 157, - 111, - 52, - 84, - 126, - 107, - 117, # lreal - 65, # char 'A' - 3, - 169, # wchar - 0, - 4, - 0, - 4, - 3, - 169, - 0, - ord("s"), - 0, - ord("t"), - 0, - 196, # wstring - 45, - 235, # date - 2, - 179, - 41, - 128, # tod - 7, - 230, - 3, - 9, - 4, - 12, - 34, - 45, - 0, - 0, - 0, - 0, # dtl - 116, - 101, - 115, - 116, - 32, - 32, - 32, - 32, # fstring 'test ' - ] -) - - -class TestPrintRow: - def test_print_row_output(self, caplog: pytest.LogCaptureFixture) -> None: - data = bytearray([65, 66, 67, 68, 69]) - with caplog.at_level(logging.INFO, logger="snap7.util.db"): - print_row(data) - assert "65" in caplog.text - assert "A" in caplog.text - - -class TestDBDictInterface: - def setup_method(self) -> None: - test_array = bytearray(_bytearray * 3) - self.db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=3, layout_offset=4, db_offset=0) - - def test_len(self) -> None: - assert len(self.db) == 3 - - def test_getitem(self) -> None: - row = self.db["0"] - assert row is not None - - def test_getitem_missing(self) -> None: - row = self.db["999"] - assert row is None - - def test_contains(self) -> None: - assert "0" in self.db - assert "999" not in self.db - - def test_keys(self) -> None: - keys = list(self.db.keys()) - assert "0" in keys - assert len(keys) == 3 - - def test_items(self) -> None: - items = list(self.db.items()) - assert len(items) == 3 - for key, row in items: - assert isinstance(key, str) - assert isinstance(row, Row) - - def test_iter(self) -> None: - for key, row in self.db: - assert isinstance(key, str) - assert isinstance(row, Row) - - def test_get_bytearray(self) -> None: - ba = self.db.get_bytearray() - assert isinstance(ba, bytearray) - - -class TestDBWithIdField: - def test_id_field_creates_named_index(self) -> None: - test_array = bytearray(_bytearray * 2) - # Set different ID values for each row - struct.pack_into(">h", test_array, 0, 10) # row 0, ID at offset 0 (spec offset 4, layout_offset 4) - struct.pack_into(">h", test_array, len(_bytearray), 20) # row 1 - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=2, id_field="ID", layout_offset=4, db_offset=0) - assert "10" in db - assert "20" in db - - -class TestDBSetData: - def test_set_data_valid(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - new_data = bytearray(len(_bytearray)) - db.set_data(new_data) - assert db.get_bytearray() is new_data - - def test_set_data_invalid_type(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - with pytest.raises(TypeError): - db.set_data(b"not a bytearray") # type: ignore[arg-type] - - -class TestDBReadWrite: - """Test DB.read() and DB.write() with mocked client.""" - - def test_read_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - mock_client = MagicMock() - mock_client.db_read.return_value = bytearray(len(_bytearray)) - db.read(mock_client) - mock_client.db_read.assert_called_once() - - def test_read_non_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(0, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) - mock_client = MagicMock() - mock_client.read_area.return_value = bytearray(len(_bytearray)) - db.read(mock_client) - mock_client.read_area.assert_called_once() - - def test_read_negative_row_size(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - db.row_size = -1 - mock_client = MagicMock() - with pytest.raises(ValueError, match="row_size"): - db.read(mock_client) - - def test_write_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - mock_client = MagicMock() - db.write(mock_client) - mock_client.db_write.assert_called_once() - - def test_write_non_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(0, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) - mock_client = MagicMock() - db.write(mock_client) - mock_client.write_area.assert_called_once() - - def test_write_negative_row_size(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - db.row_size = -1 - mock_client = MagicMock() - with pytest.raises(ValueError, match="row_size"): - db.write(mock_client) - - def test_write_with_row_offset(self) -> None: - test_array = bytearray(_bytearray * 2) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=2, layout_offset=4, db_offset=0, row_offset=4) - mock_client = MagicMock() - db.write(mock_client) - # Should write each row individually via Row.write() - assert mock_client.db_write.call_count == 2 - - -class TestRowRepr: - def test_repr(self) -> None: - test_array = bytearray(_bytearray) - row = Row(test_array, test_spec, layout_offset=4) - r = repr(row) - assert "ID" in r - assert "NAME" in r - - -class TestRowUnchanged: - def test_unchanged_true(self) -> None: - test_array = bytearray(_bytearray) - row = Row(test_array, test_spec, layout_offset=4) - assert row.unchanged(test_array) is True - - def test_unchanged_false(self) -> None: - test_array = bytearray(_bytearray) - row = Row(test_array, test_spec, layout_offset=4) - other = bytearray(len(_bytearray)) - assert row.unchanged(other) is False - - -class TestRowTypeError: - def test_invalid_bytearray_type(self) -> None: - with pytest.raises(TypeError): - Row("not a bytearray", test_spec) # type: ignore[arg-type] - - -class TestRowReadWrite: - """Test Row.read() and Row.write() with mocked client through DB parent.""" - - def test_row_write_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - row = db["0"] - assert row is not None - mock_client = MagicMock() - row.write(mock_client) - mock_client.db_write.assert_called_once() - - def test_row_write_non_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(0, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) - row = db["0"] - assert row is not None - mock_client = MagicMock() - row.write(mock_client) - mock_client.write_area.assert_called_once() - - def test_row_write_not_db_parent(self) -> None: - test_array = bytearray(_bytearray) - row = Row(test_array, test_spec, layout_offset=4) - mock_client = MagicMock() - with pytest.raises(TypeError): - row.write(mock_client) - - def test_row_write_negative_row_size(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - row = db["0"] - assert row is not None - row.row_size = -1 - mock_client = MagicMock() - with pytest.raises(ValueError, match="row_size"): - row.write(mock_client) - - def test_row_read_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - row = db["0"] - assert row is not None - mock_client = MagicMock() - mock_client.db_read.return_value = bytearray(len(_bytearray)) - row.read(mock_client) - mock_client.db_read.assert_called_once() - - def test_row_read_non_db_area(self) -> None: - test_array = bytearray(_bytearray) - db = DB(0, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) - row = db["0"] - assert row is not None - mock_client = MagicMock() - mock_client.read_area.return_value = bytearray(len(_bytearray)) - row.read(mock_client) - mock_client.read_area.assert_called_once() - - def test_row_read_not_db_parent(self) -> None: - test_array = bytearray(_bytearray) - row = Row(test_array, test_spec, layout_offset=4) - mock_client = MagicMock() - with pytest.raises(TypeError): - row.read(mock_client) - - def test_row_read_negative_row_size(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0) - row = db["0"] - assert row is not None - row.row_size = -1 - mock_client = MagicMock() - with pytest.raises(ValueError, match="row_size"): - row.read(mock_client) - - -class TestRowSetValueTypes: - """Test set_value for various type branches.""" - - def setup_method(self) -> None: - self.test_array = bytearray(_bytearray) - self.row = Row(self.test_array, test_spec, layout_offset=4) - - def test_set_int(self) -> None: - self.row.set_value(4, "INT", 42) - assert self.row.get_value(4, "INT") == 42 - - def test_set_uint(self) -> None: - self.row.set_value(51, "UINT", 1000) - assert self.row.get_value(51, "UINT") == 1000 - - def test_set_dint(self) -> None: - self.row.set_value(23, "DINT", -100) - assert self.row.get_value(23, "DINT") == -100 - - def test_set_udint(self) -> None: - self.row.set_value(53, "UDINT", 999999) - assert self.row.get_value(53, "UDINT") == 999999 - - def test_set_word(self) -> None: - self.row.set_value(27, "WORD", 12345) - assert self.row.get_value(27, "WORD") == 12345 - - def test_set_usint(self) -> None: - self.row.set_value(43, "USINT", 200) - assert self.row.get_value(43, "USINT") == 200 - - def test_set_sint(self) -> None: - self.row.set_value(44, "SINT", -50) - assert self.row.get_value(44, "SINT") == -50 - - def test_set_time(self) -> None: - self.row.set_value(46, "TIME", "1:2:3:4.5") - assert self.row.get_value(46, "TIME") is not None - - def test_set_date(self) -> None: - d = datetime.date(2024, 1, 15) - self.row.set_value(80, "DATE", d) - assert self.row.get_value(80, "DATE") == d - - def test_set_tod(self) -> None: - td = datetime.timedelta(hours=5, minutes=30) - self.row.set_value(82, "TOD", td) - assert self.row.get_value(82, "TOD") == td - - def test_set_time_of_day(self) -> None: - td = datetime.timedelta(hours=1) - self.row.set_value(82, "TIME_OF_DAY", td) - assert self.row.get_value(82, "TIME_OF_DAY") == td - - def test_set_dtl(self) -> None: - dt = datetime.datetime(2024, 6, 15, 10, 20, 30) - self.row.set_value(86, "DTL", dt) - result = self.row.get_value(86, "DTL") - assert result.year == 2024 # type: ignore[union-attr] - - def test_set_date_and_time(self) -> None: - dt = datetime.datetime(2020, 7, 12, 17, 32, 2, 854000) - self.row.set_value(31, "DATE_AND_TIME", dt) - result = self.row.get_value(31, "DATE_AND_TIME") - assert "2020" in str(result) - - def test_set_unknown_type_raises(self) -> None: - with pytest.raises(ValueError): - self.row.set_value(4, "UNKNOWN_TYPE", 42) - - def test_set_string(self) -> None: - self.row.set_value(6, "STRING[4]", "ab") - assert self.row.get_value(6, "STRING[4]") == "ab" - - def test_set_wstring(self) -> None: - self.row.set_value(68, "WSTRING[4]", "ab") - assert self.row.get_value(68, "WSTRING[4]") == "ab" - - def test_set_fstring(self) -> None: - self.row.set_value(98, "FSTRING[8]", "hi") - assert self.row.get_value(98, "FSTRING[8]") == "hi" - - def test_set_real(self) -> None: - self.row.set_value(13, "REAL", 3.14) - assert abs(self.row.get_value(13, "REAL") - 3.14) < 0.01 # type: ignore[operator] - - def test_set_lreal(self) -> None: - self.row.set_value(57, "LREAL", 2.718281828) - assert abs(self.row.get_value(57, "LREAL") - 2.718281828) < 0.0001 # type: ignore[operator] - - def test_set_char(self) -> None: - self.row.set_value(65, "CHAR", "Z") - assert self.row.get_value(65, "CHAR") == "Z" - - def test_set_wchar(self) -> None: - self.row.set_value(66, "WCHAR", "W") - assert self.row.get_value(66, "WCHAR") == "W" - - -class TestRowGetValueEdgeCases: - """Test get_value for edge cases.""" - - def setup_method(self) -> None: - self.test_array = bytearray(_bytearray) - self.row = Row(self.test_array, test_spec, layout_offset=4) - - def test_unknown_type_raises(self) -> None: - with pytest.raises(ValueError): - self.row.get_value(4, "NONEXISTENT") - - def test_string_no_max_size(self) -> None: - spec = "4 test STRING" - row = Row(bytearray(20), spec, layout_offset=0) - with pytest.raises(ValueError, match="Max size"): - row.get_value(4, "STRING") - - def test_fstring_no_max_size(self) -> None: - with pytest.raises(ValueError, match="Max size"): - self.row.get_value(98, "FSTRING") - - def test_wstring_no_max_size(self) -> None: - with pytest.raises(ValueError, match="Max size"): - self.row.get_value(68, "WSTRING") - - -class TestRowSetValueEdgeCases: - """Test set_value edge cases for string types.""" - - def setup_method(self) -> None: - self.test_array = bytearray(_bytearray) - self.row = Row(self.test_array, test_spec, layout_offset=4) - - def test_fstring_no_max_size(self) -> None: - with pytest.raises(ValueError, match="Max size"): - self.row.set_value(98, "FSTRING", "test") - - def test_string_no_max_size(self) -> None: - with pytest.raises(ValueError, match="Max size"): - self.row.set_value(6, "STRING", "test") - - def test_wstring_no_max_size(self) -> None: - with pytest.raises(ValueError, match="Max size"): - self.row.set_value(68, "WSTRING", "test") - - -class TestRowWriteWithRowOffset: - """Test Row.write() with row_offset set.""" - - def test_write_with_row_offset(self) -> None: - test_array = bytearray(_bytearray) - db = DB(1, test_array, test_spec, row_size=len(_bytearray), size=1, layout_offset=4, db_offset=0, row_offset=10) - row = db["0"] - assert row is not None - mock_client = MagicMock() - row.write(mock_client) - # The data written should start at db_offset + row_offset - mock_client.db_write.assert_called_once() diff --git a/tests/test_logo_client.py b/tests/test_logo_client.py index 58bf5d5c..a5d48a6f 100644 --- a/tests/test_logo_client.py +++ b/tests/test_logo_client.py @@ -4,8 +4,9 @@ from typing import Optional import snap7 +from snap7.logo import Logo, parse_address from snap7.server import Server -from snap7.type import Parameter, SrvArea +from snap7.type import Parameter, SrvArea, WordLen logging.basicConfig(level=logging.WARNING) @@ -124,5 +125,247 @@ def test_set_param(self) -> None: self.client.set_param(param, value) +logo_coverage_tcpport = 11102 + + +# --------------------------------------------------------------------------- +# parse_address() unit tests (no server needed) +# --------------------------------------------------------------------------- + + +@pytest.mark.logo +class TestParseAddress(unittest.TestCase): + """Test every branch of parse_address().""" + + def test_byte_address(self) -> None: + start, wl = parse_address("V10") + self.assertEqual(start, 10) + self.assertEqual(wl, WordLen.Byte) + + def test_byte_address_large(self) -> None: + start, wl = parse_address("V999") + self.assertEqual(start, 999) + self.assertEqual(wl, WordLen.Byte) + + def test_word_address(self) -> None: + start, wl = parse_address("VW20") + self.assertEqual(start, 20) + self.assertEqual(wl, WordLen.Word) + + def test_word_address_zero(self) -> None: + start, wl = parse_address("VW0") + self.assertEqual(start, 0) + self.assertEqual(wl, WordLen.Word) + + def test_dword_address(self) -> None: + start, wl = parse_address("VD30") + self.assertEqual(start, 30) + self.assertEqual(wl, WordLen.DWord) + + def test_bit_address(self) -> None: + start, wl = parse_address("V10.3") + # bit offset = 10*8 + 3 = 83 + self.assertEqual(start, 83) + self.assertEqual(wl, WordLen.Bit) + + def test_bit_address_zero(self) -> None: + start, wl = parse_address("V0.0") + self.assertEqual(start, 0) + self.assertEqual(wl, WordLen.Bit) + + def test_bit_address_high_bit(self) -> None: + start, wl = parse_address("V0.7") + self.assertEqual(start, 7) + self.assertEqual(wl, WordLen.Bit) + + def test_invalid_address_raises(self) -> None: + with self.assertRaises(ValueError): + parse_address("INVALID") + + def test_invalid_address_empty(self) -> None: + with self.assertRaises(ValueError): + parse_address("") + + def test_invalid_address_wrong_prefix(self) -> None: + with self.assertRaises(ValueError): + parse_address("M10") + + +# --------------------------------------------------------------------------- +# Integration tests: Logo client against the built-in Server +# --------------------------------------------------------------------------- + + +@pytest.mark.logo +class TestLogoReadWrite(unittest.TestCase): + """Test Logo read/write against a real server with DB1 registered.""" + + server: Optional[Server] = None + db_data: bytearray + + @classmethod + def setUpClass(cls) -> None: + cls.db_data = bytearray(256) + cls.server = Server() + cls.server.register_area(SrvArea.DB, 0, bytearray(256)) + cls.server.register_area(SrvArea.DB, 1, cls.db_data) + cls.server.start(tcp_port=logo_coverage_tcpport) + + @classmethod + def tearDownClass(cls) -> None: + if cls.server: + cls.server.stop() + cls.server.destroy() + + def setUp(self) -> None: + self.client = Logo() + self.client.connect(ip, 0x1000, 0x2000, logo_coverage_tcpport) + + def tearDown(self) -> None: + self.client.disconnect() + self.client.destroy() + + # -- read tests --------------------------------------------------------- + + def test_read_byte(self) -> None: + """Write a known byte into DB1 via client, then read it back.""" + self.client.write("V5", 0xAB) + result = self.client.read("V5") + self.assertEqual(result, 0xAB) + + def test_read_word(self) -> None: + """Write and read back a word (signed 16-bit big-endian).""" + self.client.write("VW10", 1234) + result = self.client.read("VW10") + self.assertEqual(result, 1234) + + def test_read_word_negative(self) -> None: + """Words are signed — negative values should round-trip.""" + self.client.write("VW12", -500) + result = self.client.read("VW12") + self.assertEqual(result, -500) + + def test_read_dword(self) -> None: + """Write and read back a dword (signed 32-bit big-endian).""" + self.client.write("VD20", 70000) + result = self.client.read("VD20") + self.assertEqual(result, 70000) + + def test_read_dword_negative(self) -> None: + """DWords are signed — negative values should round-trip.""" + self.client.write("VD24", -123456) + result = self.client.read("VD24") + self.assertEqual(result, -123456) + + def test_read_bit_set(self) -> None: + """Write bit=1, then read it back.""" + self.client.write("V50.2", 1) + result = self.client.read("V50.2") + self.assertEqual(result, 1) + + def test_read_bit_clear(self) -> None: + """Write bit=0, then read it back.""" + # First set it so we know we're actually clearing + self.client.write("V51.5", 1) + self.assertEqual(self.client.read("V51.5"), 1) + self.client.write("V51.5", 0) + result = self.client.read("V51.5") + self.assertEqual(result, 0) + + def test_read_bit_zero(self) -> None: + """Read bit 0 of byte 0.""" + self.client.write("V60", 0) # clear byte first + self.client.write("V60.0", 1) + self.assertEqual(self.client.read("V60.0"), 1) + # Other bits should be 0 + self.assertEqual(self.client.read("V60.1"), 0) + + def test_read_bit_seven(self) -> None: + """Read bit 7 of a byte.""" + self.client.write("V61", 0) # clear byte + self.client.write("V61.7", 1) + self.assertEqual(self.client.read("V61.7"), 1) + # Byte should be 0x80 + self.assertEqual(self.client.read("V61"), 0x80) + + # -- write tests -------------------------------------------------------- + + def test_write_byte(self) -> None: + """Write a byte and verify.""" + result = self.client.write("V70", 42) + self.assertEqual(result, 0) + self.assertEqual(self.client.read("V70"), 42) + + def test_write_word(self) -> None: + """Write a word and verify.""" + result = self.client.write("VW80", 2000) + self.assertEqual(result, 0) + self.assertEqual(self.client.read("VW80"), 2000) + + def test_write_dword(self) -> None: + """Write a dword and verify.""" + result = self.client.write("VD90", 100000) + self.assertEqual(result, 0) + self.assertEqual(self.client.read("VD90"), 100000) + + def test_write_bit_true(self) -> None: + """Write a bit to True.""" + result = self.client.write("V100.4", 1) + self.assertEqual(result, 0) + self.assertEqual(self.client.read("V100.4"), 1) + + def test_write_bit_false(self) -> None: + """Write a bit to False after setting it.""" + self.client.write("V101.6", 1) + result = self.client.write("V101.6", 0) + self.assertEqual(result, 0) + self.assertEqual(self.client.read("V101.6"), 0) + + def test_write_bit_preserves_other_bits(self) -> None: + """Setting one bit should not disturb other bits in the same byte.""" + # Write 0xFF to the byte + self.client.write("V110", 0xFF) + # Clear bit 3 + self.client.write("V110.3", 0) + # Byte should now be 0xF7 (all bits set except bit 3) + self.assertEqual(self.client.read("V110"), 0xF7) + # Set bit 3 back + self.client.write("V110.3", 1) + self.assertEqual(self.client.read("V110"), 0xFF) + + def test_write_byte_boundary_values(self) -> None: + """Test boundary values: 0 and 255.""" + self.client.write("V120", 0) + self.assertEqual(self.client.read("V120"), 0) + self.client.write("V120", 255) + self.assertEqual(self.client.read("V120"), 255) + + def test_write_word_boundary_values(self) -> None: + """Test word boundary values: max positive and max negative.""" + self.client.write("VW130", 32767) + self.assertEqual(self.client.read("VW130"), 32767) + self.client.write("VW130", -32768) + self.assertEqual(self.client.read("VW130"), -32768) + + def test_write_dword_boundary_values(self) -> None: + """Test dword boundary values.""" + self.client.write("VD140", 2147483647) + self.assertEqual(self.client.read("VD140"), 2147483647) + self.client.write("VD140", -2147483648) + self.assertEqual(self.client.read("VD140"), -2147483648) + + def test_read_write_multiple_addresses(self) -> None: + """Verify different address types can coexist.""" + self.client.write("V200", 0x42) + self.client.write("VW202", 1000) + self.client.write("VD204", 50000) + self.client.write("V208.1", 1) + + self.assertEqual(self.client.read("V200"), 0x42) + self.assertEqual(self.client.read("VW202"), 1000) + self.assertEqual(self.client.read("VD204"), 50000) + self.assertEqual(self.client.read("V208.1"), 1) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_logo_coverage.py b/tests/test_logo_coverage.py deleted file mode 100644 index 8437d585..00000000 --- a/tests/test_logo_coverage.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Tests for snap7/logo.py to improve coverage of parse_address, read, and write.""" - -import logging -import unittest -from typing import Optional - -import pytest - -from snap7.logo import Logo, parse_address -from snap7.server import Server -from snap7.type import SrvArea, WordLen - -logging.basicConfig(level=logging.WARNING) - -ip = "127.0.0.1" -tcpport = 11102 -db_number = 1 - - -# --------------------------------------------------------------------------- -# parse_address() unit tests (no server needed) -# --------------------------------------------------------------------------- - - -@pytest.mark.logo -class TestParseAddress(unittest.TestCase): - """Test every branch of parse_address().""" - - def test_byte_address(self) -> None: - start, wl = parse_address("V10") - self.assertEqual(start, 10) - self.assertEqual(wl, WordLen.Byte) - - def test_byte_address_large(self) -> None: - start, wl = parse_address("V999") - self.assertEqual(start, 999) - self.assertEqual(wl, WordLen.Byte) - - def test_word_address(self) -> None: - start, wl = parse_address("VW20") - self.assertEqual(start, 20) - self.assertEqual(wl, WordLen.Word) - - def test_word_address_zero(self) -> None: - start, wl = parse_address("VW0") - self.assertEqual(start, 0) - self.assertEqual(wl, WordLen.Word) - - def test_dword_address(self) -> None: - start, wl = parse_address("VD30") - self.assertEqual(start, 30) - self.assertEqual(wl, WordLen.DWord) - - def test_bit_address(self) -> None: - start, wl = parse_address("V10.3") - # bit offset = 10*8 + 3 = 83 - self.assertEqual(start, 83) - self.assertEqual(wl, WordLen.Bit) - - def test_bit_address_zero(self) -> None: - start, wl = parse_address("V0.0") - self.assertEqual(start, 0) - self.assertEqual(wl, WordLen.Bit) - - def test_bit_address_high_bit(self) -> None: - start, wl = parse_address("V0.7") - self.assertEqual(start, 7) - self.assertEqual(wl, WordLen.Bit) - - def test_invalid_address_raises(self) -> None: - with self.assertRaises(ValueError): - parse_address("INVALID") - - def test_invalid_address_empty(self) -> None: - with self.assertRaises(ValueError): - parse_address("") - - def test_invalid_address_wrong_prefix(self) -> None: - with self.assertRaises(ValueError): - parse_address("M10") - - -# --------------------------------------------------------------------------- -# Integration tests: Logo client against the built-in Server -# --------------------------------------------------------------------------- - - -@pytest.mark.logo -class TestLogoReadWrite(unittest.TestCase): - """Test Logo read/write against a real server with DB1 registered.""" - - server: Optional[Server] = None - db_data: bytearray - - @classmethod - def setUpClass(cls) -> None: - cls.db_data = bytearray(256) - cls.server = Server() - cls.server.register_area(SrvArea.DB, 0, bytearray(256)) - cls.server.register_area(SrvArea.DB, 1, cls.db_data) - 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 = Logo() - self.client.connect(ip, 0x1000, 0x2000, tcpport) - - def tearDown(self) -> None: - self.client.disconnect() - self.client.destroy() - - # -- read tests --------------------------------------------------------- - - def test_read_byte(self) -> None: - """Write a known byte into DB1 via client, then read it back.""" - self.client.write("V5", 0xAB) - result = self.client.read("V5") - self.assertEqual(result, 0xAB) - - def test_read_word(self) -> None: - """Write and read back a word (signed 16-bit big-endian).""" - self.client.write("VW10", 1234) - result = self.client.read("VW10") - self.assertEqual(result, 1234) - - def test_read_word_negative(self) -> None: - """Words are signed — negative values should round-trip.""" - self.client.write("VW12", -500) - result = self.client.read("VW12") - self.assertEqual(result, -500) - - def test_read_dword(self) -> None: - """Write and read back a dword (signed 32-bit big-endian).""" - self.client.write("VD20", 70000) - result = self.client.read("VD20") - self.assertEqual(result, 70000) - - def test_read_dword_negative(self) -> None: - """DWords are signed — negative values should round-trip.""" - self.client.write("VD24", -123456) - result = self.client.read("VD24") - self.assertEqual(result, -123456) - - def test_read_bit_set(self) -> None: - """Write bit=1, then read it back.""" - self.client.write("V50.2", 1) - result = self.client.read("V50.2") - self.assertEqual(result, 1) - - def test_read_bit_clear(self) -> None: - """Write bit=0, then read it back.""" - # First set it so we know we're actually clearing - self.client.write("V51.5", 1) - self.assertEqual(self.client.read("V51.5"), 1) - self.client.write("V51.5", 0) - result = self.client.read("V51.5") - self.assertEqual(result, 0) - - def test_read_bit_zero(self) -> None: - """Read bit 0 of byte 0.""" - self.client.write("V60", 0) # clear byte first - self.client.write("V60.0", 1) - self.assertEqual(self.client.read("V60.0"), 1) - # Other bits should be 0 - self.assertEqual(self.client.read("V60.1"), 0) - - def test_read_bit_seven(self) -> None: - """Read bit 7 of a byte.""" - self.client.write("V61", 0) # clear byte - self.client.write("V61.7", 1) - self.assertEqual(self.client.read("V61.7"), 1) - # Byte should be 0x80 - self.assertEqual(self.client.read("V61"), 0x80) - - # -- write tests -------------------------------------------------------- - - def test_write_byte(self) -> None: - """Write a byte and verify.""" - result = self.client.write("V70", 42) - self.assertEqual(result, 0) - self.assertEqual(self.client.read("V70"), 42) - - def test_write_word(self) -> None: - """Write a word and verify.""" - result = self.client.write("VW80", 2000) - self.assertEqual(result, 0) - self.assertEqual(self.client.read("VW80"), 2000) - - def test_write_dword(self) -> None: - """Write a dword and verify.""" - result = self.client.write("VD90", 100000) - self.assertEqual(result, 0) - self.assertEqual(self.client.read("VD90"), 100000) - - def test_write_bit_true(self) -> None: - """Write a bit to True.""" - result = self.client.write("V100.4", 1) - self.assertEqual(result, 0) - self.assertEqual(self.client.read("V100.4"), 1) - - def test_write_bit_false(self) -> None: - """Write a bit to False after setting it.""" - self.client.write("V101.6", 1) - result = self.client.write("V101.6", 0) - self.assertEqual(result, 0) - self.assertEqual(self.client.read("V101.6"), 0) - - def test_write_bit_preserves_other_bits(self) -> None: - """Setting one bit should not disturb other bits in the same byte.""" - # Write 0xFF to the byte - self.client.write("V110", 0xFF) - # Clear bit 3 - self.client.write("V110.3", 0) - # Byte should now be 0xF7 (all bits set except bit 3) - self.assertEqual(self.client.read("V110"), 0xF7) - # Set bit 3 back - self.client.write("V110.3", 1) - self.assertEqual(self.client.read("V110"), 0xFF) - - def test_write_byte_boundary_values(self) -> None: - """Test boundary values: 0 and 255.""" - self.client.write("V120", 0) - self.assertEqual(self.client.read("V120"), 0) - self.client.write("V120", 255) - self.assertEqual(self.client.read("V120"), 255) - - def test_write_word_boundary_values(self) -> None: - """Test word boundary values: max positive and max negative.""" - self.client.write("VW130", 32767) - self.assertEqual(self.client.read("VW130"), 32767) - self.client.write("VW130", -32768) - self.assertEqual(self.client.read("VW130"), -32768) - - def test_write_dword_boundary_values(self) -> None: - """Test dword boundary values.""" - self.client.write("VD140", 2147483647) - self.assertEqual(self.client.read("VD140"), 2147483647) - self.client.write("VD140", -2147483648) - self.assertEqual(self.client.read("VD140"), -2147483648) - - def test_read_write_multiple_addresses(self) -> None: - """Verify different address types can coexist.""" - self.client.write("V200", 0x42) - self.client.write("VW202", 1000) - self.client.write("VD204", 50000) - self.client.write("V208.1", 1) - - self.assertEqual(self.client.read("V200"), 0x42) - self.assertEqual(self.client.read("VW202"), 1000) - self.assertEqual(self.client.read("VD204"), 50000) - self.assertEqual(self.client.read("V208.1"), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_partner.py b/tests/test_partner.py index 34c9cb27..570fbca9 100644 --- a/tests/test_partner.py +++ b/tests/test_partner.py @@ -1,10 +1,16 @@ import logging +import socket +import struct +import threading +import time import pytest import unittest as unittest -from snap7.error import error_text +from snap7.connection import ISOTCPConnection +from snap7.error import error_text, S7Error, S7ConnectionError import snap7.partner +from snap7.partner import Partner, PartnerStatus from snap7.type import Parameter logging.basicConfig(level=logging.WARNING) @@ -116,5 +122,614 @@ def test_wait_as_b_send_completion(self) -> None: self.assertRaises(RuntimeError, self.partner.wait_as_b_send_completion) +def _free_port() -> int: + """Return a free TCP port chosen by the OS.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port: int = s.getsockname()[1] + return port + + +# --------------------------------------------------------------------------- +# PDU building / parsing unit tests (no network required) +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPartnerPDU: + """Unit tests for partner PDU building and parsing.""" + + def test_build_partner_data_pdu_small(self) -> None: + p = Partner() + data = b"\x01\x02\x03" + pdu = p._build_partner_data_pdu(data) + assert pdu[0:1] == b"\x32" + assert pdu[1:2] == b"\x07" + assert struct.unpack(">H", pdu[2:4])[0] == len(data) + assert pdu[6:] == data + + def test_build_partner_data_pdu_empty(self) -> None: + p = Partner() + pdu = p._build_partner_data_pdu(b"") + assert pdu[0:1] == b"\x32" + assert struct.unpack(">H", pdu[2:4])[0] == 0 + + def test_build_partner_data_pdu_large(self) -> None: + p = Partner() + data = bytes(range(256)) * 4 # 1024 bytes + pdu = p._build_partner_data_pdu(data) + assert struct.unpack(">H", pdu[2:4])[0] == 1024 + assert pdu[6:] == data + + def test_parse_partner_data_pdu_roundtrip(self) -> None: + p = Partner() + original = b"Hello, Partner!" + pdu = p._build_partner_data_pdu(original) + parsed = p._parse_partner_data_pdu(pdu) + assert parsed == original + + def test_parse_partner_data_pdu_roundtrip_various_sizes(self) -> None: + p = Partner() + for size in [0, 1, 10, 100, 500, 1024]: + data = (bytes(range(256)) * (size // 256 + 1))[:size] + pdu = p._build_partner_data_pdu(data) + assert p._parse_partner_data_pdu(pdu) == data + + def test_parse_partner_data_pdu_too_short(self) -> None: + p = Partner() + with pytest.raises(S7Error, match="too short"): + p._parse_partner_data_pdu(b"\x32\x07\x00") + + def test_build_partner_ack(self) -> None: + p = Partner() + ack = p._build_partner_ack() + assert len(ack) == 6 + assert ack[0:1] == b"\x32" + assert ack[1:2] == b"\x08" + + def test_parse_partner_ack_valid(self) -> None: + p = Partner() + ack = p._build_partner_ack() + p._parse_partner_ack(ack) + + def test_parse_partner_ack_too_short(self) -> None: + p = Partner() + with pytest.raises(S7Error, match="too short"): + p._parse_partner_ack(b"\x32") + + def test_parse_partner_ack_wrong_type(self) -> None: + p = Partner() + bad_ack = struct.pack(">BBHH", 0x32, 0x07, 0x0000, 0x0000) + with pytest.raises(S7Error, match="Expected partner ACK"): + p._parse_partner_ack(bad_ack) + + def test_ack_roundtrip(self) -> None: + p = Partner() + ack = p._build_partner_ack() + p._parse_partner_ack(ack) + + +# --------------------------------------------------------------------------- +# Status, stats, lifecycle tests +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPartnerLifecycle: + """Tests for partner lifecycle, status, and context manager.""" + + def test_initial_status_stopped(self) -> None: + p = Partner() + assert p.get_status().value == PartnerStatus.STOPPED + + def test_status_running_passive(self) -> None: + port = _free_port() + p = Partner(active=False) + p.port = port + try: + p.start_to("127.0.0.1", "", 0x0100, 0x0102) + assert p.running is True + assert p.get_status().value == PartnerStatus.RUNNING + finally: + p.stop() + + def test_stop_idempotent(self) -> None: + p = Partner() + p.stop() + p.stop() + + def test_destroy_returns_zero(self) -> None: + p = Partner() + assert p.destroy() == 0 + + def test_context_manager(self) -> None: + port = _free_port() + with Partner(active=False) as p: + p.port = port + p.start_to("127.0.0.1", "", 0x0100, 0x0102) + assert p.running is True + assert p.running is False + + def test_del_cleanup(self) -> None: + port = _free_port() + p = Partner(active=False) + p.port = port + p.start_to("127.0.0.1", "", 0x0100, 0x0102) + assert p.running is True + p.__del__() + assert p.running is False + + def test_create_noop(self) -> None: + p = Partner() + p.create(active=True) + + def test_get_stats_initial(self) -> None: + p = Partner() + sent, recv, s_err, r_err = p.get_stats() + assert sent.value == 0 + assert recv.value == 0 + assert s_err.value == 0 + assert r_err.value == 0 + + def test_get_times_initial(self) -> None: + p = Partner() + send_t, recv_t = p.get_times() + assert send_t.value == 0 + assert recv_t.value == 0 + + def test_get_last_error_initial(self) -> None: + p = Partner() + assert p.get_last_error().value == 0 + + +# --------------------------------------------------------------------------- +# Send / recv data buffer tests +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPartnerSendRecvBuffers: + """Tests for set_send_data / get_recv_data and error paths.""" + + def test_set_send_data_and_retrieve(self) -> None: + p = Partner() + assert p._send_data is None + p.set_send_data(b"test") + assert p._send_data == b"test" + + def test_get_recv_data_initially_none(self) -> None: + p = Partner() + assert p.get_recv_data() is None + + def test_b_send_no_data(self) -> None: + p = Partner() + assert p.b_send() == -1 + + def test_b_send_not_connected(self) -> None: + p = Partner() + p.set_send_data(b"data") + with pytest.raises(S7ConnectionError, match="Not connected"): + p.b_send() + + def test_b_recv_not_connected(self) -> None: + p = Partner() + result = p.b_recv() + assert result == -1 + assert p.get_recv_data() is None + + def test_as_b_send_no_data(self) -> None: + p = Partner() + assert p.as_b_send() == -1 + + def test_as_b_send_not_connected(self) -> None: + p = Partner() + p.set_send_data(b"data") + result = p.as_b_send() + assert result == -1 + + def test_check_as_b_recv_completion_empty(self) -> None: + p = Partner() + assert p.check_as_b_recv_completion() == 1 + + def test_check_as_b_recv_completion_with_data(self) -> None: + p = Partner() + p._async_recv_queue.put(b"queued data") + assert p.check_as_b_recv_completion() == 0 + assert p._recv_data == b"queued data" + + def test_check_as_b_send_completion_not_in_progress(self) -> None: + p = Partner() + status, result = p.check_as_b_send_completion() + assert status == "job complete" + + def test_check_as_b_send_completion_in_progress(self) -> None: + p = Partner() + p._async_send_in_progress = True + status, result = p.check_as_b_send_completion() + assert status == "job in progress" + + def test_wait_as_b_send_no_operation(self) -> None: + p = Partner() + with pytest.raises(RuntimeError, match="No async send"): + p.wait_as_b_send_completion() + + def test_wait_as_b_send_timeout(self) -> None: + p = Partner() + p._async_send_in_progress = True + result = p.wait_as_b_send_completion(timeout=50) + assert result == -1 + + def test_wait_as_b_send_completes(self) -> None: + p = Partner() + p._async_send_in_progress = True + p._async_send_result = 0 + + def clear_flag() -> None: + time.sleep(0.05) + p._async_send_in_progress = False + + t = threading.Thread(target=clear_flag) + t.start() + result = p.wait_as_b_send_completion(timeout=2000) + t.join() + assert result == 0 + + +# --------------------------------------------------------------------------- +# Parameter tests +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPartnerParams: + """Tests for get_param / set_param.""" + + def test_get_param_unsupported(self) -> None: + p = Partner() + with pytest.raises(RuntimeError, match="not supported"): + p.get_param(Parameter.MaxClients) + + def test_set_param_remote_port_raises(self) -> None: + p = Partner() + with pytest.raises(RuntimeError, match="Cannot set"): + p.set_param(Parameter.RemotePort, 1234) + + def test_set_param_local_port(self) -> None: + p = Partner() + p.set_param(Parameter.LocalPort, 5555) + assert p.local_port == 5555 + + def test_set_param_returns_zero(self) -> None: + p = Partner() + assert p.set_param(Parameter.PingTimeout, 999) == 0 + + def test_set_recv_callback_returns_zero(self) -> None: + p = Partner() + assert p.set_recv_callback() == 0 + + def test_set_send_callback_returns_zero(self) -> None: + p = Partner() + assert p.set_send_callback() == 0 + + +# --------------------------------------------------------------------------- +# Dual-partner integration tests using raw socket pairing +# --------------------------------------------------------------------------- + + +def _make_socket_pair() -> tuple[socket.socket, socket.socket]: + """Create a connected TCP socket pair via a temporary server socket.""" + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.connect(("127.0.0.1", port)) + server_side, _ = srv.accept() + srv.close() + return client, server_side + + +def _wire_partner(partner: Partner, sock: socket.socket) -> None: + """Wire a connected socket into a Partner so it appears connected.""" + conn = ISOTCPConnection(host="127.0.0.1", port=0, local_tsap=0x0100, remote_tsap=0x0102) + conn.socket = sock + conn.connected = True + partner._socket = sock + partner._connection = conn + partner.connected = True + partner.running = True + + +@pytest.mark.partner +class TestDualPartner: + """Integration tests using two Partner instances exchanging data over sockets.""" + + def test_active_to_passive_send(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + payload = b"Hello from A" + pa.set_send_data(payload) + + errors: list[Exception] = [] + + def do_send() -> None: + try: + pa.b_send() + except Exception as e: + errors.append(e) + + t = threading.Thread(target=do_send) + t.start() + + assert pb.b_recv() == 0 + t.join(timeout=3.0) + assert pb.get_recv_data() == payload + assert not errors + finally: + pa.stop() + pb.stop() + + def test_passive_to_active_send(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + payload = b"Hello from B" + pb.set_send_data(payload) + + errors: list[Exception] = [] + + def do_send() -> None: + try: + pb.b_send() + except Exception as e: + errors.append(e) + + t = threading.Thread(target=do_send) + t.start() + + assert pa.b_recv() == 0 + t.join(timeout=3.0) + assert pa.get_recv_data() == payload + assert not errors + finally: + pa.stop() + pb.stop() + + def test_bidirectional_exchange(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + errors: list[Exception] = [] + + # A -> B + pa.set_send_data(b"A->B") + + def send_a() -> None: + try: + pa.b_send() + except Exception as e: + errors.append(e) + + t1 = threading.Thread(target=send_a) + t1.start() + pb.b_recv() + t1.join(timeout=3.0) + assert pb.get_recv_data() == b"A->B" + + # B -> A + pb.set_send_data(b"B->A") + + def send_b() -> None: + try: + pb.b_send() + except Exception as e: + errors.append(e) + + t2 = threading.Thread(target=send_b) + t2.start() + pa.b_recv() + t2.join(timeout=3.0) + assert pa.get_recv_data() == b"B->A" + assert not errors + finally: + pa.stop() + pb.stop() + + def test_various_payload_sizes(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + for size in [1, 10, 100, 480]: + payload = (bytes(range(256)) * (size // 256 + 1))[:size] + pa.set_send_data(payload) + errors: list[Exception] = [] + + def do_send() -> None: + try: + pa.b_send() + except Exception as e: + errors.append(e) + + t = threading.Thread(target=do_send) + t.start() + pb.b_recv() + t.join(timeout=3.0) + assert pb.get_recv_data() == payload, f"Failed for size {size}" + assert not errors + finally: + pa.stop() + pb.stop() + + def test_stats_updated_after_exchange(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + payload = b"stats test" + pa.set_send_data(payload) + + def do_send() -> None: + pa.b_send() + + t = threading.Thread(target=do_send) + t.start() + pb.b_recv() + t.join(timeout=3.0) + + sent, _, s_err, _ = pa.get_stats() + assert sent.value == len(payload) + assert s_err.value == 0 + + _, recv, _, r_err = pb.get_stats() + assert recv.value == len(payload) + assert r_err.value == 0 + + send_t, _ = pa.get_times() + assert send_t.value >= 0 + _, recv_t = pb.get_times() + assert recv_t.value >= 0 + finally: + pa.stop() + pb.stop() + + def test_status_connected(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + assert pa.get_status().value == PartnerStatus.CONNECTED + assert pb.get_status().value == PartnerStatus.CONNECTED + finally: + pa.stop() + pb.stop() + + def test_status_after_stop(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + pa.stop() + assert pa.get_status().value == PartnerStatus.STOPPED + finally: + pa.stop() + pb.stop() + + def test_recv_callback_fires(self) -> None: + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + + received_data: list[bytes] = [] + pb._recv_callback = lambda data: received_data.append(data) + + payload = b"callback test" + pa.set_send_data(payload) + + def do_send() -> None: + pa.b_send() + + t = threading.Thread(target=do_send) + t.start() + pb.b_recv() + t.join(timeout=3.0) + + assert len(received_data) == 1 + assert received_data[0] == payload + finally: + pa.stop() + pb.stop() + + def test_b_recv_error_returns_negative(self) -> None: + """b_recv returns -1 on receive error when no data arrives.""" + sock_a, sock_b = _make_socket_pair() + pa, pb = Partner(), Partner() + try: + _wire_partner(pa, sock_a) + _wire_partner(pb, sock_b) + # Close sender side so receiver gets an error + sock_a.close() + result = pb.b_recv() + assert result == -1 + finally: + pa.stop() + pb.stop() + + +# --------------------------------------------------------------------------- +# Passive partner accept/listen tests +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPassivePartner: + """Tests for passive partner listening and accept behavior.""" + + def test_accept_connection_server_socket_none(self) -> None: + """_accept_connection returns immediately if server socket is None.""" + p = Partner(active=False) + p._server_socket = None + p._accept_connection() # Should not raise + + +# --------------------------------------------------------------------------- +# Active partner connection error tests +# --------------------------------------------------------------------------- + + +@pytest.mark.partner +class TestPartnerConnectionErrors: + """Tests for connection error paths.""" + + def test_active_no_remote_ip(self) -> None: + p = Partner(active=True) + with pytest.raises(S7ConnectionError, match="Remote IP"): + p.start_to("127.0.0.1", "", 0x0100, 0x0102) + + def test_active_connect_refused(self) -> None: + p = Partner(active=True) + port = _free_port() + p.port = port + with pytest.raises(S7ConnectionError): + p.start_to("127.0.0.1", "127.0.0.1", 0x0100, 0x0102) + + def test_b_send_increments_send_errors(self) -> None: + p = Partner() + p.set_send_data(b"data") + try: + p.b_send() + except S7ConnectionError: + pass + _, _, s_err, _ = p.get_stats() + assert s_err.value == 1 + + def test_b_recv_increments_recv_errors(self) -> None: + p = Partner() + p.b_recv() + _, _, _, r_err = p.get_stats() + assert r_err.value == 1 + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_partner_coverage.py b/tests/test_partner_coverage.py deleted file mode 100644 index bc36b043..00000000 --- a/tests/test_partner_coverage.py +++ /dev/null @@ -1,625 +0,0 @@ -"""Extended tests for snap7/partner.py to improve coverage. - -Includes unit tests for PDU building/parsing and dual-partner -integration tests for bidirectional data exchange. -""" - -import socket -import struct -import threading -import time - -import pytest - -from snap7.connection import ISOTCPConnection -from snap7.error import S7Error, S7ConnectionError -from snap7.partner import Partner, PartnerStatus -from snap7.type import Parameter - - -def _free_port() -> int: - """Return a free TCP port chosen by the OS.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -# --------------------------------------------------------------------------- -# PDU building / parsing unit tests (no network required) -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPartnerPDU: - """Unit tests for partner PDU building and parsing.""" - - def test_build_partner_data_pdu_small(self) -> None: - p = Partner() - data = b"\x01\x02\x03" - pdu = p._build_partner_data_pdu(data) - assert pdu[0:1] == b"\x32" - assert pdu[1:2] == b"\x07" - assert struct.unpack(">H", pdu[2:4])[0] == len(data) - assert pdu[6:] == data - - def test_build_partner_data_pdu_empty(self) -> None: - p = Partner() - pdu = p._build_partner_data_pdu(b"") - assert pdu[0:1] == b"\x32" - assert struct.unpack(">H", pdu[2:4])[0] == 0 - - def test_build_partner_data_pdu_large(self) -> None: - p = Partner() - data = bytes(range(256)) * 4 # 1024 bytes - pdu = p._build_partner_data_pdu(data) - assert struct.unpack(">H", pdu[2:4])[0] == 1024 - assert pdu[6:] == data - - def test_parse_partner_data_pdu_roundtrip(self) -> None: - p = Partner() - original = b"Hello, Partner!" - pdu = p._build_partner_data_pdu(original) - parsed = p._parse_partner_data_pdu(pdu) - assert parsed == original - - def test_parse_partner_data_pdu_roundtrip_various_sizes(self) -> None: - p = Partner() - for size in [0, 1, 10, 100, 500, 1024]: - data = (bytes(range(256)) * (size // 256 + 1))[:size] - pdu = p._build_partner_data_pdu(data) - assert p._parse_partner_data_pdu(pdu) == data - - def test_parse_partner_data_pdu_too_short(self) -> None: - p = Partner() - with pytest.raises(S7Error, match="too short"): - p._parse_partner_data_pdu(b"\x32\x07\x00") - - def test_build_partner_ack(self) -> None: - p = Partner() - ack = p._build_partner_ack() - assert len(ack) == 6 - assert ack[0:1] == b"\x32" - assert ack[1:2] == b"\x08" - - def test_parse_partner_ack_valid(self) -> None: - p = Partner() - ack = p._build_partner_ack() - p._parse_partner_ack(ack) - - def test_parse_partner_ack_too_short(self) -> None: - p = Partner() - with pytest.raises(S7Error, match="too short"): - p._parse_partner_ack(b"\x32") - - def test_parse_partner_ack_wrong_type(self) -> None: - p = Partner() - bad_ack = struct.pack(">BBHH", 0x32, 0x07, 0x0000, 0x0000) - with pytest.raises(S7Error, match="Expected partner ACK"): - p._parse_partner_ack(bad_ack) - - def test_ack_roundtrip(self) -> None: - p = Partner() - ack = p._build_partner_ack() - p._parse_partner_ack(ack) - - -# --------------------------------------------------------------------------- -# Status, stats, lifecycle tests -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPartnerLifecycle: - """Tests for partner lifecycle, status, and context manager.""" - - def test_initial_status_stopped(self) -> None: - p = Partner() - assert p.get_status().value == PartnerStatus.STOPPED - - def test_status_running_passive(self) -> None: - port = _free_port() - p = Partner(active=False) - p.port = port - try: - p.start_to("127.0.0.1", "", 0x0100, 0x0102) - assert p.running is True - assert p.get_status().value == PartnerStatus.RUNNING - finally: - p.stop() - - def test_stop_idempotent(self) -> None: - p = Partner() - p.stop() - p.stop() - - def test_destroy_returns_zero(self) -> None: - p = Partner() - assert p.destroy() == 0 - - def test_context_manager(self) -> None: - port = _free_port() - with Partner(active=False) as p: - p.port = port - p.start_to("127.0.0.1", "", 0x0100, 0x0102) - assert p.running is True - assert p.running is False - - def test_del_cleanup(self) -> None: - port = _free_port() - p = Partner(active=False) - p.port = port - p.start_to("127.0.0.1", "", 0x0100, 0x0102) - assert p.running is True - p.__del__() - assert p.running is False - - def test_create_noop(self) -> None: - p = Partner() - p.create(active=True) - - def test_get_stats_initial(self) -> None: - p = Partner() - sent, recv, s_err, r_err = p.get_stats() - assert sent.value == 0 - assert recv.value == 0 - assert s_err.value == 0 - assert r_err.value == 0 - - def test_get_times_initial(self) -> None: - p = Partner() - send_t, recv_t = p.get_times() - assert send_t.value == 0 - assert recv_t.value == 0 - - def test_get_last_error_initial(self) -> None: - p = Partner() - assert p.get_last_error().value == 0 - - -# --------------------------------------------------------------------------- -# Send / recv data buffer tests -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPartnerSendRecvBuffers: - """Tests for set_send_data / get_recv_data and error paths.""" - - def test_set_send_data_and_retrieve(self) -> None: - p = Partner() - assert p._send_data is None - p.set_send_data(b"test") - assert p._send_data == b"test" - - def test_get_recv_data_initially_none(self) -> None: - p = Partner() - assert p.get_recv_data() is None - - def test_b_send_no_data(self) -> None: - p = Partner() - assert p.b_send() == -1 - - def test_b_send_not_connected(self) -> None: - p = Partner() - p.set_send_data(b"data") - with pytest.raises(S7ConnectionError, match="Not connected"): - p.b_send() - - def test_b_recv_not_connected(self) -> None: - p = Partner() - result = p.b_recv() - assert result == -1 - assert p.get_recv_data() is None - - def test_as_b_send_no_data(self) -> None: - p = Partner() - assert p.as_b_send() == -1 - - def test_as_b_send_not_connected(self) -> None: - p = Partner() - p.set_send_data(b"data") - result = p.as_b_send() - assert result == -1 - - def test_check_as_b_recv_completion_empty(self) -> None: - p = Partner() - assert p.check_as_b_recv_completion() == 1 - - def test_check_as_b_recv_completion_with_data(self) -> None: - p = Partner() - p._async_recv_queue.put(b"queued data") - assert p.check_as_b_recv_completion() == 0 - assert p._recv_data == b"queued data" - - def test_check_as_b_send_completion_not_in_progress(self) -> None: - p = Partner() - status, result = p.check_as_b_send_completion() - assert status == "job complete" - - def test_check_as_b_send_completion_in_progress(self) -> None: - p = Partner() - p._async_send_in_progress = True - status, result = p.check_as_b_send_completion() - assert status == "job in progress" - - def test_wait_as_b_send_no_operation(self) -> None: - p = Partner() - with pytest.raises(RuntimeError, match="No async send"): - p.wait_as_b_send_completion() - - def test_wait_as_b_send_timeout(self) -> None: - p = Partner() - p._async_send_in_progress = True - result = p.wait_as_b_send_completion(timeout=50) - assert result == -1 - - def test_wait_as_b_send_completes(self) -> None: - p = Partner() - p._async_send_in_progress = True - p._async_send_result = 0 - - def clear_flag() -> None: - time.sleep(0.05) - p._async_send_in_progress = False - - t = threading.Thread(target=clear_flag) - t.start() - result = p.wait_as_b_send_completion(timeout=2000) - t.join() - assert result == 0 - - -# --------------------------------------------------------------------------- -# Parameter tests -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPartnerParams: - """Tests for get_param / set_param.""" - - def test_get_param_unsupported(self) -> None: - p = Partner() - with pytest.raises(RuntimeError, match="not supported"): - p.get_param(Parameter.MaxClients) - - def test_set_param_remote_port_raises(self) -> None: - p = Partner() - with pytest.raises(RuntimeError, match="Cannot set"): - p.set_param(Parameter.RemotePort, 1234) - - def test_set_param_local_port(self) -> None: - p = Partner() - p.set_param(Parameter.LocalPort, 5555) - assert p.local_port == 5555 - - def test_set_param_returns_zero(self) -> None: - p = Partner() - assert p.set_param(Parameter.PingTimeout, 999) == 0 - - def test_set_recv_callback_returns_zero(self) -> None: - p = Partner() - assert p.set_recv_callback() == 0 - - def test_set_send_callback_returns_zero(self) -> None: - p = Partner() - assert p.set_send_callback() == 0 - - -# --------------------------------------------------------------------------- -# Dual-partner integration tests using raw socket pairing -# --------------------------------------------------------------------------- - - -def _make_socket_pair() -> tuple[socket.socket, socket.socket]: - """Create a connected TCP socket pair via a temporary server socket.""" - srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - srv.bind(("127.0.0.1", 0)) - srv.listen(1) - port = srv.getsockname()[1] - - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - client.connect(("127.0.0.1", port)) - server_side, _ = srv.accept() - srv.close() - return client, server_side - - -def _wire_partner(partner: Partner, sock: socket.socket) -> None: - """Wire a connected socket into a Partner so it appears connected.""" - conn = ISOTCPConnection(host="127.0.0.1", port=0, local_tsap=0x0100, remote_tsap=0x0102) - conn.socket = sock - conn.connected = True - partner._socket = sock - partner._connection = conn - partner.connected = True - partner.running = True - - -@pytest.mark.partner -class TestDualPartner: - """Integration tests using two Partner instances exchanging data over sockets.""" - - def test_active_to_passive_send(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - payload = b"Hello from A" - pa.set_send_data(payload) - - errors: list[Exception] = [] - - def do_send() -> None: - try: - pa.b_send() - except Exception as e: - errors.append(e) - - t = threading.Thread(target=do_send) - t.start() - - assert pb.b_recv() == 0 - t.join(timeout=3.0) - assert pb.get_recv_data() == payload - assert not errors - finally: - pa.stop() - pb.stop() - - def test_passive_to_active_send(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - payload = b"Hello from B" - pb.set_send_data(payload) - - errors: list[Exception] = [] - - def do_send() -> None: - try: - pb.b_send() - except Exception as e: - errors.append(e) - - t = threading.Thread(target=do_send) - t.start() - - assert pa.b_recv() == 0 - t.join(timeout=3.0) - assert pa.get_recv_data() == payload - assert not errors - finally: - pa.stop() - pb.stop() - - def test_bidirectional_exchange(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - errors: list[Exception] = [] - - # A -> B - pa.set_send_data(b"A->B") - - def send_a() -> None: - try: - pa.b_send() - except Exception as e: - errors.append(e) - - t1 = threading.Thread(target=send_a) - t1.start() - pb.b_recv() - t1.join(timeout=3.0) - assert pb.get_recv_data() == b"A->B" - - # B -> A - pb.set_send_data(b"B->A") - - def send_b() -> None: - try: - pb.b_send() - except Exception as e: - errors.append(e) - - t2 = threading.Thread(target=send_b) - t2.start() - pa.b_recv() - t2.join(timeout=3.0) - assert pa.get_recv_data() == b"B->A" - assert not errors - finally: - pa.stop() - pb.stop() - - def test_various_payload_sizes(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - for size in [1, 10, 100, 480]: - payload = (bytes(range(256)) * (size // 256 + 1))[:size] - pa.set_send_data(payload) - errors: list[Exception] = [] - - def do_send() -> None: - try: - pa.b_send() - except Exception as e: - errors.append(e) - - t = threading.Thread(target=do_send) - t.start() - pb.b_recv() - t.join(timeout=3.0) - assert pb.get_recv_data() == payload, f"Failed for size {size}" - assert not errors - finally: - pa.stop() - pb.stop() - - def test_stats_updated_after_exchange(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - payload = b"stats test" - pa.set_send_data(payload) - - def do_send() -> None: - pa.b_send() - - t = threading.Thread(target=do_send) - t.start() - pb.b_recv() - t.join(timeout=3.0) - - sent, _, s_err, _ = pa.get_stats() - assert sent.value == len(payload) - assert s_err.value == 0 - - _, recv, _, r_err = pb.get_stats() - assert recv.value == len(payload) - assert r_err.value == 0 - - send_t, _ = pa.get_times() - assert send_t.value >= 0 - _, recv_t = pb.get_times() - assert recv_t.value >= 0 - finally: - pa.stop() - pb.stop() - - def test_status_connected(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - assert pa.get_status().value == PartnerStatus.CONNECTED - assert pb.get_status().value == PartnerStatus.CONNECTED - finally: - pa.stop() - pb.stop() - - def test_status_after_stop(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - pa.stop() - assert pa.get_status().value == PartnerStatus.STOPPED - finally: - pa.stop() - pb.stop() - - def test_recv_callback_fires(self) -> None: - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - - received_data: list[bytes] = [] - pb._recv_callback = lambda data: received_data.append(data) - - payload = b"callback test" - pa.set_send_data(payload) - - def do_send() -> None: - pa.b_send() - - t = threading.Thread(target=do_send) - t.start() - pb.b_recv() - t.join(timeout=3.0) - - assert len(received_data) == 1 - assert received_data[0] == payload - finally: - pa.stop() - pb.stop() - - def test_b_recv_error_returns_negative(self) -> None: - """b_recv returns -1 on receive error when no data arrives.""" - sock_a, sock_b = _make_socket_pair() - pa, pb = Partner(), Partner() - try: - _wire_partner(pa, sock_a) - _wire_partner(pb, sock_b) - # Close sender side so receiver gets an error - sock_a.close() - result = pb.b_recv() - assert result == -1 - finally: - pa.stop() - pb.stop() - - -# --------------------------------------------------------------------------- -# Passive partner accept/listen tests -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPassivePartner: - """Tests for passive partner listening and accept behavior.""" - - def test_accept_connection_server_socket_none(self) -> None: - """_accept_connection returns immediately if server socket is None.""" - p = Partner(active=False) - p._server_socket = None - p._accept_connection() # Should not raise - - -# --------------------------------------------------------------------------- -# Active partner connection error tests -# --------------------------------------------------------------------------- - - -@pytest.mark.partner -class TestPartnerConnectionErrors: - """Tests for connection error paths.""" - - def test_active_no_remote_ip(self) -> None: - p = Partner(active=True) - with pytest.raises(S7ConnectionError, match="Remote IP"): - p.start_to("127.0.0.1", "", 0x0100, 0x0102) - - def test_active_connect_refused(self) -> None: - p = Partner(active=True) - port = _free_port() - p.port = port - with pytest.raises(S7ConnectionError): - p.start_to("127.0.0.1", "127.0.0.1", 0x0100, 0x0102) - - def test_b_send_increments_send_errors(self) -> None: - p = Partner() - p.set_send_data(b"data") - try: - p.b_send() - except S7ConnectionError: - pass - _, _, s_err, _ = p.get_stats() - assert s_err.value == 1 - - def test_b_recv_increments_recv_errors(self) -> None: - p = Partner() - p.b_recv() - _, _, _, r_err = p.get_stats() - assert r_err.value == 1 diff --git a/tests/test_s7protocol_coverage.py b/tests/test_s7protocol.py similarity index 98% rename from tests/test_s7protocol_coverage.py rename to tests/test_s7protocol.py index 264c15bd..c0d62f1b 100644 --- a/tests/test_s7protocol_coverage.py +++ b/tests/test_s7protocol.py @@ -1,6 +1,8 @@ """Tests for snap7.s7protocol — response parsers with crafted PDUs, error paths.""" import struct +from typing import Any + import pytest from datetime import datetime @@ -227,7 +229,7 @@ def test_short_response(self) -> None: assert result["block_length"] == 0 def test_no_raw_parameters(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_start_upload_response(response) assert result["upload_id"] == 0 @@ -259,7 +261,7 @@ def test_empty_response(self) -> None: assert result == b"" def test_no_data_key(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_upload_response(response) assert result == b"" @@ -287,7 +289,7 @@ def test_empty_data(self) -> None: assert result["DBCount"] == 0 def test_no_data(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_list_blocks_response(response) assert all(v == 0 for v in result.values()) @@ -315,7 +317,7 @@ def test_empty_data(self) -> None: assert result == [] def test_no_data(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_list_blocks_of_type_response(response) assert result == [] @@ -353,7 +355,7 @@ def test_valid_data(self) -> None: assert result["version"] == 0x03 def test_no_data(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_get_block_info_response(response) assert result["block_type"] == 0 @@ -383,7 +385,7 @@ def test_followup_fragment(self) -> None: assert result["szl_id"] == 0 def test_empty_data(self) -> None: - response = {} + response: dict[str, Any] = {} result = self.proto.parse_read_szl_response(response) assert result["data"] == b"" diff --git a/tests/test_server.py b/tests/test_server.py index 99ac7b60..4e17c895 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,14 +1,16 @@ from ctypes import c_char import logging import time +from datetime import datetime import pytest import unittest from threading import Thread +from snap7.client import Client from snap7.error import server_errors, error_text from snap7.server import Server -from snap7.type import SrvEvent, mkEvent, mkLog, SrvArea, Parameter +from snap7.type import SrvEvent, mkEvent, mkLog, SrvArea, Parameter, Block logging.basicConfig(level=logging.WARNING) @@ -237,8 +239,358 @@ def test_server_area_management(self) -> None: pass -if __name__ == "__main__": - import logging +ip = "127.0.0.1" +SERVER_PORT = 12200 + + +@pytest.mark.server +class TestServerBlockOperations(unittest.TestCase): + """Test block operations through client-server communication.""" + + server: Server = None # type: ignore + + @classmethod + def setUpClass(cls) -> None: + cls.server = Server() + # Register several DBs so list_blocks / list_blocks_of_type have something to report + cls.server.register_area(SrvArea.DB, 1, bytearray(100)) + cls.server.register_area(SrvArea.DB, 2, bytearray(200)) + cls.server.register_area(SrvArea.DB, 3, bytearray(50)) + # Also register other area types + cls.server.register_area(SrvArea.MK, 0, bytearray(64)) + cls.server.register_area(SrvArea.PA, 0, bytearray(64)) + cls.server.register_area(SrvArea.PE, 0, bytearray(64)) + cls.server.register_area(SrvArea.TM, 0, bytearray(64)) + cls.server.register_area(SrvArea.CT, 0, bytearray(64)) + cls.server.start(tcp_port=SERVER_PORT) + + @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, 0, 1, SERVER_PORT) + + def tearDown(self) -> None: + self.client.disconnect() + self.client.destroy() + + # ------------------------------------------------------------------ + # list_blocks + # ------------------------------------------------------------------ + def test_list_blocks(self) -> None: + """list_blocks() should return counts; DBCount >= 3 since we registered 3 DBs.""" + bl = self.client.list_blocks() + self.assertGreaterEqual(bl.DBCount, 3) + # OB/FB/FC should be 0 since the emulator only tracks DBs + self.assertEqual(bl.OBCount, 0) + self.assertEqual(bl.FBCount, 0) + self.assertEqual(bl.FCCount, 0) + + # ------------------------------------------------------------------ + # list_blocks_of_type + # ------------------------------------------------------------------ + def test_list_blocks_of_type_db(self) -> None: + """list_blocks_of_type(DB) should include the DB numbers we registered.""" + block_nums = self.client.list_blocks_of_type(Block.DB, 100) + self.assertIn(1, block_nums) + self.assertIn(2, block_nums) + self.assertIn(3, block_nums) + + def test_list_blocks_of_type_ob(self) -> None: + """list_blocks_of_type(OB) should return an empty list (no OBs registered).""" + block_nums = self.client.list_blocks_of_type(Block.OB, 100) + self.assertEqual(block_nums, []) + + # ------------------------------------------------------------------ + # get_block_info + # ------------------------------------------------------------------ + def test_get_block_info(self) -> None: + """get_block_info for a registered DB should return valid metadata.""" + info = self.client.get_block_info(Block.DB, 1) + self.assertEqual(info.MC7Size, 100) # matches registered size + self.assertEqual(info.BlkNumber, 1) + + def test_get_block_info_db2(self) -> None: + """get_block_info for DB2 with size 200.""" + info = self.client.get_block_info(Block.DB, 2) + self.assertEqual(info.MC7Size, 200) + self.assertEqual(info.BlkNumber, 2) + + # ------------------------------------------------------------------ + # upload (block transfer: START_UPLOAD -> UPLOAD -> END_UPLOAD) + # ------------------------------------------------------------------ + def test_upload(self) -> None: + """Upload a DB from the server and verify the returned data length.""" + # Write known data to DB1 first + test_data = bytearray(range(10)) + self.client.db_write(1, 0, test_data) + + # Upload the block + block_data = self.client.upload(1) + self.assertGreater(len(block_data), 0) + # Verify the first bytes match what we wrote + self.assertEqual(block_data[:10], test_data) + + def test_full_upload(self) -> None: + """full_upload should return block data and its size.""" + data, size = self.client.full_upload(Block.DB, 1) + self.assertGreater(size, 0) + self.assertEqual(len(data), size) + + # ------------------------------------------------------------------ + # download (block transfer: REQUEST_DOWNLOAD -> DOWNLOAD_BLOCK -> DOWNLOAD_ENDED) + # ------------------------------------------------------------------ + def test_download(self) -> None: + """Download data to a registered DB on the server.""" + download_data = bytearray([0xAA, 0xBB, 0xCC, 0xDD]) + result = self.client.download(download_data, block_num=1) + self.assertEqual(result, 0) + + # Verify the data was written by reading it back + read_back = self.client.db_read(1, 0, 4) + self.assertEqual(read_back, download_data) + + +@pytest.mark.server +class TestServerUserdataOperations(unittest.TestCase): + """Test USERDATA handlers (SZL, clock, CPU state) through client-server communication.""" + + server: Server = None # type: ignore + + @classmethod + def setUpClass(cls) -> None: + cls.server = Server() + cls.server.register_area(SrvArea.DB, 1, bytearray(100)) + cls.server.start(tcp_port=SERVER_PORT + 1) + + @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, 0, 1, SERVER_PORT + 1) + + def tearDown(self) -> None: + self.client.disconnect() + self.client.destroy() + + # ------------------------------------------------------------------ + # read_szl + # ------------------------------------------------------------------ + def test_read_szl_0x001c(self) -> None: + """read_szl(0x001C) should return component identification data.""" + szl = self.client.read_szl(0x001C, 0) + self.assertGreater(szl.Header.LengthDR, 0) + + def test_read_szl_0x0011(self) -> None: + """read_szl(0x0011) should return module identification data.""" + szl = self.client.read_szl(0x0011, 0) + self.assertGreater(szl.Header.LengthDR, 0) + + def test_read_szl_0x0131(self) -> None: + """read_szl(0x0131) should return communication parameters.""" + szl = self.client.read_szl(0x0131, 0) + self.assertGreater(szl.Header.LengthDR, 0) + + def test_read_szl_0x0232(self) -> None: + """read_szl(0x0232) should return protection level data.""" + szl = self.client.read_szl(0x0232, 0) + self.assertGreater(szl.Header.LengthDR, 0) + + def test_read_szl_0x0000(self) -> None: + """read_szl(0x0000) should return the list of available SZL IDs.""" + szl = self.client.read_szl(0x0000, 0) + self.assertGreater(szl.Header.LengthDR, 0) + + def test_read_szl_list(self) -> None: + """read_szl_list should return raw bytes of available SZL IDs.""" + data = self.client.read_szl_list() + self.assertIsInstance(data, bytes) + self.assertGreater(len(data), 0) + + # ------------------------------------------------------------------ + # get_cpu_info (uses read_szl 0x001C internally) + # ------------------------------------------------------------------ + def test_get_cpu_info(self) -> None: + """get_cpu_info should populate the S7CpuInfo structure.""" + info = self.client.get_cpu_info() + # The emulated server returns "CPU 315-2 PN/DP" + self.assertIn(b"CPU", info.ModuleTypeName) + + # ------------------------------------------------------------------ + # get_order_code (uses read_szl 0x0011 internally) + # ------------------------------------------------------------------ + def test_get_order_code(self) -> None: + """get_order_code should return order code data.""" + oc = self.client.get_order_code() + self.assertIn(b"6ES7", oc.OrderCode) + + # ------------------------------------------------------------------ + # get_cp_info (uses read_szl 0x0131 internally) + # ------------------------------------------------------------------ + def test_get_cp_info(self) -> None: + """get_cp_info should return communication parameters.""" + cp = self.client.get_cp_info() + self.assertGreater(cp.MaxPduLength, 0) + self.assertGreater(cp.MaxConnections, 0) + + # ------------------------------------------------------------------ + # get_protection (uses read_szl 0x0232 internally) + # ------------------------------------------------------------------ + def test_get_protection(self) -> None: + """get_protection should return protection settings.""" + prot = self.client.get_protection() + # Emulator returns no protection (sch_schal=1) + self.assertEqual(prot.sch_schal, 1) + + # ------------------------------------------------------------------ + # get/set PLC datetime (clock USERDATA handlers) + # ------------------------------------------------------------------ + def test_get_plc_datetime(self) -> None: + """get_plc_datetime should return a valid datetime object.""" + dt = self.client.get_plc_datetime() + self.assertIsInstance(dt, datetime) + # Should be recent (within last minute) + now = datetime.now() + delta = abs((now - dt).total_seconds()) + self.assertLess(delta, 60) + + def test_set_plc_datetime(self) -> None: + """set_plc_datetime should succeed (returns 0).""" + test_dt = datetime(2025, 6, 15, 12, 30, 45) + result = self.client.set_plc_datetime(test_dt) + self.assertEqual(result, 0) + + def test_set_plc_system_datetime(self) -> None: + """set_plc_system_datetime should succeed.""" + result = self.client.set_plc_system_datetime() + self.assertEqual(result, 0) + + # ------------------------------------------------------------------ + # get_cpu_state (SZL-based CPU state request) + # ------------------------------------------------------------------ + def test_get_cpu_state(self) -> None: + """get_cpu_state should return a string state.""" + state = self.client.get_cpu_state() + self.assertIsInstance(state, str) + + +@pytest.mark.server +class TestServerPLCControl(unittest.TestCase): + """Test PLC control operations (stop/start) through client-server communication.""" + + server: Server = None # type: ignore + + @classmethod + def setUpClass(cls) -> None: + cls.server = Server() + cls.server.register_area(SrvArea.DB, 1, bytearray(100)) + cls.server.start(tcp_port=SERVER_PORT + 2) - logging.basicConfig() + @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, 0, 1, SERVER_PORT + 2) + + def tearDown(self) -> None: + self.client.disconnect() + self.client.destroy() + + def test_plc_stop(self) -> None: + """plc_stop should succeed and set the server CPU state to STOP.""" + result = self.client.plc_stop() + self.assertEqual(result, 0) + + def test_plc_hot_start(self) -> None: + """plc_hot_start should succeed.""" + result = self.client.plc_hot_start() + self.assertEqual(result, 0) + + def test_plc_cold_start(self) -> None: + """plc_cold_start should succeed.""" + result = self.client.plc_cold_start() + self.assertEqual(result, 0) + + def test_plc_stop_then_start(self) -> None: + """Stopping then starting the PLC should work in sequence.""" + self.assertEqual(self.client.plc_stop(), 0) + self.assertEqual(self.client.plc_hot_start(), 0) + + def test_compress(self) -> None: + """compress should succeed.""" + result = self.client.compress(timeout=1000) + self.assertEqual(result, 0) + + def test_copy_ram_to_rom(self) -> None: + """copy_ram_to_rom should succeed.""" + result = self.client.copy_ram_to_rom(timeout=1000) + self.assertEqual(result, 0) + + +@pytest.mark.server +class TestServerErrorScenarios(unittest.TestCase): + """Test error handling paths in the server.""" + + server: Server = None # type: ignore + + @classmethod + def setUpClass(cls) -> None: + cls.server = Server() + # Only register DB1 with a small area + cls.server.register_area(SrvArea.DB, 1, bytearray(10)) + cls.server.start(tcp_port=SERVER_PORT + 3) + + @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, 0, 1, SERVER_PORT + 3) + + def tearDown(self) -> None: + self.client.disconnect() + self.client.destroy() + + def test_read_unregistered_db(self) -> None: + """Reading from an unregistered DB should still return data (server returns dummy data).""" + # The server returns dummy data for unregistered areas rather than an error + data = self.client.db_read(99, 0, 4) + self.assertEqual(len(data), 4) + + def test_write_beyond_area_bounds(self) -> None: + """Writing beyond area bounds should raise an error.""" + # DB1 is only 10 bytes, writing 20 bytes at offset 0 should fail + with self.assertRaises(Exception): + self.client.db_write(1, 0, bytearray(20)) + + def test_get_block_info_nonexistent(self) -> None: + """get_block_info for a non-existent block should raise an error.""" + with self.assertRaises(Exception): + self.client.get_block_info(Block.DB, 999) + + def test_upload_nonexistent_block(self) -> None: + """Uploading a non-existent block returns empty data (server has no data for that block).""" + # The server defaults to block_num=1 for unknown blocks due to parsing fallback, + # so the upload still completes but returns the default block's data. + # We just verify the operation doesn't crash. + data = self.client.upload(999) + self.assertIsInstance(data, bytearray) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_server_coverage.py b/tests/test_server_coverage.py deleted file mode 100644 index 27e1e49c..00000000 --- a/tests/test_server_coverage.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Integration tests for server block operations, USERDATA handlers, and PLC control. - -These tests exercise the server-side handlers that are not covered by the existing -test_server.py (which only tests the server API) or test_client.py (which focuses -on client-side logic). The goal is to improve coverage for snap7/server/__init__.py -from ~74% to ~85%+ by driving traffic through the protocol handlers. -""" - -import logging - -import pytest -import unittest -from datetime import datetime - -from snap7.client import Client -from snap7.server import Server -from snap7.type import SrvArea, Block - -logging.basicConfig(level=logging.WARNING) - -ip = "127.0.0.1" -SERVER_PORT = 12200 - - -@pytest.mark.server -class TestServerBlockOperations(unittest.TestCase): - """Test block operations through client-server communication.""" - - server: Server = None # type: ignore - - @classmethod - def setUpClass(cls) -> None: - cls.server = Server() - # Register several DBs so list_blocks / list_blocks_of_type have something to report - cls.server.register_area(SrvArea.DB, 1, bytearray(100)) - cls.server.register_area(SrvArea.DB, 2, bytearray(200)) - cls.server.register_area(SrvArea.DB, 3, bytearray(50)) - # Also register other area types - cls.server.register_area(SrvArea.MK, 0, bytearray(64)) - cls.server.register_area(SrvArea.PA, 0, bytearray(64)) - cls.server.register_area(SrvArea.PE, 0, bytearray(64)) - cls.server.register_area(SrvArea.TM, 0, bytearray(64)) - cls.server.register_area(SrvArea.CT, 0, bytearray(64)) - cls.server.start(tcp_port=SERVER_PORT) - - @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, 0, 1, SERVER_PORT) - - def tearDown(self) -> None: - self.client.disconnect() - self.client.destroy() - - # ------------------------------------------------------------------ - # list_blocks - # ------------------------------------------------------------------ - def test_list_blocks(self) -> None: - """list_blocks() should return counts; DBCount >= 3 since we registered 3 DBs.""" - bl = self.client.list_blocks() - self.assertGreaterEqual(bl.DBCount, 3) - # OB/FB/FC should be 0 since the emulator only tracks DBs - self.assertEqual(bl.OBCount, 0) - self.assertEqual(bl.FBCount, 0) - self.assertEqual(bl.FCCount, 0) - - # ------------------------------------------------------------------ - # list_blocks_of_type - # ------------------------------------------------------------------ - def test_list_blocks_of_type_db(self) -> None: - """list_blocks_of_type(DB) should include the DB numbers we registered.""" - block_nums = self.client.list_blocks_of_type(Block.DB, 100) - self.assertIn(1, block_nums) - self.assertIn(2, block_nums) - self.assertIn(3, block_nums) - - def test_list_blocks_of_type_ob(self) -> None: - """list_blocks_of_type(OB) should return an empty list (no OBs registered).""" - block_nums = self.client.list_blocks_of_type(Block.OB, 100) - self.assertEqual(block_nums, []) - - # ------------------------------------------------------------------ - # get_block_info - # ------------------------------------------------------------------ - def test_get_block_info(self) -> None: - """get_block_info for a registered DB should return valid metadata.""" - info = self.client.get_block_info(Block.DB, 1) - self.assertEqual(info.MC7Size, 100) # matches registered size - self.assertEqual(info.BlkNumber, 1) - - def test_get_block_info_db2(self) -> None: - """get_block_info for DB2 with size 200.""" - info = self.client.get_block_info(Block.DB, 2) - self.assertEqual(info.MC7Size, 200) - self.assertEqual(info.BlkNumber, 2) - - # ------------------------------------------------------------------ - # upload (block transfer: START_UPLOAD -> UPLOAD -> END_UPLOAD) - # ------------------------------------------------------------------ - def test_upload(self) -> None: - """Upload a DB from the server and verify the returned data length.""" - # Write known data to DB1 first - test_data = bytearray(range(10)) - self.client.db_write(1, 0, test_data) - - # Upload the block - block_data = self.client.upload(1) - self.assertGreater(len(block_data), 0) - # Verify the first bytes match what we wrote - self.assertEqual(block_data[:10], test_data) - - def test_full_upload(self) -> None: - """full_upload should return block data and its size.""" - data, size = self.client.full_upload(Block.DB, 1) - self.assertGreater(size, 0) - self.assertEqual(len(data), size) - - # ------------------------------------------------------------------ - # download (block transfer: REQUEST_DOWNLOAD -> DOWNLOAD_BLOCK -> DOWNLOAD_ENDED) - # ------------------------------------------------------------------ - def test_download(self) -> None: - """Download data to a registered DB on the server.""" - download_data = bytearray([0xAA, 0xBB, 0xCC, 0xDD]) - result = self.client.download(download_data, block_num=1) - self.assertEqual(result, 0) - - # Verify the data was written by reading it back - read_back = self.client.db_read(1, 0, 4) - self.assertEqual(read_back, download_data) - - -@pytest.mark.server -class TestServerUserdataOperations(unittest.TestCase): - """Test USERDATA handlers (SZL, clock, CPU state) through client-server communication.""" - - server: Server = None # type: ignore - - @classmethod - def setUpClass(cls) -> None: - cls.server = Server() - cls.server.register_area(SrvArea.DB, 1, bytearray(100)) - cls.server.start(tcp_port=SERVER_PORT + 1) - - @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, 0, 1, SERVER_PORT + 1) - - def tearDown(self) -> None: - self.client.disconnect() - self.client.destroy() - - # ------------------------------------------------------------------ - # read_szl - # ------------------------------------------------------------------ - def test_read_szl_0x001c(self) -> None: - """read_szl(0x001C) should return component identification data.""" - szl = self.client.read_szl(0x001C, 0) - self.assertGreater(szl.Header.LengthDR, 0) - - def test_read_szl_0x0011(self) -> None: - """read_szl(0x0011) should return module identification data.""" - szl = self.client.read_szl(0x0011, 0) - self.assertGreater(szl.Header.LengthDR, 0) - - def test_read_szl_0x0131(self) -> None: - """read_szl(0x0131) should return communication parameters.""" - szl = self.client.read_szl(0x0131, 0) - self.assertGreater(szl.Header.LengthDR, 0) - - def test_read_szl_0x0232(self) -> None: - """read_szl(0x0232) should return protection level data.""" - szl = self.client.read_szl(0x0232, 0) - self.assertGreater(szl.Header.LengthDR, 0) - - def test_read_szl_0x0000(self) -> None: - """read_szl(0x0000) should return the list of available SZL IDs.""" - szl = self.client.read_szl(0x0000, 0) - self.assertGreater(szl.Header.LengthDR, 0) - - def test_read_szl_list(self) -> None: - """read_szl_list should return raw bytes of available SZL IDs.""" - data = self.client.read_szl_list() - self.assertIsInstance(data, bytes) - self.assertGreater(len(data), 0) - - # ------------------------------------------------------------------ - # get_cpu_info (uses read_szl 0x001C internally) - # ------------------------------------------------------------------ - def test_get_cpu_info(self) -> None: - """get_cpu_info should populate the S7CpuInfo structure.""" - info = self.client.get_cpu_info() - # The emulated server returns "CPU 315-2 PN/DP" - self.assertIn(b"CPU", info.ModuleTypeName) - - # ------------------------------------------------------------------ - # get_order_code (uses read_szl 0x0011 internally) - # ------------------------------------------------------------------ - def test_get_order_code(self) -> None: - """get_order_code should return order code data.""" - oc = self.client.get_order_code() - self.assertIn(b"6ES7", oc.OrderCode) - - # ------------------------------------------------------------------ - # get_cp_info (uses read_szl 0x0131 internally) - # ------------------------------------------------------------------ - def test_get_cp_info(self) -> None: - """get_cp_info should return communication parameters.""" - cp = self.client.get_cp_info() - self.assertGreater(cp.MaxPduLength, 0) - self.assertGreater(cp.MaxConnections, 0) - - # ------------------------------------------------------------------ - # get_protection (uses read_szl 0x0232 internally) - # ------------------------------------------------------------------ - def test_get_protection(self) -> None: - """get_protection should return protection settings.""" - prot = self.client.get_protection() - # Emulator returns no protection (sch_schal=1) - self.assertEqual(prot.sch_schal, 1) - - # ------------------------------------------------------------------ - # get/set PLC datetime (clock USERDATA handlers) - # ------------------------------------------------------------------ - def test_get_plc_datetime(self) -> None: - """get_plc_datetime should return a valid datetime object.""" - dt = self.client.get_plc_datetime() - self.assertIsInstance(dt, datetime) - # Should be recent (within last minute) - now = datetime.now() - delta = abs((now - dt).total_seconds()) - self.assertLess(delta, 60) - - def test_set_plc_datetime(self) -> None: - """set_plc_datetime should succeed (returns 0).""" - test_dt = datetime(2025, 6, 15, 12, 30, 45) - result = self.client.set_plc_datetime(test_dt) - self.assertEqual(result, 0) - - def test_set_plc_system_datetime(self) -> None: - """set_plc_system_datetime should succeed.""" - result = self.client.set_plc_system_datetime() - self.assertEqual(result, 0) - - # ------------------------------------------------------------------ - # get_cpu_state (SZL-based CPU state request) - # ------------------------------------------------------------------ - def test_get_cpu_state(self) -> None: - """get_cpu_state should return a string state.""" - state = self.client.get_cpu_state() - self.assertIsInstance(state, str) - - -@pytest.mark.server -class TestServerPLCControl(unittest.TestCase): - """Test PLC control operations (stop/start) through client-server communication.""" - - server: Server = None # type: ignore - - @classmethod - def setUpClass(cls) -> None: - cls.server = Server() - cls.server.register_area(SrvArea.DB, 1, bytearray(100)) - cls.server.start(tcp_port=SERVER_PORT + 2) - - @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, 0, 1, SERVER_PORT + 2) - - def tearDown(self) -> None: - self.client.disconnect() - self.client.destroy() - - def test_plc_stop(self) -> None: - """plc_stop should succeed and set the server CPU state to STOP.""" - result = self.client.plc_stop() - self.assertEqual(result, 0) - - def test_plc_hot_start(self) -> None: - """plc_hot_start should succeed.""" - result = self.client.plc_hot_start() - self.assertEqual(result, 0) - - def test_plc_cold_start(self) -> None: - """plc_cold_start should succeed.""" - result = self.client.plc_cold_start() - self.assertEqual(result, 0) - - def test_plc_stop_then_start(self) -> None: - """Stopping then starting the PLC should work in sequence.""" - self.assertEqual(self.client.plc_stop(), 0) - self.assertEqual(self.client.plc_hot_start(), 0) - - def test_compress(self) -> None: - """compress should succeed.""" - result = self.client.compress(timeout=1000) - self.assertEqual(result, 0) - - def test_copy_ram_to_rom(self) -> None: - """copy_ram_to_rom should succeed.""" - result = self.client.copy_ram_to_rom(timeout=1000) - self.assertEqual(result, 0) - - -@pytest.mark.server -class TestServerErrorScenarios(unittest.TestCase): - """Test error handling paths in the server.""" - - server: Server = None # type: ignore - - @classmethod - def setUpClass(cls) -> None: - cls.server = Server() - # Only register DB1 with a small area - cls.server.register_area(SrvArea.DB, 1, bytearray(10)) - cls.server.start(tcp_port=SERVER_PORT + 3) - - @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, 0, 1, SERVER_PORT + 3) - - def tearDown(self) -> None: - self.client.disconnect() - self.client.destroy() - - def test_read_unregistered_db(self) -> None: - """Reading from an unregistered DB should still return data (server returns dummy data).""" - # The server returns dummy data for unregistered areas rather than an error - data = self.client.db_read(99, 0, 4) - self.assertEqual(len(data), 4) - - def test_write_beyond_area_bounds(self) -> None: - """Writing beyond area bounds should raise an error.""" - # DB1 is only 10 bytes, writing 20 bytes at offset 0 should fail - with self.assertRaises(Exception): - self.client.db_write(1, 0, bytearray(20)) - - def test_get_block_info_nonexistent(self) -> None: - """get_block_info for a non-existent block should raise an error.""" - with self.assertRaises(Exception): - self.client.get_block_info(Block.DB, 999) - - def test_upload_nonexistent_block(self) -> None: - """Uploading a non-existent block returns empty data (server has no data for that block).""" - # The server defaults to block_num=1 for unknown blocks due to parsing fallback, - # so the upload still completes but returns the default block's data. - # We just verify the operation doesn't crash. - data = self.client.upload(999) - self.assertIsInstance(data, bytearray) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_util.py b/tests/test_util.py index b541cfc2..66e7d244 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,13 +1,16 @@ import datetime +import logging import pytest import unittest import struct from typing import cast +from unittest.mock import MagicMock from snap7 import DB, Row +from snap7.type import Area, WordLen 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 +from snap7.util.db import print_row test_spec = """ @@ -936,5 +939,554 @@ def test_set_time_memoryview(self) -> None: self.assertNotEqual(buf, bytearray(4)) +_db_test_spec = """ +4 ID INT +6 NAME STRING[4] + +12.0 testbool1 BOOL +12.1 testbool2 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] +""" + +_db_bytearray = bytearray( + [ + 0, + 0, # test int + 4, + 4, + ord("t"), + ord("e"), + ord("s"), + ord("t"), # test string + 0x0F, # 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 + 32, + 7, + 18, + 23, + 50, + 2, + 133, + 65, # date_and_time (8 bytes) + 254, + 254, + 254, + 254, + 254, # padding + 127, # usint + 128, # sint + 143, + 255, + 255, + 255, # time + 254, # byte + 48, + 57, # uint + 7, + 91, + 205, + 21, # udint + 65, + 157, + 111, + 52, + 84, + 126, + 107, + 117, # lreal + 65, # char 'A' + 3, + 169, # wchar + 0, + 4, + 0, + 4, + 3, + 169, + 0, + ord("s"), + 0, + ord("t"), + 0, + 196, # wstring + 45, + 235, # date + 2, + 179, + 41, + 128, # tod + 7, + 230, + 3, + 9, + 4, + 12, + 34, + 45, + 0, + 0, + 0, + 0, # dtl + 116, + 101, + 115, + 116, + 32, + 32, + 32, + 32, # fstring 'test ' + ] +) + + +class TestPrintRow: + def test_print_row_output(self, caplog: pytest.LogCaptureFixture) -> None: + data = bytearray([65, 66, 67, 68, 69]) + with caplog.at_level(logging.INFO, logger="snap7.util.db"): + print_row(data) + assert "65" in caplog.text + assert "A" in caplog.text + + +class TestDBDictInterface: + def setup_method(self) -> None: + test_array = bytearray(_db_bytearray * 3) + self.db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=3, layout_offset=4, db_offset=0) + + def test_len(self) -> None: + assert len(self.db) == 3 + + def test_getitem(self) -> None: + row = self.db["0"] + assert row is not None + + def test_getitem_missing(self) -> None: + row = self.db["999"] + assert row is None + + def test_contains(self) -> None: + assert "0" in self.db + assert "999" not in self.db + + def test_keys(self) -> None: + keys = list(self.db.keys()) + assert "0" in keys + assert len(keys) == 3 + + def test_items(self) -> None: + items = list(self.db.items()) + assert len(items) == 3 + for key, row in items: + assert isinstance(key, str) + assert isinstance(row, Row) + + def test_iter(self) -> None: + for key, row in self.db: + assert isinstance(key, str) + assert isinstance(row, Row) + + def test_get_bytearray(self) -> None: + ba = self.db.get_bytearray() + assert isinstance(ba, bytearray) + + +class TestDBWithIdField: + def test_id_field_creates_named_index(self) -> None: + test_array = bytearray(_db_bytearray * 2) + # Set different ID values for each row + struct.pack_into(">h", test_array, 0, 10) # row 0, ID at offset 0 (spec offset 4, layout_offset 4) + struct.pack_into(">h", test_array, len(_db_bytearray), 20) # row 1 + db = DB( + 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, id_field="ID", layout_offset=4, db_offset=0 + ) + assert "10" in db + assert "20" in db + + +class TestDBSetData: + def test_set_data_valid(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + new_data = bytearray(len(_db_bytearray)) + db.set_data(new_data) + assert db.get_bytearray() is new_data + + def test_set_data_invalid_type(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + with pytest.raises(TypeError): + db.set_data(b"not a bytearray") # type: ignore[arg-type] + + +class TestDBReadWrite: + """Test DB.read() and DB.write() with mocked client.""" + + def test_read_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + mock_client = MagicMock() + mock_client.db_read.return_value = bytearray(len(_db_bytearray)) + db.read(mock_client) + mock_client.db_read.assert_called_once() + + def test_read_non_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB( + 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK + ) + mock_client = MagicMock() + mock_client.read_area.return_value = bytearray(len(_db_bytearray)) + db.read(mock_client) + mock_client.read_area.assert_called_once() + + def test_read_negative_row_size(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + db.row_size = -1 + mock_client = MagicMock() + with pytest.raises(ValueError, match="row_size"): + db.read(mock_client) + + def test_write_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + mock_client = MagicMock() + db.write(mock_client) + mock_client.db_write.assert_called_once() + + def test_write_non_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB( + 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK + ) + mock_client = MagicMock() + db.write(mock_client) + mock_client.write_area.assert_called_once() + + def test_write_negative_row_size(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + db.row_size = -1 + mock_client = MagicMock() + with pytest.raises(ValueError, match="row_size"): + db.write(mock_client) + + def test_write_with_row_offset(self) -> None: + test_array = bytearray(_db_bytearray * 2) + db = DB( + 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, layout_offset=4, db_offset=0, row_offset=4 + ) + mock_client = MagicMock() + db.write(mock_client) + # Should write each row individually via Row.write() + assert mock_client.db_write.call_count == 2 + + +class TestRowRepr: + def test_repr(self) -> None: + test_array = bytearray(_db_bytearray) + row = Row(test_array, _db_test_spec, layout_offset=4) + r = repr(row) + assert "ID" in r + assert "NAME" in r + + +class TestRowUnchanged: + def test_unchanged_true(self) -> None: + test_array = bytearray(_db_bytearray) + row = Row(test_array, _db_test_spec, layout_offset=4) + assert row.unchanged(test_array) is True + + def test_unchanged_false(self) -> None: + test_array = bytearray(_db_bytearray) + row = Row(test_array, _db_test_spec, layout_offset=4) + other = bytearray(len(_db_bytearray)) + assert row.unchanged(other) is False + + +class TestRowTypeError: + def test_invalid_bytearray_type(self) -> None: + with pytest.raises(TypeError): + Row("not a bytearray", _db_test_spec) # type: ignore[arg-type] + + +class TestRowReadWrite: + """Test Row.read() and Row.write() with mocked client through DB parent.""" + + def test_row_write_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + row = db["0"] + assert row is not None + mock_client = MagicMock() + row.write(mock_client) + mock_client.db_write.assert_called_once() + + def test_row_write_non_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB( + 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK + ) + row = db["0"] + assert row is not None + mock_client = MagicMock() + row.write(mock_client) + mock_client.write_area.assert_called_once() + + def test_row_write_not_db_parent(self) -> None: + test_array = bytearray(_db_bytearray) + row = Row(test_array, _db_test_spec, layout_offset=4) + mock_client = MagicMock() + with pytest.raises(TypeError): + row.write(mock_client) + + def test_row_write_negative_row_size(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + row = db["0"] + assert row is not None + row.row_size = -1 + mock_client = MagicMock() + with pytest.raises(ValueError, match="row_size"): + row.write(mock_client) + + def test_row_read_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + row = db["0"] + assert row is not None + mock_client = MagicMock() + mock_client.db_read.return_value = bytearray(len(_db_bytearray)) + row.read(mock_client) + mock_client.db_read.assert_called_once() + + def test_row_read_non_db_area(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB( + 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK + ) + row = db["0"] + assert row is not None + mock_client = MagicMock() + mock_client.read_area.return_value = bytearray(len(_db_bytearray)) + row.read(mock_client) + mock_client.read_area.assert_called_once() + + def test_row_read_not_db_parent(self) -> None: + test_array = bytearray(_db_bytearray) + row = Row(test_array, _db_test_spec, layout_offset=4) + mock_client = MagicMock() + with pytest.raises(TypeError): + row.read(mock_client) + + def test_row_read_negative_row_size(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0) + row = db["0"] + assert row is not None + row.row_size = -1 + mock_client = MagicMock() + with pytest.raises(ValueError, match="row_size"): + row.read(mock_client) + + +class TestRowSetValueTypes: + """Test set_value for various type branches.""" + + def setup_method(self) -> None: + self.test_array = bytearray(_db_bytearray) + self.row = Row(self.test_array, _db_test_spec, layout_offset=4) + + def test_set_int(self) -> None: + self.row.set_value(4, "INT", 42) + assert self.row.get_value(4, "INT") == 42 + + def test_set_uint(self) -> None: + self.row.set_value(51, "UINT", 1000) + assert self.row.get_value(51, "UINT") == 1000 + + def test_set_dint(self) -> None: + self.row.set_value(23, "DINT", -100) + assert self.row.get_value(23, "DINT") == -100 + + def test_set_udint(self) -> None: + self.row.set_value(53, "UDINT", 999999) + assert self.row.get_value(53, "UDINT") == 999999 + + def test_set_word(self) -> None: + self.row.set_value(27, "WORD", 12345) + assert self.row.get_value(27, "WORD") == 12345 + + def test_set_usint(self) -> None: + self.row.set_value(43, "USINT", 200) + assert self.row.get_value(43, "USINT") == 200 + + def test_set_sint(self) -> None: + self.row.set_value(44, "SINT", -50) + assert self.row.get_value(44, "SINT") == -50 + + def test_set_time(self) -> None: + self.row.set_value(46, "TIME", "1:2:3:4.5") + assert self.row.get_value(46, "TIME") is not None + + def test_set_date(self) -> None: + d = datetime.date(2024, 1, 15) + self.row.set_value(80, "DATE", d) + assert self.row.get_value(80, "DATE") == d + + def test_set_tod(self) -> None: + td = datetime.timedelta(hours=5, minutes=30) + self.row.set_value(82, "TOD", td) + assert self.row.get_value(82, "TOD") == td + + def test_set_time_of_day(self) -> None: + td = datetime.timedelta(hours=1) + self.row.set_value(82, "TIME_OF_DAY", td) + assert self.row.get_value(82, "TIME_OF_DAY") == td + + def test_set_dtl(self) -> None: + dt = datetime.datetime(2024, 6, 15, 10, 20, 30) + self.row.set_value(86, "DTL", dt) + result = self.row.get_value(86, "DTL") + assert result.year == 2024 # type: ignore[union-attr] + + def test_set_date_and_time(self) -> None: + dt = datetime.datetime(2020, 7, 12, 17, 32, 2, 854000) + self.row.set_value(31, "DATE_AND_TIME", dt) + result = self.row.get_value(31, "DATE_AND_TIME") + assert "2020" in str(result) + + def test_set_unknown_type_raises(self) -> None: + with pytest.raises(ValueError): + self.row.set_value(4, "UNKNOWN_TYPE", 42) + + def test_set_string(self) -> None: + self.row.set_value(6, "STRING[4]", "ab") + assert self.row.get_value(6, "STRING[4]") == "ab" + + def test_set_wstring(self) -> None: + self.row.set_value(68, "WSTRING[4]", "ab") + assert self.row.get_value(68, "WSTRING[4]") == "ab" + + def test_set_fstring(self) -> None: + self.row.set_value(98, "FSTRING[8]", "hi") + assert self.row.get_value(98, "FSTRING[8]") == "hi" + + def test_set_real(self) -> None: + self.row.set_value(13, "REAL", 3.14) + assert abs(self.row.get_value(13, "REAL") - 3.14) < 0.01 # type: ignore[operator] + + def test_set_lreal(self) -> None: + self.row.set_value(57, "LREAL", 2.718281828) + assert abs(self.row.get_value(57, "LREAL") - 2.718281828) < 0.0001 # type: ignore[operator] + + def test_set_char(self) -> None: + self.row.set_value(65, "CHAR", "Z") + assert self.row.get_value(65, "CHAR") == "Z" + + def test_set_wchar(self) -> None: + self.row.set_value(66, "WCHAR", "W") + assert self.row.get_value(66, "WCHAR") == "W" + + +class TestRowGetValueEdgeCases: + """Test get_value for edge cases.""" + + def setup_method(self) -> None: + self.test_array = bytearray(_db_bytearray) + self.row = Row(self.test_array, _db_test_spec, layout_offset=4) + + def test_unknown_type_raises(self) -> None: + with pytest.raises(ValueError): + self.row.get_value(4, "NONEXISTENT") + + def test_string_no_max_size(self) -> None: + spec = "4 test STRING" + row = Row(bytearray(20), spec, layout_offset=0) + with pytest.raises(ValueError, match="Max size"): + row.get_value(4, "STRING") + + def test_fstring_no_max_size(self) -> None: + with pytest.raises(ValueError, match="Max size"): + self.row.get_value(98, "FSTRING") + + def test_wstring_no_max_size(self) -> None: + with pytest.raises(ValueError, match="Max size"): + self.row.get_value(68, "WSTRING") + + +class TestRowSetValueEdgeCases: + """Test set_value edge cases for string types.""" + + def setup_method(self) -> None: + self.test_array = bytearray(_db_bytearray) + self.row = Row(self.test_array, _db_test_spec, layout_offset=4) + + def test_fstring_no_max_size(self) -> None: + with pytest.raises(ValueError, match="Max size"): + self.row.set_value(98, "FSTRING", "test") + + def test_string_no_max_size(self) -> None: + with pytest.raises(ValueError, match="Max size"): + self.row.set_value(6, "STRING", "test") + + def test_wstring_no_max_size(self) -> None: + with pytest.raises(ValueError, match="Max size"): + self.row.set_value(68, "WSTRING", "test") + + +class TestRowWriteWithRowOffset: + """Test Row.write() with row_offset set.""" + + def test_write_with_row_offset(self) -> None: + test_array = bytearray(_db_bytearray) + db = DB( + 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, row_offset=10 + ) + row = db["0"] + assert row is not None + mock_client = MagicMock() + row.write(mock_client) + # The data written should start at db_offset + row_offset + mock_client.db_write.assert_called_once() + + if __name__ == "__main__": unittest.main() From 6d199cb84802caca0d7fe1b48e06a245ac25d33a Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Fri, 20 Mar 2026 13:12:21 +0200 Subject: [PATCH 2/4] Fix ruff formatting in test_util.py Co-Authored-By: Claude Opus 4.6 --- tests/test_util.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index 66e7d244..49f3d192 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1133,9 +1133,7 @@ def test_id_field_creates_named_index(self) -> None: # Set different ID values for each row struct.pack_into(">h", test_array, 0, 10) # row 0, ID at offset 0 (spec offset 4, layout_offset 4) struct.pack_into(">h", test_array, len(_db_bytearray), 20) # row 1 - db = DB( - 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, id_field="ID", layout_offset=4, db_offset=0 - ) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, id_field="ID", layout_offset=4, db_offset=0) assert "10" in db assert "20" in db @@ -1168,9 +1166,7 @@ def test_read_db_area(self) -> None: def test_read_non_db_area(self) -> None: test_array = bytearray(_db_bytearray) - db = DB( - 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK - ) + db = DB(0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) mock_client = MagicMock() mock_client.read_area.return_value = bytearray(len(_db_bytearray)) db.read(mock_client) @@ -1193,9 +1189,7 @@ def test_write_db_area(self) -> None: def test_write_non_db_area(self) -> None: test_array = bytearray(_db_bytearray) - db = DB( - 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK - ) + db = DB(0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) mock_client = MagicMock() db.write(mock_client) mock_client.write_area.assert_called_once() @@ -1210,9 +1204,7 @@ def test_write_negative_row_size(self) -> None: def test_write_with_row_offset(self) -> None: test_array = bytearray(_db_bytearray * 2) - db = DB( - 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, layout_offset=4, db_offset=0, row_offset=4 - ) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=2, layout_offset=4, db_offset=0, row_offset=4) mock_client = MagicMock() db.write(mock_client) # Should write each row individually via Row.write() @@ -1261,9 +1253,7 @@ def test_row_write_db_area(self) -> None: def test_row_write_non_db_area(self) -> None: test_array = bytearray(_db_bytearray) - db = DB( - 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK - ) + db = DB(0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) row = db["0"] assert row is not None mock_client = MagicMock() @@ -1299,9 +1289,7 @@ def test_row_read_db_area(self) -> None: def test_row_read_non_db_area(self) -> None: test_array = bytearray(_db_bytearray) - db = DB( - 0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK - ) + db = DB(0, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, area=Area.MK) row = db["0"] assert row is not None mock_client = MagicMock() @@ -1477,9 +1465,7 @@ class TestRowWriteWithRowOffset: def test_write_with_row_offset(self) -> None: test_array = bytearray(_db_bytearray) - db = DB( - 1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, row_offset=10 - ) + db = DB(1, test_array, _db_test_spec, row_size=len(_db_bytearray), size=1, layout_offset=4, db_offset=0, row_offset=10) row = db["0"] assert row is not None mock_client = MagicMock() From 8762bcf537c5ae61ed1034628b74e2aa62ce5dd0 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Fri, 20 Mar 2026 13:41:22 +0200 Subject: [PATCH 3/4] Improve S7CommPlus test coverage and fix Codecov upload Add 154 new unit tests covering codec decoders, PValue parsing for all data types, payload builders/parsers, connection response parsing, and client error paths. S7CommPlus coverage rises from 77% to 87%, with codec.py reaching 100%. Also add CODECOV_TOKEN to the workflow to fix silent upload failures on protected branches. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 1 + tests/test_s7commplus_codec.py | 462 ++++++++++++++++++++++++++++++++- tests/test_s7commplus_unit.py | 459 ++++++++++++++++++++++++++++++++ 3 files changed, 919 insertions(+), 3 deletions(-) create mode 100644 tests/test_s7commplus_unit.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b5a0de3..5d811c4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,4 +38,5 @@ jobs: uses: codecov/codecov-action@v5 with: files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false diff --git a/tests/test_s7commplus_codec.py b/tests/test_s7commplus_codec.py index 84a3212f..9b03881e 100644 --- a/tests/test_s7commplus_codec.py +++ b/tests/test_s7commplus_codec.py @@ -1,4 +1,4 @@ -"""Tests for S7CommPlus codec (header encoding, typed values).""" +"""Tests for S7CommPlus codec (header encoding, typed values, payload builders).""" import struct import pytest @@ -9,18 +9,34 @@ encode_request_header, decode_response_header, encode_typed_value, + encode_uint8, + decode_uint8, encode_uint16, decode_uint16, encode_uint32, decode_uint32, + encode_uint64, + decode_uint64, + encode_int16, + decode_int16, + encode_int32, + decode_int32, + encode_int64, + decode_int64, encode_float32, decode_float32, encode_float64, decode_float64, encode_wstring, decode_wstring, + encode_item_address, + encode_pvalue_blob, + decode_pvalue_to_bytes, + encode_object_qualifier, + _pvalue_element_size, ) -from snap7.s7commplus.protocol import PROTOCOL_ID, DataType, Opcode, FunctionCode +from snap7.s7commplus.protocol import PROTOCOL_ID, DataType, Opcode, FunctionCode, Ids +from snap7.s7commplus.vlq import encode_uint32_vlq, encode_int32_vlq, encode_uint64_vlq, encode_int64_vlq class TestFrameHeader: @@ -78,8 +94,19 @@ def test_roundtrip_request_response_header(self) -> None: assert result["session_id"] == 0x12345678 assert result["bytes_consumed"] == 14 + def test_decode_response_header_too_short(self) -> None: + with pytest.raises(ValueError, match="Not enough data"): + decode_response_header(bytes(10)) + class TestFixedWidth: + def test_uint8_roundtrip(self) -> None: + for val in [0, 1, 127, 255]: + encoded = encode_uint8(val) + decoded, consumed = decode_uint8(encoded) + assert decoded == val + assert consumed == 1 + def test_uint16_roundtrip(self) -> None: for val in [0, 1, 0xFF, 0xFFFF]: encoded = encode_uint16(val) @@ -94,6 +121,34 @@ def test_uint32_roundtrip(self) -> None: assert decoded == val assert consumed == 4 + def test_uint64_roundtrip(self) -> None: + for val in [0, 1, 0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF]: + encoded = encode_uint64(val) + decoded, consumed = decode_uint64(encoded) + assert decoded == val + assert consumed == 8 + + def test_int16_roundtrip(self) -> None: + for val in [0, 1, -1, -32768, 32767]: + encoded = encode_int16(val) + decoded, consumed = decode_int16(encoded) + assert decoded == val + assert consumed == 2 + + def test_int32_roundtrip(self) -> None: + for val in [0, 1, -1, -2147483648, 2147483647]: + encoded = encode_int32(val) + decoded, consumed = decode_int32(encoded) + assert decoded == val + assert consumed == 4 + + def test_int64_roundtrip(self) -> None: + for val in [0, 1, -1, -(2**63), 2**63 - 1]: + encoded = encode_int64(val) + decoded, consumed = decode_int64(encoded) + assert decoded == val + assert consumed == 8 + def test_float32_roundtrip(self) -> None: for val in [0.0, 1.0, -1.0, 3.14]: encoded = encode_float32(val) @@ -108,6 +163,47 @@ def test_float64_roundtrip(self) -> None: assert decoded == val assert consumed == 8 + def test_uint8_with_offset(self) -> None: + data = bytes([0xFF, 42, 0xFF]) + decoded, consumed = decode_uint8(data, offset=1) + assert decoded == 42 + + def test_uint64_with_offset(self) -> None: + prefix = bytes(4) + data = prefix + encode_uint64(0x123456789ABCDEF0) + decoded, consumed = decode_uint64(data, offset=4) + assert decoded == 0x123456789ABCDEF0 + + def test_int16_with_offset(self) -> None: + prefix = bytes(3) + data = prefix + encode_int16(-1000) + decoded, consumed = decode_int16(data, offset=3) + assert decoded == -1000 + + def test_int32_with_offset(self) -> None: + prefix = bytes(2) + data = prefix + encode_int32(-100000) + decoded, consumed = decode_int32(data, offset=2) + assert decoded == -100000 + + def test_int64_with_offset(self) -> None: + prefix = bytes(5) + data = prefix + encode_int64(-(2**50)) + decoded, consumed = decode_int64(data, offset=5) + assert decoded == -(2**50) + + def test_float32_with_offset(self) -> None: + prefix = bytes(1) + data = prefix + encode_float32(2.5) + decoded, consumed = decode_float32(data, offset=1) + assert abs(decoded - 2.5) < 1e-6 + + def test_float64_with_offset(self) -> None: + prefix = bytes(3) + data = prefix + encode_float64(1.23456789) + decoded, consumed = decode_float64(data, offset=3) + assert decoded == 1.23456789 + class TestWString: def test_ascii(self) -> None: @@ -144,10 +240,52 @@ def test_usint(self) -> None: encoded = encode_typed_value(DataType.USINT, 42) assert encoded == bytes([DataType.USINT, 42]) + def test_byte(self) -> None: + encoded = encode_typed_value(DataType.BYTE, 0xAB) + assert encoded == bytes([DataType.BYTE, 0xAB]) + def test_uint(self) -> None: encoded = encode_typed_value(DataType.UINT, 0x1234) assert encoded == bytes([DataType.UINT]) + struct.pack(">H", 0x1234) + def test_word(self) -> None: + encoded = encode_typed_value(DataType.WORD, 0xBEEF) + assert encoded == bytes([DataType.WORD]) + struct.pack(">H", 0xBEEF) + + def test_udint(self) -> None: + encoded = encode_typed_value(DataType.UDINT, 100000) + assert encoded[0] == DataType.UDINT + # Rest is VLQ-encoded + assert len(encoded) > 1 + + def test_dword(self) -> None: + encoded = encode_typed_value(DataType.DWORD, 0xDEADBEEF) + assert encoded[0] == DataType.DWORD + + def test_ulint(self) -> None: + encoded = encode_typed_value(DataType.ULINT, 2**40) + assert encoded[0] == DataType.ULINT + + def test_lword(self) -> None: + encoded = encode_typed_value(DataType.LWORD, 0xCAFEBABE12345678) + assert encoded[0] == DataType.LWORD + + def test_sint(self) -> None: + encoded = encode_typed_value(DataType.SINT, -42) + assert encoded == bytes([DataType.SINT]) + struct.pack(">b", -42) + + def test_int(self) -> None: + encoded = encode_typed_value(DataType.INT, -1000) + assert encoded == bytes([DataType.INT]) + struct.pack(">h", -1000) + + def test_dint(self) -> None: + encoded = encode_typed_value(DataType.DINT, -100000) + assert encoded[0] == DataType.DINT + + def test_lint(self) -> None: + encoded = encode_typed_value(DataType.LINT, -(2**40)) + assert encoded[0] == DataType.LINT + def test_real(self) -> None: encoded = encode_typed_value(DataType.REAL, 1.0) assert encoded == bytes([DataType.REAL]) + struct.pack(">f", 1.0) @@ -156,10 +294,26 @@ def test_lreal(self) -> None: encoded = encode_typed_value(DataType.LREAL, 3.14) assert encoded == bytes([DataType.LREAL]) + struct.pack(">d", 3.14) + def test_timestamp(self) -> None: + ts = 0x0001020304050607 + encoded = encode_typed_value(DataType.TIMESTAMP, ts) + assert encoded == bytes([DataType.TIMESTAMP]) + struct.pack(">Q", ts) + + def test_timespan(self) -> None: + encoded = encode_typed_value(DataType.TIMESPAN, -5000) + assert encoded[0] == DataType.TIMESPAN + + def test_rid(self) -> None: + encoded = encode_typed_value(DataType.RID, 0x12345678) + assert encoded == bytes([DataType.RID]) + struct.pack(">I", 0x12345678) + + def test_aid(self) -> None: + encoded = encode_typed_value(DataType.AID, 306) + assert encoded[0] == DataType.AID + def test_wstring(self) -> None: encoded = encode_typed_value(DataType.WSTRING, "test") assert encoded[0] == DataType.WSTRING - # Should contain VLQ length + UTF-8 data assert b"test" in encoded def test_blob(self) -> None: @@ -171,3 +325,305 @@ def test_blob(self) -> None: def test_unsupported_type(self) -> None: with pytest.raises(ValueError, match="Unsupported DataType"): encode_typed_value(0xFF, None) + + +class TestItemAddress: + def test_basic_db_access(self) -> None: + addr_bytes, field_count = encode_item_address( + access_area=Ids.DB_ACCESS_AREA_BASE + 1, + access_sub_area=Ids.DB_VALUE_ACTUAL, + ) + assert isinstance(addr_bytes, bytes) + assert len(addr_bytes) > 0 + # No LIDs, so field_count = 4 (SymbolCrc + AccessArea + NumLIDs + AccessSubArea) + assert field_count == 4 + + def test_with_lids(self) -> None: + addr_bytes, field_count = encode_item_address( + access_area=Ids.DB_ACCESS_AREA_BASE + 1, + access_sub_area=Ids.DB_VALUE_ACTUAL, + lids=[1, 4], + ) + assert field_count == 6 # 4 + 2 LIDs + + def test_custom_symbol_crc(self) -> None: + addr_bytes, field_count = encode_item_address( + access_area=Ids.DB_ACCESS_AREA_BASE + 1, + access_sub_area=Ids.DB_VALUE_ACTUAL, + symbol_crc=0x1234, + ) + # First bytes should be VLQ(0x1234) which is non-zero + assert addr_bytes[0] != 0 + assert field_count == 4 + + +class TestPValueBlob: + def test_basic_blob(self) -> None: + data = bytes([1, 2, 3, 4]) + encoded = encode_pvalue_blob(data) + assert encoded[0] == 0x00 # flags + assert encoded[1] == DataType.BLOB + assert encoded.endswith(data) + + def test_empty_blob(self) -> None: + encoded = encode_pvalue_blob(b"") + assert encoded[0] == 0x00 + assert encoded[1] == DataType.BLOB + + def test_roundtrip_with_decode(self) -> None: + data = bytes([0xDE, 0xAD, 0xBE, 0xEF]) + encoded = encode_pvalue_blob(data) + decoded, consumed = decode_pvalue_to_bytes(encoded, 0) + assert decoded == data + assert consumed == len(encoded) + + +class TestDecodePValue: + """Test decode_pvalue_to_bytes for all scalar and array type branches.""" + + def test_null(self) -> None: + data = bytes([0x00, DataType.NULL]) + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == b"" + assert consumed == 2 + + def test_bool_true(self) -> None: + data = bytes([0x00, DataType.BOOL, 0x01]) + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([0x01]) + assert consumed == 3 + + def test_bool_false(self) -> None: + data = bytes([0x00, DataType.BOOL, 0x00]) + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([0x00]) + + def test_usint(self) -> None: + data = bytes([0x00, DataType.USINT, 42]) + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([42]) + assert consumed == 3 + + def test_byte(self) -> None: + data = bytes([0x00, DataType.BYTE, 0xAB]) + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([0xAB]) + + def test_sint(self) -> None: + data = bytes([0x00, DataType.SINT, 0xD6]) # -42 as unsigned byte + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([0xD6]) + + def test_uint(self) -> None: + raw = struct.pack(">H", 0x1234) + data = bytes([0x00, DataType.UINT]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_word(self) -> None: + raw = struct.pack(">H", 0xBEEF) + data = bytes([0x00, DataType.WORD]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_int(self) -> None: + raw = struct.pack(">H", 0xFC18) # -1000 as unsigned + data = bytes([0x00, DataType.INT]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_udint(self) -> None: + vlq = encode_uint32_vlq(100000) + data = bytes([0x00, DataType.UDINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">I", 100000) + + def test_dword(self) -> None: + vlq = encode_uint32_vlq(0xDEADBEEF) + data = bytes([0x00, DataType.DWORD]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">I", 0xDEADBEEF) + + def test_dint_positive(self) -> None: + vlq = encode_int32_vlq(12345) + data = bytes([0x00, DataType.DINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">i", 12345) + + def test_dint_negative(self) -> None: + vlq = encode_int32_vlq(-100000) + data = bytes([0x00, DataType.DINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">i", -100000) + + def test_real(self) -> None: + raw = struct.pack(">f", 3.14) + data = bytes([0x00, DataType.REAL]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_lreal(self) -> None: + raw = struct.pack(">d", 2.718281828) + data = bytes([0x00, DataType.LREAL]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_ulint(self) -> None: + vlq = encode_uint64_vlq(2**40) + data = bytes([0x00, DataType.ULINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">Q", 2**40) + + def test_lword(self) -> None: + vlq = encode_uint64_vlq(0xCAFEBABE12345678) + data = bytes([0x00, DataType.LWORD]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">Q", 0xCAFEBABE12345678) + + def test_lint_positive(self) -> None: + vlq = encode_int64_vlq(2**50) + data = bytes([0x00, DataType.LINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">q", 2**50) + + def test_lint_negative(self) -> None: + vlq = encode_int64_vlq(-(2**40)) + data = bytes([0x00, DataType.LINT]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">q", -(2**40)) + + def test_timestamp(self) -> None: + ts = 0x0001020304050607 + raw = struct.pack(">Q", ts) + data = bytes([0x00, DataType.TIMESTAMP]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + assert consumed == 10 # 2 header + 8 bytes + + def test_timespan_positive(self) -> None: + vlq = encode_int64_vlq(5000000) + data = bytes([0x00, DataType.TIMESPAN]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">q", 5000000) + + def test_timespan_negative(self) -> None: + vlq = encode_int64_vlq(-5000000) + data = bytes([0x00, DataType.TIMESPAN]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">q", -5000000) + + def test_rid(self) -> None: + raw = struct.pack(">I", 0x12345678) + data = bytes([0x00, DataType.RID]) + raw + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == raw + + def test_aid(self) -> None: + vlq = encode_uint32_vlq(306) + data = bytes([0x00, DataType.AID]) + vlq + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == struct.pack(">I", 306) + + def test_blob(self) -> None: + blob_data = bytes([0xDE, 0xAD, 0xBE, 0xEF]) + vlq_len = encode_uint32_vlq(len(blob_data)) + data = bytes([0x00, DataType.BLOB]) + vlq_len + blob_data + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == blob_data + + def test_wstring(self) -> None: + text = "hello".encode("utf-8") + vlq_len = encode_uint32_vlq(len(text)) + data = bytes([0x00, DataType.WSTRING]) + vlq_len + text + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == text + + def test_struct_nested(self) -> None: + # Struct with 2 USINT elements + vlq_count = encode_uint32_vlq(2) + elem1 = bytes([0x00, DataType.USINT, 0x0A]) + elem2 = bytes([0x00, DataType.USINT, 0x14]) + data = bytes([0x00, DataType.STRUCT]) + vlq_count + elem1 + elem2 + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == bytes([0x0A, 0x14]) + + def test_unsupported_type(self) -> None: + data = bytes([0x00, 0xFF]) + with pytest.raises(ValueError, match="Unsupported PValue datatype"): + decode_pvalue_to_bytes(data, 0) + + def test_too_short_header(self) -> None: + with pytest.raises(ValueError, match="Not enough data for PValue header"): + decode_pvalue_to_bytes(bytes([0x00]), 0) + + def test_with_offset(self) -> None: + prefix = bytes([0xFF, 0xFF, 0xFF]) + pvalue = bytes([0x00, DataType.USINT, 42]) + result, consumed = decode_pvalue_to_bytes(prefix + pvalue, 3) + assert result == bytes([42]) + + # -- Array tests -- + + def test_array_fixed_size_usint(self) -> None: + count_vlq = encode_uint32_vlq(3) + elements = bytes([10, 20, 30]) + data = bytes([0x10, DataType.USINT]) + count_vlq + elements + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == elements + + def test_array_fixed_size_uint(self) -> None: + count_vlq = encode_uint32_vlq(2) + elements = struct.pack(">HH", 1000, 2000) + data = bytes([0x10, DataType.UINT]) + count_vlq + elements + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == elements + + def test_array_fixed_size_real(self) -> None: + count_vlq = encode_uint32_vlq(2) + elements = struct.pack(">ff", 1.0, 2.0) + data = bytes([0x10, DataType.REAL]) + count_vlq + elements + result, consumed = decode_pvalue_to_bytes(data, 0) + assert result == elements + + def test_array_variable_length_udint(self) -> None: + # Variable-length array (VLQ-encoded elements) + count_vlq = encode_uint32_vlq(2) + elem1 = encode_uint32_vlq(100) + elem2 = encode_uint32_vlq(200) + data = bytes([0x10, DataType.UDINT]) + count_vlq + elem1 + elem2 + result, consumed = decode_pvalue_to_bytes(data, 0) + # Result re-encodes each element as VLQ + assert result == encode_uint32_vlq(100) + encode_uint32_vlq(200) + + +class TestPValueElementSize: + def test_single_byte_types(self) -> None: + for dt in (DataType.BOOL, DataType.USINT, DataType.BYTE, DataType.SINT): + assert _pvalue_element_size(dt) == 1 + + def test_two_byte_types(self) -> None: + for dt in (DataType.UINT, DataType.WORD, DataType.INT): + assert _pvalue_element_size(dt) == 2 + + def test_four_byte_types(self) -> None: + assert _pvalue_element_size(DataType.REAL) == 4 + assert _pvalue_element_size(DataType.RID) == 4 + + def test_eight_byte_types(self) -> None: + assert _pvalue_element_size(DataType.LREAL) == 8 + assert _pvalue_element_size(DataType.TIMESTAMP) == 8 + + def test_variable_length_types(self) -> None: + for dt in (DataType.UDINT, DataType.DWORD, DataType.BLOB, DataType.WSTRING, DataType.STRUCT): + assert _pvalue_element_size(dt) == 0 + + +class TestObjectQualifier: + def test_encode(self) -> None: + result = encode_object_qualifier() + assert isinstance(result, bytes) + assert len(result) > 0 + # Starts with ObjectQualifier ID (1256) as uint32 big-endian + assert result[:4] == struct.pack(">I", Ids.OBJECT_QUALIFIER) + # Ends with null terminator + assert result[-1] == 0x00 diff --git a/tests/test_s7commplus_unit.py b/tests/test_s7commplus_unit.py new file mode 100644 index 00000000..f7c5e57e --- /dev/null +++ b/tests/test_s7commplus_unit.py @@ -0,0 +1,459 @@ +"""Unit tests for S7CommPlus client payload builders, connection parsing, and error paths.""" + +import struct +import pytest + +from snap7.s7commplus.client import ( + S7CommPlusClient, + _build_read_payload, + _parse_read_response, + _build_write_payload, + _parse_write_response, +) +from snap7.s7commplus.codec import encode_pvalue_blob +from snap7.s7commplus.connection import S7CommPlusConnection, _element_size +from snap7.s7commplus.protocol import DataType, ElementID, ObjectId +from snap7.s7commplus.vlq import ( + encode_uint32_vlq, + encode_uint64_vlq, + encode_int32_vlq, + decode_uint32_vlq, +) + + +# -- Payload builder / parser tests -- + + +class TestBuildReadPayload: + def test_single_item(self) -> None: + payload = _build_read_payload([(1, 0, 4)]) + assert isinstance(payload, bytes) + assert len(payload) > 0 + + def test_multi_item(self) -> None: + payload = _build_read_payload([(1, 0, 4), (2, 10, 8)]) + assert isinstance(payload, bytes) + # Multi-item payload should be larger than single + single = _build_read_payload([(1, 0, 4)]) + assert len(payload) > len(single) + + +class TestParseReadResponse: + @staticmethod + def _build_response( + return_value: int = 0, + items: list[bytes] | None = None, + errors: list[tuple[int, int]] | None = None, + ) -> bytes: + """Build a synthetic GetMultiVariables response.""" + result = bytearray() + # ReturnValue (UInt64 VLQ) + result += encode_uint64_vlq(return_value) + + # Value list + if items: + for i, item_data in enumerate(items, 1): + result += encode_uint32_vlq(i) # ItemNumber + result += encode_pvalue_blob(item_data) # PValue + result += encode_uint32_vlq(0) # Terminator + + # Error list + if errors: + for err_item_nr, err_value in errors: + result += encode_uint32_vlq(err_item_nr) + result += encode_uint64_vlq(err_value) + result += encode_uint32_vlq(0) # Terminator + + return bytes(result) + + def test_single_item_success(self) -> None: + data = bytes([1, 2, 3, 4]) + response = self._build_response(items=[data]) + results = _parse_read_response(response) + assert len(results) == 1 + assert results[0] == data + + def test_multi_item_success(self) -> None: + data1 = bytes([0x0A, 0x0B]) + data2 = bytes([0x0C, 0x0D, 0x0E]) + response = self._build_response(items=[data1, data2]) + results = _parse_read_response(response) + assert len(results) == 2 + assert results[0] == data1 + assert results[1] == data2 + + def test_error_return_value(self) -> None: + response = self._build_response(return_value=0x05A9) + results = _parse_read_response(response) + assert results == [] + + def test_empty_response(self) -> None: + response = self._build_response() + results = _parse_read_response(response) + assert results == [] + + def test_with_error_items(self) -> None: + data1 = bytes([1, 2, 3, 4]) + response = self._build_response(items=[data1], errors=[(2, 0xDEAD)]) + results = _parse_read_response(response) + assert len(results) == 2 + assert results[0] == data1 + assert results[1] is None # Error item + + +class TestParseWriteResponse: + @staticmethod + def _build_response(return_value: int = 0, errors: list[tuple[int, int]] | None = None) -> bytes: + result = bytearray() + result += encode_uint64_vlq(return_value) + if errors: + for err_item_nr, err_value in errors: + result += encode_uint32_vlq(err_item_nr) + result += encode_uint64_vlq(err_value) + result += encode_uint32_vlq(0) # Terminator + return bytes(result) + + def test_success(self) -> None: + response = self._build_response(return_value=0) + _parse_write_response(response) # Should not raise + + def test_error_return_value(self) -> None: + response = self._build_response(return_value=0x05A9) + with pytest.raises(RuntimeError, match="Write failed"): + _parse_write_response(response) + + def test_error_items(self) -> None: + response = self._build_response(return_value=0, errors=[(1, 0xDEAD)]) + with pytest.raises(RuntimeError, match="Write failed"): + _parse_write_response(response) + + +class TestBuildWritePayload: + def test_single_item(self) -> None: + payload = _build_write_payload([(1, 0, bytes([1, 2, 3, 4]))]) + assert isinstance(payload, bytes) + assert len(payload) > 0 + + def test_data_appears_in_payload(self) -> None: + data = bytes([0xDE, 0xAD, 0xBE, 0xEF]) + payload = _build_write_payload([(1, 0, data)]) + # The raw data should appear in the payload (inside the BLOB PValue) + assert data in payload + + +# -- Client/server payload agreement -- + + +class TestPayloadAgreement: + """Verify client payloads can be parsed by the server's request parser.""" + + def test_read_payload_roundtrip(self) -> None: + """Build a read payload, then manually verify it has expected structure.""" + payload = _build_read_payload([(1, 0, 4)]) + offset = 0 + + # LinkId (4 bytes fixed) + link_id = struct.unpack_from(">I", payload, offset)[0] + offset += 4 + assert link_id == 0 + + # Item count (VLQ) + item_count, consumed = decode_uint32_vlq(payload, offset) + offset += consumed + assert item_count == 1 + + # Total field count (VLQ) + total_fields, consumed = decode_uint32_vlq(payload, offset) + offset += consumed + assert total_fields == 6 # 4 base + 2 LIDs + + def test_write_read_consistency(self) -> None: + """Build write and read payloads for same address, verify both compile.""" + read_payload = _build_read_payload([(1, 0, 4)]) + write_payload = _build_write_payload([(1, 0, bytes([1, 2, 3, 4]))]) + assert isinstance(read_payload, bytes) + assert isinstance(write_payload, bytes) + + +# -- Connection unit tests -- + + +class TestConnectionElementSize: + def test_single_byte(self) -> None: + for dt in (DataType.BOOL, DataType.USINT, DataType.BYTE, DataType.SINT): + assert _element_size(dt) == 1 + + def test_two_byte(self) -> None: + for dt in (DataType.UINT, DataType.WORD, DataType.INT): + assert _element_size(dt) == 2 + + def test_four_byte(self) -> None: + for dt in (DataType.REAL, DataType.RID): + assert _element_size(dt) == 4 + + def test_eight_byte(self) -> None: + for dt in (DataType.LREAL, DataType.TIMESTAMP): + assert _element_size(dt) == 8 + + def test_variable_length(self) -> None: + for dt in (DataType.UDINT, DataType.BLOB, DataType.WSTRING, DataType.STRUCT): + assert _element_size(dt) == 0 + + +class TestSkipTypedValue: + """Test S7CommPlusConnection._skip_typed_value with constructed byte buffers.""" + + @pytest.fixture() + def conn(self) -> S7CommPlusConnection: + return S7CommPlusConnection("127.0.0.1") + + def test_null(self, conn: S7CommPlusConnection) -> None: + assert conn._skip_typed_value(b"", 0, DataType.NULL, 0x00) == 0 + + def test_bool(self, conn: S7CommPlusConnection) -> None: + data = bytes([0x01]) + assert conn._skip_typed_value(data, 0, DataType.BOOL, 0x00) == 1 + + def test_usint(self, conn: S7CommPlusConnection) -> None: + data = bytes([42]) + assert conn._skip_typed_value(data, 0, DataType.USINT, 0x00) == 1 + + def test_byte(self, conn: S7CommPlusConnection) -> None: + data = bytes([0xAB]) + assert conn._skip_typed_value(data, 0, DataType.BYTE, 0x00) == 1 + + def test_sint(self, conn: S7CommPlusConnection) -> None: + data = bytes([0xD6]) + assert conn._skip_typed_value(data, 0, DataType.SINT, 0x00) == 1 + + def test_uint(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">H", 1000) + assert conn._skip_typed_value(data, 0, DataType.UINT, 0x00) == 2 + + def test_word(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">H", 0xBEEF) + assert conn._skip_typed_value(data, 0, DataType.WORD, 0x00) == 2 + + def test_int(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">h", -1000) + assert conn._skip_typed_value(data, 0, DataType.INT, 0x00) == 2 + + def test_udint(self, conn: S7CommPlusConnection) -> None: + vlq = encode_uint32_vlq(100000) + new_offset = conn._skip_typed_value(vlq, 0, DataType.UDINT, 0x00) + assert new_offset == len(vlq) + + def test_dword(self, conn: S7CommPlusConnection) -> None: + vlq = encode_uint32_vlq(0xDEADBEEF) + new_offset = conn._skip_typed_value(vlq, 0, DataType.DWORD, 0x00) + assert new_offset == len(vlq) + + def test_aid(self, conn: S7CommPlusConnection) -> None: + vlq = encode_uint32_vlq(306) + new_offset = conn._skip_typed_value(vlq, 0, DataType.AID, 0x00) + assert new_offset == len(vlq) + + def test_dint(self, conn: S7CommPlusConnection) -> None: + vlq = encode_int32_vlq(-100000) + new_offset = conn._skip_typed_value(vlq, 0, DataType.DINT, 0x00) + assert new_offset == len(vlq) + + def test_ulint(self, conn: S7CommPlusConnection) -> None: + vlq = encode_uint64_vlq(2**40) + new_offset = conn._skip_typed_value(vlq, 0, DataType.ULINT, 0x00) + assert new_offset == len(vlq) + + def test_lword(self, conn: S7CommPlusConnection) -> None: + vlq = encode_uint64_vlq(0xCAFE) + new_offset = conn._skip_typed_value(vlq, 0, DataType.LWORD, 0x00) + assert new_offset == len(vlq) + + def test_lint(self, conn: S7CommPlusConnection) -> None: + from snap7.s7commplus.vlq import encode_int64_vlq + + vlq = encode_int64_vlq(-(2**40)) + new_offset = conn._skip_typed_value(vlq, 0, DataType.LINT, 0x00) + assert new_offset == len(vlq) + + def test_real(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">f", 3.14) + assert conn._skip_typed_value(data, 0, DataType.REAL, 0x00) == 4 + + def test_lreal(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">d", 2.718) + assert conn._skip_typed_value(data, 0, DataType.LREAL, 0x00) == 8 + + def test_timestamp(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">Q", 0x0001020304050607) + assert conn._skip_typed_value(data, 0, DataType.TIMESTAMP, 0x00) == 8 + + def test_timespan(self, conn: S7CommPlusConnection) -> None: + from snap7.s7commplus.vlq import encode_int64_vlq + + vlq = encode_int64_vlq(5000) + # TIMESPAN uses uint64_vlq for skipping in _skip_typed_value + new_offset = conn._skip_typed_value(vlq, 0, DataType.TIMESPAN, 0x00) + assert new_offset == len(vlq) + + def test_rid(self, conn: S7CommPlusConnection) -> None: + data = struct.pack(">I", 0x12345678) + assert conn._skip_typed_value(data, 0, DataType.RID, 0x00) == 4 + + def test_blob(self, conn: S7CommPlusConnection) -> None: + blob_data = bytes([1, 2, 3, 4]) + vlq_len = encode_uint32_vlq(len(blob_data)) + data = vlq_len + blob_data + new_offset = conn._skip_typed_value(data, 0, DataType.BLOB, 0x00) + assert new_offset == len(data) + + def test_wstring(self, conn: S7CommPlusConnection) -> None: + text = "hello".encode("utf-8") + vlq_len = encode_uint32_vlq(len(text)) + data = vlq_len + text + new_offset = conn._skip_typed_value(data, 0, DataType.WSTRING, 0x00) + assert new_offset == len(data) + + def test_struct(self, conn: S7CommPlusConnection) -> None: + # Struct with 2 USINT sub-values + vlq_count = encode_uint32_vlq(2) + sub1 = bytes([0x00, DataType.USINT, 0x0A]) # flags + type + value + sub2 = bytes([0x00, DataType.USINT, 0x14]) + data = vlq_count + sub1 + sub2 + new_offset = conn._skip_typed_value(data, 0, DataType.STRUCT, 0x00) + assert new_offset == len(data) + + def test_unknown_type(self, conn: S7CommPlusConnection) -> None: + # Unknown type should return same offset (can't skip) + assert conn._skip_typed_value(bytes([0xFF]), 0, 0xFF, 0x00) == 0 + + # -- Array tests -- + + def test_array_fixed_size(self, conn: S7CommPlusConnection) -> None: + count_vlq = encode_uint32_vlq(3) + elements = bytes([10, 20, 30]) + data = count_vlq + elements + new_offset = conn._skip_typed_value(data, 0, DataType.USINT, 0x10) + assert new_offset == len(data) + + def test_array_variable_length(self, conn: S7CommPlusConnection) -> None: + count_vlq = encode_uint32_vlq(2) + elem1 = encode_uint32_vlq(100) + elem2 = encode_uint32_vlq(200) + data = count_vlq + elem1 + elem2 + new_offset = conn._skip_typed_value(data, 0, DataType.UDINT, 0x10) + assert new_offset == len(data) + + def test_array_empty_data(self, conn: S7CommPlusConnection) -> None: + # Edge case: array flag but no data + assert conn._skip_typed_value(b"", 0, DataType.USINT, 0x10) == 0 + + +class TestParseCreateObjectResponse: + """Test _parse_create_object_response with constructed payloads.""" + + def _build_create_response_with_session_version(self, version: int, datatype: int = DataType.UDINT) -> bytes: + """Build a minimal CreateObject response containing ServerSessionVersion.""" + payload = bytearray() + # Attribute tag + payload += bytes([ElementID.ATTRIBUTE]) + # Attribute ID = ServerSessionVersion (306) + payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) + # Typed value: flags + datatype + VLQ value + payload += bytes([0x00, datatype]) + payload += encode_uint32_vlq(version) + return bytes(payload) + + def test_parse_udint_version(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + payload = self._build_create_response_with_session_version(3, DataType.UDINT) + conn._parse_create_object_response(payload) + assert conn._server_session_version == 3 + + def test_parse_dword_version(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + payload = self._build_create_response_with_session_version(2, DataType.DWORD) + conn._parse_create_object_response(payload) + assert conn._server_session_version == 2 + + def test_version_not_found(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + # Build payload with a different attribute, not ServerSessionVersion + payload = bytearray() + payload += bytes([ElementID.ATTRIBUTE]) + payload += encode_uint32_vlq(999) # Some other attribute ID + payload += bytes([0x00, DataType.USINT, 42]) + conn._parse_create_object_response(bytes(payload)) + assert conn._server_session_version is None + + def test_with_preceding_attributes(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + payload = bytearray() + # First attribute: some random one with a UINT value + payload += bytes([ElementID.ATTRIBUTE]) + payload += encode_uint32_vlq(100) # Random attribute ID + payload += bytes([0x00, DataType.UINT]) + payload += struct.pack(">H", 0x1234) + # Second attribute: ServerSessionVersion + payload += bytes([ElementID.ATTRIBUTE]) + payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) + payload += bytes([0x00, DataType.UDINT]) + payload += encode_uint32_vlq(1) + conn._parse_create_object_response(bytes(payload)) + assert conn._server_session_version == 1 + + def test_with_start_of_object(self) -> None: + conn = S7CommPlusConnection("127.0.0.1") + payload = bytearray() + # StartOfObject tag (needs RelationId + ClassId + ClassFlags + AttributeId) + payload += bytes([ElementID.START_OF_OBJECT]) + payload += struct.pack(">I", 0) # RelationId (4 bytes) + payload += encode_uint32_vlq(100) # ClassId + payload += encode_uint32_vlq(0) # ClassFlags + payload += encode_uint32_vlq(0) # AttributeId + # TerminatingObject + payload += bytes([ElementID.TERMINATING_OBJECT]) + # Now the attribute we want + payload += bytes([ElementID.ATTRIBUTE]) + payload += encode_uint32_vlq(ObjectId.SERVER_SESSION_VERSION) + payload += bytes([0x00, DataType.UDINT]) + payload += encode_uint32_vlq(3) + conn._parse_create_object_response(bytes(payload)) + assert conn._server_session_version == 3 + + +# -- Client error path tests -- + + +class TestClientErrorPaths: + def test_properties_not_connected(self) -> None: + client = S7CommPlusClient() + assert client.connected is False + assert client.protocol_version == 0 + assert client.session_id == 0 + assert client.using_legacy_fallback is False + + def test_db_read_not_connected(self) -> None: + client = S7CommPlusClient() + with pytest.raises(RuntimeError, match="Not connected"): + client.db_read(1, 0, 4) + + def test_db_write_not_connected(self) -> None: + client = S7CommPlusClient() + with pytest.raises(RuntimeError, match="Not connected"): + client.db_write(1, 0, bytes([1, 2, 3, 4])) + + def test_db_read_multi_not_connected(self) -> None: + client = S7CommPlusClient() + with pytest.raises(RuntimeError, match="Not connected"): + client.db_read_multi([(1, 0, 4)]) + + def test_explore_not_connected(self) -> None: + client = S7CommPlusClient() + with pytest.raises(RuntimeError, match="Not connected"): + client.explore() + + def test_context_manager_not_connected(self) -> None: + """Test that context manager works without connection (disconnect is a no-op).""" + with S7CommPlusClient() as client: + assert client.connected is False + # Should not raise From 425b3ef7c9b3a05b0d3c5c4ad02f9b4cd6ed3b99 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Fri, 20 Mar 2026 16:27:15 +0200 Subject: [PATCH 4/4] Restructure docs into logical sections Split the monolithic examples.rst and troubleshooting.rst into focused, topic-based pages and group them under clear sections in the toctree: Getting Started, User Guide, Troubleshooting, Development, API Reference. Co-Authored-By: Claude Opus 4.6 --- doc/connecting.rst | 100 ++++++++ doc/connection-issues.rst | 106 ++++++++ doc/error-reference.rst | 50 ++++ doc/index.rst | 34 ++- doc/limitations.rst | 28 +++ doc/multi-variable.rst | 51 ++++ doc/plc-support.rst | 8 +- doc/{examples.rst => reading-writing.rst} | 275 +-------------------- doc/server.rst | 113 +++++++++ doc/thread-safety.rst | 39 +++ doc/tia-portal-config.rst | 56 +++++ doc/troubleshooting.rst | 283 ---------------------- 12 files changed, 587 insertions(+), 556 deletions(-) create mode 100644 doc/connecting.rst create mode 100644 doc/connection-issues.rst create mode 100644 doc/error-reference.rst create mode 100644 doc/limitations.rst create mode 100644 doc/multi-variable.rst rename doc/{examples.rst => reading-writing.rst} (61%) create mode 100644 doc/server.rst create mode 100644 doc/thread-safety.rst create mode 100644 doc/tia-portal-config.rst delete mode 100644 doc/troubleshooting.rst diff --git a/doc/connecting.rst b/doc/connecting.rst new file mode 100644 index 00000000..8eefe4a6 --- /dev/null +++ b/doc/connecting.rst @@ -0,0 +1,100 @@ +Connecting to PLCs +================== + +This page shows how to connect to different Siemens PLC models using +python-snap7. + +.. contents:: On this page + :local: + :depth: 2 + + +Rack/Slot Reference +------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 10 10 60 + + * - PLC Model + - Rack + - Slot + - Notes + * - S7-300 + - 0 + - 2 + - + * - S7-400 + - 0 + - 3 + - May vary with multi-rack configurations + * - S7-1200 + - 0 + - 1 + - PUT/GET access must be enabled in TIA Portal + * - S7-1500 + - 0 + - 1 + - PUT/GET access must be enabled in TIA Portal + * - S7-200 / Logo + - -- + - -- + - Use ``set_connection_params`` with TSAP addressing + +.. warning:: + + S7-1200 and S7-1500 PLCs ship with PUT/GET communication disabled by + default. Enable it in TIA Portal under the CPU properties before + connecting. See :doc:`tia-portal-config` for step-by-step instructions. + + +S7-300 +------ + +.. code-block:: python + + import snap7 + + client = snap7.Client() + client.connect("192.168.1.10", 0, 2) + +S7-400 +------ + +.. code-block:: python + + import snap7 + + client = snap7.Client() + client.connect("192.168.1.10", 0, 3) + +S7-1200 / S7-1500 +------------------ + +.. code-block:: python + + import snap7 + + client = snap7.Client() + client.connect("192.168.1.10", 0, 1) + +S7-200 / Logo (TSAP Connection) +-------------------------------- + +.. code-block:: python + + import snap7 + + client = snap7.Client() + client.set_connection_params("192.168.1.10", 0x1000, 0x2000) + client.connect("192.168.1.10", 0, 0) + +Using a Non-Standard Port +-------------------------- + +.. code-block:: python + + import snap7 + + client = snap7.Client() + client.connect("192.168.1.10", 0, 1, tcp_port=1102) diff --git a/doc/connection-issues.rst b/doc/connection-issues.rst new file mode 100644 index 00000000..95008553 --- /dev/null +++ b/doc/connection-issues.rst @@ -0,0 +1,106 @@ +Connection Issues +================= + +.. contents:: On this page + :local: + :depth: 2 + + +.. _connection-recovery: + +Connection Recovery +------------------- + +Network connections to PLCs can drop due to cable issues, PLC restarts, or +network problems. Use a reconnection pattern to handle this gracefully: + +.. code-block:: python + + import snap7 + import time + import logging + + logger = logging.getLogger(__name__) + + client = snap7.Client() + + def connect(address: str = "192.168.1.10", rack: int = 0, slot: int = 1) -> None: + client.connect(address, rack, slot) + + def safe_read(db: int, start: int, size: int) -> bytearray: + """Read from DB with automatic reconnection on failure.""" + try: + return client.db_read(db, start, size) + except Exception: + logger.warning("Read failed, attempting reconnection...") + try: + client.disconnect() + except Exception: + pass + time.sleep(1) + connect() + return client.db_read(db, start, size) + + def safe_write(db: int, start: int, data: bytearray) -> None: + """Write to DB with automatic reconnection on failure.""" + try: + client.db_write(db, start, data) + except Exception: + logger.warning("Write failed, attempting reconnection...") + try: + client.disconnect() + except Exception: + pass + time.sleep(1) + connect() + client.db_write(db, start, data) + +For long-running applications, wrap your main loop with reconnection logic: + +.. code-block:: python + + while True: + try: + data = safe_read(1, 0, 10) + # process data... + time.sleep(0.5) + except Exception: + logger.error("Failed after reconnection attempt, retrying in 5s...") + time.sleep(5) + + +Connection Timeout +------------------ + +The default connection timeout is 5 seconds. You can configure it by accessing +the underlying connection object: + +.. code-block:: python + + import snap7 + + client = snap7.Client() + + # Connect with a custom timeout (in seconds) + client.connect("192.168.1.10", 0, 1) + + # The timeout is set on the underlying connection + # Default is 5.0 seconds + client.connection.timeout = 10.0 # Set to 10 seconds + +To set the timeout **before** connecting, use ``set_connection_params`` and then +connect manually, or simply reconnect after adjusting: + +.. code-block:: python + + client = snap7.Client() + client.connect("192.168.1.10", 0, 1) + + # Adjust timeout for slow networks + client.connection.timeout = 15.0 + +.. note:: + + If you are experiencing frequent timeouts, check your network quality first. + Typical S7 communication on a local network should respond within + milliseconds. diff --git a/doc/error-reference.rst b/doc/error-reference.rst new file mode 100644 index 00000000..812b28f2 --- /dev/null +++ b/doc/error-reference.rst @@ -0,0 +1,50 @@ +Error Message Reference +======================= + +The following table maps common S7 error strings to their likely cause and fix. + +.. list-table:: + :header-rows: 1 + :widths: 35 30 35 + + * - Error message + - Likely cause + - Fix + * - ``CLI : function refused by CPU (Unknown error)`` + - PUT/GET communication is not enabled on the PLC, or the data block + still has optimized block access enabled. + - Enable PUT/GET in TIA Portal and disable optimized block access on each + DB. See :doc:`tia-portal-config`. + * - ``CPU : Function not available`` + - The requested function is not supported on this PLC model. S7-1200 and + S7-1500 PLCs restrict certain operations. + - Check Siemens documentation for your PLC model. Some functions are only + available on S7-300/400. + * - ``CPU : Item not available`` + - Wrong DB number, the DB does not exist, or the address is out of range. + - Verify the DB number exists on the PLC and that the offset and size are + within bounds. + * - ``CPU : Address out of range`` + - Reading or writing past the end of a DB or memory area. + - Check the DB size in TIA Portal and ensure ``start + size`` does not + exceed it. + * - ``CPU : Function not authorized for current protection level`` + - The PLC has password protection enabled. + - Remove or lower the protection level in TIA Portal under + Protection & Security. + * - ``ISO : An error occurred during recv TCP : Connection timed out`` + - Network issue: PLC is unreachable, a firewall is blocking port 102, or + the PLC is not responding. + - Check network connectivity (``ping``), verify firewall rules, and ensure + the PLC is powered on and reachable. + * - ``ISO : An error occurred during send TCP : Connection timed out`` + - Same as above. + - Same as above. + * - ``TCP : Unreachable peer`` + - The PLC is not reachable on the network. + - Verify IP address, subnet, and routing. Ensure the PLC Ethernet port is + connected and configured. + * - ``TCP : Connection reset`` / Socket error 32 (broken pipe) + - The connection to the PLC was lost unexpectedly. + - The PLC may have been restarted, the cable disconnected, or another + client took over the connection. See :doc:`connection-issues`. diff --git a/doc/index.rst b/doc/index.rst index 8066c63d..cade988c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,18 +1,43 @@ Welcome to python-snap7's documentation! ======================================== -Contents: - .. toctree:: :maxdepth: 2 + :caption: Getting Started introduction installation plc-support - examples - troubleshooting + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + connecting + reading-writing + multi-variable + server + tia-portal-config + +.. toctree:: + :maxdepth: 2 + :caption: Troubleshooting + + error-reference + connection-issues + thread-safety + limitations + +.. toctree:: + :maxdepth: 2 + :caption: Development + development +.. toctree:: + :maxdepth: 2 + :caption: API Reference + API/client API/async_client API/s7commplus @@ -26,7 +51,6 @@ Contents: API/datatypes - Indices and tables ================== diff --git a/doc/limitations.rst b/doc/limitations.rst new file mode 100644 index 00000000..26f82c5a --- /dev/null +++ b/doc/limitations.rst @@ -0,0 +1,28 @@ +Protocol Limitations and FAQ +============================ + +python-snap7 implements the S7 protocol over TCP/IP. The following operations +are **not possible** with this protocol: + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Limitation + - Explanation + * - Read tag/symbol names from PLC + - Symbol names exist only in the TIA Portal project file, not in the PLC. + The S7 protocol only addresses data by area, DB number, and byte offset. + * - Get DB structure or layout from PLC + - The PLC stores only raw bytes. The structure definition lives in the TIA + Portal project. You must define your data layout in your Python code. + * - Discover PLCs on the network + - There is no S7 broadcast discovery mechanism. You must know the PLC's IP + address. + * - Create PLC backups + - Full project backup requires TIA Portal. python-snap7 can upload + individual blocks, but this is not a complete backup. + * - Access S7-1200/1500 PLCs with S7CommPlus security + - PLCs configured to require S7CommPlus encrypted communication cannot be + accessed with the classic S7 protocol. PUT/GET must be enabled as a + fallback. diff --git a/doc/multi-variable.rst b/doc/multi-variable.rst new file mode 100644 index 00000000..b83f35c3 --- /dev/null +++ b/doc/multi-variable.rst @@ -0,0 +1,51 @@ +Multi-Variable Read +=================== + +The ``read_multi_vars`` method reads multiple variables in a single PDU +request, which is significantly faster than individual reads. + +.. code-block:: python + + import snap7 + from snap7.type import Area, WordLen, S7DataItem + from ctypes import c_uint8, cast, POINTER + + client = snap7.Client() + client.connect("192.168.1.10", 0, 1) + + # Prepare items to read + items = [] + + # Item 1: 4 bytes from DB1, offset 0 + item1 = S7DataItem() + item1.Area = Area.DB + item1.WordLen = WordLen.Byte + item1.DBNumber = 1 + item1.Start = 0 + item1.Amount = 4 + buffer1 = (c_uint8 * 4)() + item1.pData = cast(buffer1, POINTER(c_uint8)) + items.append(item1) + + # Item 2: 2 bytes from DB2, offset 10 + item2 = S7DataItem() + item2.Area = Area.DB + item2.WordLen = WordLen.Byte + item2.DBNumber = 2 + item2.Start = 10 + item2.Amount = 2 + buffer2 = (c_uint8 * 2)() + item2.pData = cast(buffer2, POINTER(c_uint8)) + items.append(item2) + + # Execute the multi-read + result, data_items = client.read_multi_vars(items) + + # Access the returned data + value1 = bytearray(buffer1) + value2 = bytearray(buffer2) + +.. warning:: + + The S7 protocol limits multi-variable reads to **20 items** per request. + If you need more, split them across multiple calls. diff --git a/doc/plc-support.rst b/doc/plc-support.rst index dfc1cda6..281459ce 100644 --- a/doc/plc-support.rst +++ b/doc/plc-support.rst @@ -101,12 +101,8 @@ Enabling PUT/GET Access ----------------------- For S7-1200 and S7-1500 PLCs, classic S7 protocol access requires the -**PUT/GET** option to be enabled: - -1. Open TIA Portal and go to the PLC properties. -2. Navigate to **Protection & Security** → **Connection mechanisms**. -3. Check **Permit access with PUT/GET communication from remote partner**. -4. Download the configuration to the PLC. +**PUT/GET** option to be enabled. See :doc:`tia-portal-config` for +step-by-step instructions. .. warning:: diff --git a/doc/examples.rst b/doc/reading-writing.rst similarity index 61% rename from doc/examples.rst rename to doc/reading-writing.rst index dd4d713a..cea8f14d 100644 --- a/doc/examples.rst +++ b/doc/reading-writing.rst @@ -1,77 +1,10 @@ -Examples & Cookbook -=================== +Reading & Writing Data +====================== -This page provides practical examples for common python-snap7 tasks. All code -assumes you have already installed python-snap7: +This page covers address mapping, data type conversions, memory area access, +and analog I/O — everything you need for reading from and writing to a PLC. -.. code-block:: bash - - pip install python-snap7 - - -Connecting to Different PLC Models ------------------------------------ - -Rack/Slot Reference -^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :header-rows: 1 - :widths: 20 10 10 60 - - * - PLC Model - - Rack - - Slot - - Notes - * - S7-300 - - 0 - - 2 - - - * - S7-400 - - 0 - - 3 - - May vary with multi-rack configurations - * - S7-1200 - - 0 - - 1 - - PUT/GET access must be enabled in TIA Portal - * - S7-1500 - - 0 - - 1 - - PUT/GET access must be enabled in TIA Portal - * - S7-200 / Logo - - -- - - -- - - Use ``set_connection_params`` with TSAP addressing - -.. warning:: - - S7-1200 and S7-1500 PLCs ship with PUT/GET communication disabled by - default. Enable it in TIA Portal under the CPU properties before - connecting. - -S7-300 -^^^^^^ - -.. code-block:: python - - import snap7 - - client = snap7.Client() - client.connect("192.168.1.10", 0, 2) - -S7-400 -^^^^^^ - -.. code-block:: python - - import snap7 - - client = snap7.Client() - client.connect("192.168.1.10", 0, 3) - -S7-1200 / S7-1500 -^^^^^^^^^^^^^^^^^^ +All examples assume you have a connected client: .. code-block:: python @@ -80,30 +13,13 @@ S7-1200 / S7-1500 client = snap7.Client() client.connect("192.168.1.10", 0, 1) -S7-200 / Logo (TSAP Connection) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import snap7 - - client = snap7.Client() - client.set_connection_params("192.168.1.10", 0x1000, 0x2000) - client.connect("192.168.1.10", 0, 0) - -Using a Non-Standard Port -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import snap7 - - client = snap7.Client() - client.connect("192.168.1.10", 0, 1, tcp_port=1102) +.. contents:: On this page + :local: + :depth: 2 -Address Mapping Guide ---------------------- +Address Mapping +--------------- PLC addresses in Siemens TIA Portal / STEP 7 map to python-snap7 API calls as follows. @@ -155,8 +71,8 @@ as follows. You read from PLC offset 10, but ``data[0]`` *is* byte 10 from the PLC. -Data Types Cookbook -------------------- +Data Types +---------- Each example below shows a complete read and write cycle. @@ -169,11 +85,6 @@ the whole byte back. .. code-block:: python - import snap7 - - client = snap7.Client() - client.connect("192.168.1.10", 0, 1) - # Read DB1.DBX0.3 (bit 3 of byte 0) data = client.db_read(1, 0, 1) value = snap7.util.get_bool(data, 0, 3) @@ -418,7 +329,7 @@ Counters (C) Analog I/O ------------ +---------- Analog inputs are typically 16-bit integers in the peripheral input area (``Area.PE``). The raw value from the PLC needs to be scaled to engineering @@ -467,163 +378,3 @@ Writing Analog Outputs The standard scaling factor 27648 applies to most Siemens analog I/O modules. Check your module documentation for the actual range. - - -Multi-Variable Read -------------------- - -The ``read_multi_vars`` method reads multiple variables in a single PDU -request, which is significantly faster than individual reads. - -.. code-block:: python - - import snap7 - from snap7.type import Area, WordLen, S7DataItem - from ctypes import c_uint8, cast, POINTER - - client = snap7.Client() - client.connect("192.168.1.10", 0, 1) - - # Prepare items to read - items = [] - - # Item 1: 4 bytes from DB1, offset 0 - item1 = S7DataItem() - item1.Area = Area.DB - item1.WordLen = WordLen.Byte - item1.DBNumber = 1 - item1.Start = 0 - item1.Amount = 4 - buffer1 = (c_uint8 * 4)() - item1.pData = cast(buffer1, POINTER(c_uint8)) - items.append(item1) - - # Item 2: 2 bytes from DB2, offset 10 - item2 = S7DataItem() - item2.Area = Area.DB - item2.WordLen = WordLen.Byte - item2.DBNumber = 2 - item2.Start = 10 - item2.Amount = 2 - buffer2 = (c_uint8 * 2)() - item2.pData = cast(buffer2, POINTER(c_uint8)) - items.append(item2) - - # Execute the multi-read - result, data_items = client.read_multi_vars(items) - - # Access the returned data - value1 = bytearray(buffer1) - value2 = bytearray(buffer2) - -.. warning:: - - The S7 protocol limits multi-variable reads to **20 items** per request. - If you need more, split them across multiple calls. - - -Server Setup for Testing -------------------------- - -The built-in server lets you test your client code without a physical PLC. - -Basic Server Example -^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from snap7.server import Server - from snap7.type import SrvArea - from ctypes import c_char - - # Create and configure the server - server = Server() - - # Register a data block (DB1) with 100 bytes - db_size = 100 - db_data = bytearray(db_size) - db_array = (c_char * db_size).from_buffer(db_data) - server.register_area(SrvArea.DB, 1, db_array) - - # Start the server on a non-privileged port - server.start(tcp_port=1102) - -Client-Server Round Trip -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import snap7 - from snap7.server import Server - from snap7.type import SrvArea - from ctypes import c_char - - # --- Server setup --- - server = Server() - db_size = 100 - db_data = bytearray(db_size) - db_array = (c_char * db_size).from_buffer(db_data) - server.register_area(SrvArea.DB, 1, db_array) - server.start(tcp_port=1102) - - # --- Client connection --- - client = snap7.Client() - client.connect("127.0.0.1", 0, 1, tcp_port=1102) - - # Write data - client.db_write(1, 0, bytearray([0x01, 0x02, 0x03, 0x04])) - - # Read it back - data = client.db_read(1, 0, 4) - print(f"Read back: {list(data)}") # [1, 2, 3, 4] - - # Clean up - client.disconnect() - server.stop() - -Registering Multiple Areas -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from snap7.server import Server - from snap7.type import SrvArea - from ctypes import c_char - - server = Server() - - # Register DB1 - db1_data = bytearray(100) - db1 = (c_char * 100).from_buffer(db1_data) - server.register_area(SrvArea.DB, 1, db1) - - # Register DB2 - db2_data = bytearray(200) - db2 = (c_char * 200).from_buffer(db2_data) - server.register_area(SrvArea.DB, 2, db2) - - # Register merker area (256 bytes) - mk_data = bytearray(256) - mk = (c_char * 256).from_buffer(mk_data) - server.register_area(SrvArea.MK, 0, mk) - - server.start(tcp_port=1102) - -.. note:: - - Use a port number above 1024 (e.g., 1102) to avoid requiring root/admin - privileges. Port 102 is the standard S7 port but is in the privileged - range. - -Using the Mainloop Helper -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -For quick testing, the ``mainloop`` function starts a server with common -data blocks pre-registered: - -.. code-block:: python - - from snap7.server import mainloop - - # Blocks the current thread - mainloop(tcp_port=1102) diff --git a/doc/server.rst b/doc/server.rst new file mode 100644 index 00000000..f46e1649 --- /dev/null +++ b/doc/server.rst @@ -0,0 +1,113 @@ +Server Setup for Testing +======================== + +The built-in server lets you test your client code without a physical PLC. + +.. contents:: On this page + :local: + :depth: 2 + + +Basic Server Example +-------------------- + +.. code-block:: python + + from snap7.server import Server + from snap7.type import SrvArea + from ctypes import c_char + + # Create and configure the server + server = Server() + + # Register a data block (DB1) with 100 bytes + db_size = 100 + db_data = bytearray(db_size) + db_array = (c_char * db_size).from_buffer(db_data) + server.register_area(SrvArea.DB, 1, db_array) + + # Start the server on a non-privileged port + server.start(tcp_port=1102) + + +Client-Server Round Trip +------------------------- + +.. code-block:: python + + import snap7 + from snap7.server import Server + from snap7.type import SrvArea + from ctypes import c_char + + # --- Server setup --- + server = Server() + db_size = 100 + db_data = bytearray(db_size) + db_array = (c_char * db_size).from_buffer(db_data) + server.register_area(SrvArea.DB, 1, db_array) + server.start(tcp_port=1102) + + # --- Client connection --- + client = snap7.Client() + client.connect("127.0.0.1", 0, 1, tcp_port=1102) + + # Write data + client.db_write(1, 0, bytearray([0x01, 0x02, 0x03, 0x04])) + + # Read it back + data = client.db_read(1, 0, 4) + print(f"Read back: {list(data)}") # [1, 2, 3, 4] + + # Clean up + client.disconnect() + server.stop() + + +Registering Multiple Areas +--------------------------- + +.. code-block:: python + + from snap7.server import Server + from snap7.type import SrvArea + from ctypes import c_char + + server = Server() + + # Register DB1 + db1_data = bytearray(100) + db1 = (c_char * 100).from_buffer(db1_data) + server.register_area(SrvArea.DB, 1, db1) + + # Register DB2 + db2_data = bytearray(200) + db2 = (c_char * 200).from_buffer(db2_data) + server.register_area(SrvArea.DB, 2, db2) + + # Register merker area (256 bytes) + mk_data = bytearray(256) + mk = (c_char * 256).from_buffer(mk_data) + server.register_area(SrvArea.MK, 0, mk) + + server.start(tcp_port=1102) + +.. note:: + + Use a port number above 1024 (e.g., 1102) to avoid requiring root/admin + privileges. Port 102 is the standard S7 port but is in the privileged + range. + + +Using the Mainloop Helper +-------------------------- + +For quick testing, the ``mainloop`` function starts a server with common +data blocks pre-registered: + +.. code-block:: python + + from snap7.server import mainloop + + # Blocks the current thread + mainloop(tcp_port=1102) diff --git a/doc/thread-safety.rst b/doc/thread-safety.rst new file mode 100644 index 00000000..235a89f6 --- /dev/null +++ b/doc/thread-safety.rst @@ -0,0 +1,39 @@ +Thread Safety +============= + +The ``Client`` class is **not** thread-safe. Concurrent calls from multiple +threads on the same ``Client`` instance will corrupt the TCP connection state +and cause unpredictable errors. + +**Option 1: One client per thread** + +.. code-block:: python + + import threading + import snap7 + + def worker(address: str, rack: int, slot: int) -> None: + client = snap7.Client() + client.connect(address, rack, slot) + data = client.db_read(1, 0, 10) + client.disconnect() + + t1 = threading.Thread(target=worker, args=("192.168.1.10", 0, 1)) + t2 = threading.Thread(target=worker, args=("192.168.1.10", 0, 1)) + t1.start() + t2.start() + +**Option 2: Shared client with a lock** + +.. code-block:: python + + import threading + import snap7 + + client = snap7.Client() + client.connect("192.168.1.10", 0, 1) + lock = threading.Lock() + + def safe_read(db: int, start: int, size: int) -> bytearray: + with lock: + return client.db_read(db, start, size) diff --git a/doc/tia-portal-config.rst b/doc/tia-portal-config.rst new file mode 100644 index 00000000..73932db6 --- /dev/null +++ b/doc/tia-portal-config.rst @@ -0,0 +1,56 @@ +.. _tia-portal-config: + +TIA Portal Configuration +========================= + +S7-1200 and S7-1500 PLCs require specific configuration in TIA Portal before +python-snap7 can communicate with them. Without these settings, you will get +``CLI : function refused by CPU`` errors. + +.. contents:: On this page + :local: + :depth: 2 + + +Step 1: Enable PUT/GET Communication +------------------------------------- + +1. Open your project in TIA Portal. +2. In the project tree, double-click on the PLC device. +3. Go to **Properties** > **Protection & Security** > **Connection mechanisms**. +4. Check **Permit access with PUT/GET communication from remote partner**. +5. Compile and download to the PLC. + +.. warning:: + + This setting allows any network client to read and write PLC memory without + authentication. Only enable this on isolated industrial networks. + + +Step 2: Disable Optimized Block Access +--------------------------------------- + +This must be done for **each** data block you want to access: + +1. In the project tree, right-click on the data block (e.g., DB1). +2. Select **Properties**. +3. Go to the **Attributes** tab. +4. **Uncheck** "Optimized block access". +5. Click OK. +6. Compile and download to the PLC. + +.. warning:: + + Changing the "Optimized block access" setting reinitializes the data block, + which resets all values in that DB to their defaults. Do this before + commissioning, or back up your data first. + + +Step 3: Compile and Download +----------------------------- + +After making both changes: + +1. Compile the project (**Build** > **Compile**). +2. Download to the PLC (**Online** > **Download to device**). +3. The PLC may need to restart depending on the changes. diff --git a/doc/troubleshooting.rst b/doc/troubleshooting.rst deleted file mode 100644 index 27510632..00000000 --- a/doc/troubleshooting.rst +++ /dev/null @@ -1,283 +0,0 @@ -Troubleshooting -=============== - -This page covers the most common issues encountered when using python-snap7 -and how to resolve them. - -.. contents:: On this page - :local: - :depth: 2 - - -Error Message Reference ------------------------ - -The following table maps common S7 error strings to their likely cause and fix. - -.. list-table:: - :header-rows: 1 - :widths: 35 30 35 - - * - Error message - - Likely cause - - Fix - * - ``CLI : function refused by CPU (Unknown error)`` - - PUT/GET communication is not enabled on the PLC, or the data block - still has optimized block access enabled. - - Enable PUT/GET in TIA Portal and disable optimized block access on each - DB. See :ref:`s7-1200-1500-configuration` below. - * - ``CPU : Function not available`` - - The requested function is not supported on this PLC model. S7-1200 and - S7-1500 PLCs restrict certain operations. - - Check Siemens documentation for your PLC model. Some functions are only - available on S7-300/400. - * - ``CPU : Item not available`` - - Wrong DB number, the DB does not exist, or the address is out of range. - - Verify the DB number exists on the PLC and that the offset and size are - within bounds. - * - ``CPU : Address out of range`` - - Reading or writing past the end of a DB or memory area. - - Check the DB size in TIA Portal and ensure ``start + size`` does not - exceed it. - * - ``CPU : Function not authorized for current protection level`` - - The PLC has password protection enabled. - - Remove or lower the protection level in TIA Portal under - Protection & Security. - * - ``ISO : An error occurred during recv TCP : Connection timed out`` - - Network issue: PLC is unreachable, a firewall is blocking port 102, or - the PLC is not responding. - - Check network connectivity (``ping``), verify firewall rules, and ensure - the PLC is powered on and reachable. - * - ``ISO : An error occurred during send TCP : Connection timed out`` - - Same as above. - - Same as above. - * - ``TCP : Unreachable peer`` - - The PLC is not reachable on the network. - - Verify IP address, subnet, and routing. Ensure the PLC Ethernet port is - connected and configured. - * - ``TCP : Connection reset`` / Socket error 32 (broken pipe) - - The connection to the PLC was lost unexpectedly. - - The PLC may have been restarted, the cable disconnected, or another - client took over the connection. See :ref:`connection-recovery` below. - - -.. _s7-1200-1500-configuration: - -S7-1200/1500 Configuration --------------------------- - -S7-1200 and S7-1500 PLCs require specific configuration in TIA Portal before -python-snap7 can communicate with them. Without these settings, you will get -``CLI : function refused by CPU`` errors. - -Step 1: Enable PUT/GET Communication -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -1. Open your project in TIA Portal. -2. In the project tree, double-click on the PLC device. -3. Go to **Properties** > **Protection & Security** > **Connection mechanisms**. -4. Check **Permit access with PUT/GET communication from remote partner**. -5. Compile and download to the PLC. - -.. warning:: - - This setting allows any network client to read and write PLC memory without - authentication. Only enable this on isolated industrial networks. - -Step 2: Disable Optimized Block Access -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -This must be done for **each** data block you want to access: - -1. In the project tree, right-click on the data block (e.g., DB1). -2. Select **Properties**. -3. Go to the **Attributes** tab. -4. **Uncheck** "Optimized block access". -5. Click OK. -6. Compile and download to the PLC. - -.. warning:: - - Changing the "Optimized block access" setting reinitializes the data block, - which resets all values in that DB to their defaults. Do this before - commissioning, or back up your data first. - -Step 3: Compile and Download -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -After making both changes: - -1. Compile the project (**Build** > **Compile**). -2. Download to the PLC (**Online** > **Download to device**). -3. The PLC may need to restart depending on the changes. - - -.. _connection-recovery: - -Connection Recovery -------------------- - -Network connections to PLCs can drop due to cable issues, PLC restarts, or -network problems. Use a reconnection pattern to handle this gracefully: - -.. code-block:: python - - import snap7 - import time - import logging - - logger = logging.getLogger(__name__) - - client = snap7.Client() - - def connect(address: str = "192.168.1.10", rack: int = 0, slot: int = 1) -> None: - client.connect(address, rack, slot) - - def safe_read(db: int, start: int, size: int) -> bytearray: - """Read from DB with automatic reconnection on failure.""" - try: - return client.db_read(db, start, size) - except Exception: - logger.warning("Read failed, attempting reconnection...") - try: - client.disconnect() - except Exception: - pass - time.sleep(1) - connect() - return client.db_read(db, start, size) - - def safe_write(db: int, start: int, data: bytearray) -> None: - """Write to DB with automatic reconnection on failure.""" - try: - client.db_write(db, start, data) - except Exception: - logger.warning("Write failed, attempting reconnection...") - try: - client.disconnect() - except Exception: - pass - time.sleep(1) - connect() - client.db_write(db, start, data) - -For long-running applications, wrap your main loop with reconnection logic: - -.. code-block:: python - - while True: - try: - data = safe_read(1, 0, 10) - # process data... - time.sleep(0.5) - except Exception: - logger.error("Failed after reconnection attempt, retrying in 5s...") - time.sleep(5) - - -Connection Timeout ------------------- - -The default connection timeout is 5 seconds. You can configure it by accessing -the underlying connection object: - -.. code-block:: python - - import snap7 - - client = snap7.Client() - - # Connect with a custom timeout (in seconds) - client.connect("192.168.1.10", 0, 1) - - # The timeout is set on the underlying connection - # Default is 5.0 seconds - client.connection.timeout = 10.0 # Set to 10 seconds - -To set the timeout **before** connecting, use ``set_connection_params`` and then -connect manually, or simply reconnect after adjusting: - -.. code-block:: python - - client = snap7.Client() - client.connect("192.168.1.10", 0, 1) - - # Adjust timeout for slow networks - client.connection.timeout = 15.0 - -.. note:: - - If you are experiencing frequent timeouts, check your network quality first. - Typical S7 communication on a local network should respond within - milliseconds. - - -Thread Safety -------------- - -The ``Client`` class is **not** thread-safe. Concurrent calls from multiple -threads on the same ``Client`` instance will corrupt the TCP connection state -and cause unpredictable errors. - -**Option 1: One client per thread** - -.. code-block:: python - - import threading - import snap7 - - def worker(address: str, rack: int, slot: int) -> None: - client = snap7.Client() - client.connect(address, rack, slot) - data = client.db_read(1, 0, 10) - client.disconnect() - - t1 = threading.Thread(target=worker, args=("192.168.1.10", 0, 1)) - t2 = threading.Thread(target=worker, args=("192.168.1.10", 0, 1)) - t1.start() - t2.start() - -**Option 2: Shared client with a lock** - -.. code-block:: python - - import threading - import snap7 - - client = snap7.Client() - client.connect("192.168.1.10", 0, 1) - lock = threading.Lock() - - def safe_read(db: int, start: int, size: int) -> bytearray: - with lock: - return client.db_read(db, start, size) - - -Protocol Limitations and FAQ ------------------------------ - -python-snap7 implements the S7 protocol over TCP/IP. The following operations -are **not possible** with this protocol: - -.. list-table:: - :header-rows: 1 - :widths: 40 60 - - * - Limitation - - Explanation - * - Read tag/symbol names from PLC - - Symbol names exist only in the TIA Portal project file, not in the PLC. - The S7 protocol only addresses data by area, DB number, and byte offset. - * - Get DB structure or layout from PLC - - The PLC stores only raw bytes. The structure definition lives in the TIA - Portal project. You must define your data layout in your Python code. - * - Discover PLCs on the network - - There is no S7 broadcast discovery mechanism. You must know the PLC's IP - address. - * - Create PLC backups - - Full project backup requires TIA Portal. python-snap7 can upload - individual blocks, but this is not a complete backup. - * - Access S7-1200/1500 PLCs with S7CommPlus security - - PLCs configured to require S7CommPlus encrypted communication cannot be - accessed with the classic S7 protocol. PUT/GET must be enabled as a - fallback.