feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"""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.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "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"], "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}, "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"} | ({"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 in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= 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 set()
|
||||
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 extra 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"):
|
||||
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"):
|
||||
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.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.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 "Операция не выполнена. Проверьте связь и совместимость контроллера."}
|
||||
atomic(path, record)
|
||||
return record["receipt"]
|
||||
|
||||
finally:
|
||||
self.operation_lock.release()
|
||||
Reference in New Issue
Block a user