Skip to content

Commit 6d35552

Browse files
gijzelaerrclaude
andcommitted
Add missing S7CommPlus operations: area read/write, explore, invoke
Add operations that S7CommPlusDriver (C#) implements but we were missing: - read_area/write_area for controller memory areas (M, I, Q, counters, timers) — previously only DB access was supported via S7CommPlus - explore(explore_id) — browse specific objects, not just root - set_plc_operating_state(state) — start/stop PLC via INVOKE function Both sync and async clients updated. Request/response builders are module-level functions shared between both clients. Note: Link operations (ADD_LINK, REMOVE_LINK, GET_LINK) and sequencing (BEGIN_SEQUENCE, END_SEQUENCE) are defined in protocol.py but not implemented by S7CommPlusDriver either — these are rarely used features. Reference: thomas-v2/S7CommPlusDriver (C#, LGPL-3.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 310ff53 commit 6d35552

2 files changed

Lines changed: 161 additions & 6 deletions

File tree

s7/_s7commplus_async_client.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,16 @@
2626
)
2727
from .codec import encode_header, decode_header, encode_typed_value, encode_object_qualifier
2828
from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq
29-
from ._s7commplus_client import _build_read_payload, _parse_read_response, _build_write_payload, _parse_write_response
29+
from ._s7commplus_client import (
30+
_build_read_payload,
31+
_parse_read_response,
32+
_build_write_payload,
33+
_parse_write_response,
34+
_build_area_read_payload,
35+
_build_area_write_payload,
36+
_build_explore_payload,
37+
_build_invoke_payload,
38+
)
3039

3140
logger = logging.getLogger(__name__)
3241

@@ -399,9 +408,30 @@ async def db_read_multi(self, items: list[tuple[int, int, int]]) -> list[bytes]:
399408
parsed = _parse_read_response(response)
400409
return [r if r is not None else b"" for r in parsed]
401410

402-
async def explore(self) -> bytes:
411+
async def read_area(self, area_rid: int, start: int, size: int) -> bytes:
412+
"""Read raw bytes from a controller memory area (M, I, Q, counters, timers)."""
413+
payload = _build_area_read_payload(area_rid, start, size)
414+
response = await self._send_request(FunctionCode.GET_MULTI_VARIABLES, payload)
415+
results = _parse_read_response(response)
416+
if not results or results[0] is None:
417+
raise RuntimeError("Area read failed")
418+
return results[0]
419+
420+
async def write_area(self, area_rid: int, start: int, data: bytes) -> None:
421+
"""Write raw bytes to a controller memory area (M, I, Q, counters, timers)."""
422+
payload = _build_area_write_payload(area_rid, start, data)
423+
response = await self._send_request(FunctionCode.SET_MULTI_VARIABLES, payload)
424+
_parse_write_response(response)
425+
426+
async def explore(self, explore_id: int = 0) -> bytes:
403427
"""Browse the PLC object tree."""
404-
return await self._send_request(FunctionCode.EXPLORE, b"")
428+
payload = _build_explore_payload(explore_id)
429+
return await self._send_request(FunctionCode.EXPLORE, payload)
430+
431+
async def set_plc_operating_state(self, state: int) -> None:
432+
"""Set the PLC operating state (start/stop)."""
433+
payload = _build_invoke_payload(state)
434+
await self._send_request(FunctionCode.INVOKE, payload)
405435

406436
# -- Internal methods --
407437

s7/_s7commplus_client.py

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,74 @@ def db_read_multi(self, items: list[tuple[int, int, int]]) -> list[bytes]:
163163
parsed = _parse_read_response(response)
164164
return [r if r is not None else b"" for r in parsed]
165165

166-
def explore(self) -> bytes:
166+
def read_area(self, area_rid: int, start: int, size: int) -> bytes:
167+
"""Read raw bytes from a controller memory area (M, I, Q, counters, timers).
168+
169+
Args:
170+
area_rid: Native object RID for the area, e.g.
171+
``Ids.NATIVE_THE_M_AREA_RID`` (82) for Merker.
172+
start: Start byte offset.
173+
size: Number of bytes to read.
174+
175+
Returns:
176+
Raw bytes read from the area.
177+
"""
178+
if self._connection is None:
179+
raise RuntimeError("Not connected")
180+
181+
payload = _build_area_read_payload(area_rid, start, size)
182+
response = self._connection.send_request(FunctionCode.GET_MULTI_VARIABLES, payload)
183+
results = _parse_read_response(response)
184+
if not results or results[0] is None:
185+
raise RuntimeError("Area read failed")
186+
return results[0]
187+
188+
def write_area(self, area_rid: int, start: int, data: bytes) -> None:
189+
"""Write raw bytes to a controller memory area (M, I, Q, counters, timers).
190+
191+
Args:
192+
area_rid: Native object RID for the area.
193+
start: Start byte offset.
194+
data: Bytes to write.
195+
"""
196+
if self._connection is None:
197+
raise RuntimeError("Not connected")
198+
199+
payload = _build_area_write_payload(area_rid, start, data)
200+
response = self._connection.send_request(FunctionCode.SET_MULTI_VARIABLES, payload)
201+
_parse_write_response(response)
202+
203+
def explore(self, explore_id: int = 0) -> bytes:
167204
"""Browse the PLC object tree.
168205
206+
Args:
207+
explore_id: Object to explore (0 = root).
208+
169209
Returns:
170-
Raw response payload
210+
Raw response payload.
171211
"""
172212
if self._connection is None:
173213
raise RuntimeError("Not connected")
174214

175-
response = self._connection.send_request(FunctionCode.EXPLORE, b"")
215+
payload = _build_explore_payload(explore_id)
216+
response = self._connection.send_request(FunctionCode.EXPLORE, payload)
176217
return response
177218

219+
def set_plc_operating_state(self, state: int) -> None:
220+
"""Set the PLC operating state (start/stop).
221+
222+
Uses INVOKE to call the PLC's operating-state setter.
223+
224+
Args:
225+
state: Target operating state.
226+
1 = STOP, 2 = RUN, 3 = HOT_RESTART.
227+
"""
228+
if self._connection is None:
229+
raise RuntimeError("Not connected")
230+
231+
payload = _build_invoke_payload(state)
232+
self._connection.send_request(FunctionCode.INVOKE, payload)
233+
178234
def __enter__(self) -> "S7CommPlusClient":
179235
return self
180236

@@ -331,3 +387,72 @@ def _parse_write_response(response: bytes) -> None:
331387
if errors:
332388
err_str = ", ".join(f"item {nr}: error {val}" for nr, val in errors)
333389
raise RuntimeError(f"Write failed: {err_str}")
390+
391+
392+
def _build_area_read_payload(area_rid: int, start: int, size: int) -> bytes:
393+
"""Build a GetMultiVariables payload for controller memory area access.
394+
395+
Unlike DB access, controller areas (M, I, Q, counters, timers) use a
396+
native RID and the CONTROLLER_AREA_VALUE_ACTUAL sub-area.
397+
"""
398+
addr_bytes, field_count = encode_item_address(
399+
access_area=area_rid,
400+
access_sub_area=Ids.CONTROLLER_AREA_VALUE_ACTUAL,
401+
lids=[start + 1, size],
402+
)
403+
404+
payload = bytearray()
405+
payload += struct.pack(">I", 0)
406+
payload += encode_uint32_vlq(1)
407+
payload += encode_uint32_vlq(field_count)
408+
payload += addr_bytes
409+
payload += encode_object_qualifier()
410+
payload += struct.pack(">I", 0)
411+
return bytes(payload)
412+
413+
414+
def _build_area_write_payload(area_rid: int, start: int, data: bytes) -> bytes:
415+
"""Build a SetMultiVariables payload for controller memory area access."""
416+
addr_bytes, field_count = encode_item_address(
417+
access_area=area_rid,
418+
access_sub_area=Ids.CONTROLLER_AREA_VALUE_ACTUAL,
419+
lids=[start + 1, len(data)],
420+
)
421+
422+
payload = bytearray()
423+
payload += struct.pack(">I", 0)
424+
payload += encode_uint32_vlq(1)
425+
payload += encode_uint32_vlq(field_count)
426+
payload += addr_bytes
427+
payload += encode_uint32_vlq(1) # item number 1
428+
payload += encode_pvalue_blob(data)
429+
payload += bytes([0x00])
430+
payload += encode_object_qualifier()
431+
payload += struct.pack(">I", 0)
432+
return bytes(payload)
433+
434+
435+
def _build_explore_payload(explore_id: int = 0) -> bytes:
436+
"""Build an EXPLORE request payload.
437+
438+
Args:
439+
explore_id: Object to explore (0 = root, other values
440+
explore a specific object by RID).
441+
"""
442+
if explore_id == 0:
443+
return b""
444+
payload = bytearray()
445+
payload += encode_uint32_vlq(explore_id)
446+
return bytes(payload)
447+
448+
449+
def _build_invoke_payload(state: int) -> bytes:
450+
"""Build an INVOKE request payload for SetPlcOperatingState.
451+
452+
The INVOKE function triggers a method on a PLC object.
453+
For operating state changes, this calls the CPU's state setter.
454+
"""
455+
payload = bytearray()
456+
payload += struct.pack(">I", 0) # reserved
457+
payload += encode_uint32_vlq(state)
458+
return bytes(payload)

0 commit comments

Comments
 (0)