Files
Cognitive_Agent/src/cognitive_agent/application/zcan_seat_executor.py
T

349 lines
13 KiB
Python

"""Execute normal seat command plans through the shared CAN runtime.
ZLG's Windows driver gives one process exclusive ownership of a USBCAN device.
Self-repair and orchestration therefore exchange bounded, armed requests with
one transport-neutral runtime instead of opening the adapter independently.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import os
from pathlib import Path
import time
from typing import Any
import uuid
def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _read_json(path: Path) -> dict[str, Any] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return None
return value if isinstance(value, dict) else None
def _atomic_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(
f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp"
)
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(temporary, path)
class ZcanBridgeSeatExecutor:
"""Hardware-seat executor compatible with ``HardwareSeatExecutor``."""
def __init__(
self,
matrix: Any,
bridge_manager: Any,
status_path: Path,
pending_dir: Path,
*,
required_version: str,
auto_home: bool,
startup_timeout_s: float = 12.0,
request_timeout_s: float = 660.0,
) -> None:
self.matrix = matrix
self.bridge_manager = bridge_manager
self.status_path = Path(status_path)
self.pending_dir = Path(pending_dir)
self.required_version = required_version
self.auto_home = bool(auto_home)
self.startup_timeout_s = float(startup_timeout_s)
self.request_timeout_s = float(request_timeout_s)
def _online_status(self, *, require_protocol: bool) -> dict[str, Any] | None:
value = _read_json(self.status_path)
if value is None or value.get("bridge_version") != self.required_version:
return None
try:
fresh = time.time() - float(value.get("heartbeat_epoch", 0)) < 3.0
except (TypeError, ValueError):
fresh = False
if not fresh:
return None
if require_protocol and not (value.get("system") and value.get("frame_epochs")):
return None
return value
def _wait_online(self, *, require_protocol: bool) -> dict[str, Any]:
if require_protocol and hasattr(
self.bridge_manager, "ensure_seat_protocol"
):
return self.bridge_manager.ensure_seat_protocol()
self.bridge_manager.ensure_async()
deadline = time.time() + self.startup_timeout_s
while time.time() < deadline:
value = self._online_status(require_protocol=require_protocol)
if value is not None:
return value
time.sleep(0.1)
qualifier = "及座椅ECU协议" if require_protocol else ""
raise RuntimeError(f"共享CAN Runtime{qualifier}未在线")
def _payload(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
) -> dict[str, Any]:
commands = []
for command in command_plan["spec"]["commands"]:
encoded = self.matrix.encode(command["message"], command["signals"])
item = dict(command)
item["frame_id"] = int(encoded["frame_id"], 16)
item["data"] = list(bytes.fromhex(encoded["data_hex"]))
commands.append(item)
return {
"name": command_plan.get("metadata", {}).get(
"name", "自然语言座椅控制"
),
"commands": commands,
"operations": list(function_config["spec"]["operations"]),
"allow_auto_home": self.auto_home,
}
def _submit(self, payload: dict[str, Any]) -> dict[str, Any]:
status = self._wait_online(require_protocol=True)
idle_deadline = time.time() + self.startup_timeout_s
while status.get("current_request_id") and time.time() < idle_deadline:
time.sleep(0.1)
status = self._wait_online(require_protocol=True)
if status.get("current_request_id"):
raise RuntimeError(
"共享CAN Runtime正在执行其他台架任务:%s" %
status.get("current_request_id")
)
if (
hasattr(self.bridge_manager, "seat_position_sync_required")
and self.bridge_manager.seat_position_sync_required()
):
sync_id = "SEAT_SYNC_%d_%s" % (
int(time.time() * 1000), uuid.uuid4().hex[:6]
)
sync_result = self._submit_request(
sync_id,
action="initialize_known_ranges",
payload={"force_all_axes": True, "home_nonzero_axes": False},
timeout_s=self.request_timeout_s,
)
if sync_result.get("result") != "PASS":
detail = sync_result.get("details") or {}
raise RuntimeError(
detail.get("summary")
or sync_result.get("summary")
or "四轴位置同步失败,未执行座椅请求"
)
self.bridge_manager.mark_seat_position_synchronized()
request_id = "SEAT_ORCH_%d_%s" % (
int(time.time() * 1000), uuid.uuid4().hex[:6]
)
return self._submit_request(
request_id,
action="execute_command_plan",
payload=payload,
timeout_s=self.request_timeout_s,
)
def _submit_request(
self,
request_id: str,
*,
action: str,
payload: dict[str, Any],
timeout_s: float,
) -> dict[str, Any]:
request = {
"schema": "seat-function-request-v1",
"request_id": request_id,
"created_epoch": time.time(),
"armed_until_epoch": time.time() + timeout_s + 30.0,
"source": "cabin_cognitive_orchestrator",
"action": action,
"payload": payload,
}
_atomic_json(self.pending_dir / f"{request_id}.json", request)
deadline = time.time() + timeout_s
while time.time() < deadline:
status = self._online_status(require_protocol=False)
if status is None:
time.sleep(0.1)
continue
result = status.get("last_result") or {}
if result.get("request_id") == request_id:
return result
time.sleep(0.1)
raise RuntimeError("共享CAN Runtime执行座椅请求超时,已停止等待")
@staticmethod
def _physical_state_from_detail(detail: dict[str, Any]) -> dict[str, Any] | None:
motors = detail.get("motors") or []
axes = ("longitudinal", "cushion_tilt", "backrest_recline", "legrest_height")
positions: dict[str, Any] = {}
missing: list[str] = []
for motor, axis in enumerate(axes):
item = motors[motor] if motor < len(motors) else None
if not isinstance(item, dict):
missing.append(axis)
continue
positions[axis] = {
"position_pct": round(int(item.get("position", 0)) / 100),
"position_valid": bool(item.get("position_valid")),
"motion_state": "moving" if item.get("active") else "stopped",
}
thermal = detail.get("thermal") or []
if not positions and not thermal:
return None
thermal_byte = int(thermal[0]) if thermal else 0
ventilation_raw = thermal_byte & 0x07
heating_raw = (thermal_byte >> 3) & 0x07
return {
"position": positions,
"ventilation": {
"state": "off" if ventilation_raw <= 1 else "on",
"level": max(0, ventilation_raw - 1),
},
"heating": {
"state": "off" if heating_raw <= 1 else "on",
"level": max(0, heating_raw - 1),
},
"captured_at": _now(),
"source": "physical_can",
"channel": 0,
"target": {
"device": "seat", "zone": "front_right", "instance": "passenger"
},
"complete": not missing and bool(thermal),
"missing": missing + ([] if thermal else ["thermal"]),
}
@staticmethod
def _report(
function_config: dict[str, Any],
started_at: str,
bridge_result: dict[str, Any] | None,
error: Exception | None,
) -> dict[str, Any]:
request_id = function_config["metadata"]["request_id"]
passed = error is None and (bridge_result or {}).get("result") == "PASS"
detail = (bridge_result or {}).get("details") or {}
physical_state = (
ZcanBridgeSeatExecutor._physical_state_from_detail(detail)
if passed
else None
)
message = (
detail.get("summary")
or (bridge_result or {}).get("summary")
or (str(error) if error is not None else "共享CAN Runtime未返回通过结果")
)
results = [
{
"ref": operation["id"],
"domain": "seat",
"status": "succeeded" if passed else "failed",
"message": message,
"telemetry": {
"transport": "shared_can_runtime",
"channel": 0,
"hardware_feedback": passed,
"simulated": False,
"feedback_required": True,
"feedback_observed": physical_state is not None,
"feedback_match": physical_state is not None,
"physical_state": physical_state,
"bridge_result": bridge_result,
"error_type": type(error).__name__ if error is not None else None,
"stop_all_attempted": bool(detail.get("stop_all_attempted")),
},
}
for operation in function_config["spec"]["operations"]
]
spec = {
"mode": "hil",
"status": "succeeded" if passed else "failed",
"started_at": started_at,
"finished_at": _now(),
"results": results,
}
if physical_state is not None:
spec["physical_state"] = physical_state
return {
"apiVersion": "cognitive-agent/v1alpha1",
"kind": "ExecutionReport",
"metadata": {
"id": f"report-{request_id}",
"name": "T2G seat shared CAN runtime execution report",
"version": "1.0.0",
"request_id": request_id,
"labels": {"simulation": "false", "feedback": "physical_can"},
},
"spec": spec,
}
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
del actuator_state
started_at = _now()
try:
result = self._submit(self._payload(command_plan, function_config))
except Exception as exc: # normalize bridge/startup failures for the API
return self._report(function_config, started_at, None, exc)
return self._report(function_config, started_at, result, None)
def read_physical_state(self) -> dict[str, Any]:
status = self._wait_online(require_protocol=True)
axes = ("longitudinal", "cushion_tilt", "backrest_recline", "legrest_height")
positions: dict[str, Any] = {}
missing = []
motors = status.get("motors") or []
for motor, axis in enumerate(axes):
item = motors[motor] if motor < len(motors) else None
if not isinstance(item, dict):
missing.append(axis)
continue
positions[axis] = {
"position_pct": round(int(item.get("position", 0)) / 100),
"position_valid": bool(item.get("position_valid")),
"motion_state": "moving" if item.get("active") else "stopped",
}
thermal = status.get("thermal") or []
thermal_byte = int(thermal[0]) if thermal else 0
ventilation_raw = thermal_byte & 0x07
heating_raw = (thermal_byte >> 3) & 0x07
return {
"position": positions,
"ventilation": {
"state": "off" if ventilation_raw <= 1 else "on",
"level": max(0, ventilation_raw - 1),
},
"heating": {
"state": "off" if heating_raw <= 1 else "on",
"level": max(0, heating_raw - 1),
},
"captured_at": _now(),
"source": "physical_can",
"target": {
"device": "seat", "zone": "front_right", "instance": "passenger"
},
"complete": not missing,
"missing": missing,
}