This commit is contained in:
Jonas Bengtsson
2026-10-07 17:57:30 +02:00
parent 2aa040e757
commit 76b1cc5ccf
9 changed files with 538 additions and 478 deletions
+12 -1
View File
@@ -66,6 +66,13 @@ class StaleConfig:
max_age_s: float = 120.0 # D12 / D13
@dataclass(frozen=True)
class BrowseConfig:
# Address-space walk guard: deeper SCADA trees are fine, but a walk this
# deep is almost certainly a server-side cycle (list-items --depth 0).
max_depth: int = 32
@dataclass(frozen=True)
class JsonlConfig:
dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs"
@@ -96,10 +103,11 @@ class Config:
latency: LatencyConfig = field(default_factory=LatencyConfig)
quality: QualityConfig = field(default_factory=QualityConfig)
stale: StaleConfig = field(default_factory=StaleConfig)
browse: BrowseConfig = field(default_factory=BrowseConfig)
report: ReportConfig = field(default_factory=ReportConfig)
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "latency", "quality", "stale", "report"}
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "latency", "quality", "stale", "browse", "report"}
_TAG_KEYS = {"name", "expect_change"}
@@ -207,6 +215,8 @@ def _validate(config: Config) -> None:
raise ConfigError(f"quality.flapping_window_s: must be > 0, got {config.quality.flapping_window_s}")
if config.stale.enabled and config.stale.max_age_s <= 0:
raise ConfigError(f"stale.max_age_s: must be > 0, got {config.stale.max_age_s}")
if config.browse.max_depth < 1:
raise ConfigError(f"browse.max_depth: must be >= 1, got {config.browse.max_depth}")
if config.report.heartbeat_s <= 0:
raise ConfigError(f"report.heartbeat_s: must be > 0, got {config.report.heartbeat_s}")
if config.report.eventlog.min_severity not in ("INFO", "WARN", "CRITICAL"):
@@ -246,6 +256,7 @@ def load_config(path: str | Path) -> Config:
latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
quality=_build_section(QualityConfig, data.get("quality"), "quality"),
stale=_build_section(StaleConfig, data.get("stale"), "stale"),
browse=_build_section(BrowseConfig, data.get("browse"), "browse"),
report=_build_report(data.get("report")),
)
try:
+139 -147
View File
@@ -2,9 +2,9 @@
Verbs: ``run``, ``probe``, ``check-config``, ``list-items`` plus the service
verbs ``install`` / ``uninstall`` / ``start`` / ``stop``. Currently functional:
``check-config``, ``list-servers`` (OPC DA server discovery) and the browse
verbs ``list-devices`` / ``list-tags`` (devices = branches, tags = leaves of
the connected server's address space); the remaining verbs land with Phases 1–3.
``check-config``, ``list-servers`` (discovery), ``list-items`` (address-space
browsing: ``all`` / ``branches`` / ``tags``) and ``read`` / ``probe`` (one-shot
tag reads); the remaining verbs land with Phases 1–3.
"""
from __future__ import annotations
@@ -36,7 +36,6 @@ def _default_config_path() -> str:
_NOT_IMPLEMENTED: dict[str, str] = {
"run": "Phase 1 (core engine)",
"list-items": "superseded by list-devices / list-tags",
"install": "Phase 3 (Windows service)",
"uninstall": "Phase 3 (Windows service)",
"start": "Phase 3 (Windows service)",
@@ -127,9 +126,6 @@ def _build_parser() -> argparse.ArgumentParser:
p = sub.add_parser("check-config", help="validate the configuration and exit")
_add_config_option(p)
p = sub.add_parser("list-items", help="browse the server item space (validation tool)")
_add_config_option(p)
p = sub.add_parser(
"list-servers",
help="enumerate the OPC DA servers available locally or on a DCOM host",
@@ -154,52 +150,42 @@ def _build_parser() -> argparse.ArgumentParser:
)
p = sub.add_parser(
"list-devices",
help="list the devices (address-space branches) exposed by the configured server",
"list-items",
help="browse the server's item space: the full structure tree, or a "
"flat view of its branches / tags",
)
_add_config_option(p)
_add_server_overrides(p)
p.add_argument(
"kind",
nargs="?",
choices=("all", "branches", "tags"),
default="all",
help="all = the full structure tree (branches AND tags in place); "
"branches = the structure nodes only (SCADA channels/devices/folders "
"are all branches to OPC DA); tags = the flat ItemID list that goes "
"into config `tags:` (default: %(default)s)",
)
p.add_argument(
"--branch",
action="append",
metavar="PATH",
help="limit the listing to this branch subtree: one dotted string "
"('--branch \"Simulation Items.Bucket Brigade\"') or repeat the flag "
"for explicit segment names containing dots (default: the server root)",
)
p.add_argument(
"--depth",
type=int,
default=1,
metavar="N",
help="how many branch levels to descend (default: %(default)s)",
)
p.add_argument(
"--no-counts",
action="store_true",
help="skip fetching per-device tag counts (faster on huge servers)",
help="how many branch levels to descend; 0 = the whole subtree "
"(default: %(default)s)",
)
p.add_argument(
"--json",
action="store_true",
help="emit one JSON object per device (for scripts) instead of a table",
)
p = sub.add_parser(
"list-tags",
help="list the tags (leaves) of one device, or every tag on the server",
)
_add_config_option(p)
_add_server_overrides(p)
p.add_argument(
"device",
nargs="*",
metavar="BRANCH",
help="device path: one or more branch names — either a single dotted string "
"in quotes ('\"Simulation Items.Bucket Brigade\"') or separate quoted "
"segments ('\"Simulation Items\" \"Bucket Brigade\"'). Default: the server root",
)
p.add_argument(
"--recursive", "-r",
action="store_true",
help="walk the whole subtree, not just the device's direct tags",
)
p.add_argument(
"--json",
action="store_true",
help="emit one JSON object per tag (for scripts) instead of a table",
help="emit JSON objects (for scripts) instead of a tree/list",
)
p = sub.add_parser("install", help="install as a Windows service (requires admin)")
@@ -316,46 +302,65 @@ def _connect_from_config(config_path: str, server: str | None = None, host: str
return client
def _print_devices(space, as_json: bool) -> None:
"""Human table / JSON-lines output for the list-devices verb."""
if as_json:
import json
for device in space.devices:
payload = {
"name": device.name,
"path": list(device.path),
"qualified_name": device.qualified_name,
"sub_devices": list(device.sub_devices),
"tags": list(device.tag_names),
}
print(json.dumps(payload, ensure_ascii=False))
return
if not space.devices:
print(f"no devices (branches) found — organization: {space.organization}")
return
print(f"address space organization: {space.organization}")
print("devices:")
width = max(len(d.qualified_name) for d in space.devices)
for device in space.devices:
notes = [f"{len(device.tag_names)} tags"]
if device.sub_devices:
notes.append(f"{len(device.sub_devices)} sub-devices")
print(f" {device.qualified_name:<{width}} ({', '.join(notes)})")
def _print_item_tree(nodes, indent: int = 0) -> None:
"""Indented structure view: branches with a trailing /, tags with their ItemID."""
for node in nodes:
prefix = " " * indent
if node.kind == "branch":
print(f"{prefix}{node.name}/")
_print_item_tree(node.children, indent + 1)
else:
print(f"{prefix}{node.name} [ItemID: {node.item_id}]")
def _cmd_list_devices(
def _node_payload(node) -> dict:
"""JSON payload for one SpaceNode (branches carry their children)."""
return {
"kind": node.kind,
"name": node.name,
"path": list(node.path),
"item_id": node.item_id,
"children": [_node_payload(c) for c in node.children],
}
def _cmd_list_items(
config_path: str, server: str | None, host: str | None,
depth: int, include_counts: bool, as_json: bool,
kind: str, branch: Sequence[str] | None, depth: int, as_json: bool,
) -> int:
from opcda_diagnozer.opc.browse import list_devices
from opcda_diagnozer.opc.browse import (
DEFAULT_MAX_DEPTH, flatten_branches, flatten_leaves,
split_branch_path, walk_tree,
)
import json
branch_list = list(branch) if branch else None
# One --branch occurrence is a *dotted* branch path ("Simulation Items.Bucket
# Brigade"); several occurrences are explicit segment names (those may
# contain dots). Same convention the fake and browse helpers share.
branch_arg = (
branch_list[0]
if branch_list and len(branch_list) == 1
else branch_list
)
# --depth 0 means the whole subtree; the hard guard is browse.max_depth
# from the config (default 32) — a missing config is fine when --server
# is given, so fall back to the default guard.
max_depth = DEFAULT_MAX_DEPTH
if Path(config_path).is_file():
try:
max_depth = load_config(config_path).browse.max_depth
except ConfigError:
pass # an invalid config surfaces in _connect_from_config below
try:
client = _connect_from_config(config_path, server, host)
browser = None
try:
browser = client.create_browser()
space = list_devices(browser, depth=depth, include_tag_counts=include_counts)
nodes = walk_tree(
browser, branch_arg, depth=depth, max_depth=max_depth
)
finally:
# Release the browser's COM reference *before* close() tears down the
# apartment — releasing an RCW after CoUninitialize crashes the process.
@@ -363,85 +368,76 @@ def _cmd_list_devices(
browser.close()
client.close()
except ConfigError as exc:
print(f"list-devices: FAILED\n {exc}", file=sys.stderr)
print(f"list-items: FAILED\n {exc}", file=sys.stderr)
return 2
except (OpcError, ValueError) as exc:
print(f"list-devices: FAILED\n {exc}", file=sys.stderr)
print(f"list-items: FAILED\n {exc}", file=sys.stderr)
return 2
_print_devices(space, as_json)
if not as_json and not space.devices:
hint = (
"hint: the server address space is flat — every tag hangs directly off "
"the root; use: opcda-diagnozer list-tags"
if space.organization == "flat"
else "hint: no branches at the root — the server may expose only tags; "
"try: opcda-diagnozer list-tags"
)
print(hint, file=sys.stderr)
return 0
path = split_branch_path(branch_arg)
where = ".".join(path) or "(server root)"
def _print_tags(tags, device_path: tuple[str, ...], recursive: bool, as_json: bool) -> None:
"""Human table / JSON-lines output for the list-tags verb."""
where = ".".join(device_path) or "(server root)"
scope = " (recursive)" if recursive else ""
if as_json:
import json
if kind == "all":
if not nodes:
print(f"nothing defined under {where}")
return 0
if as_json:
for node in nodes:
print(json.dumps(_node_payload(node), ensure_ascii=False))
else:
scope = "whole subtree" if depth == 0 else f"depth {depth}"
print(f"items under {where} ({scope}):")
_print_item_tree(nodes)
return 0
if kind == "tags":
tags = flatten_leaves(nodes)
if as_json:
for tag in tags:
print(json.dumps(
{"name": tag.name, "item_id": tag.item_id, "path": list(tag.path)},
ensure_ascii=False,
))
return 0
if not tags:
print(
f"no tags found under {where} — increase --depth (0 = whole subtree) "
"or pick another --branch"
)
return 0
print(f"tags under {where}: {len(tags)}")
width = max(len(t.item_id) for t in tags)
for tag in tags:
payload = {
"name": tag.name,
"item_id": tag.item_id,
"path": list(tag.path),
}
print(json.dumps(payload, ensure_ascii=False))
return
if not tags:
print(f"no tags found under {where}{scope}")
return
print(f"tags under {where}{scope}: {len(tags)}")
width = max(len(t.item_id) for t in tags)
for tag in tags:
print(f" {tag.item_id:<{width}} (leaf: {tag.name})")
print(f" {tag.item_id:<{width}} (leaf: {tag.name})")
return 0
def _cmd_list_tags(
config_path: str,
server: str | None,
host: str | None,
device: Sequence[str],
recursive: bool,
as_json: bool,
) -> int:
from opcda_diagnozer.opc.browse import list_tags, split_device_path
# A single CLI argument is a *dotted* branch path; several arguments are
# explicit branch names (names may contain dots then).
device_arg = device[0] if len(device) == 1 else list(device)
try:
client = _connect_from_config(config_path, server, host)
browser = None
try:
browser = client.create_browser()
tags = list_tags(browser, device=device_arg or None, recursive=recursive)
finally:
if browser is not None:
browser.close()
client.close()
except ConfigError as exc:
print(f"list-tags: FAILED\n {exc}", file=sys.stderr)
return 2
except (OpcError, ValueError) as exc:
print(f"list-tags: FAILED\n {exc}", file=sys.stderr)
return 2
device_path = split_device_path(device_arg or None)
_print_tags(tags, device_path, recursive, as_json)
if not as_json and not tags and not recursive:
# kind == "branches"
branches = flatten_branches(nodes)
if as_json:
for node in branches:
print(json.dumps({
"name": node.name,
"path": list(node.path),
"tags": sum(1 for c in node.children if c.kind == "leaf"),
"sub_branches": sum(1 for c in node.children if c.kind == "branch"),
}, ensure_ascii=False))
return 0
if not branches:
print(
"hint: the branch exists but exposes no direct leaves — try --recursive "
"to include the whole subtree",
file=sys.stderr,
f"no branches found under {where} — the space may be flat; "
"try kind 'all' or 'tags'"
)
return 0
print(f"branches under {where}: {len(branches)}")
width = max(len(".".join(b.path)) for b in branches)
for node in branches:
dotted = ".".join(node.path)
tags = sum(1 for c in node.children if c.kind == "leaf")
subs = sum(1 for c in node.children if c.kind == "branch")
notes = [f"{tags} tags"]
if subs:
notes.append(f"{subs} sub-branches")
print(f" {dotted:<{width}} ({', '.join(notes)})")
return 0
@@ -557,13 +553,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return _cmd_check_config(args.config)
if args.verb == "list-servers":
return _cmd_list_servers(args.host, args.source, args.json)
if args.verb == "list-devices":
return _cmd_list_devices(
args.config, args.server, args.host, args.depth, not args.no_counts, args.json
)
if args.verb == "list-tags":
return _cmd_list_tags(
args.config, args.server, args.host, args.device, args.recursive, args.json
if args.verb == "list-items":
return _cmd_list_items(
args.config, args.server, args.host, args.kind, args.branch, args.depth, args.json
)
if args.verb == "read":
return _cmd_read(
+4 -3
View File
@@ -106,9 +106,10 @@ class _AutomationBrowser:
if not name:
continue
item_id = str(self._browser.GetItemID(name)).strip()
if not item_id:
# Some servers return an empty ItemID for filtered leaves;
# fall back to the browse name so config tags stay usable.
if not item_id or item_id == f".{name}":
# Some servers return an empty ItemID for filtered/special
# leaves (observed: Matrikon's alias-root items); fall back
# to the browse name so config tags stay usable.
item_id = name
leaves.append(TagInfo(name=name, item_id=item_id, path=tuple(path)))
return leaves
+21 -18
View File
@@ -5,9 +5,12 @@ are normalized to :class:`OpcError` carrying the raw HRESULT and a decoded name.
There is deliberately **no write path** — the tool is read-only by construction
(SPECIFICATION §10.5).
Browsing (devices = branches, tags = leaves) is modelled by :class:`Browser`;
the ``list-devices`` / ``list-tags`` CLI verbs walk the address space through
it without any recursion into the monitored poll path.
Browsing is modelled by :class:`Browser`. The OPC DA browse world defines
exactly two element kinds: **branches** (what SCADA vendors call channels,
devices, groups or folders — indistinguishable at the protocol level) and
**leaves** (the tags). :class:`SpaceNode` is the tree node the ``list-items``
CLI verb renders; leaves may hang directly at the server root, belonging to
no branch at all (server-internal items like ``@ClientCount``).
"""
from __future__ import annotations
@@ -16,7 +19,7 @@ from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from enum import IntEnum
from typing import Any, Protocol, runtime_checkable
from typing import Any, Literal, Protocol, runtime_checkable
@dataclass(frozen=True)
@@ -105,25 +108,25 @@ ACCESS_WRITE = 0x02
@dataclass(frozen=True)
class DeviceInfo:
"""One browsable device: a branch in the server's hierarchical address space.
class SpaceNode:
"""One node of the browsed address space: a branch or a leaf (tag).
The Automation Wrapper exposes no per-branch metadata beyond the name, so a
"device" here is simply the branch path plus the counts the CLI prints.
OPC DA's browse model has exactly these two element kinds — there is no
separate "device" or "folder" type: SCADA channels, devices, groups and
folders are all plain branches, indistinguishable at the protocol level.
A branch may hold leaves, sub-branches, or both; leaves may also hang
directly at the root, belonging to no branch at all.
"""
kind: Literal["branch", "leaf"]
name: str
#: Branch path from the server root, e.g. ``("Simulation Items", "Bucket Brigade")``.
#: For a branch: its own path from the root, e.g. ``("Simulation Items",)``.
#: For a leaf: the branch it hangs under — ``()`` means directly at the root.
path: tuple[str, ...]
#: Sub-branches directly under this device (usually channels/groups).
sub_devices: tuple[str, ...] = ()
#: Leaf (tag) names directly under this device.
tag_names: tuple[str, ...] = ()
@property
def qualified_name(self) -> str:
"""Human-friendly dotted rendering: ``Simulation Items.Bucket Brigade``."""
return ".".join(self.path) if self.path else self.name
#: Fully qualified ItemID — leaves only; what goes into config ``tags:``.
item_id: str | None = None
#: Child nodes — branches only; empty when the depth limit stopped here.
children: tuple["SpaceNode", ...] = ()
@dataclass(frozen=True)
+108 -152
View File
@@ -1,51 +1,45 @@
"""Browse the connected server's address space: devices (branches) and tags (leaves).
"""Browse the connected server's address space (IMPLEMENTATION_PLAN §6.2).
OPC DA has no formal "device" concept — servers publish a *hierarchical
address space* whose top-level branches almost always represent devices (or
device families), with tags as the leaves. This module maps that tree onto the
two questions ops asks (SPECIFICATION §8, validation tooling):
OPC DA's browse model defines exactly two element kinds:
- *Which devices does this server expose?* → :func:`list_devices`
- *Which tags does device X expose?* → :func:`list_tags`
- **branches** — the structure nodes. SCADA vendors map channels, devices,
groups and folders onto branches; the protocol cannot tell them apart, so
this tool reports them uniformly as branches.
- **leaves** — the tags (items), each with a fully qualified ItemID that goes
straight into config ``tags:``.
The Automation Wrapper browser (``opc.CreateBrowser()``) is the only portable
traversal API — it works over DCOM for remote servers too, with the same
Launch+Activation requirements as ``list-servers``. Traversal is one level at
a time; the ``max_depth`` guard keeps a pathological (cyclic/AB-tree) server
from stalling the CLI.
Leaves may also hang directly at the server root, belonging to no branch —
server-internal items (``@ClientCount`` on the Matrikon sim) look "wrong" in
a device-oriented view but are ordinary root-level leaves.
Flat-namespace servers report ``organization == "flat"``; there every leaf is
addressed by its bare ItemID, the root is treated as one implicit device.
The ``list-items`` CLI verb renders whatever the caller asks for
(``all`` / ``branches`` / ``tags``) through :func:`walk_tree`, bounded by
``--depth`` (1 = one level, :data:`WHOLE_SUBTREE` = everything) and by the
configured ``browse.max_depth`` (default 32) against server-side cycles.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from opcda_diagnozer.opc.base import Browser, DeviceInfo, OpcError, TagInfo
from opcda_diagnozer.opc.base import Browser, OpcError, SpaceNode, TagInfo
#: Walk guard: deep SCADA hierarchies exist (Channel → Device → Tag), but OPC
#: trees deeper than this are almost certainly a server-side loop.
MAX_DEPTH = 32
#: Default walk guard: deep SCADA hierarchies exist (Channel → Device → Tag),
#: but OPC trees deeper than this are almost certainly a server-side loop.
#: Overridable via the ``browse.max_depth`` config key.
DEFAULT_MAX_DEPTH = 32
#: Depth value meaning "no limit" (the whole subtree, max_depth-guarded).
WHOLE_SUBTREE = 0
@dataclass(frozen=True)
class Space:
"""Result of a device-space walk: one entry per discovered branch node."""
organization: str
root: DeviceInfo
devices: tuple[DeviceInfo, ...]
def split_device_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
"""Normalize a user-supplied device path into branch segments.
def split_branch_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
"""Normalize a user-supplied branch path into segments.
A **single** string is treated as dotted: ``"Simulation Items.Bucket Brigade"``
→ ``("Simulation Items", "Bucket Brigade")``. **Multiple** segments are
taken as explicit branch names — no dot splitting, so names that contain
a dot stay intact (they just have to be passed as separate arguments).
a dot stay intact (pass them as separate ``--branch`` arguments).
Empty segments and whitespace are dropped.
"""
if path is None:
@@ -61,134 +55,96 @@ def split_device_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
return tuple(parts)
def list_devices(
browser: Browser,
*,
depth: int = 1,
include_tag_counts: bool = True,
) -> Space:
"""Enumerate devices (branches) starting at the server root.
``depth=1`` lists top-level branches (the typical "device list"); greater
depths reveal sub-devices (channels/groups). Each :class:`DeviceInfo`
carries the direct sub-branches and — when ``include_tag_counts`` is on —
the leaf names under it, so the CLI can print ``Device (3 tags, 2 sub)``.
Raises :class:`OpcError` when the server rejects the walk (COM failure or
unsupported browse interface). The walk is bounded by ``depth`` (≤
:data:`MAX_DEPTH`), so a pathological server can never stall the CLI.
"""
if depth < 1:
raise ValueError("depth must be >= 1")
if depth > MAX_DEPTH:
raise ValueError(f"depth must be <= {MAX_DEPTH}")
organization = browser.organization
root_branches = browser.list_branches(())
root_leaves = browser.list_leaves(()) if include_tag_counts else []
root = DeviceInfo(
name="(root)",
path=(),
sub_devices=tuple(root_branches),
tag_names=tuple(t.name for t in root_leaves),
)
devices: list[DeviceInfo] = []
_walk_level(browser, (), root_branches, depth, devices, include_tag_counts)
return Space(organization=organization, root=root, devices=tuple(devices))
def _walk_level(
browser: Browser,
path: tuple[str, ...],
branches: Sequence[str],
depth: int,
devices: list[DeviceInfo],
include_tag_counts: bool,
) -> None:
if depth <= 0:
return
for branch in branches:
sub_path = path + (branch,)
sub_branches = tuple(browser.list_branches(sub_path))
tag_names = (
tuple(t.name for t in browser.list_leaves(sub_path))
if include_tag_counts
else ()
)
devices.append(
DeviceInfo(
name=branch,
path=sub_path,
sub_devices=sub_branches,
tag_names=tag_names,
)
)
_walk_level(browser, sub_path, sub_branches, depth - 1, devices, include_tag_counts)
def list_tags(
browser: Browser,
device: str | Sequence[str] | None = None,
*,
recursive: bool = False,
) -> list[TagInfo]:
"""List the tags of one device (branch), or of the whole server.
``device`` accepts either a dotted string (``"Simulation Items.Bucket
Brigade"``) or a pre-split sequence of branch names. ``None`` means the
server root; with ``recursive=True`` every leaf under it is returned
(bounded by :data:`MAX_DEPTH`). The returned :class:`TagInfo.item_id`
values are exactly what goes into the config ``tags:`` list. An unknown
device path raises :class:`OpcError` pointing at ``list-devices``.
"""
path = _require_device(browser, device, what="list-tags")
leaves = browser.list_leaves(path)
tags: list[TagInfo] = list(leaves)
if not recursive:
return sorted(tags, key=lambda t: t.item_id.lower())
sub_branches = browser.list_branches(path)
_collect_recursive(browser, path, sub_branches, tags)
return sorted(tags, key=lambda t: t.item_id.lower())
def _collect_recursive(
browser: Browser,
path: tuple[str, ...],
branches: Sequence[str],
tags: list[TagInfo],
) -> None:
for branch in branches:
sub_path = path + (branch,)
if len(sub_path) > MAX_DEPTH:
raise OpcError(
f"address space deeper than {MAX_DEPTH} levels at "
f"{'.'.join(sub_path)} — refusing to walk further (possible "
"server-side cycle)"
)
tags.extend(browser.list_leaves(sub_path))
_collect_recursive(browser, sub_path, browser.list_branches(sub_path), tags)
def _require_device(
browser: Browser,
device: str | Sequence[str] | None,
*,
what: str,
def require_branch(
browser: Browser, branch: Sequence[str] | str | None, *, what: str
) -> tuple[str, ...]:
"""Resolve and validate a user-supplied device path (exists, is a branch)."""
path = split_device_path(device)
"""Resolve and validate a user-supplied branch path (must exist)."""
path = split_branch_path(branch)
if not path:
return ()
siblings = browser.list_branches(path[:-1])
if path[-1] not in siblings:
raise OpcError(
f"{what}: device {path[-1]!r} not found under "
f"{'.'.join(path[:-1]) or '(root)'} — run list-devices to see the "
"available branches"
f"{what}: branch {path[-1]!r} not found under "
f"{'.'.join(path[:-1]) or '(root)'} — list the levels above it "
"with list-items to see the available branch names"
)
return path
def tags_for_device(browser: Browser, device: str | Sequence[str]) -> list[TagInfo]:
"""List tags of a **known** device path — validation helper (probe)."""
path = _require_device(browser, device, what="list-tags")
return sorted(browser.list_leaves(path), key=lambda t: t.item_id.lower())
def walk_tree(
browser: Browser,
branch: Sequence[str] | str | None = None,
*,
depth: int = 1,
max_depth: int = DEFAULT_MAX_DEPTH,
) -> list[SpaceNode]:
"""Return the nodes the server defines under ``branch``, keeping structure.
``depth`` is the number of branch levels to descend: 1 (the default)
returns the direct leaves and branches under ``branch`` (branches come
back with empty ``children``); :data:`WHOLE_SUBTREE` walks the entire
subtree. ``max_depth`` is the hard guard against server-side cycles —
it defaults to :data:`DEFAULT_MAX_DEPTH` and comes from the
``browse.max_depth`` config key.
"""
if depth < 0:
raise ValueError(f"depth must be >= 0 (0 = whole subtree), got {depth}")
if max_depth < 1:
raise ValueError(f"max_depth must be >= 1, got {max_depth}")
if depth > max_depth:
raise ValueError(f"depth must be <= max_depth ({max_depth}), got {depth}")
root = require_branch(browser, branch, what="list-items")
return _walk_level(browser, root, depth, max_depth)
def _walk_level(
browser: Browser, path: tuple[str, ...], depth: int, max_depth: int
) -> list[SpaceNode]:
nodes: list[SpaceNode] = [
SpaceNode(kind="leaf", name=leaf.name, path=path, item_id=leaf.item_id)
for leaf in browser.list_leaves(path)
]
for branch in browser.list_branches(path):
sub = path + (branch,)
if len(sub) > max_depth:
raise OpcError(
f"address space deeper than {max_depth} levels at {'.'.join(sub)} "
"— refusing to walk further (possible server-side cycle)"
)
if depth == 1:
children: tuple[SpaceNode, ...] = ()
else:
next_depth = WHOLE_SUBTREE if depth == WHOLE_SUBTREE else depth - 1
children = tuple(_walk_level(browser, sub, next_depth, max_depth))
nodes.append(SpaceNode(kind="branch", name=branch, path=sub, children=children))
return nodes
def flatten_leaves(nodes: Sequence[SpaceNode]) -> list[TagInfo]:
"""Every leaf in the walked tree as a flat, case-insensitively sorted list."""
tags: list[TagInfo] = []
_collect_leaves(nodes, tags)
return sorted(tags, key=lambda t: t.item_id.lower())
def _collect_leaves(nodes: Sequence[SpaceNode], tags: list[TagInfo]) -> None:
for node in nodes:
if node.kind == "leaf" and node.item_id is not None:
tags.append(TagInfo(name=node.name, item_id=node.item_id, path=node.path))
else:
_collect_leaves(node.children, tags)
def flatten_branches(nodes: Sequence[SpaceNode]) -> list[SpaceNode]:
"""Every branch in the walked tree, depth-first, as a flat list."""
branches: list[SpaceNode] = []
_collect_branches(nodes, branches)
return branches
def _collect_branches(nodes: Sequence[SpaceNode], out: list[SpaceNode]) -> None:
for node in nodes:
if node.kind == "branch":
out.append(node)
_collect_branches(node.children, out)