🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-07 10:38:21 +02:00
commit 1c64aeb071
38 changed files with 3391 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
"""Command-line interface (IMPLEMENTATION_PLAN §6.2).
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.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
from opcda_diagnozer import __version__
from opcda_diagnozer.config import ConfigError, load_config
from opcda_diagnozer.opc.base import OpcError
from opcda_diagnozer.opc.discovery import ServerInfo, list_servers
DEFAULT_CONFIG = "config.yaml"
_NOT_IMPLEMENTED: dict[str, str] = {
"run": "Phase 1 (core engine)",
"probe": "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)",
"stop": "Phase 3 (Windows service)",
}
def _add_config_option(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--config", "-c",
default=DEFAULT_CONFIG,
help="path to the YAML config (default: %(default)s)",
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="opcda-diagnozer",
description=(
"Read-only OPC DA reference client that diagnoses disconnections, "
"latency and data-quality problems (see docs/SPECIFICATION.md)."
),
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = parser.add_subparsers(dest="verb", metavar="verb", required=True)
p = sub.add_parser("run", help="run in the foreground (console)")
_add_config_option(p)
p = sub.add_parser("probe", help="connect once and print one read of every configured tag")
_add_config_option(p)
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",
)
p.add_argument(
"--host",
metavar="HOST",
help="remote host to enumerate via DCOM (default: the local machine)",
)
p.add_argument(
"--source",
choices=("auto", "automation", "registry"),
default="auto",
help="discovery mechanism: automation = OPC DA Automation Wrapper over COM; "
"registry = local COM-registration scan (no COM); auto = try automation, "
"fall back to registry (default: %(default)s)",
)
p.add_argument(
"--json",
action="store_true",
help="emit one JSON object per server (for scripts) instead of a table",
)
p = sub.add_parser(
"list-devices",
help="list the devices (address-space branches) exposed by the configured server",
)
_add_config_option(p)
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)",
)
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)
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)")
_add_config_option(p)
p.add_argument(
"--account",
metavar="DOMAIN\\USER",
help="dedicated service account (required for DCOM authentication, SPECIFICATION §11)",
)
for verb, help_text in (
("uninstall", "remove the Windows service"),
("start", "start the Windows service"),
("stop", "stop the Windows service"),
):
sub.add_parser(verb, help=help_text)
return parser
def _cmd_check_config(config_path: str) -> int:
try:
config = load_config(config_path)
except ConfigError as exc:
print(f"check-config: FAILED\n {exc}", file=sys.stderr)
return 2
host = f" on host {config.opc.host!r}" if config.opc.host else " (local)"
expect_change = sum(1 for t in config.tags if t.expect_change)
print("check-config: OK")
print(f" server: {config.opc.server!r}{host}")
print(f" tags: {len(config.tags)} ({expect_change} with expect_change)")
print(f" poll: every {config.poll.interval_s} s, hang flagged after {config.poll.timeout_ms} ms")
return 0
def _print_servers(servers: list[ServerInfo], host: str | None, as_json: bool) -> None:
where = f" on {host!r}" if host else " (local machine)"
if as_json:
import json
for server in servers:
payload = {
"prog_id": server.prog_id,
"source": server.source,
"clsid": server.clsid,
"description": server.description,
"da_versions": list(server.da_versions),
}
print(json.dumps(payload, ensure_ascii=False))
return
if not servers:
print(f"no OPC DA servers found{where}")
return
print(f"OPC DA servers available{where}:")
width = max(len(s.prog_id) for s in servers)
for server in servers:
notes = [f"via {server.source}"]
if server.da_versions:
notes.append(", ".join(server.da_versions))
if server.description:
notes.append(server.description)
print(f" {server.prog_id:<{width}} ({'; '.join(notes)})")
def _cmd_list_servers(host: str | None, source: str, as_json: bool) -> int:
try:
servers = list_servers(host=host, source=source) # type: ignore[arg-type]
except OpcError as exc:
print(f"list-servers: FAILED\n {exc}", file=sys.stderr)
return 2
_print_servers(servers, host, as_json)
if not as_json and not servers and host is None and source in ("auto", "registry"):
print(
"hint: no servers found — is an OPC DA server installed on this host? "
"(servers that skip the OPC component categories are invisible to "
"this scan; try --source automation)",
file=sys.stderr,
)
return 0
def _connect_from_config(config_path: str):
"""Load the config and connect to its configured server (Automation Wrapper).
Returns the connected client; raises :class:`ConfigError` / :class:`OpcError`
with actionable messages for the caller to print.
"""
from opcda_diagnozer.opc.automation import AutomationOpcClient
config = load_config(config_path)
client = AutomationOpcClient(
server=config.opc.server,
host=config.opc.host,
connect_timeout_s=config.opc.connect_timeout_s,
)
client.connect()
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 _cmd_list_devices(config_path: str, depth: int, include_counts: bool, as_json: bool) -> int:
from opcda_diagnozer.opc.browse import list_devices
try:
client = _connect_from_config(config_path)
browser = None
try:
browser = client.create_browser()
space = list_devices(browser, depth=depth, include_tag_counts=include_counts)
finally:
# Release the browser's COM reference *before* close() tears down the
# apartment — releasing an RCW after CoUninitialize crashes the process.
if browser is not None:
browser.close()
client.close()
except ConfigError as exc:
print(f"list-devices: FAILED\n {exc}", file=sys.stderr)
return 2
except (OpcError, ValueError) as exc:
print(f"list-devices: 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
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
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})")
def _cmd_list_tags(
config_path: str,
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)
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(
"hint: the branch exists but exposes no direct leaves — try --recursive "
"to include the whole subtree",
file=sys.stderr,
)
return 0
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entry point (console script + ``python -m opcda_diagnozer``)."""
args = _build_parser().parse_args(argv)
if args.verb == "check-config":
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.depth, not args.no_counts, args.json)
if args.verb == "list-tags":
return _cmd_list_tags(args.config, args.device, args.recursive, args.json)
phase = _NOT_IMPLEMENTED[args.verb]
print(
f"{args.verb}: not implemented yet — planned for {phase} "
"(see docs/IMPLEMENTATION_PLAN.md)",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())