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

158 lines
8.5 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
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}