"""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 from .configuration import decode from .receiver import neutral_band 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") if kind == "application": owner.receiver_bands[device.id] = neutral_band(decode(actual, kind)) 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}