170 lines
7.6 KiB
Python
170 lines
7.6 KiB
Python
"""Private lifecycle/RPC boundary to unmodified upstream VESC Tool C++.
|
|
|
|
No wire encoding or calibration algorithm lives here. The native process owns
|
|
one exact USB attachment and sends all commands through upstream Commands.
|
|
"""
|
|
import base64
|
|
from collections import deque
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import subprocess
|
|
import time
|
|
|
|
from .serial import check_attachment
|
|
|
|
|
|
class NativeLink:
|
|
def __init__(self, attachment):
|
|
self.attachment = attachment
|
|
self.process = None
|
|
self.buffer = b""
|
|
self.sequence = 0
|
|
self.hall_pending = False
|
|
self.history = deque(maxlen=32)
|
|
self.check()
|
|
root = Path("/usr/lib/mission-core-vesc/native")
|
|
config = Path("/run/mission-core-vesc/native") / hashlib.sha256(attachment.binding.encode()).hexdigest()[:24]
|
|
config.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
env = dict(os.environ, QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(config),
|
|
XDG_CACHE_HOME=str(config / "cache"), LD_LIBRARY_PATH=str(root / "lib"),
|
|
QT_PLUGIN_PATH=str(root / "plugins"))
|
|
# Preserve native startup diagnostics in the private runtime directory.
|
|
with (config / "engine.log").open("wb") as diagnostic:
|
|
self.process = subprocess.Popen([str(root / "bin/mission-core-vesc-engine"), "/dev/" + attachment.tty],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostic,
|
|
env=env, bufsize=0, close_fds=True)
|
|
try:
|
|
hello = self._receive(time.monotonic() + 6)
|
|
if hello.get("ready") is not True or not hello.get("engine", {}).get("connected"):
|
|
raise OSError("Native VESC Tool did not connect")
|
|
self.engine = hello["engine"]
|
|
self.check()
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
@property
|
|
def alive(self):
|
|
return self.process is not None and self.process.poll() is None
|
|
|
|
def check(self):
|
|
check_attachment(self.attachment)
|
|
|
|
def _receive(self, deadline):
|
|
while b"\n" not in self.buffer:
|
|
if not self.alive or not select.select([self.process.stdout], [], [], max(0, deadline-time.monotonic()))[0]:
|
|
raise TimeoutError("Native VESC Tool response timed out")
|
|
data = os.read(self.process.stdout.fileno(), 65536)
|
|
if not data: raise OSError("Native VESC Tool exited")
|
|
self.buffer += data
|
|
if len(self.buffer) > 2 * 1024 * 1024: raise ValueError("Native response exceeds bound")
|
|
line, self.buffer = self.buffer.split(b"\n", 1)
|
|
return json.loads(line)
|
|
|
|
def rpc(self, method, timeout=2, **parameters):
|
|
self.sequence += 1
|
|
request = json.dumps({"id": self.sequence, "method": method, **parameters}, allow_nan=False).encode()+b"\n"
|
|
if len(request) > 65536: raise ValueError("Native request exceeds bound")
|
|
started = time.monotonic()
|
|
deadline = started + timeout
|
|
trace = {"method": method, "command": parameters.get("command"),
|
|
"timeout_ms": parameters.get("timeout_ms", timeout*1000)}
|
|
try:
|
|
trace["stage"] = "attachment_before"
|
|
self.check()
|
|
trace["attachment_before_ms"] = (time.monotonic()-started)*1000
|
|
trace["stage"] = "request_write"
|
|
if not self.alive: raise OSError("Native VESC Tool is not running")
|
|
if not select.select([], [self.process.stdin], [], max(0, deadline-time.monotonic()))[1]:
|
|
raise TimeoutError("Native VESC Tool request timed out")
|
|
if os.write(self.process.stdin.fileno(), request) != len(request):
|
|
raise OSError("Native request write incomplete")
|
|
sent_at = time.monotonic()
|
|
trace["request_write_ms"] = (sent_at-started)*1000-trace["attachment_before_ms"]
|
|
trace["stage"] = "native_response"
|
|
response = self._receive(deadline)
|
|
received_at = time.monotonic()
|
|
trace["native_response_ms"] = (received_at-sent_at)*1000
|
|
if not isinstance(response, dict): raise ValueError("Invalid native response")
|
|
if response.get("id") != self.sequence: raise ValueError("Native response identity mismatch")
|
|
if response.get("ok") is not True:
|
|
if isinstance(response.get("diagnostics"), dict):
|
|
trace["transport"] = response["diagnostics"]
|
|
raise OSError(response.get("error", "Native operation failed"))
|
|
trace["stage"] = "attachment_after"
|
|
self.check()
|
|
trace["attachment_after_ms"] = (time.monotonic()-received_at)*1000
|
|
trace["stage"] = "complete"
|
|
result = response.get("result")
|
|
if not isinstance(result, dict): raise ValueError("Invalid native result")
|
|
trace.update(ok=True, elapsed_ms=(time.monotonic()-started)*1000)
|
|
self.last_rpc = trace
|
|
if hasattr(self, "history"): self.history.append(trace)
|
|
return result
|
|
except (OSError, ValueError, TimeoutError) as error:
|
|
# An unconfirmed reply is never silently retried on the same stream.
|
|
trace.update(ok=False, elapsed_ms=(time.monotonic()-started)*1000,
|
|
process_alive=self.alive, error=str(error))
|
|
try: self.check(); trace["attachment_present"] = True
|
|
except OSError: trace["attachment_present"] = False
|
|
self.last_rpc = trace
|
|
if hasattr(self, "history"): self.history.append(trace)
|
|
error.native_rpc = trace
|
|
error.native_history = list(getattr(self, "history", []))
|
|
self.close()
|
|
raise
|
|
|
|
def query(self, command, timeout=2):
|
|
result = self.rpc("query", timeout=timeout+0.1, command=command, timeout_ms=max(20, int(timeout*1000)))
|
|
return base64.b64decode(result["payload"], validate=True)
|
|
|
|
def test_command(self, action):
|
|
if action not in ("claim", "release"): raise ValueError("Unknown control action")
|
|
self.rpc("lease" if action == "claim" else "release", timeout=0.1)
|
|
|
|
def test_current(self, current_a):
|
|
self.rpc("current", timeout=0.1, current_a=current_a)
|
|
|
|
def test_speed(self, erpm):
|
|
self.rpc("rpm", timeout=0.1, erpm=erpm)
|
|
|
|
def set_temporary_limits(self, config):
|
|
from .temporary_limits import FIELDS
|
|
self.rpc("limits", timeout=2.2, parameters={k: config[k] for k in FIELDS})
|
|
|
|
def configuration(self):
|
|
return self.rpc("configuration", timeout=5)
|
|
|
|
def detect_hall(self):
|
|
if self.hall_pending: raise ValueError("Hall measurement already pending")
|
|
self.hall_pending = True
|
|
self.rpc("hall_start", timeout=0.2, current_a=5)
|
|
|
|
def calibrate_foc(self, max_power_loss_w):
|
|
self.rpc("foc_start", timeout=0.2, max_power_loss_w=max_power_loss_w)
|
|
|
|
def procedure_result(self):
|
|
return self.rpc("procedure_result", timeout=0.2)
|
|
|
|
@property
|
|
def hall_result(self):
|
|
if not self.hall_pending: return None
|
|
state = self.rpc("procedure_result", timeout=0.1)
|
|
result = state.get("result", {})
|
|
if state.get("running") or not result.get("completed"): return None
|
|
return base64.b64decode(result["payload"], validate=True)
|
|
|
|
def close(self):
|
|
process, self.process = self.process, None
|
|
if process is None: return
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try: process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill(); process.wait(timeout=1)
|
|
for stream in (process.stdin, process.stdout):
|
|
if stream: stream.close()
|