Skip to content

Commit 8a0ff08

Browse files
gijzelaerrclaude
andauthored
4.0 polish: docs, examples, property tests, stress tests, optimizer fix (#696)
Documentation: - Add logging doc page (API/log.rst) to index - Update CHANGES.md with all recent features (block transfer, array helpers, setters, cpu_info fix) - Add s7 examples (s7_basic.py, s7_symbols.py, s7_server.py) Optimizer fix: - Exclude counter (0x1C) and timer (0x1D) areas from byte-range merging — they use element-based addressing SymbolTable: - Accept both snap7.Client and s7.Client (use Any type hint) Property-based tests (Hypothesis): - Round-trip tests for all 20 getter/setter pairs - Found and fixed float precision bug in set_ltime/set_ltod/set_ldt (use integer arithmetic instead of float multiplication) Multi-client stress tests: - Concurrent reads from 4 clients - Concurrent writer + reader on same DB - Rapid connect/disconnect cycles Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7423a28 commit 8a0ff08

11 files changed

Lines changed: 515 additions & 8 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ Major release: new `s7` package with S7CommPlus protocol support.
2020
* Multi-variable read optimizer with parallel dispatch (experimental)
2121
* S7 routing for multi-subnet PLC access (experimental)
2222
* Symbolic addressing via SymbolTable (experimental)
23+
* S7CommPlus CPU state reading and block transfer (upload/download)
24+
* Array read/write helpers (`db_read_array`, `db_write_array`)
25+
* Missing data type setters: `set_lint`, `set_ulint`, `set_ltime`, `set_ltod`, `set_ldt`
26+
* Optimized `SymbolTable.read_many()` with multi-variable batching
27+
* Optimizer excludes counter/timer areas from byte-range merging
28+
* Fixed `get_cpu_info` field offsets for real S7-300/1500 (thanks @qzertywsx)
29+
* Fixed `S7SZL.__str__` attribute name typo (thanks @qzertywsx)
2330
* Dependabot auto-merge for dependency updates
2431
* Documentation restructured: API Reference + Internals sections
2532

doc/API/log.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Logging
2+
=======
3+
4+
Structured logging with PLC connection context for multi-PLC environments.
5+
6+
The :class:`~snap7.log.PLCLoggerAdapter` automatically injects PLC host,
7+
rack, and slot into every log message:
8+
9+
.. code-block:: python
10+
11+
import logging
12+
logging.basicConfig(level=logging.DEBUG)
13+
14+
from s7 import Client
15+
client = Client()
16+
client.connect("192.168.1.10", 0, 1)
17+
# Logs: [192.168.1.10 R0/S1] Connected to 192.168.1.10:102 ...
18+
19+
For JSON output (ELK, Datadog, Loki):
20+
21+
.. code-block:: python
22+
23+
from snap7.log import JSONFormatter
24+
25+
handler = logging.StreamHandler()
26+
handler.setFormatter(JSONFormatter())
27+
logging.getLogger("snap7").addHandler(handler)
28+
29+
API reference
30+
-------------
31+
32+
.. automodule:: snap7.log
33+
:members:

doc/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Welcome to python-snap7's documentation!
4646
API/util
4747
API/symbols
4848
API/optimizer
49+
API/log
4950
API/type
5051

5152
.. toctree::

example/s7_basic.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Basic s7 Client example — auto-detects S7CommPlus vs legacy S7.
2+
3+
Usage:
4+
python example/s7_basic.py 192.168.1.10
5+
"""
6+
7+
import sys
8+
from s7 import Client
9+
10+
address = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10"
11+
12+
client = Client()
13+
client.connect(address, 0, 1)
14+
15+
print(f"Connected via {client.protocol.value}")
16+
17+
# Read 4 bytes from DB1
18+
data = client.db_read(1, 0, 4)
19+
print(f"DB1.DBB0-3: {data.hex()}")
20+
21+
# Write and read back
22+
client.db_write(1, 0, bytearray([0x01, 0x02, 0x03, 0x04]))
23+
data = client.db_read(1, 0, 4)
24+
print(f"After write: {data.hex()}")
25+
26+
client.disconnect()

example/s7_server.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""s7 Server emulator example — run a PLC simulator for testing.
2+
3+
Usage:
4+
python example/s7_server.py
5+
"""
6+
7+
import struct
8+
import time
9+
from ctypes import c_char
10+
11+
from s7 import Server
12+
from snap7.type import SrvArea
13+
14+
server = Server()
15+
16+
# Register DB1 with test data on the legacy server
17+
db1_data = bytearray(100)
18+
struct.pack_into(">f", db1_data, 0, 23.5) # temperature
19+
struct.pack_into(">h", db1_data, 4, 42) # set point
20+
db1_array = (c_char * 100).from_buffer(db1_data)
21+
server.legacy_server.register_area(SrvArea.DB, 1, db1_array)
22+
23+
# Register DB1 on S7CommPlus server too
24+
server.register_raw_db(1, bytearray(db1_data))
25+
26+
# Start both servers
27+
server.start(tcp_port=1102, s7commplus_port=11020)
28+
29+
print("Server running on port 1102 (legacy) and 11020 (S7CommPlus)")
30+
print("Press Ctrl+C to stop")
31+
32+
try:
33+
while True:
34+
time.sleep(1)
35+
except KeyboardInterrupt:
36+
server.stop()
37+
print("Server stopped")

example/s7_symbols.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Symbolic addressing example — read/write by tag name.
2+
3+
Usage:
4+
python example/s7_symbols.py 192.168.1.10
5+
"""
6+
7+
import sys
8+
from s7 import Client, SymbolTable
9+
10+
address = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.10"
11+
12+
# Define symbols (or use SymbolTable.from_csv("tags.csv"))
13+
symbols = SymbolTable(
14+
{
15+
"Motor1.Speed": {"db": 1, "offset": 0, "type": "REAL"},
16+
"Motor1.Running": {"db": 1, "offset": 4, "bit": 0, "type": "BOOL"},
17+
"SetPoint": {"db": 1, "offset": 6, "type": "INT"},
18+
}
19+
)
20+
21+
client = Client()
22+
client.connect(address, 0, 1)
23+
24+
# Read by name
25+
speed = symbols.read(client, "Motor1.Speed")
26+
running = symbols.read(client, "Motor1.Running")
27+
print(f"Speed: {speed!r}, Running: {running!r}")
28+
29+
# Write by name
30+
symbols.write(client, "SetPoint", 1500)
31+
32+
# Batch read (uses optimizer when available)
33+
values = symbols.read_many(client, ["Motor1.Speed", "SetPoint"])
34+
print(f"Batch: {values}")
35+
36+
client.disconnect()

snap7/optimizer.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414

1515
logger = logging.getLogger(__name__)
1616

17+
# Areas that support contiguous-block merging. Counter (0x1C) and Timer
18+
# (0x1D) use element-based addressing, not byte-based, so merging them
19+
# as contiguous byte ranges would produce incorrect reads.
20+
_MERGEABLE_AREAS: frozenset[int] = frozenset({0x81, 0x82, 0x83, 0x84}) # PE, PA, MK, DB
21+
1722

1823
@dataclass
1924
class ReadItem:
@@ -116,10 +121,11 @@ def merge_items(sorted_items: list[ReadItem], max_gap: int = 5, max_block_size:
116121
item_end = item.byte_offset + item.byte_length
117122

118123
same_region = item.area == block.area and item.db_number == block.db_number
124+
mergeable = block.area in _MERGEABLE_AREAS
119125
gap = item.byte_offset - block_end
120126
new_length = max(block_end, item_end) - block.start_offset
121127

122-
if same_region and gap <= max_gap and new_length <= max_block_size:
128+
if same_region and mergeable and gap <= max_gap and new_length <= max_block_size:
123129
# Merge: extend block to cover the new item
124130
block.byte_length = new_length
125131
block.items.append(item)

snap7/util/setters.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,8 @@ def set_ltime(bytearray_: Buffer, byte_index: int, value: timedelta) -> Buffer:
809809
>>> data = bytearray(8)
810810
>>> set_ltime(data, 0, timedelta(seconds=1))
811811
"""
812-
nanoseconds = int(value.total_seconds() * 1_000_000_000)
812+
# Use integer arithmetic to avoid float precision loss
813+
nanoseconds = (value.days * 86400 + value.seconds) * 1_000_000_000 + value.microseconds * 1000
813814
bytearray_[byte_index : byte_index + 8] = struct.pack(">q", nanoseconds)
814815
return bytearray_
815816

@@ -836,7 +837,7 @@ def set_ltod(bytearray_: Buffer, byte_index: int, value: timedelta) -> Buffer:
836837
"""
837838
if value.days >= 1:
838839
raise ValueError("LTOD value must be less than 24 hours")
839-
nanoseconds = int(value.total_seconds() * 1_000_000_000)
840+
nanoseconds = (value.days * 86400 + value.seconds) * 1_000_000_000 + value.microseconds * 1000
840841
bytearray_[byte_index : byte_index + 8] = struct.pack(">Q", nanoseconds)
841842
return bytearray_
842843

@@ -864,6 +865,6 @@ def set_ldt(bytearray_: Buffer, byte_index: int, value: datetime) -> Buffer:
864865
"""
865866
epoch = datetime(1970, 1, 1)
866867
delta = value - epoch
867-
nanoseconds = int(delta.total_seconds() * 1_000_000_000)
868+
nanoseconds = (delta.days * 86400 + delta.seconds) * 1_000_000_000 + delta.microseconds * 1000
868869
bytearray_[byte_index : byte_index + 8] = struct.pack(">Q", nanoseconds)
869870
return bytearray_

snap7/util/symbols.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from pathlib import Path
2424
from typing import Any, Dict, Union
2525

26-
from snap7.client import Client
2726
from snap7.type import ValueType
2827
from snap7.util import (
2928
get_bool,
@@ -485,7 +484,7 @@ def __contains__(self, tag: str) -> bool:
485484
# Read / write
486485
# ------------------------------------------------------------------
487486

488-
def read(self, client: Client, tag: str) -> ValueType:
487+
def read(self, client: Any, tag: str) -> ValueType:
489488
"""Read a single tag value from the PLC.
490489
491490
Args:
@@ -500,7 +499,7 @@ def read(self, client: Client, tag: str) -> ValueType:
500499
data = client.db_read(addr.db, addr.byte_offset, size)
501500
return self._get_value(data, 0, addr)
502501

503-
def write(self, client: Client, tag: str, value: Any) -> None:
502+
def write(self, client: Any, tag: str, value: Any) -> None:
504503
"""Write a single tag value to the PLC.
505504
506505
Args:
@@ -524,7 +523,7 @@ def write(self, client: Client, tag: str, value: Any) -> None:
524523
self._set_value(data, 0, addr, value)
525524
client.db_write(addr.db, addr.byte_offset, data)
526525

527-
def read_many(self, client: Client, tags: list[str]) -> Dict[str, ValueType]:
526+
def read_many(self, client: Any, tags: list[str]) -> Dict[str, ValueType]:
528527
"""Read multiple tags in batched requests.
529528
530529
Groups tags by DB number and uses :meth:`~snap7.client.Client.read_multi_vars`

0 commit comments

Comments
 (0)