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