🚧 WIP
This commit is contained in:
+139
-147
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user