diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ced482..23fb93e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,13 @@ jobs: - uses: pre-commit/action@v3.0.1 test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 - - run: uv sync --group test + - run: uv python install ${{ matrix.python-version }} + - run: uv sync --group test --python ${{ matrix.python-version }} - run: uv pip install -e . - run: uv run pytest tests/ --ignore=tests/real_plc_s1200/ diff --git a/AGENTS.md b/AGENTS.md index a06d530..3269b2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,13 @@ 1. **High-Level Clients** (`python_s7comm/sync_client.py`, `async_client.py`) - `Client` - Synchronous high-level API with string-based addresses - `AsyncClient` - Asynchronous high-level API with string-based addresses - - Provides convenience methods: `read_area()`, `write_area()`, `get_cpu_state()`, `get_order_code()`, etc. + - Provides convenience methods: + - `connect()`, `disconnect()`, `close()` + - `read_area()`, `write_area()` + - `read_multi_vars()`, `write_multi_vars()` + - `get_cpu_state()`, `get_order_code()` + - `read_szl()`, `read_szl_list()` + - `plc_stop()` 2. **Core S7Comm** (`python_s7comm/s7comm/client.py`, `async_client.py`) - `S7Comm` - Synchronous core implementation @@ -58,7 +64,7 @@ TCP/IP Socket ### General Rules -- **Python 3.12+** required +- **Python 3.12+** required (tested on 3.12 and 3.13) - **Line length**: 120 characters max (configured in ruff) - **Formatting**: Use `ruff format` - **Linting**: Use `ruff` for linting @@ -209,7 +215,7 @@ Examples: ## Important Implementation Notes 1. **PDU Reference**: Auto-incremented 16-bit counter for request/response matching -2. **PDU Length**: Negotiated during connection setup (default 480 bytes) +2. **PDU Length**: Negotiated during connection setup (default 480 bytes, max depends on PLC model) 3. **Max Variables**: 20 items per multi-read/write operation (`MAX_VARS`) 4. **Byte Order**: Big-endian (`!` in struct format) for protocol, some little-endian for PDU reference 5. **Protocol ID**: Always `0x32` for S7comm @@ -229,3 +235,11 @@ Examples: 1. Add to relevant enums in `enums.py` 2. Update `VariableAddress` regex patterns if needed 3. Add tests in `test_addresses.py` + +## Release Process + +1. Update version in `pyproject.toml` +2. Run all checks: `uv run pre-commit run --all-files` +3. Run tests: `uv run pytest tests/ --ignore=tests/real_plc_s1200/` +4. Create and push tag: `git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1` +5. GitHub Actions will automatically build and publish to PyPI diff --git a/pyproject.toml b/pyproject.toml index 2a94569..99b7c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + [project] -name="python-s7comm" -version="0.0.1" +name = "python-s7comm" +version = "0.0.1" +description = "Unofficial Python implementation of Siemens S7 communication protocol" +readme = "README.md" requires-python = ">=3.12" license = "MIT" +authors = [ + { name = "nikteliy" } +] +keywords = ["siemens", "s7", "plc", "s7comm", "automation", "industrial"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Hardware", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Homepage = "https://github.com/nikteliy/python-s7comm" +Repository = "https://github.com/nikteliy/python-s7comm" +Issues = "https://github.com/nikteliy/python-s7comm/issues" [dependency-groups] test = [ diff --git a/src/python_s7comm/async_client.py b/src/python_s7comm/async_client.py index c4c85d6..d016a73 100644 --- a/src/python_s7comm/async_client.py +++ b/src/python_s7comm/async_client.py @@ -1,5 +1,3 @@ -import datetime - from .s7comm import AsyncS7Comm, enums from .s7comm.packets.variable_address import VariableAddress from .s7comm.szl import ( @@ -74,91 +72,8 @@ async def read_multi_vars(self, items: list[str]) -> list[bytes]: response = await self.s7comm.read_multi_vars(items=vars_) return response.values() - async def write_multi_vars(self, items: list[tuple[str, bytes]]) -> bool: + async def write_multi_vars(self, items: list[tuple[str, bytes]]) -> None: vars_ = [(VariableAddress.from_string(address), data) for address, data in items] response = await self.s7comm.write_multi_vars(items=vars_) - return response.check_result() - - async def set_plc_system_datetime(self) -> int: - raise NotImplementedError - - async def delete(self, block_type: str, block_num: int) -> int: - raise NotImplementedError - - async def full_upload(self, _type: str, block_num: int) -> tuple[bytearray, int]: - raise NotImplementedError - - async def upload(self, block_num: int) -> bytearray: - raise NotImplementedError - - async def download(self, data: bytearray, block_num: int = -1) -> int: - raise NotImplementedError - - async def db_get(self, db_number: int) -> bytearray: - raise NotImplementedError - - async def get_cpu_info(self) -> None: - raise NotImplementedError - - async def get_pg_block_info(self, block: bytearray) -> None: - raise NotImplementedError - - async def get_protection(self) -> None: - raise NotImplementedError - - async def iso_exchange_buffer(self, data: bytearray) -> bytearray: - raise NotImplementedError - - async def list_blocks(self) -> None: - raise NotImplementedError - - async def list_blocks_of_type(self, blocktype: str, size: int) -> None: - raise NotImplementedError - - async def get_block_info(self, blocktype: str, db_number: int) -> None: - raise NotImplementedError - - async def set_session_password(self, password: str) -> int: - raise NotImplementedError - - async def clear_session_password(self) -> int: - raise NotImplementedError - - async def set_connection_params(self, address: str, local_tsap: int, remote_tsap: int) -> None: - raise NotImplementedError - - async def set_connection_type(self, connection_type: int) -> None: - raise NotImplementedError - - async def compress(self, time: int) -> int: - raise NotImplementedError - - async def set_param(self, number: int, value: int) -> int: - raise NotImplementedError - - async def get_param(self, number: int) -> int: - raise NotImplementedError - - async def plc_stop(self) -> None: - raise NotImplementedError - - async def plc_cold_start(self) -> int: - raise NotImplementedError - - async def plc_hot_start(self) -> int: - raise NotImplementedError - - async def get_plc_datetime(self) -> None: - raise NotImplementedError - - async def set_plc_datetime(self, datetime: datetime.datetime) -> int: - raise NotImplementedError - - async def copy_ram_to_rom(self, timeout: int = 1) -> int: - raise NotImplementedError - - async def db_fill(self, db_number: int, filler: int) -> int: - raise NotImplementedError - - async def get_cp_info(self) -> None: - raise NotImplementedError + response.check_result() + return None diff --git a/src/python_s7comm/s7comm/async_client.py b/src/python_s7comm/s7comm/async_client.py index acf59aa..cf2e261 100644 --- a/src/python_s7comm/s7comm/async_client.py +++ b/src/python_s7comm/s7comm/async_client.py @@ -12,7 +12,6 @@ S7AckDataHeader, S7Packet, SetupCommunicationRequest, - SZLResponseData, UserDataContinuationRequest, UserDataRequest, UserDataResponse, @@ -152,12 +151,10 @@ async def write_area(self, address: VariableAddress, data: bytes) -> WriteVariab if not isinstance(response, WriteVariableResponse): raise ValueError("Invalid response class") response.check_result() - # TODO: склеить все отдельные запросы в один общий изначальный и вернуть + # TODO: combine all separate requests into one common initial request and return return cast(WriteVariableResponse, response) async def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse: - # Общий размер запроса, должен быть меньше pdu_length - # RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem)); if len(items) > self.MAX_VARS: raise ValueError("Too many items") @@ -168,8 +165,6 @@ async def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableRes return response async def write_multi_vars(self, items: list[tuple[VariableAddress, bytes]]) -> WriteVariableResponse: - # Общий размер запроса, должен быть меньше pdu_length - # RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem)); if len(items) > self.MAX_VARS: raise ValueError("Too many items") diff --git a/src/python_s7comm/s7comm/client.py b/src/python_s7comm/s7comm/client.py index 7fa534c..8628c8a 100644 --- a/src/python_s7comm/s7comm/client.py +++ b/src/python_s7comm/s7comm/client.py @@ -44,9 +44,6 @@ def __init__( self.transport = transport def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse: - # Общий размер запроса, должен быть меньше pdu_length - # RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem) - if len(items) > self.MAX_VARS: raise ValueError("Too many items") @@ -57,8 +54,6 @@ def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse: return response def write_multi_vars(self, items: list[tuple[VariableAddress, bytes]]) -> WriteVariableResponse: - # Общий размер запроса, должен быть меньше pdu_length - # RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem)); if len(items) > self.MAX_VARS: raise ValueError("Too many items") @@ -101,7 +96,7 @@ def write_area(self, address: VariableAddress, data: bytes) -> WriteVariableResp if not isinstance(response, WriteVariableResponse): raise ValueError("Invalid response class") response.check_result() - # TODO: склеить все отдельные запросы в один общий изначальный и вернуть + # TODO: combine all separate requests into one common initial request and return return cast(WriteVariableResponse, response) def connect( diff --git a/src/python_s7comm/s7comm/enums.py b/src/python_s7comm/s7comm/enums.py index 908ab9a..edb9165 100644 --- a/src/python_s7comm/s7comm/enums.py +++ b/src/python_s7comm/s7comm/enums.py @@ -94,7 +94,7 @@ class DataTypeTransportSize(Enum): class DataType(str, Enum): - BIT = "BOOL" # Костыль для получения data_type из transport_size + BIT = "BOOL" # Workaround for getting data_type from transport_size BOOL = "BOOL" BYTE = "BYTE" CHAR = "CHAR" diff --git a/src/python_s7comm/s7comm/packets/packet.py b/src/python_s7comm/s7comm/packets/packet.py index babb6fd..4ac7bad 100644 --- a/src/python_s7comm/s7comm/packets/packet.py +++ b/src/python_s7comm/s7comm/packets/packet.py @@ -1,4 +1,4 @@ -from typing import Any, ClassVar, Protocol, Self +from typing import Any, ClassVar, Protocol from .headers import S7AckDataHeader, S7Header diff --git a/src/python_s7comm/s7comm/packets/rw_variable.py b/src/python_s7comm/s7comm/packets/rw_variable.py index 3b5e11b..c8e5df9 100644 --- a/src/python_s7comm/s7comm/packets/rw_variable.py +++ b/src/python_s7comm/s7comm/packets/rw_variable.py @@ -41,7 +41,7 @@ class RequestParameterItem: LENGTH = 12 def __init__(self, address: VariableAddress, transport_size: ParameterTransportSize | None = None): - # word_size нуже для генератора пакетов + # word_size is required by the request_generator to calculate maximum elements per packet if transport_size is None: self.transport_size = ParameterTransportSize[address.data_type.value] else: @@ -145,7 +145,17 @@ def serialize(self) -> bytes: return self.parameter.serialize() def request_generator(self, pdu_length: int) -> Generator["VariableReadRequest", None, None]: - """Генератор который разбивает запрос на более мелкие запросы, которы умещаются в pdu_length""" + """Generate multiple read requests that fit within the PDU length limit. + + Splits a large read request into smaller chunks based on the maximum + payload size allowed by the negotiated PDU length. + + Args: + pdu_length: Maximum PDU size negotiated during connection setup. + + Yields: + VariableReadRequest: Individual requests sized to fit within pdu_length. + """ fixed_packet_part = S7AckDataHeader.LENGTH + VariableRequestParameter.LENGTH + DataItem.HEADER_LENGTH max_payload_length = pdu_length - fixed_packet_part parameter_item = self.parameter.items[0] @@ -228,7 +238,17 @@ def _create_data_item(cls, parameter_item: RequestParameterItem, data: bytes) -> return DataItem(transport_size=data_transport_size, data_length=data_length, data=data) def request_generator(self, pdu_length: int) -> Generator["VariableWriteRequest", None, None]: - """Генератор который разбивает запрос на более мелкие запросы, которы умещаются в pdu_length""" + """Generate multiple write requests that fit within the PDU length limit. + + Splits a large write request into smaller chunks based on the maximum + payload size allowed by the negotiated PDU length. + + Args: + pdu_length: Maximum PDU size negotiated during connection setup. + + Yields: + VariableWriteRequest: Individual requests sized to fit within pdu_length. + """ max_payload_length = ( pdu_length - S7Header.LENGTH @@ -348,7 +368,14 @@ def parse(cls, packet: bytes) -> "ReadVariableResponse": return cls(parameter=parameter, data=items) def values(self) -> list[bytes]: - """Возвращает только значения в виде списка, после проверки на результат ответа""" + """Extract and return data values from the response. + + Returns: + list[bytes]: List of raw data bytes from each response item. + + Raises: + ReadVariableException: If any item in the response has a non-success return code. + """ result = [] for item in self.data: if item.return_code != ItemReturnCode.SUCCESS: @@ -385,9 +412,13 @@ def serialize_parameter(self) -> bytes: def serialize_data(self) -> bytes: return struct.pack(f"!{len(self.data)}B", *self.data) - def check_result(self) -> bool: - """Проверяет значения каждой из записей и возвращает True, если все записалось успешно""" + def check_result(self) -> None: + """Check the result of each write operation. + + Raises: + WriteVariableException: If any item in the response has a non-success return code. + """ for item in self.data: if item != ItemReturnCode.SUCCESS: raise WriteVariableException(f"WriteVariableResponseItem return code: {item}", response=self) - return True + return None diff --git a/src/python_s7comm/s7comm/packets/user_data.py b/src/python_s7comm/s7comm/packets/user_data.py index f366d85..7ac74fe 100644 --- a/src/python_s7comm/s7comm/packets/user_data.py +++ b/src/python_s7comm/s7comm/packets/user_data.py @@ -1,5 +1,3 @@ -"""Скорее всего общие объекты для всех запросов типа UserData""" - import struct from ..enums import ( diff --git a/src/python_s7comm/s7comm/packets/variable_address.py b/src/python_s7comm/s7comm/packets/variable_address.py index f4c36d8..6821d81 100644 --- a/src/python_s7comm/s7comm/packets/variable_address.py +++ b/src/python_s7comm/s7comm/packets/variable_address.py @@ -30,9 +30,8 @@ @dataclass class VariableAddress: - """data_type=None на случай парсинга из ответа, возможно стоит вынести в отдельный класс VariableAddresResponse - Потому что в ответе нет data_typе, или может вообще убрать datatype - """ + # TODO: data_type=None is for parsing from response, consider moving to a separate VariableAddressResponse class + # because the response doesn't contain data_type, or maybe remove datatype entirely area: Area db_number: int diff --git a/src/python_s7comm/sync_client.py b/src/python_s7comm/sync_client.py index 29807f4..e1263e9 100644 --- a/src/python_s7comm/sync_client.py +++ b/src/python_s7comm/sync_client.py @@ -1,5 +1,3 @@ -import datetime - from .s7comm import S7Comm, enums from .s7comm.packets import S7Packet from .s7comm.packets.variable_address import VariableAddress @@ -80,91 +78,10 @@ def read_multi_vars(self, items: list[str]) -> list[bytes]: response = self.s7comm.read_multi_vars(items=items_) return response.values() - def write_multi_vars(self, items: list[tuple[str, bytes]]) -> bool: + def write_multi_vars(self, items: list[tuple[str, bytes]]) -> None: vars_ = [(VariableAddress.from_string(address), data) for address, data in items] response = self.s7comm.write_multi_vars(items=vars_) - return response.check_result() + response.check_result() def plc_stop(self) -> None: self.s7comm.plc_stop() - - def set_plc_system_datetime(self) -> int: - raise NotImplementedError - - def delete(self, block_type: str, block_num: int) -> int: - raise NotImplementedError - - def full_upload(self, _type: str, block_num: int) -> tuple[bytearray, int]: - raise NotImplementedError - - def upload(self, block_num: int) -> bytearray: - raise NotImplementedError - - def download(self, data: bytearray, block_num: int = -1) -> int: - raise NotImplementedError - - def db_get(self, db_number: int) -> bytearray: - raise NotImplementedError - - def get_cpu_info(self) -> None: - raise NotImplementedError - - def get_pg_block_info(self, block: bytearray) -> None: - raise NotImplementedError - - def get_protection(self) -> None: - raise NotImplementedError - - def iso_exchange_buffer(self, data: bytearray) -> bytearray: - raise NotImplementedError - - def list_blocks(self) -> None: - raise NotImplementedError - - def list_blocks_of_type(self, blocktype: str, size: int) -> None: - raise NotImplementedError - - def get_block_info(self, blocktype: str, db_number: int) -> None: - raise NotImplementedError - - def set_session_password(self, password: str) -> int: - raise NotImplementedError - - def clear_session_password(self) -> int: - raise NotImplementedError - - def set_connection_params(self, address: str, local_tsap: int, remote_tsap: int) -> None: - raise NotImplementedError - - def set_connection_type(self, connection_type: int) -> None: - raise NotImplementedError - - def compress(self, time: int) -> int: - raise NotImplementedError - - def set_param(self, number: int, value: int) -> int: - raise NotImplementedError - - def get_param(self, number: int) -> int: - raise NotImplementedError - - def plc_cold_start(self) -> int: - raise NotImplementedError - - def plc_hot_start(self) -> int: - raise NotImplementedError - - def get_plc_datetime(self) -> None: - raise NotImplementedError - - def set_plc_datetime(self, datetime: datetime.datetime) -> int: - raise NotImplementedError - - def copy_ram_to_rom(self, timeout: int = 1) -> int: - raise NotImplementedError - - def db_fill(self, db_number: int, filler: int) -> int: - raise NotImplementedError - - def get_cp_info(self) -> None: - raise NotImplementedError diff --git a/tests/real_plc_s1200/test_async_write_area.py b/tests/real_plc_s1200/test_async_write_area.py index 3b80802..31ecb9a 100644 --- a/tests/real_plc_s1200/test_async_write_area.py +++ b/tests/real_plc_s1200/test_async_write_area.py @@ -115,7 +115,6 @@ async def test_write_multi_vars(async_client: AsyncClient) -> None: b"\x00", ] items = list(zip(addresses, expected)) - response = await async_client.write_multi_vars(items=items) - assert response + await async_client.write_multi_vars(items=items) assert await async_client.read_multi_vars(items=addresses) == expected diff --git a/tests/real_plc_s1200/test_write_area.py b/tests/real_plc_s1200/test_write_area.py index 5d07839..8c5312d 100644 --- a/tests/real_plc_s1200/test_write_area.py +++ b/tests/real_plc_s1200/test_write_area.py @@ -106,7 +106,6 @@ def test_write_multi_vars(client: Client) -> None: b"\x00", ] items = list(zip(addresses, expected)) - response = client.write_multi_vars(items=items) - assert response + client.write_multi_vars(items=items) assert client.read_multi_vars(items=addresses) == expected diff --git a/uv.lock b/uv.lock index 1425d7e..704d5f0 100644 --- a/uv.lock +++ b/uv.lock @@ -273,7 +273,7 @@ wheels = [ [[package]] name = "python-s7comm" version = "0.0.1" -source = { virtual = "." } +source = { editable = "." } [package.dev-dependencies] dev = [