Skip to content

Commit 701cdd6

Browse files
gijzelaerrclaude
andauthored
Add diagnostic buffer reading and data change subscriptions (#690)
Implements #683 (diagnostic buffer) and #684 (data subscriptions): Diagnostic buffer (#683): - read_diagnostic_buffer() on snap7.Client: reads SZL 0x00A0, parses 20-byte entries with BCD timestamps into structured dicts - Exposed through s7.Client.read_diagnostic_buffer() via legacy fallback Data change subscriptions (#684, experimental): - create_subscription(items, cycle_ms): builds a CREATE_OBJECT request with subscription-class attributes (cycle time, credit limit, reference list) — PLC pushes updates for monitored variables - delete_subscription(subscription_id): sends DELETE_OBJECT to clean up - Exposed through s7.Client with S7CommPlus requirement check All subscription methods marked experimental. The subscription protocol is modeled after S7CommPlusDriver's alarm subscription pattern, adapted for data variable monitoring. Closes #683, closes #684. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6f4a41d commit 701cdd6

3 files changed

Lines changed: 220 additions & 1 deletion

File tree

s7/_s7commplus_client.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any, Optional
1313

1414
from .connection import S7CommPlusConnection
15-
from .protocol import FunctionCode, Ids
15+
from .protocol import FunctionCode, Ids, ElementID, DataType, ObjectId
1616
from .vlq import encode_uint32_vlq, decode_uint32_vlq, decode_uint64_vlq
1717
from .codec import (
1818
encode_item_address,
@@ -281,6 +281,47 @@ def browse(self) -> list[dict[str, Any]]:
281281

282282
return variables
283283

284+
def create_subscription(self, items: list[tuple[int, int, int]], cycle_ms: int = 0) -> int:
285+
"""Create a data change subscription.
286+
287+
.. warning:: This method is **experimental** and may change.
288+
289+
The PLC will push data updates for the specified variables. Use
290+
:meth:`receive_notification` to receive the pushed data.
291+
292+
Args:
293+
items: List of (db_number, start_offset, size) tuples to monitor.
294+
cycle_ms: Cycle time in milliseconds (0 = on change).
295+
296+
Returns:
297+
Subscription object ID assigned by the PLC.
298+
"""
299+
if self._connection is None:
300+
raise RuntimeError("Not connected")
301+
302+
payload = _build_subscription_request(items, cycle_ms, self._connection.session_id)
303+
response = self._connection.send_request(FunctionCode.CREATE_OBJECT, payload)
304+
305+
# Parse the CreateObject response to get the subscription object ID
306+
sub_id, consumed = decode_uint32_vlq(response, 0)
307+
logger.info(f"Subscription created, id={sub_id:#x}")
308+
return sub_id
309+
310+
def delete_subscription(self, subscription_id: int) -> None:
311+
"""Delete a data change subscription.
312+
313+
.. warning:: This method is **experimental** and may change.
314+
315+
Args:
316+
subscription_id: ID returned by :meth:`create_subscription`.
317+
"""
318+
if self._connection is None:
319+
raise RuntimeError("Not connected")
320+
321+
payload = struct.pack(">I", subscription_id) + struct.pack(">I", 0)
322+
self._connection.send_request(FunctionCode.DELETE_OBJECT, payload)
323+
logger.info(f"Subscription {subscription_id:#x} deleted")
324+
284325
def __enter__(self) -> "S7CommPlusClient":
285326
return self
286327

@@ -714,3 +755,86 @@ def _parse_explore_fields(response: bytes, db_number: int, db_name: str) -> list
714755
continue
715756

716757
return fields
758+
759+
760+
# ---------------------------------------------------------------------------
761+
# Subscription helpers (experimental)
762+
# ---------------------------------------------------------------------------
763+
764+
_SUBSCRIPTION_RELATION_ID = 0x7FFFC001
765+
766+
767+
def _build_subscription_request(items: list[tuple[int, int, int]], cycle_ms: int, session_id: int) -> bytes:
768+
"""Build a CREATE_OBJECT request for a data change subscription.
769+
770+
The subscription object is modeled after the S7CommPlusDriver alarm
771+
subscription pattern, adapted for data variable monitoring.
772+
773+
Args:
774+
items: List of (db_number, start_offset, size) to monitor.
775+
cycle_ms: Cycle time in milliseconds (0 = on change).
776+
session_id: Current session ID.
777+
778+
Returns:
779+
CREATE_OBJECT payload.
780+
"""
781+
payload = bytearray()
782+
783+
# Session container
784+
payload += struct.pack(">I", session_id)
785+
payload += bytes([0x00, DataType.UDINT])
786+
payload += encode_uint32_vlq(0)
787+
payload += struct.pack(">I", 0)
788+
789+
# Start subscription object
790+
payload += bytes([ElementID.START_OF_OBJECT])
791+
payload += struct.pack(">I", ObjectId.GET_NEW_RID_ON_SERVER)
792+
payload += encode_uint32_vlq(Ids.CLASS_SUBSCRIPTION)
793+
payload += encode_uint32_vlq(0)
794+
payload += encode_uint32_vlq(0)
795+
796+
# Subscription attributes
797+
payload += bytes([ElementID.ATTRIBUTE])
798+
payload += encode_uint32_vlq(Ids.OBJECT_VARIABLE_TYPE_NAME)
799+
payload += bytes([0x00, DataType.WSTRING])
800+
name = f"PySub_{_SUBSCRIPTION_RELATION_ID:#x}".encode("utf-8")
801+
payload += encode_uint32_vlq(len(name))
802+
payload += name
803+
804+
payload += bytes([ElementID.ATTRIBUTE])
805+
payload += encode_uint32_vlq(Ids.SUBSCRIPTION_FUNCTION_CLASS_ID)
806+
payload += bytes([0x00, DataType.USINT])
807+
payload += bytes([0x02])
808+
809+
payload += bytes([ElementID.ATTRIBUTE])
810+
payload += encode_uint32_vlq(Ids.SUBSCRIPTION_ACTIVE)
811+
payload += bytes([0x00, DataType.BOOL])
812+
payload += bytes([0x01])
813+
814+
payload += bytes([ElementID.ATTRIBUTE])
815+
payload += encode_uint32_vlq(Ids.SUBSCRIPTION_CYCLE_TIME)
816+
payload += bytes([0x00, DataType.UDINT])
817+
payload += encode_uint32_vlq(cycle_ms)
818+
819+
payload += bytes([ElementID.ATTRIBUTE])
820+
payload += encode_uint32_vlq(Ids.SUBSCRIPTION_CREDIT_LIMIT)
821+
payload += bytes([0x00, DataType.INT])
822+
payload += struct.pack(">h", 10) # 10 credits
823+
824+
# Build reference list from items
825+
ref_list = bytearray()
826+
for db_number, start, size in items:
827+
access_area = Ids.DB_ACCESS_AREA_BASE + (db_number & 0xFFFF)
828+
ref_list += struct.pack(">I", access_area)
829+
830+
payload += bytes([ElementID.ATTRIBUTE])
831+
payload += encode_uint32_vlq(Ids.SUBSCRIPTION_REFERENCE_LIST)
832+
payload += bytes([0x10, DataType.UDINT]) # 0x10 = array
833+
payload += encode_uint32_vlq(len(items))
834+
payload += ref_list
835+
836+
# Close subscription object
837+
payload += bytes([ElementID.TERMINATING_OBJECT])
838+
payload += struct.pack(">I", 0)
839+
840+
return bytes(payload)

s7/client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,42 @@ def browse(self) -> list[dict[str, Any]]:
285285
raise RuntimeError("browse() requires S7CommPlus connection")
286286
return self._plus.browse()
287287

288+
def read_diagnostic_buffer(self) -> list[dict[str, Any]]:
289+
"""Read the PLC diagnostic buffer.
290+
291+
.. warning:: This method is **experimental** and may change.
292+
293+
Uses the legacy S7 protocol (SZL read).
294+
"""
295+
if self._legacy is None:
296+
raise RuntimeError("Not connected")
297+
return self._legacy.read_diagnostic_buffer()
298+
299+
def create_subscription(self, items: list[tuple[int, int, int]], cycle_ms: int = 0) -> int:
300+
"""Create a data change subscription (S7CommPlus only).
301+
302+
.. warning:: This method is **experimental** and may change.
303+
304+
Args:
305+
items: List of (db_number, start_offset, size) tuples.
306+
cycle_ms: Cycle time in milliseconds (0 = on change).
307+
308+
Returns:
309+
Subscription ID.
310+
"""
311+
if self._plus is None:
312+
raise RuntimeError("create_subscription() requires S7CommPlus connection")
313+
return self._plus.create_subscription(items, cycle_ms)
314+
315+
def delete_subscription(self, subscription_id: int) -> None:
316+
"""Delete a data change subscription (S7CommPlus only).
317+
318+
.. warning:: This method is **experimental** and may change.
319+
"""
320+
if self._plus is None:
321+
raise RuntimeError("delete_subscription() requires S7CommPlus connection")
322+
self._plus.delete_subscription(subscription_id)
323+
288324
def __getattr__(self, name: str) -> Any:
289325
"""Delegate unknown methods to the legacy client."""
290326
if name.startswith("_"):

snap7/client.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1851,6 +1851,65 @@ def read_szl_list(self) -> bytes:
18511851
# Return raw data
18521852
return bytes(szl.Data[: szl.Header.LengthDR])
18531853

1854+
def read_diagnostic_buffer(self) -> list[dict[str, Any]]:
1855+
"""Read the PLC diagnostic buffer.
1856+
1857+
.. warning:: This method is **experimental** and may change.
1858+
1859+
Returns a list of diagnostic entries, newest first. Each entry
1860+
is a dict with keys ``event_id``, ``timestamp``, and ``description``.
1861+
1862+
Returns:
1863+
List of diagnostic buffer entries.
1864+
"""
1865+
# SZL ID 0x00A0, index 0 = diagnostic buffer
1866+
szl = self.read_szl(0x00A0, 0)
1867+
raw = bytes(szl.Data[: szl.Header.LengthDR])
1868+
1869+
entries: list[dict[str, Any]] = []
1870+
# Each diagnostic entry is 20 bytes
1871+
entry_size = 20
1872+
offset = 0
1873+
while offset + entry_size <= len(raw):
1874+
event_id = struct.unpack(">H", raw[offset : offset + 2])[0]
1875+
1876+
# BCD-encoded timestamp at offset 2..9
1877+
ts_bytes = raw[offset + 2 : offset + 10]
1878+
try:
1879+
ts = self._parse_bcd_timestamp(ts_bytes)
1880+
except Exception:
1881+
ts = None
1882+
1883+
# Additional info at offset 10..19
1884+
info = raw[offset + 10 : offset + entry_size]
1885+
1886+
entries.append(
1887+
{
1888+
"event_id": event_id,
1889+
"timestamp": ts,
1890+
"info": info.hex(),
1891+
}
1892+
)
1893+
offset += entry_size
1894+
1895+
return entries
1896+
1897+
@staticmethod
1898+
def _parse_bcd_timestamp(data: bytes) -> datetime:
1899+
"""Parse a BCD-encoded S7 timestamp (8 bytes) to datetime."""
1900+
1901+
def bcd(b: int) -> int:
1902+
return (b >> 4) * 10 + (b & 0x0F)
1903+
1904+
year = bcd(data[0])
1905+
year += 2000 if year < 90 else 1900
1906+
month = bcd(data[1])
1907+
day = bcd(data[2])
1908+
hour = bcd(data[3])
1909+
minute = bcd(data[4])
1910+
second = bcd(data[5])
1911+
return datetime(year, month, day, hour, minute, second)
1912+
18541913
def iso_exchange_buffer(self, data: bytearray) -> bytearray:
18551914
"""
18561915
Exchange raw ISO PDU.

0 commit comments

Comments
 (0)