365 lines
23 KiB
Python
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()
|