Files
NODEDC_MISSION_CORE/plugins/vesc/runtime/foc_calibration.py
T

154 lines
8.3 KiB
Python

"""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}