This commit is contained in:
Jonas Bengtsson
2026-10-08 07:26:04 +02:00
parent a1af8d68c9
commit 406fdc61ac
13 changed files with 1750 additions and 89 deletions
+2
View File
@@ -45,6 +45,8 @@ class EventKind(str, Enum):
FROZEN_VALUE = "FROZEN_VALUE" # D13
CONTROL_LINK_DEGRADED = "CONTROL_LINK_DEGRADED" # D14
HEARTBEAT = "HEARTBEAT" # D15
DEVICE_LINK_DOWN = "DEVICE_LINK_DOWN" # D16
DEVICE_LINK_UP = "DEVICE_LINK_UP" # D16 recovery
@dataclass(frozen=True)
+62 -2
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import argparse
import sys
import threading
from collections.abc import Sequence
from pathlib import Path
@@ -35,7 +36,6 @@ def _default_config_path() -> str:
return DEFAULT_CONFIG
_NOT_IMPLEMENTED: dict[str, str] = {
"run": "Phase 1 (core engine)",
"install": "Phase 3 (Windows service)",
"uninstall": "Phase 3 (Windows service)",
"start": "Phase 3 (Windows service)",
@@ -81,8 +81,12 @@ def _build_parser() -> argparse.ArgumentParser:
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)")
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",
@@ -550,11 +554,67 @@ def _cmd_probe(
return _cmd_read(config_path, server, host, (), source, as_json=False)
def _cmd_run(config_path: str, server: str | None, host: str | None) -> int:
"""Foreground monitoring loop: poll → detect → print (Ctrl+C stops)."""
import signal
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,
)
def reporter(event) -> 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)
poller = Poller(client_factory, config, reporter)
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)
poller.run_forever()
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":
+483 -8
View File
@@ -1,17 +1,28 @@
"""Detector rules D1–D15 (SPECIFICATION §6).
"""Detector rules D4–D16 (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.
Each detector is a small pure class fed one :class:`PollOutcome` per poll
cycle plus an injected clock; it returns zero or more :class:`Detection`
objects (events to report). The poller owns all timing (sleeping, RTT
measurement) — detectors only see data, so every rule is testable without COM
against scripted outcomes with a fake clock.
Stateful detectors keep their own per-tag windows (deque bounded by
``window_polls``); none of them ever block.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from opcda_diagnozer.config import Config
from opcda_diagnozer.events import EventKind, Severity
from opcda_diagnozer.opc.base import ReadResult
from opcda_diagnozer.quality import QualityClass, decode_quality, status_text
#: D14 mass degradation: >= this fraction of monitored tags BAD at once.
MASS_BAD_FRACTION = 0.5
@dataclass(frozen=True)
@@ -21,4 +32,468 @@ class PollOutcome:
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)
error: str | None = None # normalized OpcError message when the whole poll failed (D1/D2/D7)
@dataclass(frozen=True)
class Detection:
"""One detector finding: the event kind/severity/detail the poller reports."""
kind: EventKind
severity: Severity
detail: dict = field(default_factory=dict)
@property
def is_anomaly(self) -> bool:
"""True for every kind the text anomaly log shows (spec §9.1).
Heartbeats and plain CONNECTED are lifecycle noise, not anomalies.
"""
return self.kind not in (EventKind.HEARTBEAT, EventKind.CONNECTED)
def _tag_quality_class(result: ReadResult) -> QualityClass | None:
"""Quality class of a read; None when the tag itself failed (no quality)."""
if result.error is not None:
return None
return decode_quality(result.quality_raw).quality_class
# --------------------------------------------------------------------------- #
# D4/D5/D6 — latency
# --------------------------------------------------------------------------- #
class LatencyDetector:
"""D4 spike, D5 sustained-slow streak, D6 p95 trend over the window."""
def __init__(self, config: Config) -> None:
self._warn_ms = config.latency.warn_ms
self._critical_ms = config.latency.critical_ms
self._streak_limit = config.latency.slow_poll_streak
self._window: deque[float] = deque(maxlen=config.latency.window_polls)
self._slow_streak = 0
def observe(self, outcome: PollOutcome) -> list[Detection]:
if outcome.error is not None:
self._slow_streak = 0 # failed polls carry no RTT evidence
self._window.append(outcome.rtt_ms) # still measured; the hang is D7's
return []
self._window.append(outcome.rtt_ms)
detections: list[Detection] = []
if outcome.rtt_ms > self._warn_ms:
detections.append(
Detection(
kind=EventKind.SLOW_POLL,
severity=Severity.WARN,
detail={"rtt_ms": round(outcome.rtt_ms, 1), "warn_ms": self._warn_ms},
)
)
self._slow_streak += 1
if self._slow_streak >= self._streak_limit:
detections.append(
Detection(
kind=EventKind.SLOW_POLL_STREAK,
severity=Severity.CRITICAL,
detail={
"rtt_ms": round(outcome.rtt_ms, 1),
"streak": self._slow_streak,
"warn_ms": self._warn_ms,
},
)
)
else:
self._slow_streak = 0
p95 = self._p95()
if p95 is not None and p95 > self._warn_ms:
detections.append(
Detection(
kind=EventKind.LATENCY_TREND,
severity=Severity.WARN,
detail={"rtt_p95_ms": round(p95, 1), "warn_ms": self._warn_ms},
)
)
return detections
def _p95(self) -> float | None:
if not self._window:
return None
ordered = sorted(self._window)
# nearest-rank p95 of the rolling window
rank = max(1, round(0.95 * len(ordered)))
return ordered[rank - 1]
# --------------------------------------------------------------------------- #
# D8/D9/D10/D11 — per-tag quality
# --------------------------------------------------------------------------- #
@dataclass
class _TagQualityState:
"""Per-tag tracking for the quality detectors."""
last_class: QualityClass | None = None
bad_since: datetime | None = None # D10 persistence clock start
bad_reported: bool = False
persistent_reported: bool = False
transition_times: deque[datetime] = field(default_factory=lambda: deque())
class QualityDetector:
"""D8/D9 transitions, D10 persistence, D11 flapping — per monitored tag.
Tag state initializes lazily from the first observed reading; transitions
are computed from the *previous class*, so a tag starting BAD does not
fire a transition until it first becomes GOOD (no synthetic baseline).
"""
def __init__(self, config: Config) -> None:
self._persistent_bad_s = config.quality.persistent_bad_s
self._flapping_limit = config.quality.flapping_transitions_per_window
self._flapping_window = timedelta(seconds=config.quality.flapping_window_s)
self._tags: dict[str, _TagQualityState] = {}
def observe(self, outcome: PollOutcome) -> list[Detection]:
detections: list[Detection] = []
at = outcome.started_at
for result in outcome.results:
state = self._tags.setdefault(result.tag, _TagQualityState())
quality = decode_quality(result.quality_raw) if result.error is None else None
new_class = quality.quality_class if quality is not None else None
self._on_transition(state, result, quality, at, detections)
self._on_persistence(state, result, at, detections)
state.last_class = new_class
return detections
def _on_transition(
self,
state: _TagQualityState,
result: ReadResult,
quality,
at: datetime,
out: list[Detection],
) -> None:
new_class = quality.quality_class if quality is not None else None
old = state.last_class
if old is None or new_class is None or old == new_class:
return # first reading or unreadable tag: no synthetic transition
state.transition_times.append(at)
state.transition_times = deque(
t for t in state.transition_times if at - t <= self._flapping_window
)
if new_class is QualityClass.BAD:
out.append(
Detection(
kind=EventKind.QUALITY_BAD,
severity=Severity.WARN,
detail={
"tag": result.tag,
"quality": status_text(result.quality_raw),
"previous_quality": old.value,
"value": result.value,
},
)
)
elif new_class is QualityClass.UNCERTAIN:
out.append(
Detection(
kind=EventKind.QUALITY_UNCERTAIN,
severity=Severity.INFO,
detail={
"tag": result.tag,
"quality": status_text(result.quality_raw),
"previous_quality": old.value,
},
)
)
if len(state.transition_times) > self._flapping_limit:
out.append(
Detection(
kind=EventKind.QUALITY_FLAPPING,
severity=Severity.WARN,
detail={
"tag": result.tag,
"transitions_in_window": len(state.transition_times),
"window_s": self._flapping_window.total_seconds(),
},
)
)
def _on_persistence(
self,
state: _TagQualityState,
result: ReadResult,
at: datetime,
out: list[Detection],
) -> None:
bad = _tag_quality_class(result) is QualityClass.BAD
if bad:
if state.bad_since is None:
state.bad_since = at
state.bad_reported = True
been_bad_s = (at - state.bad_since).total_seconds()
if been_bad_s >= self._persistent_bad_s and not state.persistent_reported:
state.persistent_reported = True
out.append(
Detection(
kind=EventKind.QUALITY_BAD_PERSISTENT,
severity=Severity.WARN,
detail={
"tag": result.tag,
"bad_for_s": round(been_bad_s, 1),
"threshold_s": self._persistent_bad_s,
},
)
)
else:
state.bad_since = None
state.bad_reported = False
state.persistent_reported = False
def tags_bad(self) -> int:
"""Monitored tags currently BAD — feeds the heartbeat (D15) and D14."""
# current-ness approximation: a tag counts if its last class was BAD
return sum(1 for s in self._tags.values() if s.last_class is QualityClass.BAD)
# --------------------------------------------------------------------------- #
# D12/D13 — staleness & frozen values
# --------------------------------------------------------------------------- #
class StaleDetector:
"""D12 stale device timestamps; D13 frozen values (``expect_change`` tags).
Both compare the device time against the local receive clock (the spec's
``max_age_s`` tolerance absorbs reasonable clock skew; NTP on both hosts
is the real fix — spec §12 row 9).
"""
def __init__(self, config: Config) -> None:
self._max_age = timedelta(seconds=config.stale.max_age_s)
self._enabled = config.stale.enabled
self._expect_change = {t.name for t in config.tags if t.expect_change}
# last (value, device_time) per tag for the frozen check
self._last_seen: dict[str, tuple[object, datetime]] = {}
def observe(self, outcome: PollOutcome) -> list[Detection]:
if not self._enabled or outcome.error is not None:
return []
detections: list[Detection] = []
at = outcome.started_at
for result in outcome.results:
if result.error is not None or result.device_time is None:
continue
if _tag_quality_class(result) is not QualityClass.GOOD:
continue # staleness while quality still reports GOOD (spec D12)
age = at - result.device_time
if age > self._max_age:
detections.append(
Detection(
kind=EventKind.STALE_TIMESTAMP,
severity=Severity.WARN,
detail={
"tag": result.tag,
"device_time_utc": result.device_time.isoformat(),
"age_s": round(age.total_seconds(), 1),
"max_age_s": self._max_age.total_seconds(),
},
)
)
self._frozen_check(result, at, detections)
return detections
def _frozen_check(
self, result: ReadResult, at: datetime, out: list[Detection]
) -> None:
if result.tag not in self._expect_change:
return
previous = self._last_seen.get(result.tag)
self._last_seen[result.tag] = (result.value, result.device_time)
if previous is None or previous[1] is None or result.device_time is None:
return
same_value = str(previous[0]) == str(result.value)
same_time = previous[1] == result.device_time
if same_value and same_time:
gap = at - result.device_time
if gap > self._max_age:
out.append(
Detection(
kind=EventKind.FROZEN_VALUE,
severity=Severity.WARN,
detail={
"tag": result.tag,
"value": result.value,
"device_time_utc": result.device_time.isoformat(),
"unchanged_for_s": round(gap.total_seconds(), 1),
},
)
)
# --------------------------------------------------------------------------- #
# D14 — mass quality degradation
# --------------------------------------------------------------------------- #
class MassBadDetector:
"""D14: >= half of monitored tags BAD simultaneously while connection OK."""
def __init__(self, config: Config | None = None) -> None:
# config reserved for a future threshold key; the fraction is fixed
# by the spec (MASS_BAD_FRACTION).
self._reported = False
def observe(self, outcome: PollOutcome) -> list[Detection]:
if outcome.error is not None:
self._reported = False # reconnect resets the mass-degradation latch
return []
total = len(outcome.results)
if total == 0:
return []
bad = sum(1 for r in outcome.results if _tag_quality_class(r) is QualityClass.BAD)
degraded = bad >= MASS_BAD_FRACTION * total
detections: list[Detection] = []
if degraded and not self._reported:
detections.append(
Detection(
kind=EventKind.CONTROL_LINK_DEGRADED,
severity=Severity.CRITICAL,
detail={"tags_bad": bad, "tags_total": total},
)
)
self._reported = True
elif not degraded and self._reported:
self._reported = False # recovered; next degradation reports again
return detections
# --------------------------------------------------------------------------- #
# D16 — device-link indicators
# --------------------------------------------------------------------------- #
def _rendered(value: object) -> str:
"""Value as the comparison string (config ok_values normalization twin)."""
if isinstance(value, bool):
return "true" if value else "false"
return str(value).strip()
class DeviceLinkDetector:
"""D16: per-device link state from configured indicator tags (spec §6.1).
Edge-level by design: one ``DEVICE_LINK_DOWN`` when a device leaves its
normal state (GOOD + value in ``ok_values``), one ``DEVICE_LINK_UP`` when
it returns — flapping links produce transitions, not a stream of events.
"""
def __init__(self, config: Config) -> None:
self._devices: dict[str, dict] = {
device.tag: {"name": device.name, "ok": set(device.ok_values), "down": False}
for device in config.devices
}
def observe(self, outcome: PollOutcome) -> list[Detection]:
if outcome.error is not None:
return [] # whole-poll failure is D1's business, not per-device
detections: list[Detection] = []
for result in outcome.results:
entry = self._devices.get(result.tag)
if entry is None:
continue
quality = (
decode_quality(result.quality_raw)
if result.error is None
else None
)
down = result.error is not None
reason = None
if not down:
if quality is None or quality.quality_class is not QualityClass.GOOD:
down = True
reason = (
"quality " + status_text(result.quality_raw)
if quality is not None
else "unreadable tag: " + (result.error or "?")
)
else:
rendered = _rendered(result.value)
if rendered not in entry["ok"]:
down = True
reason = f"read {rendered!r}, expected one of {sorted(entry['ok'])}"
if down and not entry["down"]:
entry["down"] = True
detail = {
"device": entry["name"],
"indicator_tag": result.tag,
"value": result.value,
"quality": status_text(result.quality_raw) if result.error is None else None,
}
if reason:
detail["reason"] = reason
detections.append(
Detection(kind=EventKind.DEVICE_LINK_DOWN, severity=Severity.CRITICAL, detail=detail)
)
elif not down and entry["down"]:
entry["down"] = False
detections.append(
Detection(
kind=EventKind.DEVICE_LINK_UP,
severity=Severity.INFO,
detail={"device": entry["name"], "indicator_tag": result.tag},
)
)
return detections
def devices_down(self) -> int:
return sum(1 for e in self._devices.values() if e["down"])
# --------------------------------------------------------------------------- #
# The bundle the poller feeds every cycle
# --------------------------------------------------------------------------- #
class DetectorSuite:
"""All per-poll detectors run in a fixed order; one call per outcome."""
def __init__(self, config: Config) -> None:
self.latency = LatencyDetector(config)
self.quality = QualityDetector(config)
self.stale = StaleDetector(config)
self.mass_bad = MassBadDetector(config)
self.device_link = DeviceLinkDetector(config)
def observe(self, outcome: PollOutcome) -> list[Detection]:
detections: list[Detection] = []
detections.extend(self.latency.observe(outcome))
detections.extend(self.quality.observe(outcome))
detections.extend(self.stale.observe(outcome))
detections.extend(self.mass_bad.observe(outcome))
detections.extend(self.device_link.observe(outcome))
return detections
def rtt_p95_ms(self) -> float | None:
p95 = self.latency._p95() # noqa: SLF001 — suite owns its parts
return round(p95, 1) if p95 is not None else None
def tags_bad(self) -> int:
return self.quality.tags_bad()
def devices_down(self) -> int:
return self.device_link.devices_down()
__all__ = [
"Detection",
"DetectorSuite",
"PollOutcome",
"MASS_BAD_FRACTION",
"LatencyDetector",
"QualityDetector",
"StaleDetector",
"MassBadDetector",
"DeviceLinkDetector",
]
+279 -12
View File
@@ -1,25 +1,292 @@
"""The poll loop on the single dedicated COM thread (SPECIFICATION §10.3).
"""The poll loop on the single dedicated COM thread (SPECIFICATION §10.3/§10.4).
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.
Architecture (all COM stays on the worker thread — SPECIFICATION §10.3):
- :meth:`Poller.run_forever` runs on the *caller's* thread as the coordinator:
it starts a worker per (re)connect session, consumes its queue, feeds the
state machine, applies backoff, and owns the stop flag.
- The worker thread builds its **own** client via ``client_factory`` (COM
initialization is per-thread — the client must be born there), polls every
``interval_s``, measures RTT, and pushes messages onto the queue.
- Hang watchdog (SPECIFICATION §10.4): the coordinator timestamps every
worker message; silence longer than ``hard_hang_s`` means the thread is
stuck in a dead RPC channel — it is **abandoned** (daemon thread, never
joined) and a fresh worker is built after D1/backoff. A poll that merely
exceeds ``timeout_ms`` raises the D7 soft-hang event but keeps the thread.
Messages the worker sends: ``("connected", ts)``, ``("outcome", PollOutcome)``,
``("failed", reason)`` — after which the worker exits its loop.
"""
from __future__ import annotations
import queue
import threading
import time
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from opcda_diagnozer.config import Config
from opcda_diagnozer.opc.base import OpcClient
from opcda_diagnozer.events import DiagEvent, EventContext, EventKind, Severity
from opcda_diagnozer.monitor.detectors import Detection, DetectorSuite, PollOutcome
from opcda_diagnozer.monitor.state import (
ConnectionState,
ConnectionStateMachine,
StateEvent,
)
from opcda_diagnozer.opc.base import OpcClient, OpcError
#: Coordinator queue poll slice: small enough that stop is snappy, large
#: enough to not busy-spin (hard_hang watching happens between slices).
_WATCH_SLICE_S = 0.25
_MESSAGE = tuple[str, Any]
def _utc_now() -> datetime:
"""Production wall clock (injected as ``clock`` in tests)."""
return datetime.now(timezone.utc)
class Poller:
"""Drives one :class:`OpcClient` on its own thread — implemented in Phase 1."""
"""Drives one :class:`OpcClient` per connection on its own thread, forever.
def __init__(self, client: OpcClient, config: Config) -> None:
self._client = client
``client_factory`` is called **on the worker thread** for every
(re)connect — the client is thread-confined by construction (spec §10.3).
``reporter`` receives every event; the coordinator never touches the
client object.
"""
def __init__(
self,
client_factory: Callable[[], OpcClient],
config: Config,
reporter: Callable[[DiagEvent], None],
clock: Callable[[], datetime] = _utc_now,
stop: threading.Event | None = None,
) -> None:
self._client_factory = client_factory
self._config = config
self._reporter = reporter
self._clock = clock
self._stop = stop if stop is not None else threading.Event()
self._sm = ConnectionStateMachine()
self._suite = DetectorSuite(config)
self._started_at: datetime | None = None
# Surveillance tags + device-indicator tags (polled automatically, §6.1),
# deduplicated in order.
self._tags: tuple[str, ...] = tuple(
dict.fromkeys(
[t.name for t in config.tags] + [d.tag for d in config.devices]
)
)
# ------------------------------------------------------------------ #
# public surface
# ------------------------------------------------------------------ #
def request_stop(self) -> None:
"""Ask the loop to finish after the current poll (from any thread)."""
self._stop.set()
def run_forever(self) -> None:
raise NotImplementedError(
"Phase 1: poller + hang watchdog (docs/IMPLEMENTATION_PLAN.md §4.7)"
)
"""Coordinate worker sessions until :meth:`request_stop`.
Never raises for COM/OPC failures — they are events (D1/D2). A worker
crash surfaces as DISCONNECTED with the exception text, then reconnect.
"""
self._started_at = self._clock()
last_heartbeat = self._started_at
while not self._stop.is_set():
outcomes: queue.Queue[_MESSAGE] = queue.Queue()
worker = threading.Thread(
target=self._worker_main, args=(outcomes,), daemon=True
)
worker.start()
hung = self._coordinate(outcomes)
if hung:
# SPECIFICATION §10.4: abandon a thread stuck in a dead RPC
# channel — never join it; daemon=True lets the process exit.
self._emit(
EventKind.POLL_HANG,
Severity.CRITICAL,
{
"hard_hang_s": self._config.poll.hard_hang_s,
"action": "COM thread abandoned and rebuilt",
},
)
else:
worker.join(timeout=self._config.poll.hard_hang_s)
if self._stop.is_set():
break
if self._sm.state is ConnectionState.RECONNECT_WAIT:
delay = self._sm.next_attempt_delay_s()
if delay:
self._stop.wait(delay)
self._emit(
EventKind.DISCONNECTED,
Severity.INFO,
{"reason": "shutting down (stop requested)"},
)
# ------------------------------------------------------------------ #
# coordinator side (caller's thread)
# ------------------------------------------------------------------ #
def _coordinate(self, outcomes: "queue.Queue[_MESSAGE]") -> bool:
"""Consume one worker session; returns True when the worker was hung.
Feeds the state machine and detectors, emits D1/D2/D7/D15/D4–D6/D8–D14/D16
events, and watches for hard hangs by wall-clock silence.
"""
last_worker_word = self._clock()
last_heartbeat = self._started_at or self._clock()
while not self._stop.is_set():
try:
kind, payload = outcomes.get(timeout=_WATCH_SLICE_S)
except queue.Empty:
silent_s = (self._clock() - last_worker_word).total_seconds()
if silent_s > self._config.poll.hard_hang_s:
return True
continue
last_worker_word = self._clock()
if kind == "connected":
for ev in self._sm.on_connected(payload):
self._emit_state_event(ev)
self._emit(
EventKind.CONNECTED,
Severity.INFO,
{"server": self._config.opc.server},
)
elif kind == "outcome":
outcome: PollOutcome = payload
for event in self._consume_outcome(outcome):
self._reporter(event)
now = outcome.started_at
if (now - last_heartbeat).total_seconds() >= self._config.report.heartbeat_s:
last_heartbeat = now
self._emit(EventKind.HEARTBEAT, Severity.INFO, self._heartbeat_detail())
elif kind == "failed":
for ev in self._sm.on_disconnected(self._clock(), reason=str(payload)):
self._emit_state_event(ev)
return False
return False
def _consume_outcome(self, outcome: PollOutcome) -> list[DiagEvent]:
"""Run the detector suite on one outcome; add D7 soft-hang; map to events."""
events: list[DiagEvent] = []
for detection in self._suite.observe(outcome):
events.append(self._to_event(detection))
if outcome.error is None and outcome.rtt_ms > self._config.poll.timeout_ms:
events.append(
self._to_event(
Detection(
kind=EventKind.POLL_HANG,
severity=Severity.CRITICAL,
detail={
"rtt_ms": round(outcome.rtt_ms, 1),
"timeout_ms": self._config.poll.timeout_ms,
"note": "call returned late; thread kept",
},
)
)
)
return events
def _to_event(self, detection: Detection) -> DiagEvent:
return DiagEvent(
time_utc=self._clock(),
kind=detection.kind,
severity=detection.severity,
detail=detection.detail,
context=EventContext(
uptime_s=self._uptime_s(),
reconnects_total=self._sm.reconnects_total,
rtt_p95_ms=self._suite.rtt_p95_ms(),
tags_bad=self._suite.tags_bad(),
tags_total=len(self._tags),
),
)
def _emit_state_event(self, ev: StateEvent) -> None:
kind, severity = {
"disconnected": (EventKind.DISCONNECTED, Severity.CRITICAL),
"reconnected": (EventKind.RECONNECTED, Severity.INFO),
"flapping": (EventKind.RECONNECT_FLAPPING, Severity.CRITICAL),
}[ev.kind]
self._emit(kind, severity, ev.detail)
def _heartbeat_detail(self) -> dict:
return {
"uptime_s": self._uptime_s(),
"reconnects_total": self._sm.reconnects_total,
"rtt_p95_ms": self._suite.rtt_p95_ms(),
"tags_bad": self._suite.tags_bad(),
"tags_total": len(self._tags),
"devices_down": self._suite.devices_down(),
}
def _uptime_s(self) -> float:
if self._started_at is None:
return 0.0
return (self._clock() - self._started_at).total_seconds()
def _emit(self, kind: EventKind, severity: Severity, detail: dict) -> None:
self._reporter(self._to_event(Detection(kind=kind, severity=severity, detail=detail)))
# ------------------------------------------------------------------ #
# worker side (the dedicated COM thread)
# ------------------------------------------------------------------ #
def _worker_main(self, outcomes: "queue.Queue[_MESSAGE]") -> None:
"""One connection session: own client, poll loop, push messages.
Exits (putting ``("failed", reason)``) whenever the connection drops
or the stop flag is observed — the coordinator decides what next.
"""
client: OpcClient | None = None
try:
client = self._client_factory()
client.connect()
outcomes.put(("connected", self._clock()))
while not self._stop.is_set():
started = time.monotonic()
started_at = _utc_now()
try:
results = client.read(self._tags)
rtt_ms = (time.monotonic() - started) * 1000.0
outcomes.put(
(
"outcome",
PollOutcome(
started_at=started_at,
rtt_ms=rtt_ms,
results=tuple(results),
),
)
)
except OpcError as exc:
rtt_ms = (time.monotonic() - started) * 1000.0
outcomes.put(
(
"outcome",
PollOutcome(
started_at=started_at, rtt_ms=rtt_ms, error=str(exc)
),
)
)
outcomes.put(("failed", str(exc)))
return
if self._stop.wait(self._config.poll.interval_s):
break
except OpcError as exc:
outcomes.put(("failed", str(exc)))
except Exception as exc: # noqa: BLE001 — a worker death is an event, not a crash
outcomes.put(("failed", f"worker crashed: {type(exc).__name__}: {exc}"))
finally:
if client is not None:
try:
client.close()
except Exception: # noqa: BLE001 — best-effort teardown
pass
+98 -2
View File
@@ -4,11 +4,14 @@ 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).
The engine is pure: it is fed timestamps and returns transitions plus events,
so the poller owns the actual clock/sleeping and tests drive it deterministically.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
@@ -17,4 +20,97 @@ class ConnectionState(Enum):
CONNECTING = "connecting"
CONNECTED = "connected"
RECONNECT_WAIT = "reconnect_wait"
RECONNECT_WAIT = "reconnect_wait"
#: Backoff constants (SPECIFICATION §10.2).
BACKOFF_INITIAL_S = 1.0
BACKOFF_MAX_S = 60.0
#: D3 reconnect flapping: > this many disconnects within the window is CRITICAL.
FLAPPING_DISCONNECTS = 3
FLAPPING_WINDOW = timedelta(minutes=10)
@dataclass(frozen=True)
class StateEvent:
"""One state-machine transition worth reporting (D1/D2/D3)."""
kind: str # "disconnected" | "reconnected" | "flapping"
at: datetime
detail: dict = field(default_factory=dict)
@dataclass
class ConnectionStateMachine:
"""Tracks connection state, backoff and reconnect accounting (D1–D3).
The poller calls :meth:`on_connected` / :meth:`on_disconnected` at the
moments those things are observed; the machine decides *when* to emit
events (D1 with the failure reason, D2 with the outage duration, D3 when
disconnects flap) and *how long* to wait before the next attempt.
"""
state: ConnectionState = ConnectionState.CONNECTING
backoff_s: float = BACKOFF_INITIAL_S
connected_since: datetime | None = None
disconnected_since: datetime | None = None
reconnects_total: int = 0
_disconnect_times: list[datetime] = field(default_factory=list)
def on_connected(self, at: datetime) -> list[StateEvent]:
"""A connect (or reconnect) succeeded at ``at``."""
events: list[StateEvent] = []
was_outage = self.state is ConnectionState.RECONNECT_WAIT
self.state = ConnectionState.CONNECTED
self.connected_since = at
self.backoff_s = BACKOFF_INITIAL_S # reset on success (spec §10.2)
if was_outage and self.disconnected_since is not None:
self.reconnects_total += 1
events.append(
StateEvent(
kind="reconnected",
at=at,
detail={
"outage_s": (at - self.disconnected_since).total_seconds(),
"reconnects_total": self.reconnects_total,
},
)
)
self.disconnected_since = None
return events
def on_disconnected(self, at: datetime, reason: str) -> list[StateEvent]:
"""The connection dropped at ``at`` (reason = decoded failure)."""
events: list[StateEvent] = []
first_drop = self.state is not ConnectionState.RECONNECT_WAIT
self.state = ConnectionState.RECONNECT_WAIT
self.connected_since = None
if first_drop:
self.disconnected_since = at
self._disconnect_times.append(at)
self._disconnect_times = [
t for t in self._disconnect_times if at - t <= FLAPPING_WINDOW
]
events.append(StateEvent(kind="disconnected", at=at, detail={"reason": reason}))
if len(self._disconnect_times) > FLAPPING_DISCONNECTS:
events.append(
StateEvent(
kind="flapping",
at=at,
detail={"disconnects_in_window": len(self._disconnect_times)},
)
)
return events
def next_attempt_delay_s(self) -> float:
"""How long to wait before the next connect attempt (exponential backoff).
Call once per failed attempt; the value doubles up to
:data:`BACKOFF_MAX_S` and resets only on a successful connect.
"""
if self.state is not ConnectionState.RECONNECT_WAIT:
return 0.0
delay = self.backoff_s
self.backoff_s = min(self.backoff_s * 2, BACKOFF_MAX_S)
return delay
+45
View File
@@ -17,6 +17,19 @@ from datetime import datetime, timezone
from opcda_diagnozer.opc.base import Browser, OPCDataSource, OpcError, ReadResult, TagInfo
#: Scenario-engine directives (Phase 1, IMPLEMENTATION_PLAN §4.9): the fake
#: consumes one per read() call. A "hang" is not modeled here — the poller's
#: watchdog handles hangs via a client whose read() simply sleeps; the fake
#: stays non-blocking so unit tests never slow down.
PollScriptStep = "PollScriptError | list[ReadResult] | str"
class PollScriptError:
"""Scenario directive: the whole read call fails with this message."""
def __init__(self, message: str) -> None:
self.message = message
#: 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, ...]]] = {
@@ -101,16 +114,32 @@ class FakeOpcClient:
``space`` is injectable so tests can exercise flat namespaces, empty
servers, and deep trees; ``read_results`` scripts the next read() result
(Phase 1 scenario engine seed, IMPLEMENTATION_PLAN §4.9).
Scenario engine (Phase 1): ``script`` is a list of per-poll directives the
fake consumes one per read() call, in order — after the script is
exhausted the client falls back to ``read_results`` or plain GOOD zeros.
Each entry may be:
- ``PollOutcomeError(message)`` — the whole read call fails (COM-style
OpcError for D1/D2 testing; a ``PollOutcomeHang`` directive is handled by
the *poller* watchdog, not the client — see :class:`FakeHangClient`),
- a ``list[ReadResult]`` — the exact results for this poll,
- ``"good"`` — shorthand for all-tags GOOD zeros.
The read log (``reads``) records every consumed (tags, source) pair so
tests can assert poll cadence and scripted order.
"""
def __init__(
self,
space: dict[tuple[str, ...], tuple[tuple[str, ...], tuple[str, ...]]] | None = None,
read_results: list[ReadResult] | None = None,
script: list["PollScriptStep"] | None = None,
) -> None:
self.connected = False
self._space = FAKE_SPACE if space is None else space
self._scripted_reads = read_results
self._script = list(script) if script is not None else None
self.reads: list[tuple[tuple[str, ...], OPCDataSource]] = [] # call log
def connect(self) -> None:
@@ -127,6 +156,22 @@ class FakeOpcClient:
if not self.connected:
raise OpcError("FakeOpcClient: read() before connect()")
self.reads.append((tuple(tags), source))
# scenario engine: consume one scripted step per poll, in order
if self._script:
step = self._script.pop(0)
if isinstance(step, PollScriptError):
raise OpcError(step.message)
if isinstance(step, list):
by_tag = {r.tag: r for r in step}
return [
by_tag.get(
tag,
ReadResult(tag=tag, value=None, quality_raw=0, device_time=None,
error=f"no scripted result for {tag!r}"),
)
for tag in tags
]
# "good" shorthand falls through to the default below
if self._scripted_reads is not None:
scripted = {r.tag: r for r in self._scripted_reads}
return [
+22 -1
View File
@@ -66,4 +66,25 @@ def decode_quality(raw: int) -> QualityInfo:
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))
return QualityInfo(raw=raw, name=name, quality_class=_class_of(low))
def status_text(raw: int) -> str:
"""Clear-text status with the raw code in parentheses (SPECIFICATION §7).
Every reporter writes quality this way — e.g. ``BAD / COMM FAILURE
(0x18)`` — so nobody decodes hex by hand while the raw code stays
available for unambiguous correlation with vendor documentation.
"""
info = decode_quality(raw)
classes = {c.value for c in QualityClass}
name = info.name
for cls in classes:
prefix = cls + "_"
if name.startswith(prefix):
sub = name[len(prefix):].replace("_", " ")
name = f"{cls} / {sub}" if sub else cls
break
else:
name = name.replace("_", " ")
return f"{name} ({info.raw_hex})"