🎉 Initial commit
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user