"""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()