"""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`` (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 import argparse import sys import threading from collections.abc import Sequence from pathlib import Path 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" def _default_config_path() -> str: """Where the config lives by default: CWD in source runs, exe dir when frozen. A frozen exe must not rely on the CWD: a Windows service starts with ``CWD = C:\\Windows\\System32``, so the relative name would silently resolve into System32. Frozen runs therefore default to ``config.yaml`` **next to the exe** — the deployment layout the Phase 3 service will use (SPEC §6.4). """ if getattr(sys, "frozen", False): return str(Path(sys.executable).resolve().parent / DEFAULT_CONFIG) return DEFAULT_CONFIG _NOT_IMPLEMENTED: dict[str, str] = { "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_path(), help="path to the YAML config (default: %(default)s)", ) def _add_server_overrides(parser: argparse.ArgumentParser) -> None: """--server/--host override the config's opc section (SPECIFICATION §6). They make one-shot CLI use possible without a config file — and let an ops engineer point a validation verb at a different server without touching the deployed config.yaml. """ parser.add_argument( "--server", metavar="PROGID", help="OPC DA server ProgID, overriding the config (e.g. Matrikon.OPC.Simulation.1)", ) parser.add_argument( "--host", metavar="HOST", help="remote DCOM host, overriding the config (omit for a local server)", ) 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 the monitoring loop in the foreground (Ctrl+C stops)", ) _add_config_option(p) _add_server_overrides(p) p = sub.add_parser( "read", help="connect once and read the given tags (a quick validation tool)", ) _add_config_option(p) _add_server_overrides(p) p.add_argument( "tags", nargs="*", metavar="TAG", help="tag ItemIDs to read (e.g. 'Bucket Brigade.Int1'); omitted -> the " "config's tags", ) p.add_argument( "--source", choices=("device", "cache"), default="device", help="read from the device (round-trip to the PLC) or the server cache " "(default: %(default)s)", ) p.add_argument( "--json", action="store_true", help="emit one JSON object per tag (for scripts) instead of a table", ) p = sub.add_parser( "probe", help="connect once and print one read of every configured tag", ) _add_config_option(p) _add_server_overrides(p) p.add_argument( "--source", choices=("device", "cache"), default="device", help="read from the device or the server cache (default: %(default)s)", ) p = sub.add_parser("check-config", help="validate the configuration and exit") _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-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; 0 = the whole subtree " "(default: %(default)s)", ) p.add_argument( "--json", action="store_true", help="emit JSON objects (for scripts) instead of a tree/list", ) 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)") if config.devices: print(f" devices: {len(config.devices)} link indicator(s):") for device in config.devices: print(f" {device.name} <- {device.tag}") if not config.tags and config.devices: print(" note: no surveillance tags - monitoring device links only") 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, server: str | None = None, host: str | None = None): """Load the config (when needed) and connect; --server/--host override opc. Overrides make one-shot CLI use possible without a config file: when ``--server`` is given, a missing config file is not an error and the remaining settings fall back to their defaults. Without ``--server`` the config must exist (its ``opc`` section decides where to connect). Returns the connected client; raises :class:`ConfigError` / :class:`OpcError` with actionable messages for the caller to print. """ from opcda_diagnozer.opc.automation import AutomationOpcClient connect_timeout_s = 10.0 # OpcConfig default config_exists = Path(config_path).is_file() if config_exists: config = load_config(config_path) server = server or config.opc.server if host is None: host = config.opc.host connect_timeout_s = config.opc.connect_timeout_s elif server is None: raise ConfigError( f"config file not found: {config_path} - pass --server (and optionally " "--host) to connect without a config, or create the config first" ) client = AutomationOpcClient( server=server, host=host, connect_timeout_s=connect_timeout_s, ) client.connect() return client 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 _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, kind: str, branch: Sequence[str] | None, depth: int, as_json: bool, ) -> int: 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() 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. if browser is not None: browser.close() client.close() except ConfigError as exc: print(f"list-items: FAILED\n {exc}", file=sys.stderr) return 2 except (OpcError, ValueError) as exc: print(f"list-items: FAILED\n {exc}", file=sys.stderr) return 2 path = split_branch_path(branch_arg) where = ".".join(path) or "(server root)" 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: print(f" {tag.item_id:<{width}} (leaf: {tag.name})") return 0 # 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( 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 def _print_read_results(results, as_json: bool) -> int: """Human table / JSON-lines output for the read/probe verbs.""" from opcda_diagnozer.quality import decode_quality failures = 0 if as_json: import json for result in results: quality = decode_quality(result.quality_raw) payload = { "tag": result.tag, "value": result.value, "quality_raw": result.quality_raw, "quality": quality.name, "quality_class": quality.quality_class.value, "device_time_utc": ( result.device_time.isoformat() if result.device_time else None ), "error": result.error, } if result.error or quality.quality_class.value != "GOOD": failures += 1 print(json.dumps(payload, ensure_ascii=False, default=str)) return failures if not results: print("no tags to read") return 0 width = max(len(r.tag) for r in results) print(f"{'tag':<{width}} value quality device time (UTC)") for result in results: quality = decode_quality(result.quality_raw) if result.error: failures += 1 print(f" {result.tag:<{width}} ERROR: {result.error}") continue marker = "" if quality.quality_class.value == "GOOD" else " <-- " + quality.name if quality.quality_class.value != "GOOD": failures += 1 time_str = ( result.device_time.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] if result.device_time else "-" ) print( f" {result.tag:<{width}} {str(result.value):<30} {quality.raw_hex} {time_str}{marker}" ) return failures def _cmd_read( config_path: str, server: str | None, host: str | None, tags: Sequence[str], source: str, as_json: bool, ) -> int: """One-shot read of explicit tags (or the config's tags) from the server.""" from opcda_diagnozer.opc.base import OPCDataSource requested = list(tags) try: # connect first: a missing config with no --server must fail here with # the actionable hint (before the tags load below can complain). client = _connect_from_config(config_path, server, host) if not requested: # no tags given -> use the config's tags (config is mandatory then) config = load_config(config_path) requested = [t.name for t in config.tags] except ConfigError as exc: print(f"read: FAILED\n {exc}", file=sys.stderr) return 2 except OpcError as exc: print(f"read: FAILED\n {exc}", file=sys.stderr) return 2 data_source = OPCDataSource.DEVICE if source == "device" else OPCDataSource.CACHE try: results = client.read(requested, source=data_source) except OpcError as exc: print(f"read: FAILED\n {exc}", file=sys.stderr) client.close() return 2 finally: client.close() failures = _print_read_results(results, as_json) if failures: print( f"read: {failures} of {len(results)} tag(s) did not read GOOD", file=sys.stderr, ) return 1 return 0 def _cmd_probe( config_path: str, server: str | None, host: str | None, source: str, ) -> int: """Connect once, read every configured tag once, print — the pre-deploy check.""" return _cmd_read(config_path, server, host, (), source, as_json=False) def _build_reporters(config: Config): """Assemble the reporter fan-out from the config (spec §9). Console is always on in the foreground ``run``; JSONL, the text anomaly log and the Windows Event Log follow their config sections; the webhook only when ``report.http`` is configured. Returns ``(fan_out, close)`` where ``close`` must be called exactly once on shutdown. """ from opcda_diagnozer.report.base import FanOut from opcda_diagnozer.report.jsonl import JsonlReporter, config_digest from opcda_diagnozer.report.textlog import TextLogReporter sinks = [] def console_reporter(event: DiagEvent) -> None: # Console = the human view (spec §9): clear text, status NAME (0xNN) line = f"{event.time_utc.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC {event.severity.value:<8} {event.kind.value}" extras = " ".join(f"{k}={v}" for k, v in event.detail.items()) print(f"{line} {extras}" if extras else line, flush=True) sinks.append(console_reporter) sinks.append( JsonlReporter( config.report.jsonl.dir, max_mb=config.report.jsonl.max_mb, backups=config.report.jsonl.backups, digest=config_digest(config), ) ) sinks.append( TextLogReporter( config.report.textlog.dir, max_mb=config.report.textlog.max_mb, backups=config.report.textlog.backups, ) ) if sys.platform == "win32": from opcda_diagnozer.report.eventlog import EventLogReporter sinks.append( EventLogReporter( source=config.report.eventlog.source, min_severity=config.report.eventlog.min_severity, ) ) if config.report.http is not None: from opcda_diagnozer.report.http import HttpReporter sinks.append( HttpReporter( url=config.report.http.url, bearer_token=config.report.http.bearer_token, timeout_s=config.report.http.timeout_s, ) ) fan_out = FanOut(tuple(sinks), error=lambda msg: print(f"run: {msg}", file=sys.stderr, flush=True)) return fan_out def _cmd_run(config_path: str, server: str | None, host: str | None) -> int: """Foreground monitoring loop: poll → detect → report (Ctrl+C stops).""" import signal from opcda_diagnozer.events import DiagEvent from opcda_diagnozer.monitor.poller import Poller from opcda_diagnozer.opc.automation import AutomationOpcClient try: config = load_config(config_path) except ConfigError as exc: print(f"run: FAILED\n {exc}", file=sys.stderr) return 2 # --server/--host override opc.* for this run (same as the other verbs) if server is not None or host is not None: from dataclasses import replace from opcda_diagnozer.config import OpcConfig opc = replace( config.opc, server=server or config.opc.server, host=config.opc.host if host is None else host, ) config = replace(config, opc=opc) def client_factory() -> AutomationOpcClient: # Built on the worker thread: CoInitialize is per-thread (spec §10.3). return AutomationOpcClient( server=config.opc.server, host=config.opc.host, connect_timeout_s=config.opc.connect_timeout_s, ) reporters = _build_reporters(config) poller = Poller(client_factory, config, reporters.emit) stop_requested = threading.Event() def _on_ctrl_c(signum, frame) -> None: # noqa: ANN001, ARG001 if stop_requested.is_set(): raise KeyboardInterrupt # second Ctrl+C aborts immediately stop_requested.set() poller.request_stop() print("\nrun: stop requested - finishing current poll ...", file=sys.stderr, flush=True) signal.signal(signal.SIGINT, _on_ctrl_c) try: poller.run_forever() finally: reporters.close() 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 == "run": return _cmd_run(args.config, args.server, args.host) if args.verb == "list-servers": return _cmd_list_servers(args.host, args.source, 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( args.config, args.server, args.host, args.tags, args.source, args.json ) if args.verb == "probe": return _cmd_probe(args.config, args.server, args.host, args.source) 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())