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

365 lines
23 KiB
Python

"""One onboard owner; both operator surfaces consume the same bounded device operations."""
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from . import MODEL, SCHEMA, VERSION
from .protocol import firmware, values, ppm, TEST_LIMITS, SPEED_LIMITS
from .motor_test import MotorTest, Rejected
from .drive_profile import DriveProfile, slot_label, validate as validate_drive
from .serial import device_id, discover
from .native_link import NativeLink
try:
from .archive import Archive
except ImportError: # Source checkout; packaging copies this exact shared file.
from k1link.device_plugins.vesc.archive import Archive
ACTIONS = frozenset({"verify", "details", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"})
def utc():
return datetime.now(timezone.utc).isoformat()
def atomic(path, value):
fd, name = tempfile.mkstemp(prefix=".vesc-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(value, stream, ensure_ascii=False, allow_nan=False)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
if os.path.exists(name):
os.unlink(name)
class Device:
def __init__(self, attachment):
self.attachment = attachment
self.id = attachment.id
self.session = "vesc_" + uuid.uuid4().hex
self.opened = utc()
self.link = None
self.identity = None
self.error = None
self.telemetry = None
self.backup = None
self.lock = threading.Lock()
self.retry_at = 0
@property
def readable(self):
identity = self.identity
return self.readable_identity(identity)
@staticmethod
def readable_identity(identity):
return bool(identity and identity["major"] in (5, 6, 7)
and identity["hardware_type"] in (None, 0))
def connect(self, factory):
with self.lock:
try:
self.session = "vesc_" + uuid.uuid4().hex
self.link = factory(self.attachment)
identity = firmware(self.link.query(0))
self.identity = identity
self.id = device_id("uuid:" + identity["uuid"])
self.error = None
except (OSError, ValueError, TimeoutError):
if self.link:
self.link.close()
self.link = None
self.error = "Контроллер не ответил. Проверьте питание, USB и доступность порта."
self.retry_at = time.monotonic() + 15
def close(self):
with self.lock:
if self.link:
self.link.close()
self.link = None
class Service:
def __init__(self, root, discover_fn=discover, link_factory=NativeLink):
self.root = Path(root)
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
self.archive = Archive(self.root / "archive")
for path in sorted(self.root.glob("backup_op_*.json")):
self.archive.add("local", json.loads(path.read_text()))
self.discover_fn, self.link_factory = discover_fn, link_factory
self.devices = {}
self.lock = threading.RLock()
self.operation_lock = threading.Lock()
self.journal_lock = threading.Lock()
self.motor = MotorTest(self, atomic, utc)
self.drive = DriveProfile(self.root, atomic)
self.instance = "vesc_" + uuid.uuid4().hex
self.revision = 0
def scan(self):
attachments = {item.binding: item for item in self.discover_fn()}
with self.lock:
removed = [self.devices.pop(k) for k, d in list(self.devices.items())
if attachments.get(k) != d.attachment]
for key, item in attachments.items():
if key not in self.devices:
self.devices[key] = Device(item)
devices = list(self.devices.values())
for device in removed:
device.close()
for device in devices:
if device.link is not None and not getattr(device.link, "alive", True):
device.close()
if device.link is None and time.monotonic() >= device.retry_at:
device.connect(self.link_factory)
if self.operation_lock.acquire(blocking=False):
try:
for device in devices:
if device.link is not None and self.motor.limits.pending(device):
with device.lock:
try:
self.motor.limits.restore(device)
device.error = None
except (OSError, ValueError, TimeoutError):
device.error = "Прежние токовые пределы ещё не восстановлены. Новые проверки заблокированы."
finally:
self.operation_lock.release()
with self.lock:
self.revision += 1
def inventory(self, node_id):
with self.lock:
# Copy each generation before counting identities. Serial I/O never
# holds the inventory lock; it can complete during this projection.
devices = [d.__dict__.copy() for d in self.devices.values()]
keys = [device_id("uuid:" + d["identity"]["uuid"]) if d["identity"] and d["link"] else d["attachment"].id for d in devices]
items = []
for d, key in zip(devices, keys):
identity, attachment = d["identity"], d["attachment"]
unique = keys.count(key) == 1 and identity is not None and d["link"] is not None
identifier = key if unique else attachment.id
message = d["error"] if keys.count(key) == 1 else "Контроллеры сообщили одинаковый UUID. Настройка недоступна."
items.append({"id": identifier, "attachment_id": attachment.id,
"name": "VESC " + (identity["uuid"][:6].upper() if unique else "· USB " + attachment.usb),
"model": identity["hardware"] if unique else "VESC · USB", "kind": MODEL,
"firmware": identity["version"] if unique else None,
"initializable": True, "prepared": True, "configured": unique, "verified": unique,
"preparation_safe": True, "online": True, "usb": attachment.speed,
"connection_label": "USB " + attachment.usb + next((" · " + slot_label(self.drive.value["layout"], slot) for slot, binding in self.drive.value["bindings"].items() if binding["device_id"] == identifier), ""), "layers": [],
"vesc_status": {"identity": identity if unique else None, "readable": unique and Device.readable_identity(identity),
"engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "board_settings_supported": True, "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False, "standstill_confirmation_required": True}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched},
"snapshot": {"context": {"session_id": d["session"],
"device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc",
"plugin_version": VERSION, "model_id": MODEL},
"stability": "stable" if unique else "provisional",
"basis": "hardware-identifier" if unique else "transport-local"},
"execution": {"node_id": node_id, "agent_instance_id": self.instance, "platform": "linux"},
"opened_at": d["opened"]}, "revision": self.revision, "observed_at": utc(),
"enrollment": "enrolled" if unique else "empty", "acquisition": "idle",
"connectivity": "connected" if unique else "degraded", "message": message}})
return {"items": items}
def validate(self, command):
if not isinstance(command, dict) or command.get("api_version") != SCHEMA or command.get("kind") != "OperationRequest":
raise ValueError("Invalid contract")
identifier = command.get("operation_id", "")
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier) or command.get("idempotency_key") != identifier:
raise ValueError("Invalid operation identity")
action, params = command.get("action_id"), command.get("parameters")
if action not in ACTIONS or not isinstance(params, dict):
raise ValueError("Unsupported operation")
if action in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.control.release"):
keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"standstill_confirmed"} if action != "vesc.motor.pulse" else set()) | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set())
if (set(params) != keys or params["rig_clear"] is not True
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128
or type(params["current_a"]) not in (int, float) or not TEST_LIMITS["min_current_a"] <= params["current_a"] <= TEST_LIMITS["max_current_a"]
or type(params["duration_s"]) not in (int, float) or not TEST_LIMITS["min_duration_s"] <= params["duration_s"] <= TEST_LIMITS["max_duration_s"]):
raise ValueError("Explicit raised-rig confirmation, duration and controller sessions required")
if action != "vesc.motor.pulse" and params["standstill_confirmed"] is not True:
raise ValueError("Explicit observation of all motors at standstill required")
if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= abs(params["erpm"]) <= SPEED_LIMITS["max_erpm"]):
raise ValueError("Speed is outside the supported range")
if action == "vesc.drive.run" and (type(params["profile_revision"]) is not int or params["profile_revision"] < 0
or not isinstance(params["device_ids"], list) or not 2 <= len(params["device_ids"]) <= 128
or any(not isinstance(v, str) for v in params["device_ids"])
or len(set(params["device_ids"])) != len(params["device_ids"])):
raise ValueError("Explicit complete drive profile required")
elif action in ("vesc.hall.measure", "vesc.foc.calibrate"):
extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else {"standstill_confirmed"}
if (set(params) != ({"sessions", "rig_clear", "native_cycle_confirmed"} | extra)
or params["rig_clear"] is not True or params["native_cycle_confirmed"] is not True
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128):
raise ValueError("Native procedure and rig confirmation required")
if action == "vesc.hall.measure" and params["standstill_confirmed"] is not True:
raise ValueError("Explicit observation of all motors at standstill required")
if action == "vesc.foc.calibrate" and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150):
raise ValueError("Heating budget must be between 10 and 150 W")
elif action == "vesc.link.check":
if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128:
raise ValueError("Controller sessions required")
elif action in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
validate_drive(action, params)
elif params != {}:
raise ValueError("This operation has no parameters")
start = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
end = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
if start.tzinfo is None or end.tzinfo is None or not 0 < (end - start).total_seconds() <= 360:
raise ValueError("Invalid operation deadline")
return end
def execute(self, command):
deadline = self.validate(command)
path = self.root / (command["operation_id"] + ".json")
digest = hashlib.sha256(json.dumps(command, sort_keys=True).encode()).hexdigest()
if command["action_id"] == "vesc.motor.stop":
with self.journal_lock:
if path.exists():
previous = json.loads(path.read_text())
if previous["digest"] != digest: raise ValueError("Operation identity conflict")
return previous["receipt"]
if deadline <= datetime.now(timezone.utc): raise ValueError("Operation expired")
self.motor.cancel()
result = {"state": "complete", "result": {"stop_requested": True, "interruptible": self.motor.mode not in ("hall", "foc"), "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "test_active": self.motor.active}}
atomic(path, {"digest": digest, "receipt": result})
return result
if not self.operation_lock.acquire(blocking=False):
raise ValueError("Another VESC operation is running")
try:
if path.exists():
previous = json.loads(path.read_text())
if previous["digest"] != digest:
raise ValueError("Operation identity conflict")
return previous["receipt"]
if deadline <= datetime.now(timezone.utc):
raise ValueError("Operation expired")
with self.lock:
matches = [d for d in self.devices.values() if d.id == command["session"]["device_id"]]
if len(matches) != 1 or matches[0].session != command["session"]["session_id"]:
raise ValueError("Device session changed")
device = matches[0]
# Keep backups and receipts bounded without deleting evidence automatically.
if sum(p.stat().st_size for p in self.root.glob("*.json")) > 32 * 1024 * 1024:
raise ValueError("Read journal is full")
record = {"digest": digest, "receipt": {"state": "unknown", "error": "Чтение не подтверждено. Обновите состояние."}}
with self.journal_lock:
if path.exists(): raise ValueError("Operation identity already reserved")
atomic(path, record)
try:
if command["action_id"] == "vesc.link.check":
from .link_check import measure
with self.lock: devices = list(self.devices.values())
result = measure(self, command, devices)
record["receipt"] = {"state": "complete", "result": result}
atomic(path, record)
return record["receipt"]
if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
if device.identity is None: raise Rejected("Сначала подтвердите личность VESC.")
try:
result = self.drive.update(command["action_id"], command["parameters"], device)
except ValueError as error:
raise Rejected(str(error)) from error
record["receipt"] = {"state": "complete", "result": result}
atomic(path, record)
return record["receipt"]
if command["action_id"] in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.control.release"):
with self.lock:
devices = list(self.devices.values())
actual = {d.id: d.session for d in devices}
if len(actual) != len(devices) or actual != command["parameters"]["sessions"]:
raise Rejected("Состав или сеансы VESC изменились. Обновите устройства.")
run_command = command
if command["action_id"] == "vesc.hall.measure":
run_command = {**command, "parameters": {**command["parameters"], "current_a": 5, "duration_s": 30}}
if command["action_id"] == "vesc.foc.calibrate":
run_command = {**command, "parameters": {**command["parameters"], "current_a": 5, "duration_s": 225}}
result = self.motor.run(run_command, devices, device, command["action_id"] == "vesc.control.release")
record["receipt"] = {"state": "complete", "result": result}
atomic(path, record)
return record["receipt"]
with device.lock:
if device.link is None:
raise OSError("Device disconnected")
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
if remaining < 8:
raise TimeoutError("Insufficient time for bounded read")
actual = firmware(device.link.query(0))
if actual != device.identity:
device.link.close()
device.link = None
device.identity = None
device.session = "vesc_" + uuid.uuid4().hex
raise ValueError("Controller identity changed")
result = {"identity": actual, "device_id": device.id, "observed_at": utc()}
action = command["action_id"]
if action in {"vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup"} and not device.readable:
raise ValueError("Firmware read layout is unsupported")
if action == "vesc.telemetry.read":
result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic())
device.telemetry = result
elif action == "vesc.limits.read":
from .limits_view import read_limits
result.update(parameters=read_limits(device.link))
if firmware(device.link.query(0)) != actual:
raise ValueError("Controller changed during configuration read")
elif action == "vesc.input.read":
if actual["version"] != "5.02": raise ValueError("Input layout unsupported")
result["input"] = ppm(device.link.query(31))
elif action == "vesc.can.read":
if actual["version"] != "5.02": raise ValueError("CAN layout unsupported")
started = time.monotonic()
reply = device.link.query(62, timeout=8)
if not reply or reply[0] != 62: raise ValueError("Invalid CAN reply")
result.update(can_ids=list(reply[1:]), elapsed_s=time.monotonic()-started)
elif action == "vesc.config.backup":
configs = {}
for name, code in (("motor", 14), ("application", 17)):
raw = device.link.query(code)
if not 5 <= len(raw) <= 10000:
raise ValueError("Incomplete configuration")
configs[name] = {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
"signature_hex": raw[1:5].hex()}
# Identity is checked on the same exclusively held attachment at both ends.
if firmware(device.link.query(0)) != actual:
raise ValueError("Controller changed during backup")
result.update(schema="missioncore.vesc.config-backup/v1", configs=configs,
decoded=False, operation_id=command["operation_id"], monotonic_at=time.monotonic())
backup_path = self.root / ("backup_" + command["operation_id"] + ".json")
atomic(backup_path, result)
self.archive.add("local", result)
device.backup = {"observed_at": result["observed_at"], "operation_id": command["operation_id"],
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in configs.items()}}
record["receipt"] = {"state": "complete", "result": result}
except (OSError, ValueError, TimeoutError) as error:
record["receipt"] = {"state": "error", "error": str(error) if isinstance(error, Rejected) else "Операция не выполнена. Проверьте связь и совместимость контроллера."}
if getattr(error, "native_rpc", None) is not None:
# Keep transport evidence in the receipt, not product copy.
# This covers ordinary reads and preflight before a motor
# procedure has its own result/failure envelope.
record["receipt"]["result"] = {"failure": {
"type": type(error).__name__, "native_rpc": error.native_rpc,
"native_history": getattr(error, "native_history", []),
}}
atomic(path, record)
return record["receipt"]
finally:
self.operation_lock.release()