🎉 Initial commit

This commit is contained in:
Jonas Bengtsson
2026-10-07 10:38:21 +02:00
commit 1c64aeb071
38 changed files with 3391 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
"""OPC DA Diagnozer — a read-only OPC DA reference diagnostic client.
Continuously polls a configured tag list over OPC DA and reports anomalies —
disconnections, latency, hung calls, quality degradation, stale/frozen values —
as structured events. See docs/SPECIFICATION.md.
Strictly read-only by construction: there is no write path to the OPC server
anywhere in this codebase (SPECIFICATION §10.5).
"""
__version__ = "0.1.0"
__all__ = ["__version__"]
+6
View File
@@ -0,0 +1,6 @@
"""Support for ``python -m opcda_diagnozer``."""
from opcda_diagnozer.main import main
if __name__ == "__main__":
raise SystemExit(main())
+255
View File
@@ -0,0 +1,255 @@
"""Typed configuration: dataclasses, YAML loading and strict validation.
Pydantic-free by design (IMPLEMENTATION_PLAN §4.1). Every validation error is
actionable: it names the offending key and explains what is wrong, so
``opcda-diagnozer check-config`` output lets an ops engineer fix the file
without reading the code.
"""
from __future__ import annotations
from dataclasses import MISSING, dataclass, field, fields, replace
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
_yaml = YAML(typ="safe") # pure-Python safe loader; config files are small
class ConfigError(Exception):
"""Invalid configuration; the message is user-facing (check-config output)."""
@dataclass(frozen=True)
class OpcConfig:
# server: OPC DA server ProgID, e.g. "Matrikon.OPC.Simulation.1"
# host: DCOM host; None means a local (same-machine) server
server: str
host: str | None = None
connect_timeout_s: float = 10.0
@dataclass(frozen=True)
class TagEntry:
# expect_change: True -> frozen-value detection (D13) applies to this tag
name: str
expect_change: bool = False
@dataclass(frozen=True)
class PollConfig:
interval_s: float = 1.0
timeout_ms: int = 5000 # D7: beyond this the call is flagged as a hang
hard_hang_s: float = 120.0 # SPECIFICATION §10.4: abandon + rebuild the COM thread
@dataclass(frozen=True)
class LatencyConfig:
warn_ms: float = 1000.0 # D4 spike threshold
critical_ms: float = 5000.0 # D5 sustained-slow severity
slow_poll_streak: int = 5 # D5 consecutive slow polls
window_polls: int = 60 # D6 rolling window for the p95 trend
@dataclass(frozen=True)
class QualityConfig:
persistent_bad_s: float = 30.0 # D10
flapping_transitions_per_window: int = 5 # D11
flapping_window_s: float = 600.0 # D11 window (10 min)
@dataclass(frozen=True)
class StaleConfig:
enabled: bool = True
max_age_s: float = 120.0 # D12 / D13
@dataclass(frozen=True)
class JsonlConfig:
dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs"
max_mb: int = 50
backups: int = 10
@dataclass(frozen=True)
class EventLogConfig:
source: str = "OPCDA-Diagnozer"
min_severity: str = "WARN" # INFO / WARN / CRITICAL
@dataclass(frozen=True)
class ReportConfig:
heartbeat_s: float = 300.0 # D15
jsonl: JsonlConfig = field(default_factory=JsonlConfig)
eventlog: EventLogConfig = field(default_factory=EventLogConfig)
@dataclass(frozen=True)
class Config:
"""Fully validated tool configuration (see config.example.yaml)."""
opc: OpcConfig
poll: PollConfig = field(default_factory=PollConfig)
tags: tuple[TagEntry, ...] = ()
latency: LatencyConfig = field(default_factory=LatencyConfig)
quality: QualityConfig = field(default_factory=QualityConfig)
stale: StaleConfig = field(default_factory=StaleConfig)
report: ReportConfig = field(default_factory=ReportConfig)
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "latency", "quality", "stale", "report"}
_TAG_KEYS = {"name", "expect_change"}
def _build_section(cls: type[Any], data: Any, where: str) -> Any:
"""Instantiate a frozen dataclass section, rejecting unknown/missing keys."""
data = {} if data is None else data
if not isinstance(data, dict):
raise ConfigError(f"{where}: must be a mapping, got {type(data).__name__}")
known = {f.name for f in fields(cls)}
unknown = sorted(set(data) - known)
if unknown:
raise ConfigError(f"{where}: unknown key(s) {unknown}; accepted keys: {sorted(known)}")
for f in fields(cls):
if f.name not in data and f.default is MISSING and f.default_factory is MISSING:
raise ConfigError(f"{where}: missing required key '{f.name}'")
try:
return cls(**data)
except TypeError as exc:
raise ConfigError(f"{where}: {exc}") from None
def _build_report(data: Any) -> ReportConfig:
data = {} if data is None else dict(data)
jsonl = _build_section(JsonlConfig, data.pop("jsonl", None), "report.jsonl")
eventlog = _build_section(EventLogConfig, data.pop("eventlog", None), "report.eventlog")
rest = _build_section(ReportConfig, data, "report")
return replace(rest, jsonl=jsonl, eventlog=eventlog)
def _build_tags(data: Any) -> tuple[TagEntry, ...]:
if data is None:
return ()
if not isinstance(data, list):
raise ConfigError(f"tags: must be a list, got {type(data).__name__}")
entries: list[TagEntry] = []
for i, item in enumerate(data):
if isinstance(item, str):
if not item.strip():
raise ConfigError(f"tags[{i}]: name must be a non-empty string")
entries.append(TagEntry(name=item))
continue
if not isinstance(item, dict):
raise ConfigError(
f"tags[{i}]: must be a tag name string or a mapping with 'name'"
" (and optional 'expect_change')"
)
name = item.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError(f"tags[{i}]: 'name' is required and must be a non-empty string")
unknown = sorted(set(item) - _TAG_KEYS)
if unknown:
raise ConfigError(f"tags[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_TAG_KEYS)}")
expect = item.get("expect_change", False)
if not isinstance(expect, bool):
raise ConfigError(
f"tags[{i}]: 'expect_change' must be a boolean, got {type(expect).__name__}"
)
entries.append(TagEntry(name=name, expect_change=expect))
return tuple(entries)
def _validate(config: Config) -> None:
"""Cross-field validation; each message names the offending key."""
if not config.opc.server or not config.opc.server.strip():
raise ConfigError(
"opc.server: required — the OPC DA server ProgID, "
"e.g. 'Matrikon.OPC.Simulation.1'"
)
if config.opc.connect_timeout_s <= 0:
raise ConfigError(f"opc.connect_timeout_s: must be > 0, got {config.opc.connect_timeout_s}")
if not config.tags:
raise ConfigError(
"tags: at least one tag is required — without tags there is nothing to monitor"
)
names = [t.name for t in config.tags]
duplicates = sorted({n for n in names if names.count(n) > 1})
if duplicates:
raise ConfigError(f"tags: duplicate tag name(s) {duplicates}")
if config.poll.interval_s <= 0:
raise ConfigError(f"poll.interval_s: must be > 0, got {config.poll.interval_s}")
if config.poll.timeout_ms <= 0:
raise ConfigError(f"poll.timeout_ms: must be > 0, got {config.poll.timeout_ms}")
if config.poll.hard_hang_s * 1000 <= config.poll.timeout_ms:
raise ConfigError(
f"poll.hard_hang_s: must exceed poll.timeout_ms "
f"({config.poll.timeout_ms} ms); got {config.poll.hard_hang_s} s"
)
if not 0 < config.latency.warn_ms <= config.latency.critical_ms:
raise ConfigError(
"latency.warn_ms: must satisfy 0 < warn_ms <= critical_ms "
f"(got warn_ms={config.latency.warn_ms}, critical_ms={config.latency.critical_ms})"
)
if config.latency.slow_poll_streak < 1:
raise ConfigError(f"latency.slow_poll_streak: must be >= 1, got {config.latency.slow_poll_streak}")
if config.latency.window_polls < 1:
raise ConfigError(f"latency.window_polls: must be >= 1, got {config.latency.window_polls}")
if config.quality.persistent_bad_s <= 0:
raise ConfigError(f"quality.persistent_bad_s: must be > 0, got {config.quality.persistent_bad_s}")
if config.quality.flapping_transitions_per_window < 1:
raise ConfigError(
f"quality.flapping_transitions_per_window: must be >= 1, "
f"got {config.quality.flapping_transitions_per_window}"
)
if config.quality.flapping_window_s <= 0:
raise ConfigError(f"quality.flapping_window_s: must be > 0, got {config.quality.flapping_window_s}")
if config.stale.enabled and config.stale.max_age_s <= 0:
raise ConfigError(f"stale.max_age_s: must be > 0, got {config.stale.max_age_s}")
if config.report.heartbeat_s <= 0:
raise ConfigError(f"report.heartbeat_s: must be > 0, got {config.report.heartbeat_s}")
if config.report.eventlog.min_severity not in ("INFO", "WARN", "CRITICAL"):
raise ConfigError(
"report.eventlog.min_severity: must be one of INFO/WARN/CRITICAL, "
f"got {config.report.eventlog.min_severity!r}"
)
if config.report.jsonl.max_mb < 1:
raise ConfigError(f"report.jsonl.max_mb: must be >= 1, got {config.report.jsonl.max_mb}")
if config.report.jsonl.backups < 0:
raise ConfigError(f"report.jsonl.backups: must be >= 0, got {config.report.jsonl.backups}")
def load_config(path: str | Path) -> Config:
"""Load and strictly validate a YAML configuration file."""
p = Path(path)
if not p.is_file():
raise ConfigError(f"config file not found: {p}")
try:
data = _yaml.load(p.read_text(encoding="utf-8"))
except YAMLError as exc:
raise ConfigError(f"{p}: invalid YAML: {exc}") from None
if data is None:
data = {}
if not isinstance(data, dict):
raise ConfigError(f"{p}: top level must be a mapping")
unknown_top = sorted(set(data) - _ALLOWED_TOP_LEVEL)
if unknown_top:
raise ConfigError(
f"{p}: unknown section(s) {unknown_top}; "
f"accepted sections: {sorted(_ALLOWED_TOP_LEVEL)}"
)
config = Config(
opc=_build_section(OpcConfig, data.get("opc"), "opc"),
poll=_build_section(PollConfig, data.get("poll"), "poll"),
tags=_build_tags(data.get("tags")),
latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
quality=_build_section(QualityConfig, data.get("quality"), "quality"),
stale=_build_section(StaleConfig, data.get("stale"), "stale"),
report=_build_report(data.get("report")),
)
try:
_validate(config)
except TypeError as exc:
raise ConfigError(f"invalid value type in config: {exc}") from None
return config
+87
View File
@@ -0,0 +1,87 @@
"""Event model: DiagEvent, Severity, EventKind (SPECIFICATION §8).
Every anomaly report is one :class:`DiagEvent`. ``time_utc`` is always the
**local client receive time** (wall clock), so events stay comparable even when
the remote host clock is wrong; device timestamps travel inside ``detail``,
clearly labeled (SPECIFICATION §8).
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
def utc_now() -> datetime:
"""Timezone-aware current UTC time (the local receive clock)."""
return datetime.now(timezone.utc)
class Severity(str, Enum):
INFO = "INFO"
WARN = "WARN"
CRITICAL = "CRITICAL"
class EventKind(str, Enum):
"""Event kinds — one per detection rule D1–D15 (SPECIFICATION §6)."""
CONNECTED = "CONNECTED"
DISCONNECTED = "DISCONNECTED" # D1
RECONNECTED = "RECONNECTED" # D2
RECONNECT_FLAPPING = "RECONNECT_FLAPPING" # D3
SLOW_POLL = "SLOW_POLL" # D4
SLOW_POLL_STREAK = "SLOW_POLL_STREAK" # D5
LATENCY_TREND = "LATENCY_TREND" # D6
POLL_HANG = "POLL_HANG" # D7
QUALITY_BAD = "QUALITY_BAD" # D8
QUALITY_UNCERTAIN = "QUALITY_UNCERTAIN" # D9
QUALITY_BAD_PERSISTENT = "QUALITY_BAD_PERSISTENT" # D10
QUALITY_FLAPPING = "QUALITY_FLAPPING" # D11
STALE_TIMESTAMP = "STALE_TIMESTAMP" # D12
FROZEN_VALUE = "FROZEN_VALUE" # D13
CONTROL_LINK_DEGRADED = "CONTROL_LINK_DEGRADED" # D14
HEARTBEAT = "HEARTBEAT" # D15
@dataclass(frozen=True)
class EventContext:
"""Snapshot counters attached to every event (SPECIFICATION §8)."""
uptime_s: float
reconnects_total: int
rtt_p95_ms: float | None = None
tags_bad: int = 0
tags_total: int = 0
@dataclass(frozen=True)
class DiagEvent:
"""One structured diagnostic record — JSONL-ready via :meth:`to_json`."""
time_utc: datetime
kind: EventKind
severity: Severity
detail: dict[str, Any] = field(default_factory=dict)
context: EventContext | None = None
def to_json(self) -> str:
"""Serialize as one JSON line (SPECIFICATION §8, JSONL append-safe)."""
if self.time_utc.tzinfo is None:
raise ValueError("time_utc must be timezone-aware (use events.utc_now())")
payload: dict[str, Any] = {
"time_utc": _utc_z(self.time_utc),
"kind": self.kind.value,
"severity": self.severity.value,
"detail": self.detail,
"context": asdict(self.context) if self.context is not None else None,
}
return json.dumps(payload, ensure_ascii=False)
def _utc_z(dt: datetime) -> str:
"""ISO-8601 UTC with millisecond precision and a Z suffix."""
return dt.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
+377
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Monitoring core: connection state machine, poller, detectors D1–D15."""
+24
View File
@@ -0,0 +1,24 @@
"""Detector rules D1–D15 (SPECIFICATION §6).
Phase 1 (IMPLEMENTATION_PLAN §4.6): each detector becomes a small pure class
over :class:`PollOutcome` sequences with an injected clock; a rolling window
(deque, size ``window_polls``) powers p95/streak/flapping logic. Severity
mapping lives in SPECIFICATION §6.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from opcda_diagnozer.opc.base import ReadResult
@dataclass(frozen=True)
class PollOutcome:
"""One completed poll cycle, as fed to the detectors."""
started_at: datetime # local receive time when the read call started (wall clock)
rtt_ms: float # measured round-trip time of the read call
results: tuple[ReadResult, ...] = () # per-tag results; empty if the call itself failed
error: str | None = None # normalized OpcError message when the whole poll failed (D1/D2/D7)
+25
View File
@@ -0,0 +1,25 @@
"""The poll loop on the single dedicated COM thread (SPECIFICATION §10.3).
Phase 1 (IMPLEMENTATION_PLAN §4.7): connect → poll loop → feed detectors, plus
the hang watchdog (SPECIFICATION §10.4): a second thread timestamps poll
starts/ends; when a call exceeds ``hard_hang_s`` the stuck COM thread is
abandoned and a fresh connection is built on a new thread.
"""
from __future__ import annotations
from opcda_diagnozer.config import Config
from opcda_diagnozer.opc.base import OpcClient
class Poller:
"""Drives one :class:`OpcClient` on its own thread — implemented in Phase 1."""
def __init__(self, client: OpcClient, config: Config) -> None:
self._client = client
self._config = config
def run_forever(self) -> None:
raise NotImplementedError(
"Phase 1: poller + hang watchdog (docs/IMPLEMENTATION_PLAN.md §4.7)"
)
+20
View File
@@ -0,0 +1,20 @@
"""Connection state machine + reconnect backoff (SPECIFICATION §10.2).
States: Connecting → Connected → ReconnectWait → Connecting …
Backoff is exponential 1 s → 2 s → … capped at 60 s; every disconnect/reconnect
pair is an event (D1/D2) with the outage duration computed and reported.
The transition/backoff engine lands in Phase 1 (IMPLEMENTATION_PLAN §4.5).
"""
from __future__ import annotations
from enum import Enum
class ConnectionState(Enum):
"""States of the connection state machine (SPECIFICATION §10.2)."""
CONNECTING = "connecting"
CONNECTED = "connected"
RECONNECT_WAIT = "reconnect_wait"
+1
View File
@@ -0,0 +1 @@
"""OPC DA client backends: shared protocol, Automation Wrapper adapter, fake."""
+222
View File
@@ -0,0 +1,222 @@
"""OPC DA Automation Wrapper adapter (``OPCAutomation.dll``) — Phase 1.
The Automation Wrapper is a ubiquitous **32-bit in-proc** COM DLL: the Python
process must be 32-bit to load it (SPECIFICATION §11 — the Phase 0 bitness
decision). All COM work happens on one dedicated STA thread owned by the
poller (SPECIFICATION §10.3); COM exceptions map to :class:`OpcError` with the
``win32com`` scode (IMPLEMENTATION_PLAN §4.4).
pywin32 imports are deferred into the methods so this module stays importable
on any platform — tests run cross-OS with the fake client (plan §9).
Verified against the live ``OPC.Automation`` on this machine (2026-10-07,
Matrikon.OPC.Simulation.1): ``Connect(progId)`` / ``Connect(progId, node)``,
``CreateBrowser()`` returns a dispatch object with ``ShowBranches`` /
``ShowLeafs`` (sic) / ``Count`` / ``Item(i)`` (1-based) / ``GetItemID`` /
``MoveDown`` / ``MoveUp`` / ``MoveToRoot`` / ``MoveTo(list)`` /
``CurrentPosition``; ``Organization`` 1 = hierarchical, 2 = flat. Bad branch
names raise ``E_INVALIDARG``; ``MoveTo`` requires a *sequence* (a bare string
is a pywin32 SAFEARRAY ``TypeError``).
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from opcda_diagnozer.opc.base import (
Browser,
OpcError,
ReadResult,
TagInfo,
as_com_error,
)
_NOT_IMPL = "Phase 1: Automation Wrapper adapter (docs/IMPLEMENTATION_PLAN.md §4.4)"
#: OPC_BROWSE organization constants (Automation Wrapper): 1 = hierarchical,
#: 2 = flat (tags addressed by bare ItemID, no branches to walk).
_ORGANIZATION_LABELS = {1: "hierarchical", 2: "flat"}
class _AutomationBrowser:
"""Thin, read-only :class:`Browser` over one wrapper browser object.
COM failures are re-raised as :class:`OpcError` with the raw HRESULT. The
underlying dispatch object is dropped in :meth:`close` **before**
``CoUninitialize`` — releasing an RCW after apartment teardown crashes the
process ("Win32 exception releasing IUnknown" / access violation).
"""
def __init__(self, raw_browser: Any) -> None:
self._browser = raw_browser
@property
def organization(self) -> str:
try:
raw = int(self._browser.Organization)
except Exception as exc: # noqa: BLE001
raise as_com_error("could not read the browser Organization property", exc) from None
return _ORGANIZATION_LABELS.get(raw, f"unknown ({raw})")
def _move_to(self, path: Sequence[str]) -> None:
"""Reposition the browser at ``path`` (root when empty)."""
try:
if path:
# A bare string is a pywin32 marshalling TypeError — always a list.
self._browser.MoveTo(list(path))
else:
self._browser.MoveToRoot()
except Exception as exc: # noqa: BLE001
dotted = ".".join(path)
raise as_com_error(
f"could not move the browser to {dotted or 'the root'} "
"(bad branch name?)",
exc,
) from None
def list_branches(self, path: Sequence[str] = ()) -> list[str]:
self._move_to(path)
try:
self._browser.ShowBranches()
count = int(self._browser.Count)
return [str(self._browser.Item(i)).strip() for i in range(1, count + 1)]
except Exception as exc: # noqa: BLE001
raise as_com_error(
f"could not enumerate branches under {'.'.join(path) or 'the root'}",
exc,
) from None
def list_leaves(self, path: Sequence[str] = ()) -> list[TagInfo]:
self._move_to(path)
try:
self._browser.ShowLeafs() # sic — the wrapper spells it "Leafs"
count = int(self._browser.Count)
leaves: list[TagInfo] = []
for i in range(1, count + 1):
name = str(self._browser.Item(i)).strip()
if not name:
continue
item_id = str(self._browser.GetItemID(name)).strip()
if not item_id:
# Some servers return an empty ItemID for filtered leaves;
# fall back to the browse name so config tags stay usable.
item_id = name
leaves.append(TagInfo(name=name, item_id=item_id, path=tuple(path)))
return leaves
except Exception as exc: # noqa: BLE001
raise as_com_error(
f"could not enumerate tags under {'.'.join(path) or 'the root'}",
exc,
) from None
def move_to(self, path: Sequence[str]) -> None:
self._move_to(path)
def close(self) -> None:
self._browser = None
class AutomationOpcClient:
"""``win32com``-based :class:`OpcClient`.
``connect`` / ``create_browser`` / ``close`` are live (they power the
``list-devices`` / ``list-tags`` validation verbs); ``read`` lands with the
Phase 1 core engine (IMPLEMENTATION_PLAN §4.4).
"""
def __init__(self, server: str, host: str | None = None, connect_timeout_s: float = 10.0) -> None:
self._server = server
self._host = host
self._connect_timeout_s = connect_timeout_s
self._com_initialized = False
self._opc: Any = None # the wrapper's dispatch object when connected
self._browsers: list[_AutomationBrowser] = [] # live browsers of this client
@staticmethod
def _require_pywin32() -> None:
try:
import pythoncom # noqa: F401 (import guard, Windows + pywin32 only)
except ImportError as exc:
raise OpcError(
"the Automation Wrapper backend requires pywin32 (Windows only); "
"install it into the 32-bit venv with: pip install pywin32"
) from exc
def connect(self) -> None:
self._require_pywin32()
import pythoncom
from win32com.client import Dispatch
try:
pythoncom.CoInitialize()
self._com_initialized = True
except Exception as exc: # noqa: BLE001
raise as_com_error("could not initialize COM on this thread", exc) from None
try:
try:
opc = Dispatch("OPC.Automation")
if self._host:
opc.Connect(self._server, self._host)
else:
opc.Connect(self._server)
self._opc = opc
except Exception as exc: # noqa: BLE001
where = f"host {self._host!r}" if self._host else "the local machine"
raise as_com_error(
f"could not connect to OPC DA server {self._server!r} on {where}",
exc,
) from None
except BaseException:
# The apartment must be torn down whenever the connection is not up.
self._release_and_uninit()
raise
def create_browser(self) -> Browser:
if self._opc is None:
raise OpcError("AutomationOpcClient: create_browser() before connect()")
try:
raw = self._opc.CreateBrowser()
except Exception as exc: # noqa: BLE001
raise as_com_error(
f"server {self._server!r} does not support browsing (CreateBrowser failed)",
exc,
) from None
browser = _AutomationBrowser(raw)
self._browsers.append(browser)
return browser
def _release_and_uninit(self) -> None:
"""Drop COM references **inside** the apartment, then CoUninitialize.
Live browsers created by this client are released first: releasing an
RCW after the apartment is torn down causes "Win32 exception releasing
IUnknown" / access violations at exit (observed: 0x80010108 fatal
exception under pytest).
"""
for browser in self._browsers:
browser.close()
self._browsers.clear()
if self._opc is not None:
try:
# Graceful server-side disconnect before dropping our references:
# the wrapper tears down its server connection (and the RPC
# channel) instead of the RPC runtime discovering the drop at
# CoUninitialize time.
self._opc.Disconnect()
except Exception: # noqa: BLE001 — best-effort teardown, never fatal
pass
self._opc = None
if self._com_initialized:
import pythoncom
self._com_initialized = False
pythoncom.CoUninitialize()
def read(self, tags: Sequence[str]) -> list[ReadResult]:
self._require_pywin32()
raise NotImplementedError(_NOT_IMPL)
def close(self) -> None:
self._release_and_uninit()
+164
View File
@@ -0,0 +1,164 @@
"""OPC client abstraction (IMPLEMENTATION_PLAN §4.3).
Backends implement :class:`OpcClient` (Automation Wrapper, fake). COM errors
are normalized to :class:`OpcError` carrying the raw HRESULT and a decoded name.
There is deliberately **no write path** — the tool is read-only by construction
(SPECIFICATION §10.5).
Browsing (devices = branches, tags = leaves) is modelled by :class:`Browser`;
the ``list-devices`` / ``list-tags`` CLI verbs walk the address space through
it without any recursion into the monitored poll path.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol, runtime_checkable
@dataclass(frozen=True)
class ReadResult:
"""One tag read: value, raw quality, device timestamp — or a per-tag error."""
tag: str
value: Any
quality_raw: int
device_time: datetime | None
error: str | None = None
# Common HRESULTs the tool decodes and reports (README "DCOM troubleshooting").
_HRESULT_NAMES: dict[int, str] = {
0x800706BA: "RPC_S_SERVER_UNAVAILABLE — server process down, firewall, or DCOM activation refused",
0x800706BE: "RPC_S_CALL_FAILED — server crashed or connection reset mid-flight",
0x80010108: "RPC_E_DISCONNECTED — OPC server dropped us (crash/restart)",
0x80070005: "E_ACCESSDENIED — DCOM security misconfiguration",
0x80040154: "REGDB_E_CLASSNOTREG — wrong ProgID, or wrapper/server registered under a different bitness",
0x80020009: "DISP_E_EXCEPTION — the server rejected the call (e.g. no such branch/tag)",
0x80070057: "E_INVALIDARG — invalid argument (e.g. wrong branch or item name)",
0xC0040007: "OPC_E_INVALIDITEMID — tag name wrong in config (surfaced by probe)",
}
def decode_hresult(hresult: int) -> str:
"""Explain a raw HRESULT in ops terms; unknown codes get a stable label."""
return _HRESULT_NAMES.get(hresult & 0xFFFFFFFF, "unrecognized HRESULT")
def as_com_error(message: str, exc: BaseException) -> OpcError:
"""Map a pywin32 error to :class:`OpcError`, keeping the raw HRESULT when present.
The source exception's traceback is **cleared** before it is discarded: a
propagating exception pins its frames — including locals holding raw COM
dispatch pointers (``CDispatch``) — until a later garbage collection. When
that GC runs after ``CoUninitialize`` the RCW release hits a dead apartment
and Windows prints "Win32 exception occurred releasing IUnknown" (observed
with the Automation Wrapper). Clearing the traceback keeps the HRESULT
(already extracted) while letting the RCWs die inside the apartment.
"""
hresult = getattr(exc, "hresult", None)
if hresult is None and getattr(exc, "args", None):
first = exc.args[0]
if isinstance(first, int):
hresult = first
exc.__traceback__ = None
return OpcError(message, hresult=hresult)
class OpcError(RuntimeError):
"""Normalized COM/OPC error; carries the raw HRESULT when available."""
def __init__(self, message: str, hresult: int | None = None) -> None:
super().__init__(message)
self.hresult = hresult
def __str__(self) -> str:
base = super().__str__()
if self.hresult is None:
return base
code = self.hresult & 0xFFFFFFFF
return f"{base} (HRESULT 0x{code:08X}: {decode_hresult(code)})"
#: Access rights mask bits (OPCEnum / Automation Wrapper OPC_ACCESS-rights).
#: The wrapper's ``AccessRights`` property combines READ (1) and WRITE (2);
#: this tool is read-only by construction and never sets a WRITE-only view.
ACCESS_READ = 0x01
ACCESS_WRITE = 0x02
@dataclass(frozen=True)
class DeviceInfo:
"""One browsable device: a branch in the server's hierarchical address space.
The Automation Wrapper exposes no per-branch metadata beyond the name, so a
"device" here is simply the branch path plus the counts the CLI prints.
"""
name: str
#: Branch path from the server root, e.g. ``("Simulation Items", "Bucket Brigade")``.
path: tuple[str, ...]
#: Sub-branches directly under this device (usually channels/groups).
sub_devices: tuple[str, ...] = ()
#: Leaf (tag) names directly under this device.
tag_names: tuple[str, ...] = ()
@property
def qualified_name(self) -> str:
"""Human-friendly dotted rendering: ``Simulation Items.Bucket Brigade``."""
return ".".join(self.path) if self.path else self.name
@dataclass(frozen=True)
class TagInfo:
"""One browsable tag (leaf): its ItemID is what goes into config ``tags:``."""
name: str
#: Fully qualified ItemID as accepted by read calls, e.g. ``Bucket Brigade.Int1``.
item_id: str
#: Branch path of the device the tag lives under; ``()`` for flat servers.
path: tuple[str, ...] = ()
@runtime_checkable
class Browser(Protocol):
"""Read-only traversal of a connected server's address space.
Mirrors the Automation Wrapper's ``IOPCBrowse`` surface (SPECIFICATION §8):
branches are devices/sub-devices, leaves are tags. Implementations walk on
demand; the browse verbs fetch one level at a time so a huge server never
stalls the CLI on an unbounded recursion.
"""
@property
def organization(self) -> str:
"""``"hierarchical"`` or ``"flat"`` (OPC_BROWSE constants)."""
...
def list_branches(self, path: Sequence[str] = ()) -> list[str]:
"""Sub-branch names directly under ``path`` (empty path = server root)."""
...
def list_leaves(self, path: Sequence[str] = ()) -> list[TagInfo]:
"""Tags directly under ``path``, with their fully qualified ItemIDs."""
...
def move_to(self, path: Sequence[str]) -> None:
"""Reposition at ``path`` (root when empty); raises OpcError when it does not exist."""
...
def close(self) -> None:
"""Release the underlying COM/browser resources (inside the apartment)."""
...
@runtime_checkable
class OpcClient(Protocol):
"""A read-only OPC DA client: connect → browse/read → close. No write path."""
def connect(self) -> None: ...
def create_browser(self) -> Browser: ...
def read(self, tags: Sequence[str]) -> list[ReadResult]: ...
def close(self) -> None: ...
+194
View File
@@ -0,0 +1,194 @@
"""Browse the connected server's address space: devices (branches) and tags (leaves).
OPC DA has no formal "device" concept — servers publish a *hierarchical
address space* whose top-level branches almost always represent devices (or
device families), with tags as the leaves. This module maps that tree onto the
two questions ops asks (SPECIFICATION §8, validation tooling):
- *Which devices does this server expose?* → :func:`list_devices`
- *Which tags does device X expose?* → :func:`list_tags`
The Automation Wrapper browser (``opc.CreateBrowser()``) is the only portable
traversal API — it works over DCOM for remote servers too, with the same
Launch+Activation requirements as ``list-servers``. Traversal is one level at
a time; the ``max_depth`` guard keeps a pathological (cyclic/AB-tree) server
from stalling the CLI.
Flat-namespace servers report ``organization == "flat"``; there every leaf is
addressed by its bare ItemID, the root is treated as one implicit device.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from opcda_diagnozer.opc.base import Browser, DeviceInfo, OpcError, TagInfo
#: Walk guard: deep SCADA hierarchies exist (Channel → Device → Tag), but OPC
#: trees deeper than this are almost certainly a server-side loop.
MAX_DEPTH = 32
@dataclass(frozen=True)
class Space:
"""Result of a device-space walk: one entry per discovered branch node."""
organization: str
root: DeviceInfo
devices: tuple[DeviceInfo, ...]
def split_device_path(path: Sequence[str] | str | None) -> tuple[str, ...]:
"""Normalize a user-supplied device path into branch segments.
A **single** string is treated as dotted: ``"Simulation Items.Bucket Brigade"``
→ ``("Simulation Items", "Bucket Brigade")``. **Multiple** segments are
taken as explicit branch names — no dot splitting, so names that contain
a dot stay intact (they just have to be passed as separate arguments).
Empty segments and whitespace are dropped.
"""
if path is None:
return ()
if isinstance(path, str):
segments: list[str] = [p.strip() for p in path.split(".")]
return tuple(p for p in segments if p)
parts: list[str] = []
for segment in path:
piece = str(segment).strip()
if piece:
parts.append(piece)
return tuple(parts)
def list_devices(
browser: Browser,
*,
depth: int = 1,
include_tag_counts: bool = True,
) -> Space:
"""Enumerate devices (branches) starting at the server root.
``depth=1`` lists top-level branches (the typical "device list"); greater
depths reveal sub-devices (channels/groups). Each :class:`DeviceInfo`
carries the direct sub-branches and — when ``include_tag_counts`` is on —
the leaf names under it, so the CLI can print ``Device (3 tags, 2 sub)``.
Raises :class:`OpcError` when the server rejects the walk (COM failure or
unsupported browse interface). The walk is bounded by ``depth`` (≤
:data:`MAX_DEPTH`), so a pathological server can never stall the CLI.
"""
if depth < 1:
raise ValueError("depth must be >= 1")
if depth > MAX_DEPTH:
raise ValueError(f"depth must be <= {MAX_DEPTH}")
organization = browser.organization
root_branches = browser.list_branches(())
root_leaves = browser.list_leaves(()) if include_tag_counts else []
root = DeviceInfo(
name="(root)",
path=(),
sub_devices=tuple(root_branches),
tag_names=tuple(t.name for t in root_leaves),
)
devices: list[DeviceInfo] = []
_walk_level(browser, (), root_branches, depth, devices, include_tag_counts)
return Space(organization=organization, root=root, devices=tuple(devices))
def _walk_level(
browser: Browser,
path: tuple[str, ...],
branches: Sequence[str],
depth: int,
devices: list[DeviceInfo],
include_tag_counts: bool,
) -> None:
if depth <= 0:
return
for branch in branches:
sub_path = path + (branch,)
sub_branches = tuple(browser.list_branches(sub_path))
tag_names = (
tuple(t.name for t in browser.list_leaves(sub_path))
if include_tag_counts
else ()
)
devices.append(
DeviceInfo(
name=branch,
path=sub_path,
sub_devices=sub_branches,
tag_names=tag_names,
)
)
_walk_level(browser, sub_path, sub_branches, depth - 1, devices, include_tag_counts)
def list_tags(
browser: Browser,
device: str | Sequence[str] | None = None,
*,
recursive: bool = False,
) -> list[TagInfo]:
"""List the tags of one device (branch), or of the whole server.
``device`` accepts either a dotted string (``"Simulation Items.Bucket
Brigade"``) or a pre-split sequence of branch names. ``None`` means the
server root; with ``recursive=True`` every leaf under it is returned
(bounded by :data:`MAX_DEPTH`). The returned :class:`TagInfo.item_id`
values are exactly what goes into the config ``tags:`` list. An unknown
device path raises :class:`OpcError` pointing at ``list-devices``.
"""
path = _require_device(browser, device, what="list-tags")
leaves = browser.list_leaves(path)
tags: list[TagInfo] = list(leaves)
if not recursive:
return sorted(tags, key=lambda t: t.item_id.lower())
sub_branches = browser.list_branches(path)
_collect_recursive(browser, path, sub_branches, tags)
return sorted(tags, key=lambda t: t.item_id.lower())
def _collect_recursive(
browser: Browser,
path: tuple[str, ...],
branches: Sequence[str],
tags: list[TagInfo],
) -> None:
for branch in branches:
sub_path = path + (branch,)
if len(sub_path) > MAX_DEPTH:
raise OpcError(
f"address space deeper than {MAX_DEPTH} levels at "
f"{'.'.join(sub_path)} — refusing to walk further (possible "
"server-side cycle)"
)
tags.extend(browser.list_leaves(sub_path))
_collect_recursive(browser, sub_path, browser.list_branches(sub_path), tags)
def _require_device(
browser: Browser,
device: str | Sequence[str] | None,
*,
what: str,
) -> tuple[str, ...]:
"""Resolve and validate a user-supplied device path (exists, is a branch)."""
path = split_device_path(device)
if not path:
return ()
siblings = browser.list_branches(path[:-1])
if path[-1] not in siblings:
raise OpcError(
f"{what}: device {path[-1]!r} not found under "
f"{'.'.join(path[:-1]) or '(root)'} — run list-devices to see the "
"available branches"
)
return path
def tags_for_device(browser: Browser, device: str | Sequence[str]) -> list[TagInfo]:
"""List tags of a **known** device path — validation helper (probe)."""
path = _require_device(browser, device, what="list-tags")
return sorted(browser.list_leaves(path), key=lambda t: t.item_id.lower())
+224
View File
@@ -0,0 +1,224 @@
"""Enumerate the OPC DA servers available to this process (local, or via DCOM).
Two mechanisms, matching how OPC DA discovery actually works on Windows:
- ``automation`` — the OPC DA Automation Wrapper (``OPC.Automation``, i.e.
``OPCAutomation.dll``). ``GetOPCServers(node)`` returns the ProgIDs the host
publishes through the COM component categories; with a node name it
enumerates a remote host over DCOM (requires Launch+Activation permissions,
SPECIFICATION §11). Needs COM initialized; a 32-bit interpreter sees the
32-bit registrations — the same servers this tool can load.
- ``registry`` — a direct COM-registration scan of
``HKCR\\CLSID\\{...}\\Implemented Categories`` for the OPC DA component
categories. Local only, needs no COM at all — the fallback for locked-down
hosts or boxes where the wrapper DLL is not registered.
OPC Foundation DA component categories (verified):
DA 1.0 ``{63D5F430-CFE4-11D1-B2C8-0060083BA1FB}``
DA 2.0 ``{63D5F432-CFE4-11D1-B2C8-0060083BA1FB}``
DA 3.0 ``{CC603E42-48D7-4E57-A010-7563B3B0A715}``
"""
from __future__ import annotations
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Literal
from opcda_diagnozer.opc.base import OpcError, as_com_error
AUTOMATION_PROG_ID = "OPC.Automation" # OPC DA Automation Wrapper (OPCAutomation.dll)
#: Component-category CATID -> human label (OPC Foundation).
DA_CATEGORIES: dict[str, str] = {
"{63D5F430-CFE4-11D1-B2C8-0060083BA1FB}": "DA 1.0",
"{63D5F432-CFE4-11D1-B2C8-0060083BA1FB}": "DA 2.0",
"{CC603E42-48D7-4E57-A010-7563B3B0A715}": "DA 3.0",
}
Source = Literal["auto", "automation", "registry"]
@dataclass(frozen=True)
class ServerInfo:
"""One discoverable OPC DA server."""
prog_id: str
source: Literal["automation", "registry"]
clsid: str | None = None
description: str | None = None
da_versions: tuple[str, ...] = ()
def normalize_string_array(raw: Any) -> list[str]:
"""Coerce a COM VARIANT result (tuple of str / str / None) to a clean str list."""
if raw is None:
items: tuple[Any, ...] = ()
elif isinstance(raw, str):
items = (raw,)
elif isinstance(raw, (list, tuple)):
items = tuple(raw)
elif hasattr(raw, "__iter__"):
items = tuple(raw)
else:
items = ()
cleaned = [item.strip() for item in items if isinstance(item, str) and item.strip()]
seen: set[str] = set()
deduped: list[str] = []
for item in cleaned:
if item not in seen:
seen.add(item)
deduped.append(item)
return deduped
@contextmanager
def _com_apartment() -> Iterator[None]:
"""Initialize COM for the duration of the enumeration (SPECIFICATION §10.3).
Enumeration runs on the CLI thread, not the future poller's dedicated COM
thread, so it initializes/uninitializes its own apartment here.
"""
import pythoncom
pythoncom.CoInitialize()
try:
yield
finally:
pythoncom.CoUninitialize()
def _via_automation(host: str | None) -> list[ServerInfo]:
"""Enumerate via the Automation Wrapper — local machine, or remote via DCOM."""
try:
import pywintypes
from win32com.client import Dispatch
except ImportError as exc:
raise OpcError(
"the Automation Wrapper source requires pywin32 (Windows only); "
"install it into the 32-bit venv with: pip install pywin32"
) from exc
where = f"host {host!r}" if host else "the local machine"
with _com_apartment():
try:
opc = Dispatch(AUTOMATION_PROG_ID)
try:
raw = opc.GetOPCServers(host) if host else opc.GetOPCServers()
# Marshal everything to plain Python data and release the COM
# reference *inside* the apartment: releasing an IUnknown after
# CoUninitialize crashes (observed: "Win32 exception releasing
# IUnknown" / access violation at interpreter shutdown).
prog_ids = normalize_string_array(raw)
finally:
opc = None # drop the COM reference while COM is still initialized
except pywintypes.com_error as exc:
raise as_com_error(
f"could not enumerate OPC DA servers on {where} via the "
"Automation Wrapper (is OPCAutomation.dll registered?)",
exc,
) from None
except AttributeError as exc:
raise OpcError(
"the registered Automation Wrapper does not expose "
f"GetOPCServers ({exc}); try --source registry"
) from None
servers = [ServerInfo(prog_id=prog, source="automation") for prog in prog_ids]
return sorted(servers, key=lambda s: s.prog_id.lower())
def _registry_value(root: Any, subkey: str) -> str | None:
"""Default string value of ``root\\subkey``; None when absent/not a string."""
import winreg
try:
with winreg.OpenKey(root, subkey) as key:
value, _ = winreg.QueryValueEx(key, "")
except OSError:
return None
return value if isinstance(value, str) else None
def _implemented_da_categories(root: Any, clsid: str) -> tuple[str, ...]:
"""DA versions a CLSID implements, from its Implemented Categories key."""
import winreg
found: list[str] = []
try:
with winreg.OpenKey(root, f"{clsid}\\Implemented Categories") as cats:
index = 0
while True:
try:
category = winreg.EnumKey(cats, index)
except OSError:
break
index += 1
label = DA_CATEGORIES.get(category)
if label is not None:
found.append(label)
except OSError:
pass # no Implemented Categories subkey -> not an OPC DA server
return tuple(sorted(found))
def _via_registry(host: str | None) -> list[ServerInfo]:
"""Scan COM registrations for DA component categories — local, no COM needed.
A 32-bit process sees the 32-bit registry view, which is exactly the set
of servers loadable by this (32-bit) tool (SPECIFICATION §11).
"""
if host is not None:
raise OpcError(
"the registry source only enumerates the local machine; "
"remote enumeration requires the Automation wrapper over DCOM "
"(use --source auto or --source automation with --host)"
)
if sys.platform != "win32":
raise OpcError("the registry source requires Windows")
import winreg
servers: list[ServerInfo] = []
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, "CLSID") as root:
index = 0
while True:
try:
clsid = winreg.EnumKey(root, index)
except OSError:
break
index += 1
if not (clsid.startswith("{") and clsid.endswith("}")):
continue
da_versions = _implemented_da_categories(root, clsid)
if not da_versions:
continue
prog_id = _registry_value(root, f"{clsid}\\ProgID")
servers.append(
ServerInfo(
prog_id=prog_id or clsid,
source="registry",
clsid=clsid,
description=_registry_value(root, clsid),
da_versions=da_versions,
)
)
return sorted(servers, key=lambda s: s.prog_id.lower())
def list_servers(host: str | None = None, source: Source = "auto") -> list[ServerInfo]:
"""Enumerate available OPC DA servers (see module docstring for mechanisms).
``auto`` tries the Automation Wrapper first and falls back to the registry
scan when COM enumeration fails on the local machine; with a remote
``host`` only the Automation Wrapper can be used, so its error propagates.
"""
if source not in ("auto", "automation", "registry"):
raise ValueError(f"unknown source {source!r}")
if source in ("auto", "automation"):
try:
return _via_automation(host)
except OpcError:
if source == "automation" or host is not None:
raise
# local auto: fall back to the COM-registration scan
return _via_registry(host)
+130
View File
@@ -0,0 +1,130 @@
"""Scriptable in-process fake OPC client — tests and demo without COM/hardware.
Phase 1 (IMPLEMENTATION_PLAN §4.9) turns this into the scenario engine
(scripted sequences of good reads, errors, hangs, quality flips) that powers
the entire unit-test suite. This placeholder always reads GOOD-quality zeros
so the skeleton's smoke tests have something deterministic to assert on.
The fake carries a small **hierarchical address space** (Matrikon-like:
``Simulation Items → Bucket Brigade → Int1…``) so the browse verbs
(``list-devices`` / ``list-tags``) and their tests run cross-OS without COM.
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from opcda_diagnozer.opc.base import Browser, OpcError, ReadResult, TagInfo
#: The fake address space: branch path -> (sub-branches, leaf names).
#: Mirrors the shape of a real SCADA server (Channel → Device → Tags).
FAKE_SPACE: dict[tuple[str, ...], tuple[tuple[str, ...], tuple[str, ...]]] = {
(): (("Simulation Items", "Configured Aliases"), ("#MonitorACLFile", "@ClientCount", "@Clients")),
("Simulation Items",): (
(
"Bucket Brigade",
"Random",
"Read Error",
"Saw-toothed Waves",
"Square Waves",
"Triangle Waves",
"Write Error",
"Write Only",
),
(),
),
("Simulation Items", "Bucket Brigade"): (
(),
("Int1", "Int2", "Int4", "Real4", "Real8", "String", "Boolean"),
),
("Simulation Items", "Random"): ((), ("Int1", "Int4", "Money", "Time")),
("Simulation Items", "Read Error"): ((), ("Int1", "Int4")),
("Simulation Items", "Saw-toothed Waves"): ((), ("Int1", "Real8")),
("Simulation Items", "Square Waves"): ((), ("Int1", "Real8")),
("Simulation Items", "Triangle Waves"): ((), ("Int1", "Real8")),
("Simulation Items", "Write Error"): ((), ("Int1", "Int4")),
("Simulation Items", "Write Only"): ((), ("Int1", "Int4")),
("Configured Aliases",): ((), ("Alias1", "Alias2")),
}
def _item_id(path: tuple[str, ...], leaf: str) -> str:
"""Fully qualified ItemID, mirroring the verified Matrikon sim convention:
the immediate branch + leaf (``Bucket Brigade.Int1`` even though the branch
is nested under ``Simulation Items``); bare leaf at the root. This is what
the real server's ``GetItemID`` returns for the same tree.
"""
return f"{path[-1]}.{leaf}" if path else leaf
class _FakeBrowser:
"""In-process :class:`Browser` over a fake space — pure data, no COM."""
def __init__(self, space: dict[tuple[str, ...], tuple[tuple[str, ...], tuple[str, ...]]]) -> None:
self._space = space
@property
def organization(self) -> str:
return "hierarchical" if self._space.get(()) and self._space[()][0] else "flat"
def list_branches(self, path: Sequence[str] = ()) -> list[str]:
key = tuple(path)
entry = self._space.get(key)
if entry is None:
raise OpcError(f"no such branch: {'.'.join(key) or '(root)'}")
return list(entry[0])
def list_leaves(self, path: Sequence[str] = ()) -> list[TagInfo]:
key = tuple(path)
entry = self._space.get(key)
if entry is None:
raise OpcError(f"no such branch: {'.'.join(key) or '(root)'}")
return [
TagInfo(name=leaf, item_id=_item_id(key, leaf), path=key)
for leaf in entry[1]
]
def move_to(self, path: Sequence[str]) -> None:
key = tuple(path)
if key and key not in self._space:
raise OpcError(f"no such branch: {'.'.join(key)}")
def close(self) -> None:
self._space = {}
class FakeOpcClient:
"""In-process :class:`OpcClient`; every read returns GOOD-quality zeros.
``space`` is injectable so tests can exercise flat namespaces, empty
servers, and deep trees; the default mirrors a Matrikon-like hierarchy.
"""
def __init__(
self,
space: dict[tuple[str, ...], tuple[tuple[str, ...], tuple[str, ...]]] | None = None,
) -> None:
self.connected = False
self._space = FAKE_SPACE if space is None else space
def connect(self) -> None:
self.connected = True
def create_browser(self) -> Browser:
if not self.connected:
raise OpcError("FakeOpcClient: create_browser() before connect()")
return _FakeBrowser(self._space)
def read(self, tags: Sequence[str]) -> list[ReadResult]:
if not self.connected:
raise OpcError("FakeOpcClient: read() before connect()")
now = datetime.now(timezone.utc)
return [
ReadResult(tag=tag, value=0, quality_raw=0xC0, device_time=now)
for tag in tags
]
def close(self) -> None:
self.connected = False
+69
View File
@@ -0,0 +1,69 @@
"""OPC DA quality decode tables (SPECIFICATION §7).
Quality is a 16-bit field: bits 7–6 of the low byte give the class
(0xC0 GOOD / 0x40 UNCERTAIN / 0x00 BAD); bits 5–0 are the substatus. Every
quality event must carry the raw hex code **and** the decoded name.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class QualityClass(str, Enum):
GOOD = "GOOD"
UNCERTAIN = "UNCERTAIN"
BAD = "BAD"
@dataclass(frozen=True)
class QualityInfo:
"""Decoded quality for one read (SPECIFICATION §7)."""
raw: int
name: str
quality_class: QualityClass
@property
def raw_hex(self) -> str:
"""Raw code formatted for events, e.g. ``0x18``."""
return f"0x{self.raw & 0xFF:02X}"
# Codes from SPECIFICATION §7.
# TODO(Phase 1): complete the remaining DA 2.05a UNCERTAIN subcodes in this
# table — they decode by the same scheme (spec §7 closing note).
_QUALITY_NAMES: dict[int, str] = {
0xC0: "GOOD",
0xD8: "GOOD_LOCAL_OVERRIDE",
0x40: "UNCERTAIN",
0x44: "UNCERTAIN_LAST_USABLE",
0x00: "BAD",
0x04: "BAD_CONFIG_ERROR",
0x08: "BAD_NOT_CONNECTED",
0x0C: "BAD_DEVICE_FAILURE",
0x10: "BAD_SENSOR_FAILURE",
0x14: "BAD_LAST_KNOWN_VALUE",
0x18: "BAD_COMM_FAILURE",
0x1C: "BAD_OUT_OF_SERVICE",
}
def _class_of(low: int) -> QualityClass:
"""Class comes from bits 7–6 of the low byte."""
bits = low & 0xC0
if bits == 0xC0:
return QualityClass.GOOD
if bits == 0x40:
return QualityClass.UNCERTAIN
return QualityClass.BAD
def decode_quality(raw: int) -> QualityInfo:
"""Decode a raw OPC DA quality word into a named :class:`QualityInfo`."""
low = raw & 0xFF
name = _QUALITY_NAMES.get(low)
if name is None:
name = f"{_class_of(low).value}_0x{low:02X}"
return QualityInfo(raw=raw, name=name, quality_class=_class_of(low))
+1
View File
@@ -0,0 +1 @@
"""Event reporters: JSONL file, Windows Event Log, optional HTTP webhook."""
+13
View File
@@ -0,0 +1,13 @@
"""Reporter protocol — pluggable event sinks (SPECIFICATION §9)."""
from __future__ import annotations
from typing import Protocol
from opcda_diagnozer.events import DiagEvent
class Reporter(Protocol):
"""Anything that can receive events; reporting never blocks the poll loop."""
def emit(self, event: DiagEvent) -> None: ...
+22
View File
@@ -0,0 +1,22 @@
"""Windows Event Log reporter, WARN+ only (IMPLEMENTATION_PLAN §5.2).
Uses ``win32evtlog``; the source is registered during ``install``. pywin32 is
import-guarded so tests can run on any OS (IMPLEMENTATION_PLAN §9).
"""
from __future__ import annotations
from opcda_diagnozer.events import DiagEvent, Severity
class EventLogReporter:
"""Windows Event Log sink — implemented in Phase 2."""
def __init__(self, source: str = "OPCDA-Diagnozer", min_severity: str = "WARN") -> None:
self._source = source
self._min_severity = Severity(min_severity)
def emit(self, event: DiagEvent) -> None:
raise NotImplementedError(
"Phase 2: Event Log reporter (docs/IMPLEMENTATION_PLAN.md §5.2)"
)
+23
View File
@@ -0,0 +1,23 @@
"""Optional HTTP webhook reporter (IMPLEMENTATION_PLAN §5.3).
Phase 2: POST one JSON event per request, optional bearer token, timeout plus
simple retry/drop on failure — must never block the poll loop (bounded queue).
"""
from __future__ import annotations
from opcda_diagnozer.events import DiagEvent
class HttpReporter:
"""Webhook sink — implemented in Phase 2."""
def __init__(self, url: str, bearer_token: str | None = None, timeout_s: float = 5.0) -> None:
self._url = url
self._bearer_token = bearer_token
self._timeout_s = timeout_s
def emit(self, event: DiagEvent) -> None:
raise NotImplementedError(
"Phase 2: HTTP webhook reporter (docs/IMPLEMENTATION_PLAN.md §5.3)"
)
+25
View File
@@ -0,0 +1,25 @@
"""Rotating JSONL reporter — the primary evidence trail (IMPLEMENTATION_PLAN §5.1).
Phase 2 adds: size-based rotation with ``backups`` kept, atomic-ish append,
and a header line carrying the config digest for provenance.
"""
from __future__ import annotations
from pathlib import Path
from opcda_diagnozer.events import DiagEvent
class JsonlReporter:
"""One JSON event per line, rotated by size — implemented in Phase 2."""
def __init__(self, directory: str | Path, max_mb: int = 50, backups: int = 10) -> None:
self._directory = Path(directory)
self._max_mb = max_mb
self._backups = backups
def emit(self, event: DiagEvent) -> None:
raise NotImplementedError(
"Phase 2: JSONL reporter (docs/IMPLEMENTATION_PLAN.md §5.1)"
)
+19
View File
@@ -0,0 +1,19 @@
"""Windows service host via pywin32 ``ServiceFramework`` — Phase 3.
IMPLEMENTATION_PLAN §6.1: the service spawns the COM worker thread and must
stop cleanly (``STOP_PENDING``, join with timeout, abandon a hung COM thread
on shutdown). SCM recovery (restart on failure) is configured at install time.
"""
from __future__ import annotations
from collections.abc import Sequence
class OpcdaDiagnozerService:
"""pywin32 service wrapper — implemented in Phase 3."""
def __init__(self, args: Sequence[str]) -> None:
raise NotImplementedError(
"Phase 3: Windows service host (docs/IMPLEMENTATION_PLAN.md §6)"
)