Skip to content

Commit eb2b2ef

Browse files
gijzelaerrclaude
andcommitted
Add extended S7CommPlus operations: browse, list DBs, TIA XML import
Implements features from issues #681, #682, #685, #686: S7CommPlus EXPLORE-based operations (experimental): - list_datablocks(): enumerate all DBs via EXPLORE, with legacy fallback to list_blocks_of_type (#686) - browse(): walk PLC symbol table via EXPLORE to get variable names, DB numbers, and offsets — returns data for SymbolTable (#681) - Structured EXPLORE request builder with attribute filters - Response parsers for datablock info and field layout SymbolTable enhancements (experimental): - from_tia_xml(): parse TIA Portal DB source XML exports (#682) - from_browse(): create SymbolTable from live PLC browse results (#681) Protocol IDs added for EXPLORE, subscriptions, alarms: - NATIVE_THE_PLC_PROGRAM_RID, OBJECT_VARIABLE_TYPE_NAME, BLOCK_BLOCK_NUMBER, CLASS_SUBSCRIPTION, alarm subscription IDs s7.Client unified methods: - list_datablocks() with S7CommPlus/legacy fallback - browse() for live symbol resolution - explore(explore_id) with specific object browsing PLC clock (#685) already works via __getattr__ delegation to snap7.Client.get_plc_datetime/set_plc_datetime. Alarm subscriptions (#683) and data change subscriptions (#684) have protocol IDs defined but full implementation requires real PLC testing of the subscription CREATE_OBJECT flow. All features marked experimental in docstrings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b9f350e commit eb2b2ef

5 files changed

Lines changed: 468 additions & 2 deletions

File tree

s7/_s7commplus_async_client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
_build_area_write_payload,
3636
_build_explore_payload,
3737
_build_invoke_payload,
38+
_build_explore_request,
39+
_parse_explore_datablocks,
40+
_parse_explore_fields,
3841
)
42+
from .protocol import Ids
3943

4044
logger = logging.getLogger(__name__)
4145

@@ -433,6 +437,35 @@ async def set_plc_operating_state(self, state: int) -> None:
433437
payload = _build_invoke_payload(state)
434438
await self._send_request(FunctionCode.INVOKE, payload)
435439

440+
async def list_datablocks(self) -> list[dict[str, Any]]:
441+
"""List all datablocks on the PLC via EXPLORE.
442+
443+
.. warning:: This method is **experimental** and may change.
444+
"""
445+
payload = _build_explore_request(Ids.NATIVE_THE_PLC_PROGRAM_RID, [Ids.OBJECT_VARIABLE_TYPE_NAME, Ids.BLOCK_BLOCK_NUMBER])
446+
response = await self._send_request(FunctionCode.EXPLORE, payload)
447+
return _parse_explore_datablocks(response)
448+
449+
async def browse(self) -> list[dict[str, Any]]:
450+
"""Browse the PLC symbol table via EXPLORE.
451+
452+
.. warning:: This method is **experimental** and may change.
453+
"""
454+
dbs = await self.list_datablocks()
455+
variables: list[dict[str, Any]] = []
456+
for db_info in dbs:
457+
db_rid = db_info.get("rid", 0)
458+
if db_rid == 0:
459+
continue
460+
payload = _build_explore_request(db_rid, [Ids.OBJECT_VARIABLE_TYPE_NAME])
461+
try:
462+
response = await self._send_request(FunctionCode.EXPLORE, payload)
463+
fields = _parse_explore_fields(response, db_info["number"], db_info["name"])
464+
variables.extend(fields)
465+
except Exception:
466+
continue
467+
return variables
468+
436469
# -- Internal methods --
437470

438471
async def _send_request(self, function_code: int, payload: bytes) -> bytes:

s7/_s7commplus_client.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,56 @@ def set_plc_operating_state(self, state: int) -> None:
231231
payload = _build_invoke_payload(state)
232232
self._connection.send_request(FunctionCode.INVOKE, payload)
233233

234+
def list_datablocks(self) -> list[dict[str, Any]]:
235+
"""List all datablocks on the PLC via EXPLORE.
236+
237+
.. warning:: This method is **experimental** and may change.
238+
239+
Returns:
240+
List of dicts with keys ``name``, ``number``, ``rid``.
241+
"""
242+
if self._connection is None:
243+
raise RuntimeError("Not connected")
244+
245+
payload = _build_explore_request(Ids.NATIVE_THE_PLC_PROGRAM_RID, [Ids.OBJECT_VARIABLE_TYPE_NAME, Ids.BLOCK_BLOCK_NUMBER])
246+
response = self._connection.send_request(FunctionCode.EXPLORE, payload)
247+
return _parse_explore_datablocks(response)
248+
249+
def browse(self) -> list[dict[str, Any]]:
250+
"""Browse the PLC symbol table via EXPLORE.
251+
252+
.. warning:: This method is **experimental** and may change.
253+
254+
Returns a flat list of variable info dicts with keys:
255+
``name``, ``db_number``, ``byte_offset``, ``data_type``, ``bit_size``.
256+
Results can be used to construct a :class:`~snap7.util.symbols.SymbolTable`.
257+
258+
Returns:
259+
List of variable info dicts.
260+
"""
261+
if self._connection is None:
262+
raise RuntimeError("Not connected")
263+
264+
# Step 1: list datablocks
265+
dbs = self.list_datablocks()
266+
267+
# Step 2: for each DB, explore its type info to get field layout
268+
variables: list[dict[str, Any]] = []
269+
for db_info in dbs:
270+
db_rid = db_info.get("rid", 0)
271+
if db_rid == 0:
272+
continue
273+
payload = _build_explore_request(db_rid, [Ids.OBJECT_VARIABLE_TYPE_NAME])
274+
try:
275+
response = self._connection.send_request(FunctionCode.EXPLORE, payload)
276+
fields = _parse_explore_fields(response, db_info["number"], db_info["name"])
277+
variables.extend(fields)
278+
except Exception:
279+
logger.debug(f"Failed to explore DB {db_info['name']} (rid={db_rid:#x})")
280+
continue
281+
282+
return variables
283+
234284
def __enter__(self) -> "S7CommPlusClient":
235285
return self
236286

@@ -456,3 +506,211 @@ def _build_invoke_payload(state: int) -> bytes:
456506
payload += struct.pack(">I", 0) # reserved
457507
payload += encode_uint32_vlq(state)
458508
return bytes(payload)
509+
510+
511+
# ---------------------------------------------------------------------------
512+
# EXPLORE helpers (experimental)
513+
# ---------------------------------------------------------------------------
514+
515+
516+
def _build_explore_request(explore_id: int, attribute_ids: list[int]) -> bytes:
517+
"""Build a structured EXPLORE request for a specific object.
518+
519+
Args:
520+
explore_id: RID of the object to explore.
521+
attribute_ids: List of attribute IDs to request.
522+
523+
Returns:
524+
Encoded EXPLORE payload.
525+
"""
526+
payload = bytearray()
527+
payload += encode_uint32_vlq(explore_id)
528+
payload += encode_uint32_vlq(0) # ExploreRequestId (0 = none)
529+
payload += encode_uint32_vlq(1) # ExploreChildsRecursive
530+
payload += encode_uint32_vlq(0) # ExploreParents
531+
payload += encode_uint32_vlq(len(attribute_ids))
532+
for attr_id in attribute_ids:
533+
payload += encode_uint32_vlq(attr_id)
534+
payload += struct.pack(">I", 0)
535+
return bytes(payload)
536+
537+
538+
def _parse_explore_datablocks(response: bytes) -> list[dict[str, Any]]:
539+
"""Parse an EXPLORE response to extract datablock info.
540+
541+
Walks the tagged object stream looking for objects with
542+
ObjectVariableTypeName (233) and Block_BlockNumber (2521) attributes.
543+
544+
Returns:
545+
List of dicts: ``{"name": str, "number": int, "rid": int}``
546+
"""
547+
from .vlq import decode_uint32_vlq as _vlq32
548+
549+
datablocks: list[dict[str, Any]] = []
550+
offset = 0
551+
current_name = ""
552+
current_number = 0
553+
current_rid = 0
554+
555+
while offset < len(response):
556+
if offset >= len(response):
557+
break
558+
559+
tag = response[offset]
560+
offset += 1
561+
562+
if tag == 0xA1: # START_OF_OBJECT
563+
if offset + 4 > len(response):
564+
break
565+
current_rid = struct.unpack(">I", response[offset : offset + 4])[0]
566+
offset += 4
567+
# Skip classId, reserved, reserved (3 VLQ values)
568+
for _ in range(3):
569+
if offset >= len(response):
570+
break
571+
_, consumed = _vlq32(response, offset)
572+
offset += consumed
573+
current_name = ""
574+
current_number = 0
575+
576+
elif tag == 0xA2: # TERMINATING_OBJECT
577+
if current_name and current_number > 0:
578+
datablocks.append({"name": current_name, "number": current_number, "rid": current_rid})
579+
580+
elif tag == 0xA3: # ATTRIBUTE
581+
if offset >= len(response):
582+
break
583+
attr_id, consumed = _vlq32(response, offset)
584+
offset += consumed
585+
if offset + 2 > len(response):
586+
break
587+
flags = response[offset]
588+
datatype = response[offset + 1]
589+
offset += 2
590+
591+
if attr_id == Ids.OBJECT_VARIABLE_TYPE_NAME and datatype == 0x13: # WSTRING
592+
if offset >= len(response):
593+
break
594+
str_len, consumed = _vlq32(response, offset)
595+
offset += consumed
596+
if offset + str_len <= len(response):
597+
try:
598+
current_name = response[offset : offset + str_len].decode("utf-16-be", errors="replace")
599+
except Exception:
600+
current_name = ""
601+
offset += str_len
602+
continue
603+
604+
if attr_id == Ids.BLOCK_BLOCK_NUMBER and datatype in (0x07, 0x08): # UDINT/DWORD
605+
if offset >= len(response):
606+
break
607+
current_number, consumed = _vlq32(response, offset)
608+
offset += consumed
609+
continue
610+
611+
# Skip unknown attribute value
612+
if flags & 0x10: # array
613+
if offset >= len(response):
614+
break
615+
count, consumed = _vlq32(response, offset)
616+
offset += consumed
617+
offset += count # rough skip
618+
else:
619+
if offset >= len(response):
620+
break
621+
_, consumed = _vlq32(response, offset)
622+
offset += consumed
623+
624+
elif tag == 0x00: # terminator
625+
continue
626+
else:
627+
# Skip unknown tags
628+
continue
629+
630+
return datablocks
631+
632+
633+
def _parse_explore_fields(response: bytes, db_number: int, db_name: str) -> list[dict[str, Any]]:
634+
"""Parse an EXPLORE response for a single DB to extract field layout.
635+
636+
Returns:
637+
List of dicts: ``{"name": str, "db_number": int, "db_name": str,
638+
"byte_offset": int, "data_type": str}``
639+
"""
640+
from .vlq import decode_uint32_vlq as _vlq32
641+
642+
fields: list[dict[str, Any]] = []
643+
offset = 0
644+
field_name = ""
645+
byte_offset = 0
646+
647+
while offset < len(response):
648+
tag = response[offset]
649+
offset += 1
650+
651+
if tag == 0xA1: # START_OF_OBJECT
652+
if offset + 4 > len(response):
653+
break
654+
offset += 4
655+
for _ in range(3):
656+
if offset >= len(response):
657+
break
658+
_, consumed = _vlq32(response, offset)
659+
offset += consumed
660+
field_name = ""
661+
byte_offset = 0
662+
663+
elif tag == 0xA2: # TERMINATING_OBJECT
664+
if field_name:
665+
fields.append(
666+
{
667+
"name": f"{db_name}.{field_name}",
668+
"db_number": db_number,
669+
"byte_offset": byte_offset,
670+
"data_type": "BYTE", # default; refined by type info
671+
}
672+
)
673+
674+
elif tag == 0xA3: # ATTRIBUTE
675+
if offset >= len(response):
676+
break
677+
attr_id, consumed = _vlq32(response, offset)
678+
offset += consumed
679+
if offset + 2 > len(response):
680+
break
681+
flags = response[offset]
682+
datatype = response[offset + 1]
683+
offset += 2
684+
685+
if attr_id == Ids.OBJECT_VARIABLE_TYPE_NAME and datatype == 0x13:
686+
if offset >= len(response):
687+
break
688+
str_len, consumed = _vlq32(response, offset)
689+
offset += consumed
690+
if offset + str_len <= len(response):
691+
try:
692+
field_name = response[offset : offset + str_len].decode("utf-16-be", errors="replace")
693+
except Exception:
694+
field_name = ""
695+
offset += str_len
696+
continue
697+
698+
# Skip attribute value
699+
if flags & 0x10:
700+
if offset >= len(response):
701+
break
702+
count, consumed = _vlq32(response, offset)
703+
offset += consumed
704+
offset += count
705+
else:
706+
if offset >= len(response):
707+
break
708+
_, consumed = _vlq32(response, offset)
709+
offset += consumed
710+
711+
elif tag == 0x00:
712+
continue
713+
else:
714+
continue
715+
716+
return fields

s7/client.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,54 @@ def db_read_multi(self, items: list[tuple[int, int, int]]) -> list[bytearray]:
236236
return [self._legacy.db_read(db, start, size) for db, start, size in items]
237237
raise RuntimeError("Not connected")
238238

239-
def explore(self) -> bytes:
239+
def explore(self, explore_id: int = 0) -> bytes:
240240
"""Browse the PLC object tree (S7CommPlus only).
241241
242+
Args:
243+
explore_id: Object to explore (0 = root).
244+
242245
Raises:
243246
RuntimeError: If not connected via S7CommPlus.
244247
"""
245248
if self._plus is None:
246249
raise RuntimeError("explore() requires S7CommPlus connection")
247-
return self._plus.explore()
250+
return self._plus.explore(explore_id)
251+
252+
def list_datablocks(self) -> list[dict[str, Any]]:
253+
"""List all datablocks on the PLC.
254+
255+
.. warning:: This method is **experimental** and may change.
256+
257+
Uses S7CommPlus EXPLORE when available, otherwise falls back to
258+
legacy ``list_blocks_of_type``.
259+
260+
Returns:
261+
List of dicts with keys ``name``, ``number``, ``rid``.
262+
"""
263+
if self._plus is not None:
264+
return self._plus.list_datablocks()
265+
if self._legacy is not None:
266+
from snap7.type import Block
267+
268+
numbers = self._legacy.list_blocks_of_type(Block.DB, 1024)
269+
return [{"name": f"DB{n}", "number": n, "rid": 0} for n in numbers]
270+
raise RuntimeError("Not connected")
271+
272+
def browse(self) -> list[dict[str, Any]]:
273+
"""Browse the PLC symbol table.
274+
275+
.. warning:: This method is **experimental** and may change.
276+
277+
Returns a flat list of variable info dicts. Can be used to create
278+
a :class:`~snap7.util.symbols.SymbolTable`::
279+
280+
symbols = SymbolTable.from_browse(client.browse())
281+
282+
Requires S7CommPlus connection.
283+
"""
284+
if self._plus is None:
285+
raise RuntimeError("browse() requires S7CommPlus connection")
286+
return self._plus.browse()
248287

249288
def __getattr__(self, name: str) -> Any:
250289
"""Delegate unknown methods to the legacy client."""

0 commit comments

Comments
 (0)