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
+13 -13
View File
@@ -105,14 +105,14 @@
} }
}, },
{ {
// List the devices (address-space branches) of the configured // Full structure tree of the configured server: every branch
// server — one level, with per-device tag counts. // AND every tag in place, whole subtree.
"name": "opcda-diagnozer: list-devices", "name": "opcda-diagnozer: list-items (whole tree)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"python": "${workspaceFolder}/.venv/Scripts/python.exe", "python": "${workspaceFolder}/.venv/Scripts/python.exe",
"module": "opcda_diagnozer", "module": "opcda_diagnozer",
"args": ["list-devices", "--config", "config.example.yaml"], "args": ["list-items", "all", "--depth", "0", "--config", "config.example.yaml"],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false, "justMyCode": false,
@@ -121,20 +121,20 @@
} }
}, },
{ {
// List the tags of one device (whole subtree) as JSON — the // Flat ItemID list of one subtree as JSON — the item_id values
// item_id values are what go into config `tags:`. Adjust the // are what go into config `tags:`. Adjust the branch path to the
// device path to the server your config points at. // server your config points at.
"name": "opcda-diagnozer: list-tags (recursive, JSON)", "name": "opcda-diagnozer: list-items tags (JSON)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"python": "${workspaceFolder}/.venv/Scripts/python.exe", "python": "${workspaceFolder}/.venv/Scripts/python.exe",
"module": "opcda_diagnozer", "module": "opcda_diagnozer",
"args": [ "args": [
"list-tags", "list-items",
"--config", "config.example.yaml", "tags",
"Simulation Items.Bucket Brigade", "--branch", "Simulation Items.Bucket Brigade",
"--recursive", "--json",
"--json" "--config", "config.example.yaml"
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"console": "integratedTerminal", "console": "integratedTerminal",
+27 -20
View File
@@ -33,8 +33,8 @@ See the full interpretation matrix in the [specification](docs/SPECIFICATION.md)
- Windows service (auto-start, SCM crash recovery) with a foreground `run` mode for testing - Windows service (auto-start, SCM crash recovery) with a foreground `run` mode for testing
- Local or remote (DCOM) connections to any OPC DA server - Local or remote (DCOM) connections to any OPC DA server
- Server browsing: `list-servers` (discovery), `list-devices` and `list-tags` - Server browsing: `list-servers` (discovery) and `list-items`
(address-space walk) — live now (address-space browse: full tree / branches / tags) — live now
- Latency monitoring: spikes, streaks, and p95 trends against configurable thresholds - Latency monitoring: spikes, streaks, and p95 trends against configurable thresholds
- Hang watchdog: flags RPC calls that never return — the silent killer - Hang watchdog: flags RPC calls that never return — the silent killer
- Quality monitoring: BAD/UNCERTAIN transitions with decoded subcodes, persistent-bad - Quality monitoring: BAD/UNCERTAIN transitions with decoded subcodes, persistent-bad
@@ -51,16 +51,15 @@ opcda-diagnozer list-servers
opcda-diagnozer list-servers --host CONTROL-HOST01 # remote, via DCOM opcda-diagnozer list-servers --host CONTROL-HOST01 # remote, via DCOM
opcda-diagnozer list-servers --source registry --json # no-COM local scan opcda-diagnozer list-servers --source registry --json # no-COM local scan
# list the devices (address-space branches) of the configured server # browse the server's item space (list-items all|branches|tags)
opcda-diagnozer list-devices # all = the full structure tree: every branch AND every tag in place
opcda-diagnozer list-devices --depth 2 # include sub-devices # branches = the structure nodes (channels/devices/folders — all "branches" to OPC DA)
opcda-diagnozer list-devices --json # script-friendly output # tags = the flat ItemID list that goes into config `tags:`
opcda-diagnozer list-items # top level of the tree
# list the tags of one device (the item_id values go into config `tags:`) opcda-diagnozer list-items --depth 0 # the whole tree (browse.max_depth guards)
opcda-diagnozer list-tags "Simulation Items.Bucket Brigade" opcda-diagnozer list-items branches --depth 2 # structure, two levels
opcda-diagnozer list-tags "Simulation Items" "Bucket Brigade" # equivalent opcda-diagnozer list-items tags --branch "Simulation Items" # one subtree, flat ItemIDs
opcda-diagnozer list-tags --recursive "Simulation Items" # whole subtree opcda-diagnozer list-items --json # script-friendly output
opcda-diagnozer list-tags # every tag on the server
# read tags right now (validation tool) — no config needed with --server # read tags right now (validation tool) — no config needed with --server
opcda-diagnozer read --server Matrikon.OPC.Simulation.1 "Bucket Brigade.Int1" opcda-diagnozer read --server Matrikon.OPC.Simulation.1 "Bucket Brigade.Int1"
@@ -88,15 +87,20 @@ hosts or when the wrapper DLL is not registered. `auto` tries the first, falls b
the second. Note the bitness rule still applies: a 32-bit process enumerates the the second. Note the bitness rule still applies: a 32-bit process enumerates the
32-bit registry view — exactly the servers this tool can load. 32-bit registry view — exactly the servers this tool can load.
`list-devices` / `list-tags` browse the **connected server's** address space through `list-items` browses the **connected server's** address space through the
the Automation Wrapper's browser (`CreateBrowser`): devices are *branches*, tags are Automation Wrapper's browser (`CreateBrowser`). OPC DA defines exactly two
*leaves*. They work locally and over DCOM (same Launch+Activation requirements). A element kinds — **branches** (the structure: what SCADA vendors call channels,
device path is either one dotted string (`"Simulation Items.Bucket Brigade"`) or devices, groups or folders; the protocol cannot tell them apart) and **leaves**
several quoted branch names — use the latter when a branch name itself contains a (the tags). The `all` view renders the full tree — including leaves that hang
dot. On flat-namespace servers (`organization: flat`) every tag hangs directly off directly at the server root and belong to no branch (server-internal items
the root; `list-tags` without a device lists them all. like `@ClientCount`). It works locally and over DCOM (same Launch+Activation
requirements). A `--branch` path is either one dotted string
(`"Simulation Items.Bucket Brigade"`) or repeated flag segments — use the
latter when a branch name itself contains a dot. `--depth 1` (default) lists
one level; `--depth 0` walks the whole subtree, guarded by `browse.max_depth`
from the config (default 32) against server-side cycles.
Every connecting verb (`read`, `probe`, `list-devices`, `list-tags`) accepts Every connecting verb (`read`, `probe`, `list-items`) accepts
`--server PROGID` and `--host HOST` to **override the config's `opc` section** — `--server PROGID` and `--host HOST` to **override the config's `opc` section** —
point a one-shot check at any server without touching the deployed config.yaml. point a one-shot check at any server without touching the deployed config.yaml.
With `--server` given, no config file is needed at all; without it, the config must With `--server` given, no config file is needed at all; without it, the config must
@@ -136,6 +140,9 @@ stale:
enabled: true enabled: true
max_age_s: 120 max_age_s: 120
browse:
max_depth: 32 # address-space walk guard (list-items --depth 0)
report: report:
heartbeat_s: 300 heartbeat_s: 300
jsonl: { dir: "C:/ProgramData/OPCDA-Diagnozer/logs", max_mb: 50, backups: 10 } jsonl: { dir: "C:/ProgramData/OPCDA-Diagnozer/logs", max_mb: 50, backups: 10 }
+3
View File
@@ -31,6 +31,9 @@ stale:
enabled: true enabled: true
max_age_s: 120 # D12 device timestamp older than this while GOOD max_age_s: 120 # D12 device timestamp older than this while GOOD
browse:
max_depth: 32 # address-space walk guard (list-items --depth 0)
report: report:
heartbeat_s: 300 # D15 periodic stats snapshot heartbeat_s: 300 # D15 periodic stats snapshot
jsonl: jsonl:
+12 -1
View File
@@ -66,6 +66,13 @@ class StaleConfig:
max_age_s: float = 120.0 # D12 / D13 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) @dataclass(frozen=True)
class JsonlConfig: class JsonlConfig:
dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs" dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs"
@@ -96,10 +103,11 @@ class Config:
latency: LatencyConfig = field(default_factory=LatencyConfig) latency: LatencyConfig = field(default_factory=LatencyConfig)
quality: QualityConfig = field(default_factory=QualityConfig) quality: QualityConfig = field(default_factory=QualityConfig)
stale: StaleConfig = field(default_factory=StaleConfig) stale: StaleConfig = field(default_factory=StaleConfig)
browse: BrowseConfig = field(default_factory=BrowseConfig)
report: ReportConfig = field(default_factory=ReportConfig) 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"} _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}") 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: 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}") 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: if config.report.heartbeat_s <= 0:
raise ConfigError(f"report.heartbeat_s: must be > 0, got {config.report.heartbeat_s}") 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"): 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"), latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
quality=_build_section(QualityConfig, data.get("quality"), "quality"), quality=_build_section(QualityConfig, data.get("quality"), "quality"),
stale=_build_section(StaleConfig, data.get("stale"), "stale"), stale=_build_section(StaleConfig, data.get("stale"), "stale"),
browse=_build_section(BrowseConfig, data.get("browse"), "browse"),
report=_build_report(data.get("report")), report=_build_report(data.get("report")),
) )
try: try:
+139 -147
View File
@@ -2,9 +2,9 @@
Verbs: ``run``, ``probe``, ``check-config``, ``list-items`` plus the service Verbs: ``run``, ``probe``, ``check-config``, ``list-items`` plus the service
verbs ``install`` / ``uninstall`` / ``start`` / ``stop``. Currently functional: verbs ``install`` / ``uninstall`` / ``start`` / ``stop``. Currently functional:
``check-config``, ``list-servers`` (OPC DA server discovery) and the browse ``check-config``, ``list-servers`` (discovery), ``list-items`` (address-space
verbs ``list-devices`` / ``list-tags`` (devices = branches, tags = leaves of browsing: ``all`` / ``branches`` / ``tags``) and ``read`` / ``probe`` (one-shot
the connected server's address space); the remaining verbs land with Phases 1–3. tag reads); the remaining verbs land with Phases 1–3.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,7 +36,6 @@ def _default_config_path() -> str:
_NOT_IMPLEMENTED: dict[str, str] = { _NOT_IMPLEMENTED: dict[str, str] = {
"run": "Phase 1 (core engine)", "run": "Phase 1 (core engine)",
"list-items": "superseded by list-devices / list-tags",
"install": "Phase 3 (Windows service)", "install": "Phase 3 (Windows service)",
"uninstall": "Phase 3 (Windows service)", "uninstall": "Phase 3 (Windows service)",
"start": "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") p = sub.add_parser("check-config", help="validate the configuration and exit")
_add_config_option(p) _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( p = sub.add_parser(
"list-servers", "list-servers",
help="enumerate the OPC DA servers available locally or on a DCOM host", 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( p = sub.add_parser(
"list-devices", "list-items",
help="list the devices (address-space branches) exposed by the configured server", help="browse the server's item space: the full structure tree, or a "
"flat view of its branches / tags",
) )
_add_config_option(p) _add_config_option(p)
_add_server_overrides(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( p.add_argument(
"--depth", "--depth",
type=int, type=int,
default=1, default=1,
metavar="N", metavar="N",
help="how many branch levels to descend (default: %(default)s)", help="how many branch levels to descend; 0 = the whole subtree "
) "(default: %(default)s)",
p.add_argument(
"--no-counts",
action="store_true",
help="skip fetching per-device tag counts (faster on huge servers)",
) )
p.add_argument( p.add_argument(
"--json", "--json",
action="store_true", action="store_true",
help="emit one JSON object per device (for scripts) instead of a table", help="emit JSON objects (for scripts) instead of a tree/list",
)
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",
) )
p = sub.add_parser("install", help="install as a Windows service (requires admin)") 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 return client
def _print_devices(space, as_json: bool) -> None: def _print_item_tree(nodes, indent: int = 0) -> None:
"""Human table / JSON-lines output for the list-devices verb.""" """Indented structure view: branches with a trailing /, tags with their ItemID."""
if as_json: for node in nodes:
import json prefix = " " * indent
if node.kind == "branch":
for device in space.devices: print(f"{prefix}{node.name}/")
payload = { _print_item_tree(node.children, indent + 1)
"name": device.name, else:
"path": list(device.path), print(f"{prefix}{node.name} [ItemID: {node.item_id}]")
"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 _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, 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: ) -> 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: try:
client = _connect_from_config(config_path, server, host) client = _connect_from_config(config_path, server, host)
browser = None browser = None
try: try:
browser = client.create_browser() 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: finally:
# Release the browser's COM reference *before* close() tears down the # Release the browser's COM reference *before* close() tears down the
# apartment — releasing an RCW after CoUninitialize crashes the process. # apartment — releasing an RCW after CoUninitialize crashes the process.
@@ -363,85 +368,76 @@ def _cmd_list_devices(
browser.close() browser.close()
client.close() client.close()
except ConfigError as exc: 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 return 2
except (OpcError, ValueError) as exc: 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 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: if kind == "all":
"""Human table / JSON-lines output for the list-tags verb.""" if not nodes:
where = ".".join(device_path) or "(server root)" print(f"nothing defined under {where}")
scope = " (recursive)" if recursive else "" return 0
if as_json: if as_json:
import 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: for tag in tags:
payload = { print(f" {tag.item_id:<{width}} (leaf: {tag.name})")
"name": tag.name, return 0
"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})")
# kind == "branches"
def _cmd_list_tags( branches = flatten_branches(nodes)
config_path: str, if as_json:
server: str | None, for node in branches:
host: str | None, print(json.dumps({
device: Sequence[str], "name": node.name,
recursive: bool, "path": list(node.path),
as_json: bool, "tags": sum(1 for c in node.children if c.kind == "leaf"),
) -> int: "sub_branches": sum(1 for c in node.children if c.kind == "branch"),
from opcda_diagnozer.opc.browse import list_tags, split_device_path }, ensure_ascii=False))
return 0
# A single CLI argument is a *dotted* branch path; several arguments are if not branches:
# 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:
print( print(
"hint: the branch exists but exposes no direct leaves — try --recursive " f"no branches found under {where} — the space may be flat; "
"to include the whole subtree", "try kind 'all' or 'tags'"
file=sys.stderr,
) )
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 return 0
@@ -557,13 +553,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return _cmd_check_config(args.config) return _cmd_check_config(args.config)
if args.verb == "list-servers": if args.verb == "list-servers":
return _cmd_list_servers(args.host, args.source, args.json) return _cmd_list_servers(args.host, args.source, args.json)
if args.verb == "list-devices": if args.verb == "list-items":
return _cmd_list_devices( return _cmd_list_items(
args.config, args.server, args.host, args.depth, not args.no_counts, args.json args.config, args.server, args.host, args.kind, args.branch, args.depth, 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 == "read": if args.verb == "read":
return _cmd_read( return _cmd_read(
+4 -3
View File
@@ -106,9 +106,10 @@ class _AutomationBrowser:
if not name: if not name:
continue continue
item_id = str(self._browser.GetItemID(name)).strip() item_id = str(self._browser.GetItemID(name)).strip()
if not item_id: if not item_id or item_id == f".{name}":
# Some servers return an empty ItemID for filtered leaves; # Some servers return an empty ItemID for filtered/special
# fall back to the browse name so config tags stay usable. # leaves (observed: Matrikon's alias-root items); fall back
# to the browse name so config tags stay usable.
item_id = name item_id = name
leaves.append(TagInfo(name=name, item_id=item_id, path=tuple(path))) leaves.append(TagInfo(name=name, item_id=item_id, path=tuple(path)))
return leaves 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 There is deliberately **no write path** — the tool is read-only by construction
(SPECIFICATION §10.5). (SPECIFICATION §10.5).
Browsing (devices = branches, tags = leaves) is modelled by :class:`Browser`; Browsing is modelled by :class:`Browser`. The OPC DA browse world defines
the ``list-devices`` / ``list-tags`` CLI verbs walk the address space through exactly two element kinds: **branches** (what SCADA vendors call channels,
it without any recursion into the monitored poll path. 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 from __future__ import annotations
@@ -16,7 +19,7 @@ from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import IntEnum from enum import IntEnum
from typing import Any, Protocol, runtime_checkable from typing import Any, Literal, Protocol, runtime_checkable
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -105,25 +108,25 @@ ACCESS_WRITE = 0x02
@dataclass(frozen=True) @dataclass(frozen=True)
class DeviceInfo: class SpaceNode:
"""One browsable device: a branch in the server's hierarchical address space. """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 OPC DA's browse model has exactly these two element kinds — there is no
"device" here is simply the branch path plus the counts the CLI prints. 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 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, ...] path: tuple[str, ...]
#: Sub-branches directly under this device (usually channels/groups). #: Fully qualified ItemID — leaves only; what goes into config ``tags:``.
sub_devices: tuple[str, ...] = () item_id: str | None = None
#: Leaf (tag) names directly under this device. #: Child nodes — branches only; empty when the depth limit stopped here.
tag_names: tuple[str, ...] = () children: tuple["SpaceNode", ...] = ()
@property
def qualified_name(self) -> str:
"""Human-friendly dotted rendering: ``Simulation Items.Bucket Brigade``."""
return ".".join(self.path) if self.path else self.name
@dataclass(frozen=True) @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 OPC DA's browse model defines exactly two element kinds:
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):
- *Which devices does this server expose?* → :func:`list_devices` - **branches** — the structure nodes. SCADA vendors map channels, devices,
- *Which tags does device X expose?* → :func:`list_tags` 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 Leaves may also hang directly at the server root, belonging to no branch —
traversal API — it works over DCOM for remote servers too, with the same server-internal items (``@ClientCount`` on the Matrikon sim) look "wrong" in
Launch+Activation requirements as ``list-servers``. Traversal is one level at a device-oriented view but are ordinary root-level leaves.
a time; the ``max_depth`` guard keeps a pathological (cyclic/AB-tree) server
from stalling the CLI.
Flat-namespace servers report ``organization == "flat"``; there every leaf is The ``list-items`` CLI verb renders whatever the caller asks for
addressed by its bare ItemID, the root is treated as one implicit device. (``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 __future__ import annotations
from collections.abc import Sequence 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 #: Default walk guard: deep SCADA hierarchies exist (Channel → Device → Tag),
#: trees deeper than this are almost certainly a server-side loop. #: but OPC trees deeper than this are almost certainly a server-side loop.
MAX_DEPTH = 32 #: 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) def split_branch_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
class Space: """Normalize a user-supplied branch path into segments.
"""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.
A **single** string is treated as dotted: ``"Simulation Items.Bucket Brigade"`` A **single** string is treated as dotted: ``"Simulation Items.Bucket Brigade"``
→ ``("Simulation Items", "Bucket Brigade")``. **Multiple** segments are → ``("Simulation Items", "Bucket Brigade")``. **Multiple** segments are
taken as explicit branch names — no dot splitting, so names that contain 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. Empty segments and whitespace are dropped.
""" """
if path is None: if path is None:
@@ -61,134 +55,96 @@ def split_device_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
return tuple(parts) return tuple(parts)
def list_devices( def require_branch(
browser: Browser, browser: Browser, branch: Sequence[str] | str | None, *, what: str
*,
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,
) -> tuple[str, ...]: ) -> tuple[str, ...]:
"""Resolve and validate a user-supplied device path (exists, is a branch).""" """Resolve and validate a user-supplied branch path (must exist)."""
path = split_device_path(device) path = split_branch_path(branch)
if not path: if not path:
return () return ()
siblings = browser.list_branches(path[:-1]) siblings = browser.list_branches(path[:-1])
if path[-1] not in siblings: if path[-1] not in siblings:
raise OpcError( raise OpcError(
f"{what}: device {path[-1]!r} not found under " f"{what}: branch {path[-1]!r} not found under "
f"{'.'.join(path[:-1]) or '(root)'} — run list-devices to see the " f"{'.'.join(path[:-1]) or '(root)'} — list the levels above it "
"available branches" "with list-items to see the available branch names"
) )
return path return path
def tags_for_device(browser: Browser, device: str | Sequence[str]) -> list[TagInfo]: def walk_tree(
"""List tags of a **known** device path — validation helper (probe).""" browser: Browser,
path = _require_device(browser, device, what="list-tags") branch: Sequence[str] | str | None = None,
return sorted(browser.list_leaves(path), key=lambda t: t.item_id.lower()) *,
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)
+211 -124
View File
@@ -1,24 +1,28 @@
"""Browse tests (opc/browse.py + fake browser + CLI verbs). """Browse tests (opc/browse.py walk_tree + the list-items CLI verb).
Pure tests run on any OS against the fake client's address space; the COM Pure tests run on any OS against the fake client's address space; the COM
path is Windows-only and covered by the live smoke tests (IMPLEMENTATION_PLAN path is Windows-only and covered by the live smoke test (IMPLEMENTATION_PLAN
§9: COM adapters are import-guarded). §9: COM adapters are import-guarded).
""" """
import json
import sys import sys
from pathlib import Path
import pytest import pytest
from opcda_diagnozer.main import main as cli_main from opcda_diagnozer.main import main as cli_main
from opcda_diagnozer.opc.base import Browser, DeviceInfo, OpcError, TagInfo from opcda_diagnozer.opc.base import Browser, OpcError, SpaceNode
from opcda_diagnozer.opc.browse import ( from opcda_diagnozer.opc.browse import (
MAX_DEPTH, DEFAULT_MAX_DEPTH,
list_devices, WHOLE_SUBTREE,
list_tags, flatten_branches,
split_device_path, flatten_leaves,
tags_for_device, require_branch,
split_branch_path,
walk_tree,
) )
from opcda_diagnozer.opc.fake import FAKE_SPACE, FakeOpcClient from opcda_diagnozer.opc.fake import FakeOpcClient
@pytest.fixture @pytest.fixture
@@ -29,7 +33,7 @@ def browser() -> Browser:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# split_device_path # split_branch_path
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -50,57 +54,99 @@ def browser() -> Browser:
(("Bucket. Inc", "Device 1"), ("Bucket. Inc", "Device 1")), (("Bucket. Inc", "Device 1"), ("Bucket. Inc", "Device 1")),
], ],
) )
def test_split_device_path(raw, expected: tuple[str, ...]) -> None: def test_split_branch_path(raw, expected: tuple[str, ...]) -> None:
assert split_device_path(raw) == expected assert split_branch_path(raw) == expected
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# list_devices # walk_tree
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_list_devices_top_level(browser: Browser) -> None: def test_walk_tree_depth_one(browser: Browser) -> None:
space = list_devices(browser) """Default depth: direct leaves + branches, branches have no children."""
assert space.organization == "hierarchical" nodes = walk_tree(browser)
assert space.root.sub_devices == ("Simulation Items", "Configured Aliases") assert [(n.kind, n.name) for n in nodes] == [
names = [d.name for d in space.devices] ("leaf", "#MonitorACLFile"),
assert names == ["Simulation Items", "Configured Aliases"] ("leaf", "@ClientCount"),
assert all(d.path == (d.name,) for d in space.devices) ("leaf", "@Clients"),
("branch", "Simulation Items"),
("branch", "Configured Aliases"),
]
# depth 1: the walk stops at the branches themselves
assert all(n.children == () for n in nodes if n.kind == "branch")
def test_list_devices_depth_two_includes_sub_devices(browser: Browser) -> None: def test_walk_tree_depth_one_includes_root_leaves(browser: Browser) -> None:
space = list_devices(browser, depth=2) """Root-level leaves (belonging to no branch) are ordinary tree items."""
paths = [d.path for d in space.devices] nodes = walk_tree(browser)
assert ("Simulation Items", "Bucket Brigade") in paths root_leaves = [n for n in nodes if n.kind == "leaf"]
assert ("Simulation Items", "Random") in paths assert {n.name for n in root_leaves} == {"@Clients", "@ClientCount", "#MonitorACLFile"}
# depth 2: the root's direct children plus their direct children assert all(n.path == () for n in root_leaves) # they hang directly at the root
assert len(paths) == 2 + 8 # 2 top-level + 8 under Simulation Items
def test_list_devices_reports_tag_counts(browser: Browser) -> None: def test_walk_tree_depth_two(browser: Browser) -> None:
space = list_devices(browser) nodes = walk_tree(browser, depth=2)
by_name = {d.name: d for d in space.devices} sim = next(n for n in nodes if n.name == "Simulation Items")
assert by_name["Simulation Items"].tag_names == () # no direct leaves child_names = [c.name for c in sim.children]
assert by_name["Configured Aliases"].tag_names == ("Alias1", "Alias2") assert child_names == [
assert by_name["Simulation Items"].sub_devices[:4] == (
"Bucket Brigade", "Random", "Read Error", "Saw-toothed Waves", "Bucket Brigade", "Random", "Read Error", "Saw-toothed Waves",
) "Square Waves", "Triangle Waves", "Write Error", "Write Only",
]
# depth 2 stops inside those sub-branches too
bucket = next(c for c in sim.children if c.name == "Bucket Brigade")
assert bucket.kind == "branch" and bucket.children == ()
assert len(flatten_leaves(nodes)) == 5 # root leaves only; nothing deeper read
def test_list_devices_no_counts_skips_leaves(browser: Browser) -> None: def test_walk_tree_whole_subtree(browser: Browser) -> None:
space = list_devices(browser, include_tag_counts=False) """depth=0: everything defined in the tree, in structure."""
assert space.root.tag_names == () nodes = walk_tree(browser, depth=WHOLE_SUBTREE)
assert all(d.tag_names == () for d in space.devices) tags = flatten_leaves(nodes)
item_ids = {t.item_id for t in tags}
assert "Bucket Brigade.Int1" in item_ids # device tags
assert "Configured Aliases.Alias1" in item_ids # alias tags
assert "@ClientCount" in item_ids # root-level, no branch
# the structure is preserved: Bucket Brigade is nested under Simulation Items
sim = next(n for n in nodes if n.name == "Simulation Items")
bucket = next(c for c in sim.children if c.name == "Bucket Brigade")
assert bucket.path == ("Simulation Items", "Bucket Brigade")
assert any(c.name == "Int1" and c.item_id == "Bucket Brigade.Int1" for c in bucket.children)
@pytest.mark.parametrize("depth", [0, -1, MAX_DEPTH + 1]) def test_walk_tree_from_subtree(browser: Browser) -> None:
def test_list_devices_rejects_bad_depth(browser: Browser, depth: int) -> None: """--branch limits the walk to that subtree."""
with pytest.raises(ValueError): nodes = walk_tree(browser, branch="Simulation Items.Bucket Brigade", depth=WHOLE_SUBTREE)
list_devices(browser, depth=depth) # only the subtree: no root leaves, no other branches
kinds = [(n.kind, n.name) for n in nodes]
assert all(n.kind == "leaf" for n in nodes)
assert {n.name for n in nodes} == {
"Int1", "Int2", "Int4", "Real4", "Real8", "String", "Boolean",
}
def test_list_devices_cycle_guard(monkeypatch: pytest.MonkeyPatch) -> None: def test_walk_tree_missing_branch_is_actionable(browser: Browser) -> None:
"""A pathological self-nesting branch must not hang the recursive walk.""" with pytest.raises(OpcError, match="list-items"):
walk_tree(browser, branch="Simulation Items.No Such Device")
with pytest.raises(OpcError, match="not found under"):
walk_tree(browser, branch="Nope")
@pytest.mark.parametrize("depth", [-1, -5])
def test_walk_tree_rejects_negative_depth(browser: Browser, depth: int) -> None:
with pytest.raises(ValueError, match=">= 0"):
walk_tree(browser, depth=depth)
def test_walk_tree_rejects_bad_max_depth(browser: Browser) -> None:
with pytest.raises(ValueError, match="max_depth"):
walk_tree(browser, max_depth=0)
with pytest.raises(ValueError, match="max_depth"):
walk_tree(browser, depth=33, max_depth=32)
def test_walk_tree_configurable_max_depth_guards_cycles() -> None:
"""A pathological self-nesting branch must hit the max_depth guard, not hang."""
class _LoopBrowser: class _LoopBrowser:
@property @property
@@ -119,53 +165,38 @@ def test_list_devices_cycle_guard(monkeypatch: pytest.MonkeyPatch) -> None:
def close(self) -> None: def close(self) -> None:
return None return None
with pytest.raises(OpcError, match="deeper than"): with pytest.raises(OpcError, match="deeper than 2"):
list_tags(_LoopBrowser(), recursive=True) # type: ignore[arg-type] walk_tree(_LoopBrowser(), depth=WHOLE_SUBTREE, max_depth=2) # type: ignore[arg-type]
def test_walk_tree_default_max_depth_constant() -> None:
"""The default guard matches the documented config default (32)."""
assert DEFAULT_MAX_DEPTH == 32
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# list_tags # flatten helpers
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_list_tags_root_direct(browser: Browser) -> None: def test_flatten_leaves_sorted_case_insensitively(browser: Browser) -> None:
tags = list_tags(browser) tags = flatten_leaves(walk_tree(browser, depth=WHOLE_SUBTREE))
assert [t.name for t in tags] == ["#MonitorACLFile", "@ClientCount", "@Clients"] ids = [t.item_id for t in tags]
assert all(t.item_id == t.name for t in tags) # root leaves: bare ItemIDs assert ids == sorted(ids, key=str.lower)
assert all(t.path == () for t in tags)
def test_list_tags_of_device_dotted_string(browser: Browser) -> None: def test_flatten_branches_depth_first(browser: Browser) -> None:
tags = list_tags(browser, device="Simulation Items.Bucket Brigade") branches = flatten_branches(walk_tree(browser, depth=WHOLE_SUBTREE))
item_ids = [t.item_id for t in tags] paths = [".".join(b.path) for b in branches]
assert "Bucket Brigade.Int1" in item_ids # depth-first: parents before their children
assert "Bucket Brigade.Real8" in item_ids assert paths.index("Simulation Items") < paths.index("Simulation Items.Bucket Brigade")
assert len(tags) == 7 assert paths.index("Simulation Items") < paths.index("Configured Aliases")
def test_list_tags_of_device_segments(browser: Browser) -> None: def test_spacenode_is_frozen() -> None:
tags = list_tags(browser, device=["Simulation Items", "Random"]) node = SpaceNode(kind="leaf", name="X", path=(), item_id="X")
assert {t.name for t in tags} == {"Int1", "Int4", "Money", "Time"} with pytest.raises(AttributeError):
assert all(t.path == ("Simulation Items", "Random") for t in tags) node.name = "Y" # type: ignore[misc]
def test_list_tags_recursive(browser: Browser) -> None:
tags = list_tags(browser, device="Simulation Items", recursive=True)
item_ids = {t.item_id for t in tags}
# direct children of "Simulation Items" have no leaves; recursion gathers all
assert "Bucket Brigade.Int1" in item_ids
assert "Random.Money" in item_ids
assert "Read Error.Int4" in item_ids
assert "Saw-toothed Waves.Real8" in item_ids
assert all(t.path for t in tags)
def test_tags_are_sorted_case_insensitively(browser: Browser) -> None:
tags = list_tags(browser, device="Simulation Items.Bucket Brigade")
assert [t.item_id for t in tags] == sorted(
(t.item_id for t in tags), key=lambda s: s.lower()
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -174,7 +205,6 @@ def test_tags_are_sorted_case_insensitively(browser: Browser) -> None:
def test_fake_browser_matches_protocol(browser: Browser) -> None: def test_fake_browser_matches_protocol(browser: Browser) -> None:
# runtime_checkable protocol: structural check
assert isinstance(browser, Browser) assert isinstance(browser, Browser)
@@ -199,17 +229,24 @@ def test_fake_flat_space_reports_flat_organization() -> None:
client.connect() client.connect()
browser = client.create_browser() browser = client.create_browser()
assert browser.organization == "flat" assert browser.organization == "flat"
space = list_devices(browser) nodes = walk_tree(browser)
assert space.devices == () assert all(n.kind == "leaf" for n in nodes)
assert space.root.tag_names == ("Tag1", "Tag2") assert {n.name for n in nodes} == {"Tag1", "Tag2"}
def test_require_branch_root_ok_and_missing(browser: Browser) -> None:
assert require_branch(browser, None, what="list-items") == ()
assert require_branch(browser, "Simulation Items", what="list-items") == ("Simulation Items",)
with pytest.raises(OpcError, match="list-items"):
require_branch(browser, "Missing", what="list-items")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# CLI verbs (against the fake backend through a config file) # CLI verb (against the fake backend through a config file)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path, args: list[str]) -> int: def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, args: list[str]) -> int:
"""Run the CLI with the fake client injected in place of the COM adapter.""" """Run the CLI with the fake client injected in place of the COM adapter."""
import opcda_diagnozer.main as main_mod import opcda_diagnozer.main as main_mod
from opcda_diagnozer.opc.fake import FakeOpcClient from opcda_diagnozer.opc.fake import FakeOpcClient
@@ -229,69 +266,117 @@ def _run_cli(monkeypatch: pytest.MonkeyPatch, tmp_path, args: list[str]) -> int:
return cli_main(args) return cli_main(args)
def test_cli_list_devices_table(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: def test_cli_list_items_all_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, ["list-devices", "-c", str(tmp_path / "config.yaml")])
out = capsys.readouterr().out
assert code == 0
assert "Simulation Items" in out
assert "Configured Aliases" in out
assert "2 tags" in out
def test_cli_list_devices_json(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [ code = _run_cli(monkeypatch, tmp_path, [
"list-devices", "-c", str(tmp_path / "config.yaml"), "--json", "list-items", "all", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
]) ])
out = capsys.readouterr().out out = capsys.readouterr().out
assert code == 0 assert code == 0
import json # tree structure: root leaves visible, branches with trailing /
assert "@ClientCount [ItemID: @ClientCount]" in out
lines = [json.loads(line) for line in out.splitlines() if line.strip()] assert "Simulation Items/" in out
assert {d["name"] for d in lines} == {"Simulation Items", "Configured Aliases"} assert " Bucket Brigade/" in out # nested one level
assert lines[0]["qualified_name"] == "Simulation Items" # walk order = root order assert " Int1 [ItemID: Bucket Brigade.Int1]" in out # nested two levels
def test_cli_list_tags_dotted(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: def test_cli_list_items_default_is_all_depth1(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
code = _run_cli(monkeypatch, tmp_path, ["list-items", "-c", str(tmp_path / "config.yaml")])
out = capsys.readouterr().out
assert code == 0
assert "Simulation Items/" in out
assert "Bucket Brigade" not in out # depth 1 stops at the top branches
def test_cli_list_items_branches(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [ code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"), "list-items", "branches", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
"Simulation Items.Bucket Brigade", ])
out = capsys.readouterr().out
assert code == 0
assert "Simulation Items" in out
assert "Simulation Items.Bucket Brigade" in out
assert "8 sub-branches" in out
assert "[ItemID" not in out # no tags in the branches view
def test_cli_list_items_tags_flat(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "tags", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
]) ])
out = capsys.readouterr().out out = capsys.readouterr().out
assert code == 0 assert code == 0
assert "Bucket Brigade.Int1" in out assert "Bucket Brigade.Int1" in out
assert "Bucket Brigade.Real8" in out assert "Configured Aliases.Alias1" in out
assert "@ClientCount" in out # root-level tags are tags too
def test_cli_list_tags_segments_and_recursive( def test_cli_list_items_branch_subtree(
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None: ) -> None:
code = _run_cli(monkeypatch, tmp_path, [ code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"), "list-items", "tags", "--branch", "Simulation Items.Bucket Brigade",
"Simulation Items", "--recursive", "--json", "-c", str(tmp_path / "config.yaml"),
]) ])
out = capsys.readouterr().out out = capsys.readouterr().out
assert code == 0 assert code == 0
import json assert "Bucket Brigade.Int1" in out
assert "Random.Money" not in out # only the selected subtree
item_ids = {json.loads(line)["item_id"] for line in out.splitlines() if line.strip()} assert "Configured Aliases" not in out
assert "Bucket Brigade.Int1" in item_ids
assert "Random.Money" in item_ids
def test_cli_list_tags_missing_device_fails_cleanly( def test_cli_list_items_branch_repeated_segments(
monkeypatch: pytest.MonkeyPatch, tmp_path, capsys monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
"""Repeating --branch gives explicit segments (names may contain dots)."""
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "tags", "--branch", "Simulation Items", "--branch", "Bucket Brigade",
"-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
assert "Bucket Brigade.Int1" in out
def test_cli_list_items_json_tree(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys) -> None:
code = _run_cli(monkeypatch, tmp_path, [
"list-items", "--json", "--depth", "0", "-c", str(tmp_path / "config.yaml"),
])
out = capsys.readouterr().out
assert code == 0
lines = [json.loads(line) for line in out.splitlines() if line.strip()]
assert lines[0]["kind"] == "leaf"
sim = next(l for l in lines if l["name"] == "Simulation Items")
assert sim["kind"] == "branch"
assert any(c["name"] == "Bucket Brigade" for c in sim["children"])
def test_cli_list_items_missing_branch_fails_cleanly(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None: ) -> None:
code = _run_cli(monkeypatch, tmp_path, [ code = _run_cli(monkeypatch, tmp_path, [
"list-tags", "-c", str(tmp_path / "config.yaml"), "No.Such.Device", "list-items", "--branch", "No.Such.Branch", "-c", str(tmp_path / "config.yaml"),
]) ])
captured = capsys.readouterr() captured = capsys.readouterr()
assert code == 2 assert code == 2
assert "FAILED" in captured.err assert "FAILED" in captured.err
def test_cli_old_verbs_are_gone(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys
) -> None:
"""list-devices / list-tags were replaced by list-items."""
for gone in ("list-devices", "list-tags"):
with pytest.raises(SystemExit) as exc_info:
_run_cli(monkeypatch, tmp_path, [gone, "-c", str(tmp_path / "config.yaml")])
assert exc_info.value.code == 2 # argparse: unknown verb
assert "invalid choice" in capsys.readouterr().err
@pytest.mark.skipif(sys.platform != "win32", reason="COM browse needs Windows + pywin32") @pytest.mark.skipif(sys.platform != "win32", reason="COM browse needs Windows + pywin32")
def test_live_automation_browse_smoke() -> None: def test_live_automation_browse_smoke() -> None:
"""Live smoke test — requires the Matrikon simulation server on this dev box.""" """Live smoke test — requires a registered Automation Wrapper (dev box)."""
pytest.importorskip("pythoncom") pytest.importorskip("pythoncom")
from opcda_diagnozer.opc.automation import AutomationOpcClient from opcda_diagnozer.opc.automation import AutomationOpcClient
@@ -299,11 +384,13 @@ def test_live_automation_browse_smoke() -> None:
try: try:
client.connect() client.connect()
browser = client.create_browser() browser = client.create_browser()
devices = list_devices(browser) nodes = walk_tree(browser, depth=WHOLE_SUBTREE)
assert devices.organization == "hierarchical" tags = flatten_leaves(nodes)
assert any(d.name == "Simulation Items" for d in devices.devices)
tags = list_tags(browser, device="Simulation Items.Bucket Brigade")
assert any(t.item_id == "Bucket Brigade.Int1" for t in tags) assert any(t.item_id == "Bucket Brigade.Int1" for t in tags)
assert any(t.name == "@ClientCount" for t in tags) # root-level leaf
sim = next(n for n in nodes if n.name == "Simulation Items")
assert sim.kind == "branch"
assert any(c.name == "Bucket Brigade" for c in sim.children)
except OpcError as exc: except OpcError as exc:
pytest.skip(f"simulation server not usable on this machine: {exc}") pytest.skip(f"simulation server not usable on this machine: {exc}")
finally: finally: