Files
NODEDC_MISSION_CORE/plugins/vesc/runtime/link_check.py
T

100 lines
5.2 KiB
Python

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