"""Bounded idle transport measurement through the installed native owners. Only application/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 from .configuration import decode from .receiver import active, neutral_band 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} bands = {} failure = None stop_reason = "complete" def interrupted(): cancelled = service.motor.cancelled_at if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")): return "stopped" if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2: return "deadline" return None def failed(device, code, before, 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", [])} 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 = not active(reading["level"], bands[device.id]) 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 failed(device, code, before, error) 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") for device in devices: if reason := interrupted(): stop_reason = reason; break before = monotonic() try: bands[device.id] = neutral_band(decode(device.link.query(17), "application")) except (OSError, ValueError, TimeoutError) as error: failure = [failed(device, 17, before, error)] stop_reason = "read_failed"; break # 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): if stop_reason != "complete": break cycle = monotonic() if reason := interrupted(): stop_reason = reason; 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(), "neutral_band": bands.get(d.id), "summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}