154 lines
8.3 KiB
Python
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}
|