700 lines
24 KiB
Python
700 lines
24 KiB
Python
"""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()) |