Files
PingWatch/backend/app/services/state_machine.py
T

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")