"""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 DeviceEntry: """D16 device-link indicator: a tag saying if the device is reachable. DOWN (``DEVICE_LINK_DOWN``, CRITICAL) when ``tag`` reads non-GOOD, errors, or its GOOD value is **not** in ``ok_values`` — the normal (connected) reading, configurable per indicator because conventions differ per control system: connected-flags ``[true]`` (default), inverted fault-flags ``[false, 0]``, string states ``["RUN"]``. Compared by string equality on the rendered value (SPECIFICATION §6.1). """ name: str tag: str ok_values: tuple[str, ...] = ("true",) @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 BrowseConfig: # Address-space walk guard: deeper SCADA trees are fine, but a walk this # deep is almost certainly a server-side cycle (list-items --depth 0). max_depth: int = 32 @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, ...] = () devices: tuple[DeviceEntry, ...] = () latency: LatencyConfig = field(default_factory=LatencyConfig) quality: QualityConfig = field(default_factory=QualityConfig) stale: StaleConfig = field(default_factory=StaleConfig) browse: BrowseConfig = field(default_factory=BrowseConfig) report: ReportConfig = field(default_factory=ReportConfig) _ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "devices", "latency", "quality", "stale", "browse", "report"} _TAG_KEYS = {"name", "expect_change"} _DEVICE_KEYS = {"name", "tag", "ok_values"} def _normalize_ok_values(raw: Any, where: str) -> tuple[str, ...]: """ok_values as a tuple of rendered strings ("true", "1", "RUN"). The D16 comparison is string equality on the value rendered as text, so booleans/ints/strings from YAML all work uniformly: `[true]` -> `("true",)`, `[false, 0]` -> `("false", "0")`. """ if raw is None: return ("true",) if isinstance(raw, (list, tuple)): if not raw: raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)") values = [] for item in raw: if isinstance(item, bool): values.append("true" if item else "false") elif isinstance(item, (int, float, str)): values.append(str(item).strip()) else: raise ConfigError( f"{where}: ok_values entries must be scalars (bool/int/str), got {type(item).__name__}" ) cleaned = tuple(v for v in values if v) if not cleaned: raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)") return cleaned # a single scalar is shorthand for a one-element list if isinstance(raw, (bool, int, float, str)): return _normalize_ok_values([raw], where) raise ConfigError(f"{where}: ok_values must be a list of scalars, got {type(raw).__name__}") 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 _build_devices(data: Any) -> tuple[DeviceEntry, ...]: """Parse the devices: section (D16 link indicators) with actionable errors.""" if data is None: return () if not isinstance(data, list): raise ConfigError(f"devices: must be a list, got {type(data).__name__}") entries: list[DeviceEntry] = [] for i, item in enumerate(data): if not isinstance(item, dict): raise ConfigError( f"devices[{i}]: must be a mapping with 'name' and 'tag'" ) unknown = sorted(set(item) - _DEVICE_KEYS) if unknown: raise ConfigError( f"devices[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_DEVICE_KEYS)}" ) name = item.get("name") tag = item.get("tag") if not isinstance(name, str) or not name.strip(): raise ConfigError(f"devices[{i}]: 'name' is required and must be a non-empty string") if not isinstance(tag, str) or not tag.strip(): raise ConfigError( f"devices[{i}]: 'tag' is required and must be the connection-indicator " "tag ItemID (see list-items)" ) ok_values = _normalize_ok_values(item.get("ok_values"), f"devices[{i}]") entries.append(DeviceEntry(name=name, tag=tag, ok_values=ok_values)) 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}") device_names = [d.name for d in config.devices] duplicate_devices = sorted({n for n in device_names if device_names.count(n) > 1}) if duplicate_devices: raise ConfigError(f"devices: duplicate device name(s) {duplicate_devices}") indicator_tags = [d.tag for d in config.devices] duplicate_indicators = sorted({t for t in indicator_tags if indicator_tags.count(t) > 1}) if duplicate_indicators: raise ConfigError( f"devices: duplicate indicator tag(s) {duplicate_indicators} - one device per indicator" ) # An indicator doubles as a monitored tag; listing it in tags: too would double-poll # it and double-report its quality transitions (D8 AND D16 fire). overlap = sorted(set(indicator_tags) & set(names)) if overlap: raise ConfigError( f"tags: {overlap} also appear as device indicators - remove them from " "tags: (devices: entries are polled automatically)" ) 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.browse.max_depth < 1: raise ConfigError(f"browse.max_depth: must be >= 1, got {config.browse.max_depth}") 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")), devices=_build_devices(data.get("devices")), latency=_build_section(LatencyConfig, data.get("latency"), "latency"), quality=_build_section(QualityConfig, data.get("quality"), "quality"), stale=_build_section(StaleConfig, data.get("stale"), "stale"), browse=_build_section(BrowseConfig, data.get("browse"), "browse"), 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