127 lines
3.7 KiB
Python
127 lines
3.7 KiB
Python
"""Pure state transitions for device connectivity health."""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Protocol, Sequence
|
|
|
|
from app.models.device import DeviceMonitoringPolicy
|
|
|
|
|
|
class ProbeSample(Protocol):
|
|
"""Packet summary attributes required for health evaluation."""
|
|
|
|
sent_count: int
|
|
received_count: int
|
|
is_valid: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StateDecision:
|
|
"""Result of evaluating recent probes against a monitoring policy."""
|
|
|
|
next_status: str
|
|
event_type: str | None
|
|
should_notify: bool
|
|
reason: str
|
|
|
|
|
|
def _retain(previous_status: str, reason: str) -> StateDecision:
|
|
"""Return a non-notifying decision that preserves current health."""
|
|
return StateDecision(
|
|
next_status=previous_status,
|
|
event_type=None,
|
|
should_notify=False,
|
|
reason=reason,
|
|
)
|
|
|
|
|
|
def _transition(
|
|
previous_status: str,
|
|
next_status: str,
|
|
event_type: str,
|
|
reason: str,
|
|
) -> StateDecision:
|
|
"""Return a transition event only when the persisted status changes."""
|
|
if previous_status == next_status:
|
|
return _retain(previous_status, reason)
|
|
return StateDecision(
|
|
next_status=next_status,
|
|
event_type=event_type,
|
|
should_notify=True,
|
|
reason=reason,
|
|
)
|
|
|
|
|
|
def _is_valid(sample: ProbeSample) -> bool:
|
|
"""Reject impossible summaries before they influence device health."""
|
|
return (
|
|
sample.is_valid
|
|
and sample.sent_count > 0
|
|
and 0 <= sample.received_count <= sample.sent_count
|
|
)
|
|
|
|
|
|
def evaluate_health(
|
|
previous_status: str,
|
|
recent: Sequence[ProbeSample],
|
|
policy: DeviceMonitoringPolicy,
|
|
) -> StateDecision:
|
|
"""Evaluate ordered oldest-to-newest probe summaries.
|
|
|
|
Invalid input retains state. Offline has priority over aggregate degraded
|
|
loss, and an alerted state recovers only after the configured clean streak.
|
|
"""
|
|
if not recent:
|
|
return _retain(previous_status, "no probe history")
|
|
if any(not _is_valid(sample) for sample in recent):
|
|
return _retain(previous_status, "invalid probe history")
|
|
|
|
offline_rounds = policy.offline_consecutive_rounds
|
|
if len(recent) >= offline_rounds and all(
|
|
sample.received_count == 0
|
|
for sample in recent[-offline_rounds:]
|
|
):
|
|
return _transition(
|
|
previous_status,
|
|
"offline",
|
|
"offline",
|
|
f"{offline_rounds} consecutive full-loss rounds",
|
|
)
|
|
|
|
recovery_rounds = policy.recovery_consecutive_clean_rounds
|
|
if (
|
|
previous_status in {"offline", "degraded"}
|
|
and len(recent) >= recovery_rounds
|
|
and all(
|
|
sample.received_count == sample.sent_count
|
|
for sample in recent[-recovery_rounds:]
|
|
)
|
|
):
|
|
return _transition(
|
|
previous_status,
|
|
"online",
|
|
"recovered",
|
|
f"{recovery_rounds} consecutive clean rounds",
|
|
)
|
|
|
|
degraded_rounds = policy.degraded_window_rounds
|
|
if len(recent) >= degraded_rounds:
|
|
window = recent[-degraded_rounds:]
|
|
sent_count = sum(sample.sent_count for sample in window)
|
|
received_count = sum(sample.received_count for sample in window)
|
|
loss_percent = (sent_count - received_count) / sent_count * 100.0
|
|
if (
|
|
previous_status != "offline"
|
|
and loss_percent >= policy.degraded_loss_percent
|
|
):
|
|
return _transition(
|
|
previous_status,
|
|
"degraded",
|
|
"degraded",
|
|
(
|
|
f"window packet loss {loss_percent:.2f}% is at or above "
|
|
f"{policy.degraded_loss_percent:.2f}%"
|
|
),
|
|
)
|
|
|
|
return _retain(previous_status, "no transition threshold met")
|