Files
opcda-diagnozer/src/opcda_diagnozer/main.py
T
2026-10-08 08:12:04 +02:00

700 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())