Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
20 changes: 17 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
29 changes: 27 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
91 changes: 3 additions & 88 deletions src/python_s7comm/async_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import datetime

from .s7comm import AsyncS7Comm, enums
from .s7comm.packets.variable_address import VariableAddress
from .s7comm.szl import (
Expand Down Expand Up @@ -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
7 changes: 1 addition & 6 deletions src/python_s7comm/s7comm/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
S7AckDataHeader,
S7Packet,
SetupCommunicationRequest,
SZLResponseData,
UserDataContinuationRequest,
UserDataRequest,
UserDataResponse,
Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand Down
7 changes: 1 addition & 6 deletions src/python_s7comm/s7comm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/python_s7comm/s7comm/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/python_s7comm/s7comm/packets/packet.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, ClassVar, Protocol, Self
from typing import Any, ClassVar, Protocol

from .headers import S7AckDataHeader, S7Header

Expand Down
45 changes: 38 additions & 7 deletions src/python_s7comm/s7comm/packets/rw_variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
2 changes: 0 additions & 2 deletions src/python_s7comm/s7comm/packets/user_data.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Скорее всего общие объекты для всех запросов типа UserData"""

import struct

from ..enums import (
Expand Down
5 changes: 2 additions & 3 deletions src/python_s7comm/s7comm/packets/variable_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading