"""Bounded idle transport measurement through the installed native owners. Only PPM/telemetry reads, no leases, motor commands or configuration writes. The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget; it never relaxes the motor-control deadline or authorizes powered operation. """ from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack from datetime import datetime, timezone import math import time from .protocol import ppm, values def summary(samples): times = sorted(s["elapsed_ms"] for s in samples) if not times: return {"replies": 0} def percentile(p): return times[max(0, math.ceil(len(times)*p)-1)] return {"replies": len(times), "p50_ms": percentile(.5), "p95_ms": percentile(.95), "p99_ms": percentile(.99), "max_ms": times[-1], "over_60_ms": sum(t > 60 for t in times)} def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monotonic): started = monotonic() observed = datetime.now(timezone.utc).isoformat() deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00")) ids = {d.id: d.session for d in devices} if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]: raise ValueError("Controller sessions changed") samples = {d.id: [] for d in devices} failure = None stop_reason = "complete" def read(device): for code in (31, 4): before = monotonic() try: raw = device.link.query(code, timeout=.5) reading = ppm(raw) if code == 31 else values(raw) sample = {"command": code, "at_s": before-started, "elapsed_ms": (monotonic()-before)*1000, "native_rpc": getattr(device.link, "last_rpc", None)} if code == 31: sample["ppm_level"] = reading["level"] idle = abs(reading["level"]) <= .02 else: sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"], duty=reading["duty"], fault_code=reading["fault_code"]) idle = abs(reading["erpm"]) <= 30 and abs(reading["motor_current_a"]) <= 1 and abs(reading["duty"]) <= .01 samples[device.id].append(sample) if not idle: return {"device_id": device.id, "reason": "not_idle"} except (OSError, ValueError, TimeoutError) as error: return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error), "elapsed_ms": (monotonic()-before)*1000, "native_rpc": getattr(error, "native_rpc", None), "native_history": getattr(error, "native_history", [])} return None with ExitStack() as locks: for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock) if any(d.link is None for d in devices): raise ValueError("Controller unavailable") # Same cadence and per-controller query order as group rotation, with a # larger read-only deadline to expose latency instead of destroying it. with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool: for _ in range(100): cycle = monotonic() cancelled = service.motor.cancelled_at if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")): stop_reason = "stopped"; break if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2: stop_reason = "deadline"; break futures = [pool.submit(read, d) for d in devices] errors = [error for future in futures if (error := future.result()) is not None] if errors: failure = errors; stop_reason = errors[0]["reason"]; break sleep(max(0, .1-(monotonic()-cycle))) return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started, "outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500, "failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(), "summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}