Skip to content

Commit 0f635eb

Browse files
authored
Prepare for the first release (#30)
1 parent 98c0bcb commit 0f635eb

15 files changed

Lines changed: 101 additions & 210 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ jobs:
1212
- uses: pre-commit/action@v3.0.1
1313
test:
1414
runs-on: ubuntu-latest
15+
strategy:
16+
matrix:
17+
python-version: ["3.12", "3.13"]
1518
steps:
1619
- uses: actions/checkout@v4
1720
- uses: astral-sh/setup-uv@v4
18-
- run: uv sync --group test
21+
- run: uv python install ${{ matrix.python-version }}
22+
- run: uv sync --group test --python ${{ matrix.python-version }}
1923
- run: uv pip install -e .
2024
- run: uv run pytest tests/ --ignore=tests/real_plc_s1200/

AGENTS.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
1. **High-Level Clients** (`python_s7comm/sync_client.py`, `async_client.py`)
1212
- `Client` - Synchronous high-level API with string-based addresses
1313
- `AsyncClient` - Asynchronous high-level API with string-based addresses
14-
- Provides convenience methods: `read_area()`, `write_area()`, `get_cpu_state()`, `get_order_code()`, etc.
14+
- Provides convenience methods:
15+
- `connect()`, `disconnect()`, `close()`
16+
- `read_area()`, `write_area()`
17+
- `read_multi_vars()`, `write_multi_vars()`
18+
- `get_cpu_state()`, `get_order_code()`
19+
- `read_szl()`, `read_szl_list()`
20+
- `plc_stop()`
1521

1622
2. **Core S7Comm** (`python_s7comm/s7comm/client.py`, `async_client.py`)
1723
- `S7Comm` - Synchronous core implementation
@@ -58,7 +64,7 @@ TCP/IP Socket
5864

5965
### General Rules
6066

61-
- **Python 3.12+** required
67+
- **Python 3.12+** required (tested on 3.12 and 3.13)
6268
- **Line length**: 120 characters max (configured in ruff)
6369
- **Formatting**: Use `ruff format`
6470
- **Linting**: Use `ruff` for linting
@@ -209,7 +215,7 @@ Examples:
209215
## Important Implementation Notes
210216

211217
1. **PDU Reference**: Auto-incremented 16-bit counter for request/response matching
212-
2. **PDU Length**: Negotiated during connection setup (default 480 bytes)
218+
2. **PDU Length**: Negotiated during connection setup (default 480 bytes, max depends on PLC model)
213219
3. **Max Variables**: 20 items per multi-read/write operation (`MAX_VARS`)
214220
4. **Byte Order**: Big-endian (`!` in struct format) for protocol, some little-endian for PDU reference
215221
5. **Protocol ID**: Always `0x32` for S7comm
@@ -229,3 +235,11 @@ Examples:
229235
1. Add to relevant enums in `enums.py`
230236
2. Update `VariableAddress` regex patterns if needed
231237
3. Add tests in `test_addresses.py`
238+
239+
## Release Process
240+
241+
1. Update version in `pyproject.toml`
242+
2. Run all checks: `uv run pre-commit run --all-files`
243+
3. Run tests: `uv run pytest tests/ --ignore=tests/real_plc_s1200/`
244+
4. Create and push tag: `git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1`
245+
5. GitHub Actions will automatically build and publish to PyPI

pyproject.toml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
15
[project]
2-
name="python-s7comm"
3-
version="0.0.1"
6+
name = "python-s7comm"
7+
version = "0.0.1"
8+
description = "Unofficial Python implementation of Siemens S7 communication protocol"
9+
readme = "README.md"
410
requires-python = ">=3.12"
511
license = "MIT"
12+
authors = [
13+
{ name = "nikteliy" }
14+
]
15+
keywords = ["siemens", "s7", "plc", "s7comm", "automation", "industrial"]
16+
classifiers = [
17+
"Development Status :: 4 - Beta",
18+
"Intended Audience :: Developers",
19+
"Operating System :: OS Independent",
20+
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
23+
"Topic :: System :: Hardware",
24+
"Topic :: Software Development :: Libraries :: Python Modules",
25+
]
26+
27+
[project.urls]
28+
Homepage = "https://github.com/nikteliy/python-s7comm"
29+
Repository = "https://github.com/nikteliy/python-s7comm"
30+
Issues = "https://github.com/nikteliy/python-s7comm/issues"
631

732
[dependency-groups]
833
test = [

src/python_s7comm/async_client.py

Lines changed: 3 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import datetime
2-
31
from .s7comm import AsyncS7Comm, enums
42
from .s7comm.packets.variable_address import VariableAddress
53
from .s7comm.szl import (
@@ -74,91 +72,8 @@ async def read_multi_vars(self, items: list[str]) -> list[bytes]:
7472
response = await self.s7comm.read_multi_vars(items=vars_)
7573
return response.values()
7674

77-
async def write_multi_vars(self, items: list[tuple[str, bytes]]) -> bool:
75+
async def write_multi_vars(self, items: list[tuple[str, bytes]]) -> None:
7876
vars_ = [(VariableAddress.from_string(address), data) for address, data in items]
7977
response = await self.s7comm.write_multi_vars(items=vars_)
80-
return response.check_result()
81-
82-
async def set_plc_system_datetime(self) -> int:
83-
raise NotImplementedError
84-
85-
async def delete(self, block_type: str, block_num: int) -> int:
86-
raise NotImplementedError
87-
88-
async def full_upload(self, _type: str, block_num: int) -> tuple[bytearray, int]:
89-
raise NotImplementedError
90-
91-
async def upload(self, block_num: int) -> bytearray:
92-
raise NotImplementedError
93-
94-
async def download(self, data: bytearray, block_num: int = -1) -> int:
95-
raise NotImplementedError
96-
97-
async def db_get(self, db_number: int) -> bytearray:
98-
raise NotImplementedError
99-
100-
async def get_cpu_info(self) -> None:
101-
raise NotImplementedError
102-
103-
async def get_pg_block_info(self, block: bytearray) -> None:
104-
raise NotImplementedError
105-
106-
async def get_protection(self) -> None:
107-
raise NotImplementedError
108-
109-
async def iso_exchange_buffer(self, data: bytearray) -> bytearray:
110-
raise NotImplementedError
111-
112-
async def list_blocks(self) -> None:
113-
raise NotImplementedError
114-
115-
async def list_blocks_of_type(self, blocktype: str, size: int) -> None:
116-
raise NotImplementedError
117-
118-
async def get_block_info(self, blocktype: str, db_number: int) -> None:
119-
raise NotImplementedError
120-
121-
async def set_session_password(self, password: str) -> int:
122-
raise NotImplementedError
123-
124-
async def clear_session_password(self) -> int:
125-
raise NotImplementedError
126-
127-
async def set_connection_params(self, address: str, local_tsap: int, remote_tsap: int) -> None:
128-
raise NotImplementedError
129-
130-
async def set_connection_type(self, connection_type: int) -> None:
131-
raise NotImplementedError
132-
133-
async def compress(self, time: int) -> int:
134-
raise NotImplementedError
135-
136-
async def set_param(self, number: int, value: int) -> int:
137-
raise NotImplementedError
138-
139-
async def get_param(self, number: int) -> int:
140-
raise NotImplementedError
141-
142-
async def plc_stop(self) -> None:
143-
raise NotImplementedError
144-
145-
async def plc_cold_start(self) -> int:
146-
raise NotImplementedError
147-
148-
async def plc_hot_start(self) -> int:
149-
raise NotImplementedError
150-
151-
async def get_plc_datetime(self) -> None:
152-
raise NotImplementedError
153-
154-
async def set_plc_datetime(self, datetime: datetime.datetime) -> int:
155-
raise NotImplementedError
156-
157-
async def copy_ram_to_rom(self, timeout: int = 1) -> int:
158-
raise NotImplementedError
159-
160-
async def db_fill(self, db_number: int, filler: int) -> int:
161-
raise NotImplementedError
162-
163-
async def get_cp_info(self) -> None:
164-
raise NotImplementedError
78+
response.check_result()
79+
return None

src/python_s7comm/s7comm/async_client.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
S7AckDataHeader,
1313
S7Packet,
1414
SetupCommunicationRequest,
15-
SZLResponseData,
1615
UserDataContinuationRequest,
1716
UserDataRequest,
1817
UserDataResponse,
@@ -152,12 +151,10 @@ async def write_area(self, address: VariableAddress, data: bytes) -> WriteVariab
152151
if not isinstance(response, WriteVariableResponse):
153152
raise ValueError("Invalid response class")
154153
response.check_result()
155-
# TODO: склеить все отдельные запросы в один общий изначальный и вернуть
154+
# TODO: combine all separate requests into one common initial request and return
156155
return cast(WriteVariableResponse, response)
157156

158157
async def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse:
159-
# Общий размер запроса, должен быть меньше pdu_length
160-
# RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem));
161158
if len(items) > self.MAX_VARS:
162159
raise ValueError("Too many items")
163160

@@ -168,8 +165,6 @@ async def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableRes
168165
return response
169166

170167
async def write_multi_vars(self, items: list[tuple[VariableAddress, bytes]]) -> WriteVariableResponse:
171-
# Общий размер запроса, должен быть меньше pdu_length
172-
# RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem));
173168
if len(items) > self.MAX_VARS:
174169
raise ValueError("Too many items")
175170

src/python_s7comm/s7comm/client.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@ def __init__(
4444
self.transport = transport
4545

4646
def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse:
47-
# Общий размер запроса, должен быть меньше pdu_length
48-
# RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem)
49-
5047
if len(items) > self.MAX_VARS:
5148
raise ValueError("Too many items")
5249

@@ -57,8 +54,6 @@ def read_multi_vars(self, items: list[VariableAddress]) -> ReadVariableResponse:
5754
return response
5855

5956
def write_multi_vars(self, items: list[tuple[VariableAddress, bytes]]) -> WriteVariableResponse:
60-
# Общий размер запроса, должен быть меньше pdu_length
61-
# RPSize = word(2 + ItemsCount * sizeof(TReqFunReadItem));
6257
if len(items) > self.MAX_VARS:
6358
raise ValueError("Too many items")
6459

@@ -101,7 +96,7 @@ def write_area(self, address: VariableAddress, data: bytes) -> WriteVariableResp
10196
if not isinstance(response, WriteVariableResponse):
10297
raise ValueError("Invalid response class")
10398
response.check_result()
104-
# TODO: склеить все отдельные запросы в один общий изначальный и вернуть
99+
# TODO: combine all separate requests into one common initial request and return
105100
return cast(WriteVariableResponse, response)
106101

107102
def connect(

src/python_s7comm/s7comm/enums.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ class DataTypeTransportSize(Enum):
9494

9595

9696
class DataType(str, Enum):
97-
BIT = "BOOL" # Костыль для получения data_type из transport_size
97+
BIT = "BOOL" # Workaround for getting data_type from transport_size
9898
BOOL = "BOOL"
9999
BYTE = "BYTE"
100100
CHAR = "CHAR"

src/python_s7comm/s7comm/packets/packet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, ClassVar, Protocol, Self
1+
from typing import Any, ClassVar, Protocol
22

33
from .headers import S7AckDataHeader, S7Header
44

src/python_s7comm/s7comm/packets/rw_variable.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class RequestParameterItem:
4141
LENGTH = 12
4242

4343
def __init__(self, address: VariableAddress, transport_size: ParameterTransportSize | None = None):
44-
# word_size нуже для генератора пакетов
44+
# word_size is required by the request_generator to calculate maximum elements per packet
4545
if transport_size is None:
4646
self.transport_size = ParameterTransportSize[address.data_type.value]
4747
else:
@@ -145,7 +145,17 @@ def serialize(self) -> bytes:
145145
return self.parameter.serialize()
146146

147147
def request_generator(self, pdu_length: int) -> Generator["VariableReadRequest", None, None]:
148-
"""Генератор который разбивает запрос на более мелкие запросы, которы умещаются в pdu_length"""
148+
"""Generate multiple read requests that fit within the PDU length limit.
149+
150+
Splits a large read request into smaller chunks based on the maximum
151+
payload size allowed by the negotiated PDU length.
152+
153+
Args:
154+
pdu_length: Maximum PDU size negotiated during connection setup.
155+
156+
Yields:
157+
VariableReadRequest: Individual requests sized to fit within pdu_length.
158+
"""
149159
fixed_packet_part = S7AckDataHeader.LENGTH + VariableRequestParameter.LENGTH + DataItem.HEADER_LENGTH
150160
max_payload_length = pdu_length - fixed_packet_part
151161
parameter_item = self.parameter.items[0]
@@ -228,7 +238,17 @@ def _create_data_item(cls, parameter_item: RequestParameterItem, data: bytes) ->
228238
return DataItem(transport_size=data_transport_size, data_length=data_length, data=data)
229239

230240
def request_generator(self, pdu_length: int) -> Generator["VariableWriteRequest", None, None]:
231-
"""Генератор который разбивает запрос на более мелкие запросы, которы умещаются в pdu_length"""
241+
"""Generate multiple write requests that fit within the PDU length limit.
242+
243+
Splits a large write request into smaller chunks based on the maximum
244+
payload size allowed by the negotiated PDU length.
245+
246+
Args:
247+
pdu_length: Maximum PDU size negotiated during connection setup.
248+
249+
Yields:
250+
VariableWriteRequest: Individual requests sized to fit within pdu_length.
251+
"""
232252
max_payload_length = (
233253
pdu_length
234254
- S7Header.LENGTH
@@ -348,7 +368,14 @@ def parse(cls, packet: bytes) -> "ReadVariableResponse":
348368
return cls(parameter=parameter, data=items)
349369

350370
def values(self) -> list[bytes]:
351-
"""Возвращает только значения в виде списка, после проверки на результат ответа"""
371+
"""Extract and return data values from the response.
372+
373+
Returns:
374+
list[bytes]: List of raw data bytes from each response item.
375+
376+
Raises:
377+
ReadVariableException: If any item in the response has a non-success return code.
378+
"""
352379
result = []
353380
for item in self.data:
354381
if item.return_code != ItemReturnCode.SUCCESS:
@@ -385,9 +412,13 @@ def serialize_parameter(self) -> bytes:
385412
def serialize_data(self) -> bytes:
386413
return struct.pack(f"!{len(self.data)}B", *self.data)
387414

388-
def check_result(self) -> bool:
389-
"""Проверяет значения каждой из записей и возвращает True, если все записалось успешно"""
415+
def check_result(self) -> None:
416+
"""Check the result of each write operation.
417+
418+
Raises:
419+
WriteVariableException: If any item in the response has a non-success return code.
420+
"""
390421
for item in self.data:
391422
if item != ItemReturnCode.SUCCESS:
392423
raise WriteVariableException(f"WriteVariableResponseItem return code: {item}", response=self)
393-
return True
424+
return None

src/python_s7comm/s7comm/packets/user_data.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Скорее всего общие объекты для всех запросов типа UserData"""
2-
31
import struct
42

53
from ..enums import (

0 commit comments

Comments
 (0)