"""Execute and parse multi-packet fping probes without shell interpolation.""" import asyncio from dataclasses import dataclass import re from typing import Iterable FPING_RESULT_PATTERN = re.compile(r"^\s*(\S+)\s+:\s*(.*?)\s*$") @dataclass(frozen=True) class ProbeResult: """Aggregate result for one IP in one monitoring round.""" ip: str sent_count: int received_count: int average_rtt_ms: float | None is_valid: bool failure_reason: str | None = None @property def packet_loss_percent(self) -> float: """Return packet loss as a percentage of the attempted probes.""" if self.sent_count <= 0: return 0.0 return ( (self.sent_count - self.received_count) / self.sent_count * 100.0 ) def _invalid_result( ip: str, packets_per_round: int, reason: str, ) -> ProbeResult: """Build an invalid aggregate that cannot resemble measured packet loss.""" return ProbeResult( ip=ip, sent_count=packets_per_round, received_count=0, average_rtt_ms=None, is_valid=False, failure_reason=reason, ) def parse_fping_count_output( output: str, expected_ips: set[str], packets_per_round: int, ) -> dict[str, ProbeResult]: """Parse ``fping -C`` reply tokens for every expected IP. A numeric token is a measured RTT and ``-`` is packet loss. Missing, duplicated, or malformed lines are represented as invalid aggregates. """ if packets_per_round <= 0: raise ValueError("packets_per_round must be positive") parsed_lines: dict[str, list[str]] = {} duplicate_ips: set[str] = set() for line in output.splitlines(): match = FPING_RESULT_PATTERN.match(line) if match is None: continue ip, replies = match.groups() if ip not in expected_ips: continue if ip in parsed_lines: duplicate_ips.add(ip) continue parsed_lines[ip] = replies.split() results: dict[str, ProbeResult] = {} for ip in expected_ips: tokens = parsed_lines.get(ip) if tokens is None: results[ip] = _invalid_result( ip, packets_per_round, "missing fping output", ) continue if ip in duplicate_ips or len(tokens) != packets_per_round: results[ip] = _invalid_result( ip, packets_per_round, "malformed fping replies", ) continue reply_times: list[float] = [] malformed = False for token in tokens: if token == "-": continue try: reply_time = float(token) except ValueError: malformed = True break if reply_time < 0: malformed = True break reply_times.append(reply_time) if malformed: results[ip] = _invalid_result( ip, packets_per_round, "malformed fping replies", ) continue average_rtt_ms = ( sum(reply_times) / len(reply_times) if reply_times else None ) results[ip] = ProbeResult( ip=ip, sent_count=packets_per_round, received_count=len(reply_times), average_rtt_ms=average_rtt_ms, is_valid=True, ) return results async def run_fping_count( ips: Iterable[str], packets_per_round: int, timeout_ms: int, executable: str, ) -> dict[str, ProbeResult]: """Run one fping count probe and return an aggregate for every IP.""" ip_list = list(ips) if not ip_list: return {} command = [ executable, "-C", str(packets_per_round), "-q", "-t", str(timeout_ms), *ip_list, ] try: process = await asyncio.create_subprocess_exec( *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() except Exception as error: reason = f"fping execution failed: {type(error).__name__}" return { ip: _invalid_result(ip, packets_per_round, reason) for ip in ip_list } output = "\n".join( stream.decode("utf-8", errors="replace") for stream in (stdout, stderr) if stream ) return parse_fping_count_output( output, set(ip_list), packets_per_round, )