Files
opcda-diagnozer/src/opcda_diagnozer/events.py
T
2026-10-08 07:26:04 +02:00

89 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
DEVICE_LINK_DOWN = "DEVICE_LINK_DOWN" # D16
DEVICE_LINK_UP = "DEVICE_LINK_UP" # D16 recovery
@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")