This commit is contained in:
Jonas Bengtsson
2026-10-07 17:13:51 +02:00
parent 1c64aeb071
commit 2aa040e757
11 changed files with 749 additions and 29 deletions
+221 -14
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path
from opcda_diagnozer import __version__
from opcda_diagnozer.config import ConfigError, load_config
@@ -20,9 +21,21 @@ 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] = {
"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)",
@@ -34,11 +47,30 @@ _NOT_IMPLEMENTED: dict[str, str] = {
def _add_config_option(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--config", "-c",
default=DEFAULT_CONFIG,
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",
@@ -53,8 +85,44 @@ def _build_parser() -> argparse.ArgumentParser:
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")
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)
@@ -90,6 +158,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="list the devices (address-space branches) exposed by the configured server",
)
_add_config_option(p)
_add_server_overrides(p)
p.add_argument(
"--depth",
type=int,
@@ -113,6 +182,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="list the tags (leaves) of one device, or every tag on the server",
)
_add_config_option(p)
_add_server_overrides(p)
p.add_argument(
"device",
nargs="*",
@@ -211,19 +281,36 @@ def _cmd_list_servers(host: str | None, source: str, as_json: bool) -> int:
return 0
def _connect_from_config(config_path: str):
"""Load the config and connect to its configured server (Automation Wrapper).
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
config = load_config(config_path)
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=config.opc.server,
host=config.opc.host,
connect_timeout_s=config.opc.connect_timeout_s,
server=server,
host=host,
connect_timeout_s=connect_timeout_s,
)
client.connect()
return client
@@ -257,11 +344,14 @@ def _print_devices(space, as_json: bool) -> None:
print(f" {device.qualified_name:<{width}} ({', '.join(notes)})")
def _cmd_list_devices(config_path: str, depth: int, include_counts: bool, as_json: bool) -> int:
def _cmd_list_devices(
config_path: str, server: str | None, host: str | None,
depth: int, include_counts: bool, as_json: bool,
) -> int:
from opcda_diagnozer.opc.browse import list_devices
try:
client = _connect_from_config(config_path)
client = _connect_from_config(config_path, server, host)
browser = None
try:
browser = client.create_browser()
@@ -317,6 +407,8 @@ def _print_tags(tags, device_path: tuple[str, ...], recursive: bool, as_json: bo
def _cmd_list_tags(
config_path: str,
server: str | None,
host: str | None,
device: Sequence[str],
recursive: bool,
as_json: bool,
@@ -327,7 +419,7 @@ def _cmd_list_tags(
# 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)
client = _connect_from_config(config_path, server, host)
browser = None
try:
browser = client.create_browser()
@@ -353,6 +445,111 @@ def _cmd_list_tags(
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 main(argv: Sequence[str] | None = None) -> int:
"""CLI entry point (console script + ``python -m opcda_diagnozer``)."""
args = _build_parser().parse_args(argv)
@@ -361,9 +558,19 @@ def main(argv: Sequence[str] | None = None) -> int:
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)
return _cmd_list_devices(
args.config, args.server, args.host, 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)
return _cmd_list_tags(
args.config, args.server, args.host, args.device, args.recursive, 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} "