"""Transaction lifecycle around unchanged upstream Utility::detectAllFoc. No FOC algorithm or wire writes here. Native firmware owns this non-interruptible cycle; a pending marker survives any uncertain completion and prevents replay. """ import base64 import hashlib import json import os import re import uuid from .protocol import firmware, values def fresh_values(owner, devices): # FW 5.02 GET_VALUES reads AND resets accumulated average current. The # first reply after a quiet 30 s calibration includes the entire cycle. # Drain it, then measure a new bounded interval after the release command. for device in devices: values(device.link.query(4, timeout=0.2)) owner.sleep(0.25) return {device.id: values(device.link.query(4, timeout=0.2)) for device in devices} def remove_pending(pending): pending.unlink() fd = os.open(pending.parent, os.O_RDONLY) try: os.fsync(fd) finally: os.close(fd) def reconcile(owner, devices): """Explicit neutral-return recovery, never calibration or config replay. Admits only a previously completed/verified native receipt, exact archived post-configs for every peer, stable identity, neutral PPM and fresh idle telemetry. Unknown native completion always remains blocked. """ if any(device.link is None for device in devices): raise ValueError("Controller disconnected") pending = owner.service.root / "calibration-pending.json" record = json.loads(pending.read_text()) operation = record.get("operation_id", "") if not re.fullmatch(r"op_[0-9a-f]{32}", operation): raise ValueError("Invalid pending operation") receipt = json.loads((owner.service.root / (operation + ".json")).read_text())["receipt"] result = receipt.get("result", {}) native = result.get("native", {}) if not (receipt.get("state") == "complete" and result.get("completed") is True and result.get("configuration_verified") is True and native.get("validated") is True): raise ValueError("Native completion/configuration is unconfirmed") expected = {} for identifier in result.get("after_backups", []): if not re.fullmatch(r"op_[0-9a-f]{32}", identifier): raise ValueError("Invalid backup identity") backup = json.loads((owner.service.root / ("backup_" + identifier + ".json")).read_text()) if backup.get("parent_operation_id") != operation or backup["device_id"] in expected: raise ValueError("Calibration backup ownership differs") expected[backup["device_id"]] = backup if set(expected) != {d.id for d in devices}: raise ValueError("Controller set changed") for device in devices: backup = expected[device.id] if firmware(device.link.query(0)) != backup["identity"]: raise ValueError("Controller identity changed") for kind, code in (("motor",14),("application",17)): actual = device.link.query(code) if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True): raise ValueError("Post-calibration configuration changed") owner.neutral(devices) after = fresh_values(owner, devices) if any(abs(v["motor_current_a"]) > 1 or abs(v["erpm"]) > 30 or abs(v["duty"]) > .01 or v["fault_code"] != 0 for v in after.values()): raise ValueError("Fresh idle state unconfirmed") evidence = {"operation_id": operation, "observed_at": owner.utc(), "after": after, "configuration_verified": True, "release_confirmed": True, "calibration_replayed": False} owner.atomic(owner.service.root / ("calibration_recovered_" + operation + ".json"), evidence) remove_pending(pending) return evidence def archive_after(owner, command, device, configs): identifier = "op_" + uuid.uuid4().hex backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id, "identity": device.identity, "operation_id": identifier, "parent_operation_id": command["operation_id"], "observed_at": owner.utc(), "monotonic_at": owner.monotonic(), "decoded": False, "configs": {kind: {"encoding": "base64", "payload": base64.b64encode(raw).decode(), "bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "signature_hex": raw[1:5].hex()} for kind, raw in configs.items()}} owner.atomic(owner.service.root / ("backup_" + identifier + ".json"), backup) owner.service.archive.add("local", backup) device.backup = {"observed_at": backup["observed_at"], "operation_id": identifier, "configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in backup["configs"].items()}} return identifier def calibrate(owner, command, devices, target, originals, backups, unchanged): pending = owner.service.root / "calibration-pending.json" owner.atomic(pending, {"device_id": target.id, "identity": target.identity, "started_at": owner.utc(), "operation_id": command["operation_id"], "backups": backups}) owner.state("calibrating") owner.active, owner.mode = True, "foc" started = owner.monotonic() native, after, issues, saved = {}, {}, [], [] verified, attempted = False, False try: unchanged() owner.neutral(devices) if owner.stop.is_set(): raise ValueError("Cancelled before calibration") # Do not renew a 250 ms lease over the upstream 180 s calibration lease. # No host current/RPM or configuration command is sent while it runs. for device in devices: device.link.test_command("release") attempted = True target.link.calibrate_foc(command["parameters"]["max_power_loss_w"]) while owner.monotonic() - started < 225: unchanged() state = target.link.procedure_result() if not state.get("running"): native = state.get("result", {}) if state.get("uncertain"): issues.append("native_postcondition_unconfirmed") break if owner.stop.is_set() and "stop_requested_during_native_cycle" not in issues: issues.append("stop_requested_during_native_cycle") owner.sleep(0.5) if not native.get("completed"): issues.append("native_completion_unconfirmed") except (OSError, ValueError, TimeoutError): issues.append("communication_unconfirmed") finally: for device in devices: try: device.link.test_command("release") except (OSError, ValueError, TimeoutError): issues.append("release_unconfirmed") owner.sleep(0.5) try: after = fresh_values(owner, devices) except (OSError, ValueError, TimeoutError): after = {device.id: None for device in devices} released = all(v is not None and abs(v["motor_current_a"]) <= 1 and abs(v["duty"]) <= .01 for v in after.values()) if not attempted or native.get("completed"): try: unchanged() for device in devices: if firmware(device.link.query(0)) != device.identity: raise ValueError("Identity changed") configs = {kind: device.link.query(code) for kind, code in (("motor",14),("application",17))} saved.append(archive_after(owner, command, device, configs)) if configs["application"] != originals[device.id]["application"]: raise ValueError("Receiver config changed") if (device is not target or not attempted or not native.get("success")) and configs["motor"] != originals[device.id]["motor"]: raise ValueError("Unchanged/restored motor config differs") verified = not attempted or native.get("validated") is True except (OSError, ValueError, TimeoutError): issues.append("configuration_unconfirmed") if verified and released: remove_pending(pending) if not owner.latched: owner.state("ready") else: owner.state("rc") owner.active, owner.mode = False, None return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_calibration", "elapsed_s": owner.monotonic() - started, "completed": native.get("completed", False), "success": bool(native.get("success") and verified and released), "native": native, "configuration_verified": verified, "release_confirmed": released, "after": after, "issues": issues, "backups": backups, "after_backups": saved}