feat: integrate cabin CAN and verified dynamic seat modes
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Load model-facing capability context from domain-owned catalogs."""
|
||||
"""Load model-facing capability context from domain and native-CAN catalogs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,18 +21,25 @@ class CapabilityRegistry:
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
domains = []
|
||||
for catalog_path in sorted(
|
||||
(self.root / "domains").glob("*/config/capabilities.yaml")
|
||||
):
|
||||
catalog_paths = [
|
||||
*(self.root / "domains").glob("*/config/capabilities.yaml"),
|
||||
*(self.root / "config" / "native_can_capabilities").glob("*.yaml"),
|
||||
]
|
||||
for catalog_path in sorted(catalog_paths):
|
||||
if catalog_path.parts[-3] == "_template":
|
||||
continue
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8"))
|
||||
domain = catalog["spec"]["domain"]
|
||||
native_can_catalog = catalog_path.parent.name == "native_can_capabilities"
|
||||
domains.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"availability": (
|
||||
"executable" if domain in self.executable_domains else "catalog_only"
|
||||
"native_can"
|
||||
if native_can_catalog
|
||||
else "executable"
|
||||
if domain in self.executable_domains
|
||||
else "catalog_only"
|
||||
),
|
||||
"capabilities": catalog["spec"]["capabilities"],
|
||||
}
|
||||
@@ -45,6 +52,9 @@ class CapabilityRegistry:
|
||||
"execution_policy": {
|
||||
"executable_domains": sorted(self.executable_domains),
|
||||
"catalog_only_behavior": "return_as_deferred_request",
|
||||
"native_can_behavior": (
|
||||
"compile_for_body_can_when_hardware_mode_is_authorized"
|
||||
),
|
||||
"planning_environment": "dry_run_candidate_synthesis",
|
||||
"named_preset_required": False,
|
||||
"fuzzy_goal_behavior": (
|
||||
|
||||
@@ -67,6 +67,13 @@ def _new_state(
|
||||
axis: _motion_state(axis, position)
|
||||
for axis, position in MOTION_INITIAL_POSITIONS.items()
|
||||
},
|
||||
"dynamic_mode": {
|
||||
"mode_id": 0,
|
||||
"name": "",
|
||||
"state": "idle",
|
||||
"repeat": "none",
|
||||
"max_runtime_ms": 600_000,
|
||||
},
|
||||
}
|
||||
for target in TARGETS
|
||||
},
|
||||
@@ -246,6 +253,28 @@ class ConversationStateStore:
|
||||
state["last_target"] = action["target"]
|
||||
continue
|
||||
target = action["target"]
|
||||
if action.get("action_type") == "dynamic_mode_stop":
|
||||
state["devices"][target]["dynamic_mode"] = {
|
||||
"mode_id": 0,
|
||||
"name": "",
|
||||
"state": "idle",
|
||||
"repeat": "none",
|
||||
"max_runtime_ms": 600_000,
|
||||
}
|
||||
state["last_target"] = target
|
||||
continue
|
||||
if action.get("action_type") == "dynamic_mode":
|
||||
state["devices"][target]["dynamic_mode"] = {
|
||||
"mode_id": int(action["mode_id"]),
|
||||
"name": str(action.get("mode_name", "")),
|
||||
"state": "running",
|
||||
"repeat": str(action.get("repeat", "infinite")),
|
||||
"max_runtime_ms": int(
|
||||
action.get("max_runtime_ms", 600_000)
|
||||
),
|
||||
}
|
||||
state["last_target"] = target
|
||||
continue
|
||||
if action.get("action_type") == "position":
|
||||
axis = action["axis"]
|
||||
position_state = _motion_state(axis, action["position_pct"])
|
||||
|
||||
@@ -102,6 +102,195 @@ class ModeOperation:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeTimelinePhase:
|
||||
id: str
|
||||
name: str
|
||||
duration_ms: int | None
|
||||
transition: str
|
||||
targets: dict[str, Any]
|
||||
effects: dict[str, Any]
|
||||
completion: str
|
||||
timeout_ms: int | None
|
||||
motion: dict[str, Any]
|
||||
arrival_grace_ms: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, document: dict[str, Any]) -> "ModeTimelinePhase":
|
||||
phase_id = str(document.get("id", "")).strip()
|
||||
if not phase_id:
|
||||
raise ValueError("mode timeline phase id must not be empty")
|
||||
duration_ms = document.get("duration_ms")
|
||||
completion = str(
|
||||
document.get(
|
||||
"completion",
|
||||
"elapsed_time" if duration_ms is not None else "all_targets_reached",
|
||||
)
|
||||
).strip()
|
||||
if completion not in {"elapsed_time", "all_targets_reached"}:
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} completion must be elapsed_time or all_targets_reached"
|
||||
)
|
||||
if completion == "elapsed_time":
|
||||
if (
|
||||
not isinstance(duration_ms, int)
|
||||
or isinstance(duration_ms, bool)
|
||||
or duration_ms <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} duration_ms must be a positive integer"
|
||||
)
|
||||
elif duration_ms is not None:
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} duration_ms must be omitted for all_targets_reached"
|
||||
)
|
||||
timeout_ms = document.get("timeout_ms")
|
||||
if timeout_ms is not None and (
|
||||
not isinstance(timeout_ms, int)
|
||||
or isinstance(timeout_ms, bool)
|
||||
or timeout_ms <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} timeout_ms must be a positive integer"
|
||||
)
|
||||
if completion == "all_targets_reached" and timeout_ms is None:
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} timeout_ms is required for all_targets_reached"
|
||||
)
|
||||
arrival_grace_ms = document.get("arrival_grace_ms", 0)
|
||||
if (
|
||||
not isinstance(arrival_grace_ms, int)
|
||||
or isinstance(arrival_grace_ms, bool)
|
||||
or arrival_grace_ms < 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} arrival_grace_ms must be a non-negative integer"
|
||||
)
|
||||
targets = document.get("targets", {})
|
||||
effects = document.get("effects", {})
|
||||
motion = document.get("motion", {})
|
||||
if (
|
||||
not isinstance(targets, dict)
|
||||
or not isinstance(effects, dict)
|
||||
or not isinstance(motion, dict)
|
||||
):
|
||||
raise ValueError(
|
||||
f"mode timeline phase {phase_id} targets/effects/motion must be objects"
|
||||
)
|
||||
return cls(
|
||||
id=phase_id,
|
||||
name=str(document.get("name", phase_id)),
|
||||
duration_ms=duration_ms,
|
||||
transition=str(document.get("transition", "hold")),
|
||||
targets=dict(targets),
|
||||
effects=dict(effects),
|
||||
completion=completion,
|
||||
timeout_ms=timeout_ms,
|
||||
motion=dict(motion),
|
||||
arrival_grace_ms=arrival_grace_ms,
|
||||
)
|
||||
|
||||
def to_yaml(self) -> dict[str, Any]:
|
||||
result = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"transition": self.transition,
|
||||
"targets": dict(self.targets),
|
||||
"effects": dict(self.effects),
|
||||
"completion": self.completion,
|
||||
}
|
||||
if self.duration_ms is not None:
|
||||
result["duration_ms"] = self.duration_ms
|
||||
if self.timeout_ms is not None:
|
||||
result["timeout_ms"] = self.timeout_ms
|
||||
if self.motion:
|
||||
result["motion"] = dict(self.motion)
|
||||
if self.arrival_grace_ms:
|
||||
result["arrival_grace_ms"] = self.arrival_grace_ms
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeTimeline:
|
||||
repeat: str
|
||||
cycle_duration_ms: int | None
|
||||
entry: dict[str, Any]
|
||||
phases: tuple[ModeTimelinePhase, ...]
|
||||
max_runtime_ms: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, document: dict[str, Any]) -> "ModeTimeline":
|
||||
repeat = str(document.get("repeat", "")).strip()
|
||||
if repeat not in {"once", "infinite", "until_stopped_or_timeout"}:
|
||||
raise ValueError(
|
||||
"mode timeline repeat must be once, infinite, or until_stopped_or_timeout"
|
||||
)
|
||||
max_runtime_ms = document.get("max_runtime_ms")
|
||||
if max_runtime_ms is not None and (
|
||||
not isinstance(max_runtime_ms, int)
|
||||
or isinstance(max_runtime_ms, bool)
|
||||
or max_runtime_ms <= 0
|
||||
):
|
||||
raise ValueError("mode timeline max_runtime_ms must be a positive integer")
|
||||
if repeat == "until_stopped_or_timeout" and max_runtime_ms is None:
|
||||
raise ValueError(
|
||||
"mode timeline max_runtime_ms is required for until_stopped_or_timeout"
|
||||
)
|
||||
duration_ms = document.get("cycle_duration_ms")
|
||||
if duration_ms is not None and (
|
||||
not isinstance(duration_ms, int)
|
||||
or isinstance(duration_ms, bool)
|
||||
or duration_ms <= 0
|
||||
):
|
||||
raise ValueError("mode timeline cycle_duration_ms must be positive")
|
||||
raw_entry = document.get("entry", {})
|
||||
raw_phases = document.get("phases", [])
|
||||
if not isinstance(raw_entry, dict):
|
||||
raise ValueError("mode timeline entry must be an object")
|
||||
if not isinstance(raw_phases, list) or not raw_phases:
|
||||
raise ValueError("mode timeline must contain at least one phase")
|
||||
phases = tuple(ModeTimelinePhase.from_dict(item) for item in raw_phases)
|
||||
phase_ids = [phase.id for phase in phases]
|
||||
if len(set(phase_ids)) != len(phase_ids):
|
||||
raise ValueError("mode timeline phase ids must be unique")
|
||||
timed_phases = all(phase.duration_ms is not None for phase in phases)
|
||||
if timed_phases:
|
||||
calculated_duration = sum(
|
||||
int(phase.duration_ms or 0) for phase in phases
|
||||
)
|
||||
if calculated_duration != duration_ms:
|
||||
raise ValueError(
|
||||
"mode timeline cycle_duration_ms does not equal the sum of phases: "
|
||||
f"declared={duration_ms}, calculated={calculated_duration}"
|
||||
)
|
||||
elif duration_ms is not None:
|
||||
raise ValueError(
|
||||
"mode timeline cycle_duration_ms must be omitted when a phase completes by target arrival"
|
||||
)
|
||||
return cls(
|
||||
repeat=repeat,
|
||||
cycle_duration_ms=duration_ms,
|
||||
entry=dict(raw_entry),
|
||||
phases=phases,
|
||||
max_runtime_ms=max_runtime_ms,
|
||||
)
|
||||
|
||||
def to_yaml(self) -> dict[str, Any]:
|
||||
result = {
|
||||
"repeat": self.repeat,
|
||||
"entry": dict(self.entry),
|
||||
"phases": [phase.to_yaml() for phase in self.phases],
|
||||
}
|
||||
if self.cycle_duration_ms is not None:
|
||||
result["cycle_duration_ms"] = self.cycle_duration_ms
|
||||
if self.max_runtime_ms is not None:
|
||||
result["max_runtime_ms"] = self.max_runtime_ms
|
||||
return result
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
return self.to_yaml()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModeProfile:
|
||||
id: str
|
||||
@@ -113,6 +302,7 @@ class ModeProfile:
|
||||
source: str
|
||||
common: bool = True
|
||||
vehicle_ids: tuple[str, ...] = ()
|
||||
timeline: ModeTimeline | None = None
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
|
||||
@@ -128,6 +318,9 @@ class ModeProfile:
|
||||
name,
|
||||
[str(alias) for alias in document.get("aliases", [])],
|
||||
)
|
||||
raw_timeline = document.get("timeline")
|
||||
if raw_timeline is not None and not isinstance(raw_timeline, dict):
|
||||
raise ValueError(f"{path} timeline must be an object")
|
||||
return cls(
|
||||
id=str(document["id"]),
|
||||
name=name,
|
||||
@@ -138,10 +331,15 @@ class ModeProfile:
|
||||
source=str(path),
|
||||
common=common,
|
||||
vehicle_ids=vehicle_ids,
|
||||
timeline=(
|
||||
ModeTimeline.from_dict(raw_timeline)
|
||||
if isinstance(raw_timeline, dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
result = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"aliases": list(self.aliases),
|
||||
@@ -153,7 +351,11 @@ class ModeProfile:
|
||||
"common": self.common,
|
||||
"vehicle_ids": list(self.vehicle_ids),
|
||||
},
|
||||
"behavior": "cyclic" if self.timeline is not None else "static",
|
||||
}
|
||||
if self.timeline is not None:
|
||||
result["timeline"] = self.timeline.public_dict()
|
||||
return result
|
||||
|
||||
|
||||
class ModeProfileRegistry:
|
||||
@@ -370,7 +572,7 @@ def _mode_profile_document(
|
||||
"common": profile.common,
|
||||
"vehicle_ids": list(profile.vehicle_ids),
|
||||
}
|
||||
return {
|
||||
document = {
|
||||
"id": profile.id,
|
||||
"name": profile.name,
|
||||
"aliases": list(profile.aliases),
|
||||
@@ -379,6 +581,9 @@ def _mode_profile_document(
|
||||
"scope": scope,
|
||||
"operations": [operation.to_yaml() for operation in operations],
|
||||
}
|
||||
if profile.timeline is not None:
|
||||
document["timeline"] = profile.timeline.to_yaml()
|
||||
return document
|
||||
|
||||
|
||||
def _replace_yaml(path: Path, document: dict[str, Any]) -> None:
|
||||
|
||||
@@ -27,7 +27,14 @@ SEAT_MOTION_CAPABILITIES = {
|
||||
SEAT_RELATIVE_PHYSICAL_PARAMETERS = {"distance_mm", "angle_delta_deg"}
|
||||
SAVE_PRESET_CAPABILITY = "seat.save_posture_preset"
|
||||
APPLY_PRESET_CAPABILITY = "seat.apply_posture_preset"
|
||||
DYNAMIC_MODE_CAPABILITY = "seat.start_dynamic_mode"
|
||||
DYNAMIC_MODE_STOP_CAPABILITY = "seat.stop_dynamic_mode"
|
||||
DYNAMIC_MODE_MAX_RUNTIME_MS = 600_000
|
||||
HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
NATIVE_CAN_ONLY_CAPABILITIES = {
|
||||
"climate.set_control_mode",
|
||||
"roof.set_shade_motion",
|
||||
}
|
||||
|
||||
|
||||
class ModelPlanAdapter:
|
||||
@@ -69,7 +76,7 @@ class ModelPlanAdapter:
|
||||
)
|
||||
continue
|
||||
|
||||
domain, availability, capability = registered
|
||||
domain, domain_availability, capability = registered
|
||||
parameters = {
|
||||
str(item["name"]): item.get("value")
|
||||
for item in request.get("parameters", [])
|
||||
@@ -93,7 +100,26 @@ class ModelPlanAdapter:
|
||||
)
|
||||
continue
|
||||
|
||||
if availability != "executable":
|
||||
availability = (
|
||||
"native_can"
|
||||
if capability_name in NATIVE_CAN_ONLY_CAPABILITIES
|
||||
else domain_availability
|
||||
)
|
||||
native_only_request = (
|
||||
domain == "seat"
|
||||
and capability_name in SEAT_CAPABILITIES
|
||||
and (
|
||||
request.get("target_scope") in {"left_rear", "right_rear"}
|
||||
or parameters.get("level") in {4, 5}
|
||||
)
|
||||
) or (
|
||||
capability_name == "climate.set_outlet_mode"
|
||||
and (
|
||||
request.get("target_scope") == "vehicle"
|
||||
or parameters.get("mode") not in {"face", "feet"}
|
||||
)
|
||||
)
|
||||
if availability != "executable" or native_only_request:
|
||||
values = [value for value in parameters.values() if value is not None]
|
||||
deferred.append(
|
||||
{
|
||||
@@ -104,6 +130,11 @@ class ModelPlanAdapter:
|
||||
"requested_value": values[0] if len(values) == 1 else None,
|
||||
"parameters": parameters,
|
||||
"reason": request["reason"],
|
||||
"execution_backend": (
|
||||
"native_can"
|
||||
if availability == "native_can" or native_only_request
|
||||
else availability
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -182,6 +213,20 @@ class ModelPlanAdapter:
|
||||
}
|
||||
)
|
||||
continue
|
||||
if capability_name == DYNAMIC_MODE_STOP_CAPABILITY:
|
||||
dynamic_action = self._dynamic_mode_stop_action(
|
||||
index, request, parameters, ambiguities
|
||||
)
|
||||
if dynamic_action is not None:
|
||||
actions.append(dynamic_action)
|
||||
continue
|
||||
if capability_name == DYNAMIC_MODE_CAPABILITY:
|
||||
dynamic_action = self._dynamic_mode_action(
|
||||
index, request, parameters, ambiguities
|
||||
)
|
||||
if dynamic_action is not None:
|
||||
actions.append(dynamic_action)
|
||||
continue
|
||||
action = self._seat_action(
|
||||
index, request, parameters, ambiguities, context, vehicle_profile
|
||||
)
|
||||
@@ -352,6 +397,79 @@ class ModelPlanAdapter:
|
||||
return None
|
||||
return {"target": target, "preset_name": preset_name.strip()}
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_mode_action(
|
||||
index: int,
|
||||
request: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
ambiguities: list[dict[str, str]],
|
||||
) -> dict[str, Any] | None:
|
||||
target = request.get("target_scope")
|
||||
if target != "passenger":
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].target_scope",
|
||||
"question": "当前动态座椅模式仅控制副驾。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
mode_id = parameters.get("mode_id")
|
||||
if (
|
||||
not isinstance(mode_id, (int, float))
|
||||
or isinstance(mode_id, bool)
|
||||
or not float(mode_id).is_integer()
|
||||
or int(mode_id) not in {1, 2}
|
||||
):
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters.mode_id",
|
||||
"question": "动态座椅模式编号必须是 1(木马模式)或 2(动态摇篮)。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
normalized_mode_id = int(mode_id)
|
||||
return {
|
||||
"action_type": "dynamic_mode",
|
||||
"target": target,
|
||||
"mode_id": normalized_mode_id,
|
||||
"mode_name": (
|
||||
"木马模式"
|
||||
if normalized_mode_id == 1
|
||||
else "动态摇篮模式"
|
||||
),
|
||||
"repeat": "until_stopped_or_timeout",
|
||||
"max_runtime_ms": DYNAMIC_MODE_MAX_RUNTIME_MS,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dynamic_mode_stop_action(
|
||||
index: int,
|
||||
request: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
ambiguities: list[dict[str, str]],
|
||||
) -> dict[str, Any] | None:
|
||||
target = request.get("target_scope")
|
||||
if target != "passenger":
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].target_scope",
|
||||
"question": "当前动态座椅模式仅控制副驾。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
if parameters:
|
||||
ambiguities.append(
|
||||
{
|
||||
"field": f"requests[{index}].parameters",
|
||||
"question": "停止动态座椅模式不接受额外参数。",
|
||||
}
|
||||
)
|
||||
return None
|
||||
return {
|
||||
"action_type": "dynamic_mode_stop",
|
||||
"target": target,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _find_preset(
|
||||
presets: list[dict[str, Any]],
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Deterministic parsing for the trusted vehicle CAN command vocabulary.
|
||||
|
||||
The parser only emits named capabilities. It never accepts a raw CAN ID or
|
||||
payload from a user utterance; byte-level compilation stays in NativeCanRuntime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
WINDOW_TARGETS = {
|
||||
"全部": "all",
|
||||
"所有": "all",
|
||||
"四个": "all",
|
||||
"全车": "all",
|
||||
"前排": "front",
|
||||
"后排": "rear",
|
||||
"左前": "front_left",
|
||||
"前左": "front_left",
|
||||
"右前": "front_right",
|
||||
"前右": "front_right",
|
||||
"左后": "rear_left",
|
||||
"后左": "rear_left",
|
||||
"右后": "rear_right",
|
||||
"后右": "rear_right",
|
||||
}
|
||||
OUTLET_TARGETS = {
|
||||
"主驾左": "driver_left",
|
||||
"主驾右": "driver_right",
|
||||
"副驾左": "passenger_left",
|
||||
"副驾右": "passenger_right",
|
||||
}
|
||||
LIGHT_TARGETS = {
|
||||
"全部": "all",
|
||||
"所有": "all",
|
||||
"左前": "front_left",
|
||||
"右前": "front_right",
|
||||
"中排左旋钮": "middle_left_rotary",
|
||||
"中排右旋钮": "middle_right_rotary",
|
||||
"中排左": "middle_left",
|
||||
"中排右": "middle_right",
|
||||
"左后": "rear_left",
|
||||
"右后": "rear_right",
|
||||
}
|
||||
SEAT_TARGETS = {
|
||||
"driver": "driver",
|
||||
"passenger": "passenger",
|
||||
"left-rear": "left_rear",
|
||||
"right-rear": "right_rear",
|
||||
}
|
||||
|
||||
|
||||
def _action(
|
||||
domain: str,
|
||||
capability: str,
|
||||
target: str,
|
||||
parameters: dict[str, Any],
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"domain": domain,
|
||||
"capability": capability,
|
||||
"target": target,
|
||||
"parameters": parameters,
|
||||
"operation": "set",
|
||||
"reason": reason,
|
||||
"execution_backend": "native_can",
|
||||
}
|
||||
|
||||
|
||||
def _parse_exact_command(text: str) -> list[dict[str, Any]]:
|
||||
command = " ".join(text.strip().lower().split())
|
||||
match = re.fullmatch(r"door rear (lock|unlock)", command)
|
||||
if match:
|
||||
return [_action("door", "door.set_rear_lock", "rear", {"state": match[1]}, "用户输入了受信任的后排门锁命令。")]
|
||||
|
||||
match = re.fullmatch(
|
||||
r"window (all|front|rear|front-left|front-right|rear-left|rear-right) (open|close|stop)",
|
||||
command,
|
||||
)
|
||||
if match:
|
||||
return [_action("window", "window.set_motion", match[1].replace("-", "_"), {"motion": match[2]}, "用户输入了受信任的车窗动作命令。")]
|
||||
match = re.fullmatch(
|
||||
r"window (front-left|front-right|rear-left|rear-right) position (\d+)",
|
||||
command,
|
||||
)
|
||||
if match and 0 <= int(match[2]) <= 100:
|
||||
return [_action("window", "window.set_position", match[1].replace("-", "_"), {"position_pct": int(match[2])}, "用户输入了受信任的车窗位置命令。")]
|
||||
|
||||
match = re.fullmatch(r"ac (on|off)", command)
|
||||
if match:
|
||||
return [_action("climate", "climate.set_power", "vehicle", {"state": match[1]}, "用户输入了受信任的空调开关命令。")]
|
||||
match = re.fullmatch(r"ac fan ([1-7])", command)
|
||||
if match:
|
||||
return [_action("climate", "climate.set_fan_level", "vehicle", {"level": int(match[1])}, "用户输入了受信任的空调风量命令。")]
|
||||
match = re.fullmatch(
|
||||
r"ac mode (face|face-feet|feet|feet-defrost|defrost|face-feet-defrost|face-defrost)",
|
||||
command,
|
||||
)
|
||||
if match:
|
||||
return [_action("climate", "climate.set_outlet_mode", "vehicle", {"mode": match[1].replace("-", "_")}, "用户输入了受信任的空调出风模式命令。")]
|
||||
match = re.fullmatch(r"ac temp (driver|passenger|rear) (\d+)", command)
|
||||
if match and 18 <= int(match[2]) <= 32:
|
||||
return [_action("climate", "climate.set_temperature", match[1], {"temperature_c": int(match[2])}, "用户输入了受信任的空调温度命令。")]
|
||||
match = re.fullmatch(r"ac control (manual|auto)", command)
|
||||
if match:
|
||||
return [_action("climate", "climate.set_control_mode", "vehicle", {"mode": match[1]}, "用户输入了受信任的空调控制模式命令。")]
|
||||
|
||||
match = re.fullmatch(
|
||||
r"outlet (driver-left|driver-right|passenger-left|passenger-right) percent (\d+) (\d+)",
|
||||
command,
|
||||
)
|
||||
if match and all(0 <= int(value) <= 100 for value in match.groups()[1:]):
|
||||
return [_action("outlet", "outlet.set_position_percent", match[1].replace("-", "_"), {"horizontal_pct": int(match[2]), "vertical_pct": int(match[3])}, "用户输入了受信任的电动风口百分比命令。")]
|
||||
match = re.fullmatch(
|
||||
r"outlet (driver-left|driver-right|passenger-left|passenger-right) position (-?\d+) (-?\d+)",
|
||||
command,
|
||||
)
|
||||
if match and all(-128 <= int(value) <= 126 for value in match.groups()[1:]):
|
||||
return [_action("outlet", "outlet.set_calibrated_position", match[1].replace("-", "_"), {"horizontal_value": int(match[2]), "vertical_value": int(match[3])}, "用户输入了受信任的电动风口标定坐标命令。")]
|
||||
|
||||
match = re.fullmatch(
|
||||
r"seat (driver|passenger|left-rear|right-rear) (heat|vent) (off|[1-5])",
|
||||
command,
|
||||
)
|
||||
if match:
|
||||
parameters: dict[str, Any] = {"state": "off" if match[3] == "off" else "on"}
|
||||
if match[3] != "off":
|
||||
parameters["level"] = int(match[3])
|
||||
capability = "seat.set_heating" if match[2] == "heat" else "seat.set_ventilation"
|
||||
return [_action("seat", capability, SEAT_TARGETS[match[1]], parameters, "用户输入了受信任的整车座椅热舒适命令。")]
|
||||
|
||||
match = re.fullmatch(r"light (indoor|wall) (on|off)", command)
|
||||
if match:
|
||||
capability = "interior_light.set_indoor_power" if match[1] == "indoor" else "interior_light.set_wall_power"
|
||||
return [_action("interior_light", capability, "vehicle", {"state": match[2]}, "用户输入了受信任的车内灯命令。")]
|
||||
match = re.fullmatch(
|
||||
r"light reading (all|front-left|front-right|middle-left|middle-right|middle-left-rotary|middle-right-rotary|rear-left|rear-right) (on|off)",
|
||||
command,
|
||||
)
|
||||
if match:
|
||||
return [_action("interior_light", "interior_light.set_reading_power", match[1].replace("-", "_"), {"state": match[2]}, "用户输入了受信任的阅读灯命令。")]
|
||||
match = re.fullmatch(r"light ambient (on|off)", command)
|
||||
if match:
|
||||
return [_action("ambient_light", "ambient_light.set_power", "vehicle", {"state": match[1]}, "用户输入了受信任的氛围灯开关命令。")]
|
||||
match = re.fullmatch(r"light ambient scene (camping|nap|cinema|meditation|romance)", command)
|
||||
if match:
|
||||
mode = {"nap": "rest", "romance": "romantic"}.get(match[1], match[1])
|
||||
return [_action("ambient_light", "ambient_light.set_mode", "vehicle", {"mode": mode}, "用户输入了受信任的氛围灯场景命令。")]
|
||||
match = re.fullmatch(r"light ambient rgb (\d+) (\d+) (\d+) (\d+)", command)
|
||||
if match:
|
||||
red, green, blue, brightness = map(int, match.groups())
|
||||
if all(1 <= value <= 255 for value in (red, green, blue)) and 0 <= brightness <= 100:
|
||||
return [_action("ambient_light", "ambient_light.set_illumination", "vehicle", {"color_hex": f"#{red:02X}{green:02X}{blue:02X}", "brightness_pct": brightness}, "用户输入了受信任的氛围灯 RGB 命令。")]
|
||||
|
||||
match = re.fullmatch(r"shade (open|close|half|stop)", command)
|
||||
if match:
|
||||
return [_action("roof", "roof.set_shade_motion", "vehicle", {"motion": match[1]}, "用户输入了受信任的遮阳帘动作命令。")]
|
||||
match = re.fullmatch(r"shade position (\d+)", command)
|
||||
if match and 0 <= int(match[1]) <= 100:
|
||||
return [_action("roof", "roof.set_shade_position", "vehicle", {"position_pct": int(match[1])}, "用户输入了受信任的遮阳帘位置命令。")]
|
||||
return []
|
||||
|
||||
|
||||
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
actions: list[dict[str, Any]] = []
|
||||
|
||||
if "空调" in compact or re.search(r"(?:AC|A/C)", text, re.IGNORECASE):
|
||||
if re.search(r"(?:打开|开启|启动)空调", compact):
|
||||
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
|
||||
elif re.search(r"(?:关闭|关掉|关上)空调", compact):
|
||||
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "off"}, "用户要求关闭空调。"))
|
||||
|
||||
temperature = re.search(
|
||||
r"(?:(主驾|副驾|后排)(?:空调)?(?:温度)?|空调(?:温度)?|温度)(?:调到|设为|设置为|到)?(1[89]|2\d|3[0-2])(?:℃|度)?",
|
||||
compact,
|
||||
)
|
||||
if temperature:
|
||||
target = {"主驾": "driver", "副驾": "passenger", "后排": "rear"}.get(temperature[1], "vehicle")
|
||||
actions.append(_action("climate", "climate.set_temperature", target, {"temperature_c": int(temperature[2])}, "用户指定了空调温度。"))
|
||||
|
||||
fan = re.search(r"(?:风量|风速)(?:调到|设为|设置为|到)?([1-7])(?:档)?", compact)
|
||||
if fan:
|
||||
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
|
||||
|
||||
if re.search(r"自动(?:控制|模式)", compact):
|
||||
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
|
||||
elif re.search(r"手动(?:控制|模式)", compact):
|
||||
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。"))
|
||||
|
||||
outlet_modes = [
|
||||
("吹面吹脚除霜", "face_feet_defrost"),
|
||||
("吹面除霜", "face_defrost"),
|
||||
("吹脚除霜", "feet_defrost"),
|
||||
("吹面吹脚", "face_feet"),
|
||||
("除霜", "defrost"),
|
||||
("吹脚", "feet"),
|
||||
("吹面", "face"),
|
||||
]
|
||||
for label, mode in outlet_modes:
|
||||
if label in compact:
|
||||
actions.append(_action("climate", "climate.set_outlet_mode", "vehicle", {"mode": mode}, "用户指定了空调出风模式。"))
|
||||
break
|
||||
|
||||
if "车窗" in compact:
|
||||
target = next((value for label, value in WINDOW_TARGETS.items() if label in compact), "all")
|
||||
position = re.search(r"(?:位置|开度|调到|设为)(\d{1,3})%", compact)
|
||||
if position and target not in {"all", "front", "rear"} and int(position[1]) <= 100:
|
||||
actions.append(_action("window", "window.set_position", target, {"position_pct": int(position[1])}, "用户指定了单个车窗的绝对开度。"))
|
||||
else:
|
||||
motion = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "close" if re.search(r"关闭|关上|升起|关窗", compact)
|
||||
else "open" if re.search(r"打开|开启|降下|开窗", compact)
|
||||
else None
|
||||
)
|
||||
if motion:
|
||||
actions.append(_action("window", "window.set_motion", target, {"motion": motion}, "用户指定了车窗目标和动作。"))
|
||||
|
||||
if re.search(r"后排.*(?:门锁|车门)|(?:门锁|车门).*后排", compact):
|
||||
state = "unlock" if re.search(r"解锁|开锁", compact) else "lock" if re.search(r"上锁|落锁|锁上", compact) else None
|
||||
if state:
|
||||
actions.append(_action("door", "door.set_rear_lock", "rear", {"state": state}, "用户指定了后排门锁状态。"))
|
||||
|
||||
if re.search(r"遮阳帘|天幕帘", compact):
|
||||
position = re.search(r"(?:位置|开度|调到|设为)(\d{1,3})%", compact)
|
||||
if position and int(position[1]) <= 100:
|
||||
actions.append(_action("roof", "roof.set_shade_position", "vehicle", {"position_pct": int(position[1])}, "用户指定了遮阳帘开度。"))
|
||||
else:
|
||||
motion = (
|
||||
"stop" if re.search(r"停止|停下|暂停", compact)
|
||||
else "half" if re.search(r"半开|一半", compact)
|
||||
else "close" if re.search(r"关闭|关上", compact)
|
||||
else "open" if re.search(r"打开|开启", compact)
|
||||
else None
|
||||
)
|
||||
if motion:
|
||||
actions.append(_action("roof", "roof.set_shade_motion", "vehicle", {"motion": motion}, "用户指定了遮阳帘动作。"))
|
||||
|
||||
for label, capability in (("室内灯", "interior_light.set_indoor_power"), ("车内灯", "interior_light.set_indoor_power"), ("壁灯", "interior_light.set_wall_power")):
|
||||
if label in compact:
|
||||
state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
|
||||
if state:
|
||||
actions.append(_action("interior_light", capability, "vehicle", {"state": state}, "用户指定了车内灯状态。"))
|
||||
break
|
||||
|
||||
if "阅读灯" in compact:
|
||||
target = next((value for label, value in LIGHT_TARGETS.items() if label in compact), "all")
|
||||
state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
|
||||
if state:
|
||||
actions.append(_action("interior_light", "interior_light.set_reading_power", target, {"state": state}, "用户指定了阅读灯区域和状态。"))
|
||||
|
||||
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in compact), None)
|
||||
outlet_position = re.search(r"(?:左右|水平)(\d{1,3})%?.*?(?:上下|垂直)(\d{1,3})%?", compact)
|
||||
if outlet_target and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
|
||||
actions.append(_action("outlet", "outlet.set_position_percent", outlet_target, {"horizontal_pct": int(outlet_position[1]), "vertical_pct": int(outlet_position[2])}, "用户指定了已标定风口的双轴百分比位置。"))
|
||||
return actions
|
||||
|
||||
|
||||
def _signature(action: dict[str, Any]) -> tuple[str, str, str, tuple[tuple[str, str], ...]]:
|
||||
return (
|
||||
str(action.get("domain")),
|
||||
str(action.get("capability")),
|
||||
str(action.get("target", action.get("target_scope"))),
|
||||
tuple(sorted((str(key), repr(value)) for key, value in action.get("parameters", {}).items())),
|
||||
)
|
||||
|
||||
|
||||
def merge_native_can_intent(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
|
||||
"""Add deterministic vehicle-CAN actions without duplicating model requests."""
|
||||
|
||||
clauses = [part for part in re.split(r"[,,。;;!!??]+", utterance) if part.strip()]
|
||||
exact_actions = [action for clause in clauses for action in _parse_exact_command(clause)]
|
||||
actions = exact_actions or _parse_chinese_commands(utterance)
|
||||
if not actions:
|
||||
return parsed
|
||||
|
||||
result = dict(parsed)
|
||||
existing = [
|
||||
*result.get("actions", []),
|
||||
*result.get("native_can_actions", []),
|
||||
*[
|
||||
item
|
||||
for item in result.get("deferred_requests", [])
|
||||
if item.get("execution_backend") == "native_can"
|
||||
],
|
||||
]
|
||||
signatures = {_signature(item) for item in existing}
|
||||
unique_actions = [item for item in actions if _signature(item) not in signatures]
|
||||
if not unique_actions:
|
||||
return result
|
||||
|
||||
result["native_can_actions"] = [
|
||||
*result.get("native_can_actions", []),
|
||||
*unique_actions,
|
||||
]
|
||||
result["deferred_requests"] = [
|
||||
*result.get("deferred_requests", []),
|
||||
*[
|
||||
{
|
||||
"domain": item["domain"],
|
||||
"capability": item["capability"],
|
||||
"target_scope": item["target"],
|
||||
"desired_change": item["operation"],
|
||||
"requested_value": next(iter(item["parameters"].values())) if len(item["parameters"]) == 1 else None,
|
||||
"parameters": dict(item["parameters"]),
|
||||
"reason": item["reason"],
|
||||
"execution_backend": "native_can",
|
||||
}
|
||||
for item in unique_actions
|
||||
],
|
||||
]
|
||||
result["model_requests"] = [
|
||||
*result.get("model_requests", []),
|
||||
*[
|
||||
{
|
||||
"domain": item["domain"],
|
||||
"capability": item["capability"],
|
||||
"target_scope": item["target"],
|
||||
"operation": item["operation"],
|
||||
"parameters": [
|
||||
{"name": name, "value": value}
|
||||
for name, value in item["parameters"].items()
|
||||
],
|
||||
"reason": item["reason"],
|
||||
}
|
||||
for item in unique_actions
|
||||
],
|
||||
]
|
||||
domains = list(result.get("detected_domains", []))
|
||||
for item in unique_actions:
|
||||
if item["domain"] not in domains:
|
||||
domains.append(item["domain"])
|
||||
result["detected_domains"] = domains
|
||||
result["ambiguities"] = [
|
||||
item
|
||||
for item in result.get("ambiguities", [])
|
||||
if item.get("field") != "capability"
|
||||
]
|
||||
result["decision_summary"] = [
|
||||
*result.get("decision_summary", []),
|
||||
*[item["reason"] for item in unique_actions],
|
||||
]
|
||||
result["confidence"] = max(float(result.get("confidence", 0)), 0.98)
|
||||
return result
|
||||
@@ -0,0 +1,864 @@
|
||||
"""Trusted compilation and execution support for CAN_DEV vehicle controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import yaml
|
||||
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus, CanFrame
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _set_bits(payload: bytearray, start: int, length: int, value: int) -> None:
|
||||
if length <= 0 or start < 0 or start + length > len(payload) * 8:
|
||||
raise ValueError("invalid bit range")
|
||||
if value < 0 or value >= (1 << length):
|
||||
raise ValueError(f"value {value} does not fit in {length} bits")
|
||||
for offset in range(length):
|
||||
bit = start + offset
|
||||
mask = 1 << (bit % 8)
|
||||
if value & (1 << offset):
|
||||
payload[bit // 8] |= mask
|
||||
else:
|
||||
payload[bit // 8] &= ~mask
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeFrameSpec:
|
||||
frame_id: int
|
||||
payload: bytes
|
||||
is_fd: bool = False
|
||||
bitrate_switch: bool = False
|
||||
repeat_count: int = 1
|
||||
period_ms: int = 0
|
||||
feedback_ids: tuple[int, ...] = ()
|
||||
expected_feedback: str = ""
|
||||
special_handler: str = ""
|
||||
special_parameters: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class NativeCanCompileError(ValueError):
|
||||
"""Raised when a request is outside the CAN_DEV verified vocabulary."""
|
||||
|
||||
|
||||
class NativeCanRuntime:
|
||||
"""Compile named capabilities into byte-exact CAN_DEV frames."""
|
||||
|
||||
compiler_version = "1.0.0"
|
||||
mapping_version = "can-dev-6e7ed04"
|
||||
|
||||
def __init__(self, repository_root: Path):
|
||||
self.root = repository_root
|
||||
calibration_path = (
|
||||
repository_root
|
||||
/ "platforms"
|
||||
/ "t2g_cabin_bench"
|
||||
/ "calibration"
|
||||
/ "body_can.yaml"
|
||||
)
|
||||
self.calibration_path = calibration_path
|
||||
self.calibration = yaml.safe_load(calibration_path.read_text(encoding="utf-8"))
|
||||
self.capabilities: dict[str, dict[str, Any]] = {}
|
||||
catalog_paths = [
|
||||
*(repository_root / "domains").glob("*/config/capabilities.yaml"),
|
||||
*(repository_root / "config" / "native_can_capabilities").glob("*.yaml"),
|
||||
]
|
||||
for path in sorted(catalog_paths):
|
||||
catalog = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
for capability in catalog.get("spec", {}).get("capabilities", []):
|
||||
self.capabilities[capability["name"]] = capability
|
||||
|
||||
@property
|
||||
def supported_capabilities(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"door.set_rear_lock",
|
||||
"window.set_motion",
|
||||
"window.set_position",
|
||||
"climate.set_power",
|
||||
"climate.set_ac",
|
||||
"climate.set_fan_level",
|
||||
"climate.set_outlet_mode",
|
||||
"climate.set_temperature",
|
||||
"climate.set_control_mode",
|
||||
"outlet.set_position_percent",
|
||||
"outlet.set_calibrated_position",
|
||||
"seat.set_heating",
|
||||
"seat.set_ventilation",
|
||||
"interior_light.set_indoor_power",
|
||||
"interior_light.set_wall_power",
|
||||
"interior_light.set_reading_power",
|
||||
"ambient_light.set_power",
|
||||
"ambient_light.set_mode",
|
||||
"ambient_light.set_illumination",
|
||||
"roof.set_shade_motion",
|
||||
"roof.set_shade_position",
|
||||
}
|
||||
)
|
||||
|
||||
def supports(self, action: dict[str, Any]) -> bool:
|
||||
return str(action.get("capability")) in self.supported_capabilities
|
||||
|
||||
def build_function_config(
|
||||
self,
|
||||
domain: str,
|
||||
actions: list[dict[str, Any]],
|
||||
request_id: str,
|
||||
provider: str,
|
||||
) -> dict[str, Any]:
|
||||
operations = []
|
||||
for index, action in enumerate(actions, start=1):
|
||||
capability_name = str(action["capability"])
|
||||
if capability_name not in self.supported_capabilities:
|
||||
raise NativeCanCompileError(
|
||||
f"CAN_DEV does not implement {capability_name}"
|
||||
)
|
||||
capability = self.capabilities[capability_name]
|
||||
target = str(action.get("target", action.get("target_scope", "vehicle")))
|
||||
if target not in capability.get("target_scopes", []):
|
||||
raise NativeCanCompileError(
|
||||
f"{capability_name} does not support target {target}"
|
||||
)
|
||||
suffix = capability_name.split(".", 1)[1]
|
||||
operation_id = f"{target}_{suffix}_{index}"[:63]
|
||||
operations.append(
|
||||
{
|
||||
"id": operation_id,
|
||||
"capability": capability_name,
|
||||
"target": {
|
||||
"device": capability["target_devices"][0],
|
||||
"zone": target,
|
||||
},
|
||||
"parameters": dict(action.get("parameters", {})),
|
||||
"timeout_ms": 15_000 if capability_name == "window.set_position" else 4_000,
|
||||
"failure_policy": "abort",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "FunctionConfig",
|
||||
"metadata": {
|
||||
"id": f"native-{domain}-{request_id}",
|
||||
"name": f"{domain.replace('_', ' ').title()} native CAN function",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"generated_at": _now(),
|
||||
"labels": {
|
||||
"provider": provider,
|
||||
"execution_scope": "vehicle_can_channel_1",
|
||||
"source_branch": "CAN_DEV",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"domain": domain,
|
||||
"operations": operations,
|
||||
"assumptions": [
|
||||
"Only named, range-checked CAN_DEV capabilities may reach the body CAN channel.",
|
||||
"The body CAN channel uses ISO CAN FD at 500 kbit/s arbitration and 2 Mbit/s data rate.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def compile(self, function_config: dict[str, Any]) -> dict[str, Any]:
|
||||
commands: list[dict[str, Any]] = []
|
||||
for operation_index, operation in enumerate(
|
||||
function_config["spec"]["operations"], start=1
|
||||
):
|
||||
specs = self._frame_specs(operation)
|
||||
for frame_index, spec in enumerate(specs, start=1):
|
||||
signals: dict[str, int | bool | str] = {
|
||||
"frame_id": f"0x{spec.frame_id:03X}",
|
||||
"data_hex": " ".join(f"{byte:02X}" for byte in spec.payload),
|
||||
"is_fd": spec.is_fd,
|
||||
"bitrate_switch": spec.bitrate_switch,
|
||||
"feedback_ids": ",".join(
|
||||
f"0x{frame_id:03X}" for frame_id in spec.feedback_ids
|
||||
),
|
||||
"expected_feedback": spec.expected_feedback,
|
||||
}
|
||||
if spec.special_handler:
|
||||
signals["special_handler"] = spec.special_handler
|
||||
signals["special_parameters"] = json.dumps(
|
||||
spec.special_parameters or {},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
command: dict[str, Any] = {
|
||||
"id": f"native_{operation_index}_{frame_index}_control",
|
||||
"operation_ref": operation["id"],
|
||||
"bus": "body_can",
|
||||
"message": f"Native_0x{spec.frame_id:03X}",
|
||||
"signals": signals,
|
||||
"send_offset_ms": (operation_index - 1) * 20,
|
||||
}
|
||||
if spec.repeat_count > 1:
|
||||
command["repeat"] = {
|
||||
"count": spec.repeat_count,
|
||||
"period_ms": spec.period_ms,
|
||||
}
|
||||
commands.append(command)
|
||||
|
||||
canonical = json.dumps(
|
||||
function_config, ensure_ascii=False, sort_keys=True
|
||||
).encode("utf-8")
|
||||
metadata = function_config["metadata"]
|
||||
domain = function_config["spec"]["domain"]
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "CommandPlan",
|
||||
"metadata": {
|
||||
"id": f"plan-native-{domain}-{metadata['request_id']}",
|
||||
"name": f"{domain.replace('_', ' ').title()} native CAN command plan",
|
||||
"version": "1.0.0",
|
||||
"request_id": metadata["request_id"],
|
||||
"labels": {
|
||||
"execution_scope": "vehicle_can_channel_1",
|
||||
"source_branch": "CAN_DEV",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"domain": domain,
|
||||
"platform": "t2g_cabin_bench",
|
||||
"source": {
|
||||
"config_id": metadata["id"],
|
||||
"config_version": metadata["version"],
|
||||
"config_sha256": hashlib.sha256(canonical).hexdigest(),
|
||||
"compiler_version": self.compiler_version,
|
||||
"mapping_version": self.mapping_version,
|
||||
},
|
||||
"commands": commands,
|
||||
},
|
||||
}
|
||||
|
||||
def execute_virtual(
|
||||
self,
|
||||
command_plan: dict[str, Any],
|
||||
function_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
started_at = _now()
|
||||
operations = {
|
||||
operation["id"]: operation
|
||||
for operation in function_config["spec"]["operations"]
|
||||
}
|
||||
results = []
|
||||
for command in command_plan["spec"]["commands"]:
|
||||
operation = operations[command["operation_ref"]]
|
||||
results.append(
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": function_config["spec"]["domain"],
|
||||
"status": "succeeded",
|
||||
"message": "已完成 CAN_DEV 帧编译;dry-run 未打开物理 CAN。",
|
||||
"telemetry": {
|
||||
"target": operation["target"]["zone"],
|
||||
"requested_parameters": operation["parameters"],
|
||||
"command_frame": self.command_frame_public(command),
|
||||
"source_branch": "CAN_DEV",
|
||||
"simulated": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
return self._report(function_config, results, started_at, "dry_run")
|
||||
|
||||
@staticmethod
|
||||
def command_frame_public(command: dict[str, Any]) -> dict[str, Any]:
|
||||
signals = command["signals"]
|
||||
return {
|
||||
"frame_id": signals["frame_id"],
|
||||
"payload_size": len(bytes.fromhex(str(signals["data_hex"]))),
|
||||
"data_hex": signals["data_hex"],
|
||||
"channel": 1,
|
||||
"frame_format": "can_fd" if signals["is_fd"] else "classic_can",
|
||||
"bitrate_switch": bool(signals["bitrate_switch"]),
|
||||
"repeat": command.get("repeat", {"count": 1, "period_ms": 0}),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _report(
|
||||
function_config: dict[str, Any],
|
||||
results: list[dict[str, Any]],
|
||||
started_at: str,
|
||||
mode: str,
|
||||
) -> dict[str, Any]:
|
||||
succeeded = all(item["status"] == "succeeded" for item in results)
|
||||
request_id = function_config["metadata"]["request_id"]
|
||||
domain = function_config["spec"]["domain"]
|
||||
return {
|
||||
"apiVersion": "cognitive-agent/v1alpha1",
|
||||
"kind": "ExecutionReport",
|
||||
"metadata": {
|
||||
"id": f"report-native-{domain}-{request_id}",
|
||||
"name": f"{domain.replace('_', ' ').title()} native CAN execution report",
|
||||
"version": "1.0.0",
|
||||
"request_id": request_id,
|
||||
"labels": {
|
||||
"simulation": "false" if mode == "vehicle" else "true",
|
||||
"source_branch": "CAN_DEV",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"mode": mode,
|
||||
"status": "succeeded" if succeeded else "failed",
|
||||
"started_at": started_at,
|
||||
"finished_at": _now(),
|
||||
"results": results,
|
||||
},
|
||||
}
|
||||
|
||||
def _frame_specs(self, operation: dict[str, Any]) -> list[NativeFrameSpec]:
|
||||
capability = operation["capability"]
|
||||
target = operation["target"]["zone"]
|
||||
parameters = operation["parameters"]
|
||||
|
||||
if capability in {"climate.set_power", "climate.set_ac"}:
|
||||
state = self._enum(parameters, "state", {"on": 1, "off": 2})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 28, 2, state)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_power:{parameters['state']}")]
|
||||
if capability == "climate.set_fan_level":
|
||||
level = self._integer(parameters, "level", 1, 7)
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 4, 4, level)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_fan:{level}")]
|
||||
if capability == "climate.set_outlet_mode":
|
||||
mode = self._enum(
|
||||
parameters,
|
||||
"mode",
|
||||
{
|
||||
"face": 1,
|
||||
"face_feet": 2,
|
||||
"feet": 3,
|
||||
"feet_defrost": 4,
|
||||
"defrost": 5,
|
||||
"face_feet_defrost": 6,
|
||||
"face_defrost": 7,
|
||||
},
|
||||
)
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 0, 4, mode)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_mode:{mode}")]
|
||||
if capability == "climate.set_control_mode":
|
||||
mode = self._enum(parameters, "mode", {"manual": 1, "auto": 2})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 16, 2, mode)
|
||||
return [self._classic(0x1DE, payload, 3, 100, (0x404,), f"climate_control:{mode}")]
|
||||
if capability == "climate.set_temperature":
|
||||
temperature = self._integer(parameters, "temperature_c", 18, 32)
|
||||
starts = {
|
||||
"driver": (32,),
|
||||
"passenger": (40,),
|
||||
"rear": (48,),
|
||||
"rear_row": (48,),
|
||||
"rear_left": (48,),
|
||||
"rear_right": (48,),
|
||||
"vehicle": (32, 40),
|
||||
"front_row": (32, 40),
|
||||
}
|
||||
if target not in starts:
|
||||
raise NativeCanCompileError(
|
||||
f"climate.set_temperature does not support target {target}"
|
||||
)
|
||||
specs = []
|
||||
for start in starts[target]:
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, start, 8, temperature)
|
||||
label = "driver" if start == 32 else "passenger" if start == 40 else "rear"
|
||||
specs.append(
|
||||
self._classic(
|
||||
0x1DE,
|
||||
payload,
|
||||
1 if start == 48 else 3,
|
||||
100,
|
||||
(0x404,),
|
||||
f"climate_temperature:{label}:{temperature}",
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
if capability == "door.set_rear_lock":
|
||||
state = self._enum(parameters, "state", {"lock": 1, "unlock": 2})
|
||||
payload = bytearray(8)
|
||||
payload[0] = 0x0A
|
||||
_set_bits(payload, 14, 2, state)
|
||||
return [self._classic(0x32B, payload, feedback_ids=(0x407,), expected=f"rear_lock:{parameters['state']}")]
|
||||
|
||||
if capability == "window.set_motion":
|
||||
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "stop": 3})
|
||||
payload = self._window_payload(target, motion)
|
||||
return [self._fd(0x431, payload, 3, 100, (0x388,), f"window_motion:{target}:{parameters['motion']}")]
|
||||
if capability == "window.set_position":
|
||||
position = self._integer(parameters, "position_pct", 0, 100)
|
||||
if target not in {"front_left", "rear_left", "front_right", "rear_right"}:
|
||||
raise NativeCanCompileError("absolute window position requires one window")
|
||||
return [
|
||||
NativeFrameSpec(
|
||||
frame_id=0x431,
|
||||
payload=self._window_payload(target, 3),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
feedback_ids=(0x388,),
|
||||
expected_feedback=f"window_position:{target}:{position}",
|
||||
special_handler="window_position",
|
||||
special_parameters={"target": target, "position_pct": position},
|
||||
)
|
||||
]
|
||||
|
||||
if capability in {"seat.set_heating", "seat.set_ventilation"}:
|
||||
return [self._body_seat_frame(capability, target, parameters)]
|
||||
|
||||
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
|
||||
return [self._outlet_frame(capability, target, parameters)]
|
||||
|
||||
if capability == "interior_light.set_indoor_power":
|
||||
state = self._enum(parameters, "state", {"on": 1, "off": 2})
|
||||
payload = bytearray(8)
|
||||
payload[0] = 0x0A
|
||||
_set_bits(payload, 24, 3, state)
|
||||
return [self._classic(0x32B, payload, feedback_ids=(0x42E,), expected=f"indoor_light:{parameters['state']}")]
|
||||
if capability == "interior_light.set_wall_power":
|
||||
state = self._enum(parameters, "state", {"on": 1, "off": 2})
|
||||
payload = bytearray(8)
|
||||
payload[0] = 0x11
|
||||
_set_bits(payload, 20, 2, state)
|
||||
return [self._classic(0x32B, payload)]
|
||||
if capability == "interior_light.set_reading_power":
|
||||
return [self._reading_light_frame(target, parameters)]
|
||||
|
||||
if capability == "ambient_light.set_power":
|
||||
value = self._enum(parameters, "state", {"off": 6, "on": 7})
|
||||
payload = bytearray(8)
|
||||
payload[0] = 0x0A
|
||||
_set_bits(payload, 27, 5, value)
|
||||
return [self._classic(0x32B, payload, feedback_ids=(0x42E,), expected=f"ambient_power:{parameters['state']}")]
|
||||
if capability == "ambient_light.set_mode":
|
||||
value = self._enum(
|
||||
parameters,
|
||||
"mode",
|
||||
{"camping": 1, "rest": 2, "cinema": 3, "meditation": 4, "romantic": 5, "off": 6, "on": 7},
|
||||
)
|
||||
payload = bytearray(8)
|
||||
payload[0] = 0x0A
|
||||
_set_bits(payload, 27, 5, value)
|
||||
return [self._classic(0x32B, payload, feedback_ids=(0x42E,), expected=f"ambient_mode:{value}")]
|
||||
if capability == "ambient_light.set_illumination":
|
||||
color = str(parameters.get("color_hex", ""))
|
||||
if len(color) != 7 or not color.startswith("#"):
|
||||
raise NativeCanCompileError("color_hex must use #RRGGBB")
|
||||
try:
|
||||
red, green, blue = (int(color[index : index + 2], 16) for index in (1, 3, 5))
|
||||
except ValueError as exc:
|
||||
raise NativeCanCompileError("color_hex must use #RRGGBB") from exc
|
||||
if not all(1 <= value <= 255 for value in (red, green, blue)):
|
||||
raise NativeCanCompileError("CAN_DEV RGB components must be between 1 and 255")
|
||||
brightness = self._integer(parameters, "brightness_pct", 0, 100)
|
||||
payload = bytearray((0x2B, red, green, blue, 0, 0, 0, 0))
|
||||
_set_bits(payload, 32, 7, brightness)
|
||||
return [self._classic(0x32B, payload, feedback_ids=(0x42E,), expected="ambient_rgb")]
|
||||
|
||||
if capability == "roof.set_shade_motion":
|
||||
motion = self._enum(parameters, "motion", {"open": 1, "close": 2, "half": 3, "stop": 4})
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, 4, 4, motion)
|
||||
return [self._classic(0x431, payload, feedback_ids=(0x41A,), expected=f"shade_motion:{motion}")]
|
||||
if capability == "roof.set_shade_position":
|
||||
position = self._integer(parameters, "position_pct", 0, 100)
|
||||
payload = bytearray(8)
|
||||
payload[3] = 0xFF
|
||||
payload[4] = position
|
||||
return [self._classic(0x4F5, payload, feedback_ids=(0x41A,), expected=f"shade_position:{position}")]
|
||||
|
||||
raise NativeCanCompileError(f"CAN_DEV does not implement {capability}")
|
||||
|
||||
@staticmethod
|
||||
def _classic(
|
||||
frame_id: int,
|
||||
payload: bytes | bytearray,
|
||||
repeat: int = 1,
|
||||
period_ms: int = 0,
|
||||
feedback_ids: tuple[int, ...] = (),
|
||||
expected: str = "",
|
||||
) -> NativeFrameSpec:
|
||||
return NativeFrameSpec(
|
||||
frame_id,
|
||||
bytes(payload),
|
||||
repeat_count=repeat,
|
||||
period_ms=period_ms,
|
||||
feedback_ids=feedback_ids,
|
||||
expected_feedback=expected,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fd(
|
||||
frame_id: int,
|
||||
payload: bytes | bytearray,
|
||||
repeat: int = 1,
|
||||
period_ms: int = 0,
|
||||
feedback_ids: tuple[int, ...] = (),
|
||||
expected: str = "",
|
||||
) -> NativeFrameSpec:
|
||||
return NativeFrameSpec(
|
||||
frame_id,
|
||||
bytes(payload),
|
||||
is_fd=True,
|
||||
bitrate_switch=True,
|
||||
repeat_count=repeat,
|
||||
period_ms=period_ms,
|
||||
feedback_ids=feedback_ids,
|
||||
expected_feedback=expected,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _enum(parameters: dict[str, Any], name: str, values: dict[str, int]) -> int:
|
||||
value = str(parameters.get(name))
|
||||
try:
|
||||
return values[value]
|
||||
except KeyError as exc:
|
||||
raise NativeCanCompileError(
|
||||
f"{name} must be one of {', '.join(values)}"
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _integer(
|
||||
parameters: dict[str, Any], name: str, minimum: int, maximum: int
|
||||
) -> int:
|
||||
value = parameters.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise NativeCanCompileError(f"{name} must be an integer")
|
||||
integer = int(value)
|
||||
if integer != value or not minimum <= integer <= maximum:
|
||||
raise NativeCanCompileError(
|
||||
f"{name} must be between {minimum} and {maximum}"
|
||||
)
|
||||
return integer
|
||||
|
||||
@staticmethod
|
||||
def _window_payload(target: str, motion: int) -> bytes:
|
||||
masks = {
|
||||
"front_left": 0b0001,
|
||||
"rear_left": 0b0010,
|
||||
"front_right": 0b0100,
|
||||
"rear_right": 0b1000,
|
||||
"front": 0b0101,
|
||||
"rear": 0b1010,
|
||||
"all": 0b1111,
|
||||
}
|
||||
try:
|
||||
mask = masks[target]
|
||||
except KeyError as exc:
|
||||
raise NativeCanCompileError(f"unsupported window target {target}") from exc
|
||||
starts = (48, 56, 51, 59)
|
||||
payload = bytearray(8)
|
||||
for index, start in enumerate(starts):
|
||||
if mask & (1 << index):
|
||||
_set_bits(payload, start, 3, motion)
|
||||
return bytes(payload)
|
||||
|
||||
def _outlet_frame(
|
||||
self, capability: str, target: str, parameters: dict[str, Any]
|
||||
) -> NativeFrameSpec:
|
||||
ranges = self.calibration["air_outlet"]["ranges"]
|
||||
if target not in ranges:
|
||||
raise NativeCanCompileError(f"unsupported outlet target {target}")
|
||||
if capability == "outlet.set_position_percent":
|
||||
horizontal_pct = self._integer(parameters, "horizontal_pct", 0, 100)
|
||||
vertical_pct = self._integer(parameters, "vertical_pct", 0, 100)
|
||||
horizontal_range = ranges[target]["horizontal"]
|
||||
vertical_range = ranges[target]["vertical"]
|
||||
horizontal = horizontal_range[0] + (
|
||||
(horizontal_range[1] - horizontal_range[0]) * horizontal_pct + 50
|
||||
) // 100
|
||||
vertical = vertical_range[0] + (
|
||||
(vertical_range[1] - vertical_range[0]) * vertical_pct + 50
|
||||
) // 100
|
||||
else:
|
||||
horizontal = self._integer(parameters, "horizontal_value", -128, 126)
|
||||
vertical = self._integer(parameters, "vertical_value", -128, 126)
|
||||
horizontal_range = ranges[target]["horizontal"]
|
||||
vertical_range = ranges[target]["vertical"]
|
||||
if not horizontal_range[0] <= horizontal <= horizontal_range[1]:
|
||||
raise NativeCanCompileError("horizontal_value exceeds the calibrated range")
|
||||
if not vertical_range[0] <= vertical <= vertical_range[1]:
|
||||
raise NativeCanCompileError("vertical_value exceeds the calibrated range")
|
||||
|
||||
side, position = target.split("_", 1)
|
||||
sub_id = 0x02 if side == "driver" else 0x03
|
||||
starts = {
|
||||
"left": (24, 8),
|
||||
"right": (32, 16),
|
||||
}
|
||||
horizontal_start, vertical_start = starts[position]
|
||||
payload = bytearray((sub_id, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0))
|
||||
if sub_id == 0x02:
|
||||
payload[6] = 0xFF
|
||||
payload[7] = 0xFF
|
||||
_set_bits(payload, horizontal_start, 8, horizontal + 128)
|
||||
_set_bits(payload, vertical_start, 8, vertical + 128)
|
||||
return self._fd(
|
||||
0x4EF,
|
||||
payload,
|
||||
3,
|
||||
100,
|
||||
(0x2F5, 0x37B),
|
||||
f"outlet:{target}:{horizontal}:{vertical}",
|
||||
)
|
||||
|
||||
def _body_seat_frame(
|
||||
self, capability: str, target: str, parameters: dict[str, Any]
|
||||
) -> NativeFrameSpec:
|
||||
indexes = {"driver": 0, "passenger": 1, "left_rear": 2, "right_rear": 3}
|
||||
if target not in indexes:
|
||||
raise NativeCanCompileError(f"unsupported body seat target {target}")
|
||||
state = str(parameters.get("state"))
|
||||
if state not in {"on", "off"}:
|
||||
raise NativeCanCompileError("state must be on or off")
|
||||
feature = "heating" if capability.endswith("heating") else "ventilation"
|
||||
index = indexes[target]
|
||||
payload = bytearray(8)
|
||||
if state == "off":
|
||||
payload[0] = 0x20
|
||||
starts = (12, 20, 28, 36) if feature == "heating" else (8, 16, 24, 32)
|
||||
_set_bits(payload, starts[index], 2, 2)
|
||||
else:
|
||||
level = self._integer(parameters, "level", 1, 5)
|
||||
payload[0] = 0x21
|
||||
starts = (12, 20, 28, 36) if feature == "heating" else (8, 16, 24, 32)
|
||||
_set_bits(payload, starts[index], 4, level)
|
||||
return self._classic(0x4C1, payload, feedback_ids=(0x42A,), expected=f"body_seat:{target}:{feature}:{state}")
|
||||
|
||||
def _reading_light_frame(
|
||||
self, target: str, parameters: dict[str, Any]
|
||||
) -> NativeFrameSpec:
|
||||
state = self._enum(parameters, "state", {"off": 1, "on": 2})
|
||||
if target == "all":
|
||||
payload = bytearray((0, 0, 3, 0xFF, 0xFF, 0, 0, 0))
|
||||
_set_bits(payload, 50, 2, state)
|
||||
return self._fd(0x4F5, payload, 3, 100, (0x42E,), f"reading_all:{parameters['state']}")
|
||||
starts = {
|
||||
"front_left": 0,
|
||||
"front_right": 2,
|
||||
"middle_left": 4,
|
||||
"middle_right": 6,
|
||||
"middle_left_rotary": 8,
|
||||
"middle_right_rotary": 10,
|
||||
"rear_left": 12,
|
||||
"rear_right": 14,
|
||||
}
|
||||
try:
|
||||
start = starts[target]
|
||||
except KeyError as exc:
|
||||
raise NativeCanCompileError(f"unsupported reading-light target {target}") from exc
|
||||
payload = bytearray(8)
|
||||
_set_bits(payload, start, 2, state)
|
||||
return self._fd(0x1B2, payload, 3, 100, expected=f"reading:{target}:{parameters['state']}")
|
||||
|
||||
|
||||
class NativeCanExecutor:
|
||||
"""Submit compiled vehicle frames and capture post-command feedback."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus_factory: Callable[[], CanBus],
|
||||
*,
|
||||
feedback_timeout_ms: int = 1_200,
|
||||
):
|
||||
self.bus_factory = bus_factory
|
||||
self.feedback_timeout_ms = feedback_timeout_ms
|
||||
|
||||
def execute(
|
||||
self,
|
||||
runtime: NativeCanRuntime,
|
||||
command_plan: dict[str, Any],
|
||||
function_config: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
started_at = _now()
|
||||
operations = {
|
||||
operation["id"]: operation
|
||||
for operation in function_config["spec"]["operations"]
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
bus = self.bus_factory()
|
||||
try:
|
||||
for command in command_plan["spec"]["commands"]:
|
||||
operation = operations[command["operation_ref"]]
|
||||
try:
|
||||
if command["signals"].get("special_handler") == "window_position":
|
||||
sent_frames, feedback = self._execute_window_position(bus, command)
|
||||
else:
|
||||
sent_frames = self._send_command(bus, command)
|
||||
feedback = self._wait_feedback(bus, command)
|
||||
feedback_match = self._feedback_matches(command, feedback)
|
||||
feedback_required = bool(
|
||||
str(command["signals"].get("feedback_ids", "")).strip()
|
||||
)
|
||||
verified = (
|
||||
(not feedback_required or feedback is not None)
|
||||
and feedback_match is not False
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": function_config["spec"]["domain"],
|
||||
"status": "succeeded" if verified else "failed",
|
||||
"message": (
|
||||
"CAN 盒已提交车端控制帧并观察到匹配反馈。"
|
||||
if feedback_match is True
|
||||
else "CAN 盒已提交车端控制帧并观察到后续状态帧。"
|
||||
if feedback is not None
|
||||
else "CAN 盒已提交车端控制帧;当前反馈窗口未观察到对应状态帧。"
|
||||
),
|
||||
"telemetry": {
|
||||
"target": operation["target"]["zone"],
|
||||
"requested_parameters": operation["parameters"],
|
||||
"command_frame": runtime.command_frame_public(command),
|
||||
"sent_frames": [frame.public() for frame in sent_frames],
|
||||
"feedback_frame": feedback.public() if feedback else None,
|
||||
"feedback_observed": feedback is not None,
|
||||
"feedback_match": feedback_match,
|
||||
"feedback_required": feedback_required,
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - normalize adapter faults
|
||||
results.append(
|
||||
{
|
||||
"ref": operation["id"],
|
||||
"domain": function_config["spec"]["domain"],
|
||||
"status": "failed",
|
||||
"message": str(exc),
|
||||
"telemetry": {
|
||||
"target": operation["target"]["zone"],
|
||||
"requested_parameters": operation["parameters"],
|
||||
"command_frame": runtime.command_frame_public(command),
|
||||
"channel": 1,
|
||||
"simulated": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
break
|
||||
finally:
|
||||
bus.close()
|
||||
return runtime._report(function_config, results, started_at, "vehicle")
|
||||
|
||||
@staticmethod
|
||||
def _send_command(bus: CanBus, command: dict[str, Any]) -> list[CanFrame]:
|
||||
signals = command["signals"]
|
||||
frame_id = int(str(signals["frame_id"]), 0)
|
||||
payload = bytes.fromhex(str(signals["data_hex"]))
|
||||
repeat = command.get("repeat", {"count": 1, "period_ms": 0})
|
||||
sent: list[CanFrame] = []
|
||||
for index in range(int(repeat["count"])):
|
||||
sent.append(
|
||||
bus.send(
|
||||
frame_id,
|
||||
payload,
|
||||
is_fd=bool(signals["is_fd"]),
|
||||
bitrate_switch=bool(signals["bitrate_switch"]),
|
||||
)
|
||||
)
|
||||
if index + 1 < int(repeat["count"]):
|
||||
time.sleep(int(repeat["period_ms"]) / 1000)
|
||||
return sent
|
||||
|
||||
def _wait_feedback(
|
||||
self, bus: CanBus, command: dict[str, Any]
|
||||
) -> CanFrame | None:
|
||||
raw_ids = str(command["signals"].get("feedback_ids", ""))
|
||||
feedback_ids = {
|
||||
int(value, 0) for value in raw_ids.split(",") if value.strip()
|
||||
}
|
||||
if not feedback_ids:
|
||||
return None
|
||||
deadline = time.monotonic() + self.feedback_timeout_ms / 1000
|
||||
while time.monotonic() < deadline:
|
||||
remaining_ms = max(1, int((deadline - time.monotonic()) * 1000))
|
||||
frame = bus.receive(min(remaining_ms, 100), include_fd=True)
|
||||
if frame is not None and frame.arbitration_id in feedback_ids:
|
||||
return frame
|
||||
return None
|
||||
|
||||
def _execute_window_position(
|
||||
self, bus: CanBus, command: dict[str, Any]
|
||||
) -> tuple[list[CanFrame], CanFrame | None]:
|
||||
parameters = json.loads(command["signals"]["special_parameters"])
|
||||
target = str(parameters["target"])
|
||||
goal = int(parameters["position_pct"])
|
||||
indexes = {"front_left": 0, "rear_left": 1, "front_right": 2, "rear_right": 3}
|
||||
index = indexes[target]
|
||||
before = self._wait_for_id(bus, 0x388, 2_500)
|
||||
if before is None or len(before.data) < 6 or before.data[2 + index] > 100:
|
||||
raise RuntimeError("未收到有效且新鲜的 0x388 车窗位置,拒绝启动机构")
|
||||
current = int(before.data[2 + index])
|
||||
if abs(current - goal) <= 3:
|
||||
return [], before
|
||||
motion = 1 if goal > current else 2
|
||||
start_payload = NativeCanRuntime._window_payload(target, motion)
|
||||
stop_payload = NativeCanRuntime._window_payload(target, 3)
|
||||
sent: list[CanFrame] = []
|
||||
for attempt in range(3):
|
||||
sent.append(bus.send(0x431, start_payload, is_fd=True, bitrate_switch=True))
|
||||
if attempt < 2:
|
||||
time.sleep(0.1)
|
||||
deadline = time.monotonic() + 12
|
||||
reached: CanFrame | None = None
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
frame = self._wait_for_id(bus, 0x388, 200)
|
||||
if frame is None or len(frame.data) < 6 or frame.data[2 + index] > 100:
|
||||
continue
|
||||
if abs(int(frame.data[2 + index]) - goal) <= 3:
|
||||
reached = frame
|
||||
break
|
||||
finally:
|
||||
for attempt in range(3):
|
||||
sent.append(bus.send(0x431, stop_payload, is_fd=True, bitrate_switch=True))
|
||||
if attempt < 2:
|
||||
time.sleep(0.1)
|
||||
if reached is None:
|
||||
raise RuntimeError("车窗在 12 秒内未到达目标,已发送停止序列")
|
||||
return sent, reached
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_id(bus: CanBus, frame_id: int, timeout_ms: int) -> CanFrame | None:
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(1, int((deadline - time.monotonic()) * 1000))
|
||||
frame = bus.receive(min(remaining, 100), include_fd=True)
|
||||
if frame is not None and frame.arbitration_id == frame_id:
|
||||
return frame
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _feedback_matches(
|
||||
command: dict[str, Any], frame: CanFrame | None
|
||||
) -> bool | None:
|
||||
if frame is None:
|
||||
return None
|
||||
expected = str(command["signals"].get("expected_feedback", ""))
|
||||
if not expected or frame.arbitration_id != 0x404 or len(frame.data) < 6:
|
||||
return None
|
||||
parts = expected.split(":")
|
||||
if parts[0] == "climate_power":
|
||||
actual = frame.data[1] & 0x03
|
||||
return actual == (1 if parts[1] == "on" else 2)
|
||||
if parts[0] == "climate_control":
|
||||
return ((frame.data[1] >> 2) & 0x03) == int(parts[1])
|
||||
if parts[0] == "climate_mode":
|
||||
return (frame.data[2] & 0x0F) == int(parts[1])
|
||||
if parts[0] == "climate_fan":
|
||||
return ((frame.data[2] >> 4) & 0x0F) == int(parts[1])
|
||||
if parts[0] == "climate_temperature":
|
||||
byte_index = {"driver": 4, "passenger": 5}.get(parts[1])
|
||||
return None if byte_index is None else frame.data[byte_index] == int(parts[2])
|
||||
return None
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -46,10 +47,16 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402
|
||||
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
|
||||
ModelPlanAdapter,
|
||||
APPLY_PRESET_CAPABILITY,
|
||||
DYNAMIC_MODE_STOP_CAPABILITY,
|
||||
SAVE_PRESET_CAPABILITY,
|
||||
SEAT_CAPABILITIES,
|
||||
SEAT_MOTION_CAPABILITIES,
|
||||
)
|
||||
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
|
||||
from cognitive_agent.application.native_can_runtime import ( # noqa: E402
|
||||
NativeCanExecutor,
|
||||
NativeCanRuntime,
|
||||
)
|
||||
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
|
||||
from cognitive_agent.application.vehicle_profile import ( # noqa: E402
|
||||
VehicleProfile,
|
||||
@@ -63,7 +70,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareSafetyError,
|
||||
PHYSICAL_BENCH_COORDINATOR,
|
||||
SeatHardwareSettings,
|
||||
VehicleCanSettings,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import ZlgChannelConfig # noqa: E402
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
PHYSICAL_BENCH_ACCESS,
|
||||
)
|
||||
@@ -112,6 +121,18 @@ SEAT_AXIS_TO_CAPABILITY = {
|
||||
SEAT_FUNCTION_TO_CAPABILITY = {
|
||||
function: capability for capability, function in SEAT_CAPABILITIES.items()
|
||||
}
|
||||
DYNAMIC_MODE_STOP_WORDS = ("停止", "关闭", "结束", "取消", "停下", "退出")
|
||||
DYNAMIC_MODE_WORDS = (
|
||||
"动态模式",
|
||||
"动态座椅",
|
||||
"木马模式",
|
||||
"木马",
|
||||
"摇摇马",
|
||||
"骑木马",
|
||||
"rocking horse",
|
||||
"动态摇篮",
|
||||
"摇篮模式",
|
||||
)
|
||||
|
||||
|
||||
class SeatDemoService:
|
||||
@@ -142,6 +163,8 @@ class SeatDemoService:
|
||||
repository_root / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
|
||||
)
|
||||
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
|
||||
self.vehicle_can_settings = VehicleCanSettings.from_env(repository_root)
|
||||
self.native_can_runtime = NativeCanRuntime(repository_root)
|
||||
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
||||
self.hardware_access = PHYSICAL_BENCH_ACCESS
|
||||
# Shared by the short-lived hardware executor instances. A process must
|
||||
@@ -192,8 +215,18 @@ class SeatDemoService:
|
||||
"platform": "reference_vehicle",
|
||||
"execution_mode": "dry_run",
|
||||
"execution_modes": ["dry_run", "simulated_hardware", "hardware"],
|
||||
"hardware_execution_allowed": self.hardware_settings.configured,
|
||||
"hardware_execution_allowed": (
|
||||
self.hardware_settings.configured
|
||||
and self.vehicle_can_settings.configured
|
||||
),
|
||||
"hardware": hardware_info,
|
||||
"vehicle_can": self.vehicle_can_settings.public_info(),
|
||||
"multi_channel_can": {
|
||||
"adapter_ownership": "single_process",
|
||||
"seat_channel": self.hardware_settings.channel,
|
||||
"vehicle_channel": self.vehicle_can_settings.channel,
|
||||
"channels_must_be_distinct": True,
|
||||
},
|
||||
"seat_position_memory_synchronized": bool(
|
||||
self._seat_position_memory_state.get("synchronized", False)
|
||||
),
|
||||
@@ -298,10 +331,13 @@ class SeatDemoService:
|
||||
"actuator_state": context["devices"],
|
||||
}
|
||||
|
||||
def _new_hardware_executor(self) -> HardwareSeatExecutor:
|
||||
def _new_hardware_executor(
|
||||
self, bus_factory: Any | None = None
|
||||
) -> HardwareSeatExecutor:
|
||||
resolved_bus_factory = bus_factory or self.hardware_settings.bus_factory()
|
||||
return HardwareSeatExecutor(
|
||||
self.hardware_matrix,
|
||||
self.hardware_settings.bus_factory(),
|
||||
resolved_bus_factory,
|
||||
thermal_timeout_ms=self.hardware_settings.thermal_timeout_ms,
|
||||
ack_timeout_ms=self.hardware_settings.ack_timeout_ms,
|
||||
axis_maximum_x100={
|
||||
@@ -319,7 +355,7 @@ class SeatDemoService:
|
||||
),
|
||||
auto_home=self.hardware_settings.auto_home_enabled,
|
||||
home_timeout_s=self.hardware_settings.home_timeout_s,
|
||||
firmware_guard=self.hardware_settings.firmware_guard(),
|
||||
firmware_guard=self.hardware_settings.firmware_guard(resolved_bus_factory),
|
||||
synchronize_position_before_first_motion=(
|
||||
self.hardware_settings.position_sync_enabled
|
||||
),
|
||||
@@ -414,8 +450,6 @@ class SeatDemoService:
|
||||
or mode_update is not None
|
||||
)
|
||||
hardware_request = execution_mode == "hardware"
|
||||
if hardware_request and not control_plane_request:
|
||||
self.hardware_settings.authorize(approval_token)
|
||||
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
|
||||
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
|
||||
raise ValueError("conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens")
|
||||
@@ -512,7 +546,40 @@ class SeatDemoService:
|
||||
context,
|
||||
vehicle_profile,
|
||||
)
|
||||
parsed = self._apply_mode_profiles(parsed, matched_modes, context, vehicle_profile)
|
||||
if self._is_dynamic_mode_stop_request(utterance):
|
||||
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
|
||||
else:
|
||||
parsed = self._apply_mode_profiles(
|
||||
parsed, matched_modes, context, vehicle_profile
|
||||
)
|
||||
parsed = merge_native_can_intent(parsed, utterance)
|
||||
parsed = self._partition_native_actions(
|
||||
parsed, promote_supported=hardware_request
|
||||
)
|
||||
if hardware_request and not control_plane_request:
|
||||
if parsed.get("actions"):
|
||||
self.hardware_settings.authorize(approval_token)
|
||||
if parsed.get("native_can_actions"):
|
||||
self.vehicle_can_settings.authorize(approval_token)
|
||||
if not parsed.get("actions") and not parsed.get("native_can_actions"):
|
||||
raise HardwareSafetyError(
|
||||
"hardware mode requires at least one verified seat or vehicle CAN operation"
|
||||
)
|
||||
if parsed.get("actions") and parsed.get("native_can_actions"):
|
||||
if self.hardware_settings.backend != "zlgcan":
|
||||
raise HardwareSafetyError(
|
||||
"linked seat/vehicle execution requires the shared zlgcan backend"
|
||||
)
|
||||
try:
|
||||
seat_channel = int(self.hardware_settings.channel, 0)
|
||||
except ValueError as exc:
|
||||
raise HardwareSafetyError(
|
||||
"ZLG seat CAN channel must be an integer"
|
||||
) from exc
|
||||
if seat_channel == self.vehicle_can_settings.channel:
|
||||
raise HardwareSafetyError(
|
||||
"seat and vehicle CAN must use different adapter channels"
|
||||
)
|
||||
self._trace(
|
||||
traces,
|
||||
request_id,
|
||||
@@ -580,14 +647,9 @@ class SeatDemoService:
|
||||
},
|
||||
)
|
||||
|
||||
if parsed.get("domain_actions") or any(
|
||||
if parsed.get("domain_actions") or parsed.get("native_can_actions") or any(
|
||||
domain != "seat" for domain in detected_domains
|
||||
):
|
||||
if hardware_request and not parsed.get("actions"):
|
||||
raise HardwareSafetyError(
|
||||
"hardware mode requires at least one passenger seat operation; "
|
||||
"non-seat-only requests stay dry-run"
|
||||
)
|
||||
return self._process_orchestrated(
|
||||
utterance,
|
||||
request_id,
|
||||
@@ -1405,6 +1467,63 @@ class SeatDemoService:
|
||||
)
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _is_dynamic_mode_stop_request(utterance: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", utterance)
|
||||
if any(negation in compact for negation in ("不要停止", "无需停止", "别停止")):
|
||||
return False
|
||||
return any(word in compact for word in DYNAMIC_MODE_STOP_WORDS) and any(
|
||||
word in compact for word in DYNAMIC_MODE_WORDS
|
||||
)
|
||||
|
||||
def _apply_dynamic_mode_stop_request(
|
||||
self,
|
||||
parsed: dict[str, Any],
|
||||
utterance: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Make explicit stop language authoritative over a matched start profile."""
|
||||
|
||||
stopped = deepcopy(parsed)
|
||||
stopped["actions"] = [
|
||||
action
|
||||
for action in stopped.get("actions", [])
|
||||
if action.get("action_type") not in {"dynamic_mode", "dynamic_mode_stop"}
|
||||
]
|
||||
stopped["actions"].append(
|
||||
{"action_type": "dynamic_mode_stop", "target": "passenger"}
|
||||
)
|
||||
stopped["model_requests"] = [
|
||||
request
|
||||
for request in stopped.get("model_requests", [])
|
||||
if request.get("capability")
|
||||
not in {"seat.start_dynamic_mode", DYNAMIC_MODE_STOP_CAPABILITY}
|
||||
]
|
||||
stopped["model_requests"].append(
|
||||
{
|
||||
"domain": "seat",
|
||||
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
|
||||
"target_scope": "passenger",
|
||||
"operation": "deactivate",
|
||||
"parameters": [],
|
||||
"reason": "用户明确要求停止动态座椅循环。",
|
||||
"source_span": utterance,
|
||||
}
|
||||
)
|
||||
stopped["ambiguities"] = [
|
||||
item
|
||||
for item in stopped.get("ambiguities", [])
|
||||
if item.get("field") != "capability"
|
||||
]
|
||||
stopped["detected_domains"] = self._merge_domains(
|
||||
["seat"], stopped.get("detected_domains", [])
|
||||
)
|
||||
stopped["decision_summary"] = [
|
||||
"识别到停止动态座椅模式指令;不会重新启动同名长期模式。",
|
||||
*stopped.get("decision_summary", []),
|
||||
]
|
||||
stopped["confidence"] = max(float(stopped.get("confidence", 0)), 0.99)
|
||||
return stopped
|
||||
|
||||
@staticmethod
|
||||
def _contains_seat_preset_request(parsed: dict[str, Any]) -> bool:
|
||||
capabilities = {
|
||||
@@ -1454,6 +1573,19 @@ class SeatDemoService:
|
||||
action.get("target"),
|
||||
action.get("axis"),
|
||||
)
|
||||
if action.get("action_type") == "dynamic_mode_stop":
|
||||
return (
|
||||
"seat",
|
||||
"dynamic_mode_stop",
|
||||
action.get("target"),
|
||||
)
|
||||
if action.get("action_type") == "dynamic_mode":
|
||||
return (
|
||||
"seat",
|
||||
"dynamic_mode",
|
||||
action.get("target"),
|
||||
action.get("mode_id"),
|
||||
)
|
||||
return (
|
||||
"seat",
|
||||
"function",
|
||||
@@ -2136,6 +2268,24 @@ class SeatDemoService:
|
||||
"parameters": parameters,
|
||||
"reason": "用户固化的座椅位置模式动作。",
|
||||
}
|
||||
if action.get("action_type") == "dynamic_mode_stop":
|
||||
return {
|
||||
"domain": "seat",
|
||||
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
|
||||
"target_scope": action["target"],
|
||||
"operation": "deactivate",
|
||||
"parameters": {},
|
||||
"reason": "用户固化的动态座椅停止动作。",
|
||||
}
|
||||
if action.get("action_type") == "dynamic_mode":
|
||||
return {
|
||||
"domain": "seat",
|
||||
"capability": "seat.start_dynamic_mode",
|
||||
"target_scope": action["target"],
|
||||
"operation": "set",
|
||||
"parameters": {"mode_id": int(action["mode_id"])},
|
||||
"reason": "用户固化的固件原生动态座椅模式。",
|
||||
}
|
||||
function = action.get("function")
|
||||
capability = SEAT_FUNCTION_TO_CAPABILITY.get(str(function))
|
||||
if capability is None:
|
||||
@@ -2152,6 +2302,71 @@ class SeatDemoService:
|
||||
"reason": "用户固化的座椅舒适功能模式动作。",
|
||||
}
|
||||
|
||||
def _partition_native_actions(
|
||||
self, parsed: dict[str, Any], *, promote_supported: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Promote verified CAN_DEV capabilities and leave unsupported domains virtual."""
|
||||
|
||||
result = deepcopy(parsed)
|
||||
native_actions = list(result.get("native_can_actions", []))
|
||||
native_signatures = {
|
||||
(
|
||||
str(action.get("domain")),
|
||||
str(action.get("capability")),
|
||||
str(action.get("target", action.get("target_scope", "vehicle"))),
|
||||
json.dumps(action.get("parameters", {}), sort_keys=True, ensure_ascii=False),
|
||||
)
|
||||
for action in native_actions
|
||||
}
|
||||
virtual_actions: list[dict[str, Any]] = []
|
||||
for action in result.get("domain_actions", []):
|
||||
signature = (
|
||||
str(action.get("domain")),
|
||||
str(action.get("capability")),
|
||||
str(action.get("target", action.get("target_scope", "vehicle"))),
|
||||
json.dumps(action.get("parameters", {}), sort_keys=True, ensure_ascii=False),
|
||||
)
|
||||
if signature in native_signatures:
|
||||
continue
|
||||
if promote_supported and self.native_can_runtime.supports(action):
|
||||
promoted = dict(action)
|
||||
promoted["execution_backend"] = "native_can"
|
||||
native_actions.append(promoted)
|
||||
else:
|
||||
virtual_actions.append(action)
|
||||
|
||||
deduplicated: list[dict[str, Any]] = []
|
||||
signatures: set[tuple[str, str, str, str]] = set()
|
||||
for action in native_actions:
|
||||
signature = (
|
||||
str(action.get("domain")),
|
||||
str(action.get("capability")),
|
||||
str(action.get("target", action.get("target_scope", "vehicle"))),
|
||||
json.dumps(action.get("parameters", {}), sort_keys=True, ensure_ascii=False),
|
||||
)
|
||||
if signature not in signatures:
|
||||
signatures.add(signature)
|
||||
deduplicated.append(action)
|
||||
|
||||
result["domain_actions"] = virtual_actions
|
||||
result["native_can_actions"] = deduplicated
|
||||
detected_domains = list(result.get("detected_domains", []))
|
||||
if result.get("actions") and "seat" not in detected_domains:
|
||||
detected_domains.insert(0, "seat")
|
||||
for action in [*virtual_actions, *deduplicated]:
|
||||
if action["domain"] not in detected_domains:
|
||||
detected_domains.append(action["domain"])
|
||||
result["detected_domains"] = detected_domains
|
||||
result["deferred_requests"] = [
|
||||
item
|
||||
for item in result.get("deferred_requests", [])
|
||||
if not (
|
||||
item.get("execution_backend") == "native_can"
|
||||
and self.native_can_runtime.supports(item)
|
||||
)
|
||||
]
|
||||
return result
|
||||
|
||||
def _process_orchestrated(
|
||||
self,
|
||||
utterance: str,
|
||||
@@ -2172,10 +2387,17 @@ class SeatDemoService:
|
||||
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
|
||||
generation_started = time.perf_counter()
|
||||
intent_ir = build_intent_ir(parsed, utterance, request_id, provider_used)
|
||||
for action in parsed.get("domain_actions", []):
|
||||
capability = self.domain_runtimes[action["domain"]].capabilities[
|
||||
action["capability"]
|
||||
]
|
||||
intent_actions = [
|
||||
*parsed.get("domain_actions", []),
|
||||
*parsed.get("native_can_actions", []),
|
||||
]
|
||||
for action in intent_actions:
|
||||
if action in parsed.get("native_can_actions", []):
|
||||
capability = self.native_can_runtime.capabilities[action["capability"]]
|
||||
else:
|
||||
capability = self.domain_runtimes[action["domain"]].capabilities[
|
||||
action["capability"]
|
||||
]
|
||||
intent_ir["spec"]["requests"].append(
|
||||
{
|
||||
"domain": action["domain"],
|
||||
@@ -2212,6 +2434,20 @@ class SeatDemoService:
|
||||
provider_used,
|
||||
)
|
||||
|
||||
native_config_keys: set[str] = set()
|
||||
native_actions_by_domain: dict[str, list[dict[str, Any]]] = {}
|
||||
for action in parsed.get("native_can_actions", []):
|
||||
native_actions_by_domain.setdefault(action["domain"], []).append(action)
|
||||
for domain, actions in native_actions_by_domain.items():
|
||||
key = f"native_{domain}"
|
||||
native_config_keys.add(key)
|
||||
configs[key] = self.native_can_runtime.build_function_config(
|
||||
domain,
|
||||
actions,
|
||||
request_id,
|
||||
provider_used,
|
||||
)
|
||||
|
||||
result["function_configs"] = configs
|
||||
result["function_config"] = (
|
||||
configs.get("seat") or next(iter(configs.values()), None)
|
||||
@@ -2252,7 +2488,8 @@ class SeatDemoService:
|
||||
"message": ambiguity["question"],
|
||||
}
|
||||
)
|
||||
validation.append(
|
||||
if not native_config_keys:
|
||||
validation.append(
|
||||
{
|
||||
"severity": "warning",
|
||||
"code": "VIRTUAL_EXECUTION_ONLY",
|
||||
@@ -2269,8 +2506,8 @@ class SeatDemoService:
|
||||
"code": "NO_EXECUTABLE_OPERATION",
|
||||
"path": "spec.operations",
|
||||
"message": "模型没有生成任何可执行领域操作。",
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
if hardware_request:
|
||||
self._adapt_validation_for_hardware(validation)
|
||||
result["validation"] = validation
|
||||
@@ -2310,6 +2547,8 @@ class SeatDemoService:
|
||||
)
|
||||
else:
|
||||
plans[domain] = self.compiler.compile(config)
|
||||
elif domain in native_config_keys:
|
||||
plans[domain] = self.native_can_runtime.compile(config)
|
||||
else:
|
||||
runtime = self.domain_runtimes[domain]
|
||||
plans[domain] = runtime.compile(config)
|
||||
@@ -2388,37 +2627,47 @@ class SeatDemoService:
|
||||
|
||||
execute_started = time.perf_counter()
|
||||
execution_started_at = _now()
|
||||
reports: dict[str, dict[str, Any]] = {}
|
||||
for domain, config in configs.items():
|
||||
if domain == "seat":
|
||||
if hardware_request:
|
||||
reports[domain] = self._new_hardware_executor().execute(
|
||||
plans[domain], config, context["devices"]
|
||||
)
|
||||
elif simulated_hardware_request:
|
||||
reports[domain] = self.simulated_hardware_executor.execute(
|
||||
plans[domain],
|
||||
config,
|
||||
context["devices"],
|
||||
fault_scenario=simulation_fault,
|
||||
)
|
||||
else:
|
||||
reports[domain] = self.executor.execute(
|
||||
plans[domain], config, context["devices"]
|
||||
)
|
||||
else:
|
||||
reports[domain] = self.domain_runtimes[domain].execute(
|
||||
plans[domain], config
|
||||
)
|
||||
reports = self._execute_orchestrated_reports(
|
||||
configs,
|
||||
plans,
|
||||
native_config_keys,
|
||||
context,
|
||||
hardware_request=hardware_request,
|
||||
simulated_hardware_request=simulated_hardware_request,
|
||||
simulation_fault=simulation_fault,
|
||||
)
|
||||
report_results = [
|
||||
item
|
||||
for domain_report in reports.values()
|
||||
for item in domain_report["spec"]["results"]
|
||||
]
|
||||
operation_results = [
|
||||
item
|
||||
for item in report_results
|
||||
if item.get("ref") != "seat-controller-firmware"
|
||||
]
|
||||
operation_domains = {
|
||||
str(item.get("domain")) for item in operation_results
|
||||
}
|
||||
successful_domains = {
|
||||
domain
|
||||
for domain in operation_domains
|
||||
if all(
|
||||
item.get("status") == "succeeded"
|
||||
for item in operation_results
|
||||
if str(item.get("domain")) == domain
|
||||
)
|
||||
}
|
||||
failed_domains = {
|
||||
str(item.get("domain"))
|
||||
for item in operation_results
|
||||
if item.get("status") == "failed"
|
||||
}
|
||||
execution_succeeded = all(
|
||||
domain_report["spec"]["status"] == "succeeded"
|
||||
for domain_report in reports.values()
|
||||
) and all(item.get("status") == "succeeded" for item in report_results)
|
||||
execution_partially_succeeded = bool(successful_domains and failed_domains)
|
||||
physical_state = (
|
||||
reports.get("seat", {}).get("spec", {}).get("physical_state")
|
||||
if hardware_request
|
||||
@@ -2477,6 +2726,8 @@ class SeatDemoService:
|
||||
result["validation"] = validation
|
||||
if report_issues:
|
||||
result["status"] = "execution_report_invalid"
|
||||
elif execution_partially_succeeded:
|
||||
result["status"] = "partially_succeeded"
|
||||
elif not execution_succeeded:
|
||||
result["status"] = "execution_failed"
|
||||
elif parsed.get("deferred_requests"):
|
||||
@@ -2496,7 +2747,14 @@ class SeatDemoService:
|
||||
"副驾座椅已按物理 CAN 回读闭环执行,其他领域已完成虚拟 CAN 执行。"
|
||||
if hardware_request and execution_succeeded
|
||||
else (
|
||||
f"副驾座椅真实执行失败:{failed_execution['message']}"
|
||||
(
|
||||
"跨域指令部分执行成功;成功域="
|
||||
f"{','.join(sorted(successful_domains))},失败域="
|
||||
f"{','.join(sorted(failed_domains))}:"
|
||||
f"{failed_execution['message']}"
|
||||
)
|
||||
if execution_partially_succeeded and failed_execution
|
||||
else f"真实 CAN 执行失败:{failed_execution['message']}"
|
||||
if hardware_request and failed_execution
|
||||
else (
|
||||
f"模拟台架已执行 {len(report_results)} 个领域操作,物理 CAN 未打开。"
|
||||
@@ -2523,19 +2781,40 @@ class SeatDemoService:
|
||||
"failed_domain": (
|
||||
failed_execution.get("domain") if failed_execution else None
|
||||
),
|
||||
"successful_domains": sorted(successful_domains),
|
||||
"failed_domains": sorted(failed_domains),
|
||||
"partially_succeeded": execution_partially_succeeded,
|
||||
},
|
||||
domain="orchestration",
|
||||
)
|
||||
|
||||
if execution_succeeded and not report_issues:
|
||||
seat_actions = parsed.get("actions", [])
|
||||
if hardware_request:
|
||||
if (execution_succeeded or execution_partially_succeeded) and not report_issues:
|
||||
seat_actions = (
|
||||
parsed.get("actions", [])
|
||||
if execution_succeeded or "seat" in successful_domains
|
||||
else []
|
||||
)
|
||||
if hardware_request and seat_actions and "seat" in reports:
|
||||
seat_actions = self._hardware_feedback_actions(
|
||||
seat_actions,
|
||||
configs["seat"],
|
||||
reports["seat"],
|
||||
)
|
||||
all_actions = [*seat_actions, *parsed.get("domain_actions", [])]
|
||||
all_actions = [
|
||||
*seat_actions,
|
||||
*[
|
||||
action
|
||||
for action in parsed.get("domain_actions", [])
|
||||
if execution_succeeded
|
||||
or str(action.get("domain")) in successful_domains
|
||||
],
|
||||
*[
|
||||
action
|
||||
for action in parsed.get("native_can_actions", [])
|
||||
if execution_succeeded
|
||||
or str(action.get("domain")) in successful_domains
|
||||
],
|
||||
]
|
||||
updated_context = self.conversations.apply_successful_actions(
|
||||
conversation_id,
|
||||
utterance,
|
||||
@@ -2559,11 +2838,17 @@ class SeatDemoService:
|
||||
request_id,
|
||||
"state_update",
|
||||
"succeeded",
|
||||
f"跨域会话状态已更新为第 {updated_context['turn_count']} 轮。",
|
||||
(
|
||||
f"跨域会话状态已更新为第 {updated_context['turn_count']} 轮。"
|
||||
if execution_succeeded
|
||||
else "仅成功设备已写入会话状态;失败设备保持执行前状态。"
|
||||
),
|
||||
time.perf_counter(),
|
||||
{
|
||||
"seat_state": updated_context["devices"],
|
||||
"domain_state": updated_context["domain_devices"],
|
||||
"successful_domains": sorted(successful_domains),
|
||||
"failed_domains": sorted(failed_domains),
|
||||
},
|
||||
domain="orchestration",
|
||||
)
|
||||
@@ -2595,6 +2880,111 @@ class SeatDemoService:
|
||||
self._persist(result)
|
||||
return result
|
||||
|
||||
def _execute_orchestrated_reports(
|
||||
self,
|
||||
configs: dict[str, dict[str, Any]],
|
||||
plans: dict[str, dict[str, Any]],
|
||||
native_config_keys: set[str],
|
||||
context: dict[str, Any],
|
||||
*,
|
||||
hardware_request: bool,
|
||||
simulated_hardware_request: bool,
|
||||
simulation_fault: str,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
reports: dict[str, dict[str, Any]] = {}
|
||||
bridge = None
|
||||
seat_bus_factory = None
|
||||
native_bus_factory = None
|
||||
|
||||
if hardware_request and native_config_keys:
|
||||
channel_configs = [self.vehicle_can_settings.channel_config()]
|
||||
if "seat" in configs:
|
||||
seat_channel = int(self.hardware_settings.channel, 0)
|
||||
channel_configs.insert(
|
||||
0,
|
||||
ZlgChannelConfig(
|
||||
channel=seat_channel,
|
||||
arbitration_bitrate=self.hardware_settings.bitrate,
|
||||
data_bitrate=self.vehicle_can_settings.data_bitrate,
|
||||
can_fd_iso=True,
|
||||
),
|
||||
)
|
||||
bridge = self.vehicle_can_settings.bridge(channel_configs)
|
||||
native_bus_factory = lambda: bridge.channel_bus(
|
||||
self.vehicle_can_settings.channel
|
||||
)
|
||||
if "seat" in configs:
|
||||
seat_channel = int(self.hardware_settings.channel, 0)
|
||||
seat_bus_factory = lambda: bridge.channel_bus(seat_channel)
|
||||
|
||||
try:
|
||||
def execute_native_configs() -> dict[str, dict[str, Any]]:
|
||||
native_reports: dict[str, dict[str, Any]] = {}
|
||||
for key in sorted(native_config_keys):
|
||||
if not hardware_request:
|
||||
native_reports[key] = self.native_can_runtime.execute_virtual(
|
||||
plans[key], configs[key]
|
||||
)
|
||||
continue
|
||||
executor = NativeCanExecutor(
|
||||
native_bus_factory,
|
||||
feedback_timeout_ms=(
|
||||
self.vehicle_can_settings.feedback_timeout_ms
|
||||
),
|
||||
)
|
||||
native_reports[key] = executor.execute(
|
||||
self.native_can_runtime,
|
||||
plans[key],
|
||||
configs[key],
|
||||
)
|
||||
return native_reports
|
||||
|
||||
def execute_seat_config() -> dict[str, Any]:
|
||||
config = configs["seat"]
|
||||
if hardware_request:
|
||||
seat_executor = (
|
||||
self._new_hardware_executor(seat_bus_factory)
|
||||
if seat_bus_factory is not None
|
||||
else self._new_hardware_executor()
|
||||
)
|
||||
return seat_executor.execute(
|
||||
plans["seat"], config, context["devices"]
|
||||
)
|
||||
if simulated_hardware_request:
|
||||
return self.simulated_hardware_executor.execute(
|
||||
plans["seat"],
|
||||
config,
|
||||
context["devices"],
|
||||
fault_scenario=simulation_fault,
|
||||
)
|
||||
return self.executor.execute(
|
||||
plans["seat"], config, context["devices"]
|
||||
)
|
||||
|
||||
if hardware_request and native_config_keys and "seat" in configs:
|
||||
# Both channel views share one adapter owner; executor threads let
|
||||
# the two channels start within normal USB scheduling jitter.
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
native_future = pool.submit(execute_native_configs)
|
||||
seat_future = pool.submit(execute_seat_config)
|
||||
reports.update(native_future.result())
|
||||
reports["seat"] = seat_future.result()
|
||||
else:
|
||||
reports.update(execute_native_configs())
|
||||
if "seat" in configs:
|
||||
reports["seat"] = execute_seat_config()
|
||||
|
||||
for domain, config in configs.items():
|
||||
if domain in native_config_keys or domain == "seat":
|
||||
continue
|
||||
reports[domain] = self.domain_runtimes[domain].execute(
|
||||
plans[domain], config
|
||||
)
|
||||
finally:
|
||||
if bridge is not None:
|
||||
bridge.close()
|
||||
return reports
|
||||
|
||||
@staticmethod
|
||||
def _build_scene_config(
|
||||
request_id: str,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
@@ -19,6 +20,9 @@ class CanFrame:
|
||||
arbitration_id: int
|
||||
data: bytes
|
||||
timestamp: float | None = None
|
||||
channel: int | None = None
|
||||
is_fd: bool = False
|
||||
bitrate_switch: bool = False
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -26,13 +30,25 @@ class CanFrame:
|
||||
"payload_size": len(self.data),
|
||||
"data_hex": " ".join(f"{byte:02X}" for byte in self.data),
|
||||
"timestamp": self.timestamp,
|
||||
"channel": self.channel,
|
||||
"is_fd": self.is_fd,
|
||||
"bitrate_switch": self.bitrate_switch,
|
||||
}
|
||||
|
||||
|
||||
class CanBus(Protocol):
|
||||
def send(self, arbitration_id: int, data: bytes) -> CanFrame: ...
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame: ...
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None: ...
|
||||
def receive(
|
||||
self, timeout_ms: int, *, include_fd: bool = False
|
||||
) -> CanFrame | None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -58,7 +74,16 @@ class PythonCanBus:
|
||||
except Exception as exc: # noqa: BLE001 - normalize vendor exceptions
|
||||
raise CanGatewayError(f"cannot open {interface} CAN channel {channel}: {exc}") from exc
|
||||
|
||||
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame:
|
||||
if is_fd:
|
||||
raise CanGatewayError("PythonCanBus was opened for classic CAN only")
|
||||
if len(data) > 8:
|
||||
raise CanGatewayError("the T2G bench accepts classic CAN frames of at most 8 bytes")
|
||||
message = self._can.Message(
|
||||
@@ -71,9 +96,11 @@ class PythonCanBus:
|
||||
self._bus.send(message, timeout=0.5)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise CanGatewayError(f"CAN transmit failed: {exc}") from exc
|
||||
return CanFrame(arbitration_id, bytes(data))
|
||||
return CanFrame(arbitration_id, bytes(data), is_fd=False)
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None:
|
||||
def receive(
|
||||
self, timeout_ms: int, *, include_fd: bool = False
|
||||
) -> CanFrame | None:
|
||||
try:
|
||||
message = self._bus.recv(max(0, timeout_ms) / 1000)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -84,14 +111,36 @@ class PythonCanBus:
|
||||
int(message.arbitration_id),
|
||||
bytes(message.data),
|
||||
float(message.timestamp) if message.timestamp is not None else None,
|
||||
is_fd=bool(getattr(message, "is_fd", False)),
|
||||
bitrate_switch=bool(getattr(message, "bitrate_switch", False)),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._bus.shutdown()
|
||||
|
||||
|
||||
class ZlgCanBridgeBus:
|
||||
"""Use the installed 32-bit ZLG driver from the 64-bit application process."""
|
||||
@dataclass(frozen=True)
|
||||
class ZlgChannelConfig:
|
||||
"""Configuration for one channel on a shared ZLG CAN FD adapter."""
|
||||
|
||||
channel: int
|
||||
arbitration_bitrate: int = 500_000
|
||||
data_bitrate: int = 2_000_000
|
||||
can_fd_iso: bool = True
|
||||
termination_enabled: bool = True
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"arbitration_bitrate": self.arbitration_bitrate,
|
||||
"data_bitrate": self.data_bitrate,
|
||||
"can_fd_iso": self.can_fd_iso,
|
||||
"termination_enabled": self.termination_enabled,
|
||||
}
|
||||
|
||||
|
||||
class ZlgCanBridgeProcess:
|
||||
"""Own one device handle and expose several independently addressed channels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -100,9 +149,15 @@ class ZlgCanBridgeBus:
|
||||
dll_path: Path,
|
||||
device_type: int,
|
||||
device_index: int,
|
||||
channel: int,
|
||||
bitrate: int,
|
||||
channels: list[ZlgChannelConfig],
|
||||
transmit_type: int = 0,
|
||||
):
|
||||
if not channels:
|
||||
raise CanGatewayError("at least one ZLG channel must be configured")
|
||||
channel_ids = [item.channel for item in channels]
|
||||
if len(channel_ids) != len(set(channel_ids)):
|
||||
raise CanGatewayError("ZLG channel numbers must be unique")
|
||||
self._channels = frozenset(channel_ids)
|
||||
command = [
|
||||
str(python_executable),
|
||||
str(bridge_script),
|
||||
@@ -112,10 +167,10 @@ class ZlgCanBridgeBus:
|
||||
str(device_type),
|
||||
"--device-index",
|
||||
str(device_index),
|
||||
"--channel",
|
||||
str(channel),
|
||||
"--bitrate",
|
||||
str(bitrate),
|
||||
"--channels-json",
|
||||
json.dumps([item.public() for item in channels], separators=(",", ":")),
|
||||
"--transmit-type",
|
||||
str(transmit_type),
|
||||
]
|
||||
try:
|
||||
self._process = subprocess.Popen( # noqa: S603 - paths are trusted server config
|
||||
@@ -135,6 +190,11 @@ class ZlgCanBridgeBus:
|
||||
self.close()
|
||||
raise CanGatewayError(str(ready.get("message", "ZLG CAN bridge did not start")))
|
||||
|
||||
def channel_bus(self, channel: int) -> "ZlgCanChannelBus":
|
||||
if channel not in self._channels:
|
||||
raise CanGatewayError(f"ZLG channel {channel} was not initialized")
|
||||
return ZlgCanChannelBus(self, channel)
|
||||
|
||||
def _read_response(self) -> dict[str, Any]:
|
||||
if self._process.stdout is None:
|
||||
raise CanGatewayError("ZLG CAN bridge stdout is unavailable")
|
||||
@@ -160,32 +220,6 @@ class ZlgCanBridgeBus:
|
||||
self._process.stdin.flush()
|
||||
return self._read_response()
|
||||
|
||||
def send(self, arbitration_id: int, data: bytes) -> CanFrame:
|
||||
response = self._request(
|
||||
{
|
||||
"operation": "send",
|
||||
"arbitration_id": arbitration_id,
|
||||
"data_hex": data.hex(),
|
||||
}
|
||||
)
|
||||
return CanFrame(
|
||||
int(response["arbitration_id"]),
|
||||
bytes.fromhex(response["data_hex"]),
|
||||
)
|
||||
|
||||
def receive(self, timeout_ms: int) -> CanFrame | None:
|
||||
response = self._request(
|
||||
{"operation": "receive", "timeout_ms": max(0, int(timeout_ms))}
|
||||
)
|
||||
frame = response.get("frame")
|
||||
if frame is None:
|
||||
return None
|
||||
return CanFrame(
|
||||
int(frame["arbitration_id"]),
|
||||
bytes.fromhex(frame["data_hex"]),
|
||||
float(frame["timestamp_us"]) / 1_000_000,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
process = getattr(self, "_process", None)
|
||||
if process is None or process.poll() is not None:
|
||||
@@ -202,6 +236,114 @@ class ZlgCanBridgeBus:
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
def __enter__(self) -> "ZlgCanBridgeProcess":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class ZlgCanChannelBus:
|
||||
"""Channel-scoped view over a shared :class:`ZlgCanBridgeProcess`."""
|
||||
|
||||
def __init__(self, bridge: ZlgCanBridgeProcess, channel: int):
|
||||
self._bridge = bridge
|
||||
self.channel = channel
|
||||
|
||||
def send(
|
||||
self,
|
||||
arbitration_id: int,
|
||||
data: bytes,
|
||||
*,
|
||||
is_fd: bool = False,
|
||||
bitrate_switch: bool = False,
|
||||
) -> CanFrame:
|
||||
response = self._bridge._request(
|
||||
{
|
||||
"operation": "send",
|
||||
"channel": self.channel,
|
||||
"arbitration_id": arbitration_id,
|
||||
"data_hex": data.hex(),
|
||||
"is_fd": is_fd,
|
||||
"bitrate_switch": bitrate_switch,
|
||||
}
|
||||
)
|
||||
return CanFrame(
|
||||
int(response["arbitration_id"]),
|
||||
bytes.fromhex(response["data_hex"]),
|
||||
channel=self.channel,
|
||||
is_fd=bool(response.get("is_fd", False)),
|
||||
bitrate_switch=bool(response.get("bitrate_switch", False)),
|
||||
)
|
||||
|
||||
def receive(
|
||||
self, timeout_ms: int, *, include_fd: bool = False
|
||||
) -> CanFrame | None:
|
||||
requested_ms = max(0, int(timeout_ms))
|
||||
deadline = time.monotonic() + requested_ms / 1000
|
||||
while True:
|
||||
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
|
||||
response = self._bridge._request(
|
||||
{
|
||||
"operation": "receive",
|
||||
"channel": self.channel,
|
||||
# Short slices let another channel transmit or receive while
|
||||
# this synchronous JSON bridge is waiting for feedback.
|
||||
"timeout_ms": min(20, remaining_ms),
|
||||
"include_fd": include_fd,
|
||||
}
|
||||
)
|
||||
frame = response.get("frame")
|
||||
if frame is not None:
|
||||
return CanFrame(
|
||||
int(frame["arbitration_id"]),
|
||||
bytes.fromhex(frame["data_hex"]),
|
||||
float(frame["timestamp_us"]) / 1_000_000,
|
||||
channel=self.channel,
|
||||
is_fd=bool(frame.get("is_fd", False)),
|
||||
bitrate_switch=bool(frame.get("bitrate_switch", False)),
|
||||
)
|
||||
if requested_ms == 0 or time.monotonic() >= deadline:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
# The owner closes all channel handles together. Executors may safely close views.
|
||||
return None
|
||||
|
||||
|
||||
class ZlgCanBridgeBus(ZlgCanChannelBus):
|
||||
"""Backward-compatible single-channel ZLG bus."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
python_executable: Path,
|
||||
bridge_script: Path,
|
||||
dll_path: Path,
|
||||
device_type: int,
|
||||
device_index: int,
|
||||
channel: int,
|
||||
bitrate: int,
|
||||
termination_enabled: bool = True,
|
||||
):
|
||||
self._owner = ZlgCanBridgeProcess(
|
||||
python_executable=python_executable,
|
||||
bridge_script=bridge_script,
|
||||
dll_path=dll_path,
|
||||
device_type=device_type,
|
||||
device_index=device_index,
|
||||
channels=[
|
||||
ZlgChannelConfig(
|
||||
channel=channel,
|
||||
arbitration_bitrate=bitrate,
|
||||
termination_enabled=termination_enabled,
|
||||
)
|
||||
],
|
||||
)
|
||||
super().__init__(self._owner, channel)
|
||||
|
||||
def close(self) -> None:
|
||||
self._owner.close()
|
||||
|
||||
def __enter__(self) -> "ZlgCanBridgeBus":
|
||||
return self
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ from cognitive_agent.infrastructure.firmware import (
|
||||
T2GSeatProgrammer,
|
||||
)
|
||||
|
||||
from .gateway import CanBus, CanGatewayError, PythonCanBus, ZlgCanBridgeBus
|
||||
from .gateway import (
|
||||
CanBus,
|
||||
CanGatewayError,
|
||||
PythonCanBus,
|
||||
ZlgCanBridgeBus,
|
||||
ZlgCanBridgeProcess,
|
||||
ZlgChannelConfig,
|
||||
)
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on"}
|
||||
@@ -32,6 +39,8 @@ CAPABILITY_PROFILE = {
|
||||
"seat.set_cushion_tilt": "combined",
|
||||
"seat.set_backrest_recline": "combined",
|
||||
"seat.set_legrest_height": "combined",
|
||||
"seat.start_dynamic_mode": "combined",
|
||||
"seat.stop_dynamic_mode": "combined",
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +141,7 @@ class SeatHardwareSettings:
|
||||
profiles: frozenset[str]
|
||||
backend: str
|
||||
bitrate: int
|
||||
termination_enabled: bool
|
||||
channel: str
|
||||
interface: str
|
||||
device_type: int
|
||||
@@ -196,6 +206,9 @@ class SeatHardwareSettings:
|
||||
profiles=_profiles_from_env(),
|
||||
backend=backend,
|
||||
bitrate=_env_int("COGNITIVE_AGENT_SEAT_CAN_BITRATE", 500_000),
|
||||
termination_enabled=_env_bool(
|
||||
"COGNITIVE_AGENT_SEAT_CAN_TERMINATION_ENABLED", default=True
|
||||
),
|
||||
channel=os.getenv("COGNITIVE_AGENT_SEAT_CAN_CHANNEL", "0").strip(),
|
||||
interface=os.getenv("COGNITIVE_AGENT_SEAT_CAN_INTERFACE", "socketcan").strip(),
|
||||
device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41),
|
||||
@@ -234,7 +247,7 @@ class SeatHardwareSettings:
|
||||
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
|
||||
),
|
||||
firmware_probe_timeout_ms=_env_int(
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROBE_TIMEOUT_MS", 1_200
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_PROBE_TIMEOUT_MS", 3_500
|
||||
),
|
||||
firmware_boot_wait_ms=_env_int(
|
||||
"COGNITIVE_AGENT_SEAT_FIRMWARE_BOOT_WAIT_MS", 1_500
|
||||
@@ -368,6 +381,7 @@ class SeatHardwareSettings:
|
||||
device_index=self.device_index,
|
||||
channel=numeric_channel,
|
||||
bitrate=self.bitrate,
|
||||
termination_enabled=self.termination_enabled,
|
||||
)
|
||||
|
||||
return create_zlg_bus
|
||||
@@ -390,9 +404,11 @@ class SeatHardwareSettings:
|
||||
timeout_ms=self.firmware_program_timeout_ms,
|
||||
)
|
||||
|
||||
def firmware_guard(self) -> T2GFirmwareGuard:
|
||||
def firmware_guard(
|
||||
self, bus_factory: Callable[[], CanBus] | None = None
|
||||
) -> T2GFirmwareGuard:
|
||||
return T2GFirmwareGuard(
|
||||
self.bus_factory(),
|
||||
bus_factory or self.bus_factory(),
|
||||
self._programmer() if self.auto_flash_enabled else None,
|
||||
auto_flash=self.auto_flash_enabled,
|
||||
probe_timeout_ms=self.firmware_probe_timeout_ms,
|
||||
@@ -420,6 +436,7 @@ class SeatHardwareSettings:
|
||||
"adapter_configured": self.adapter_configured,
|
||||
"channel": self.channel,
|
||||
"bitrate": self.bitrate,
|
||||
"termination_enabled": self.termination_enabled,
|
||||
"frame_format": "classic_can_11_bit",
|
||||
"firmware_profiles": sorted(self.profiles),
|
||||
"firmware_images": images,
|
||||
@@ -450,3 +467,209 @@ class SeatHardwareSettings:
|
||||
"current diagnostics use conservative bench thresholds and do not replace external power limiting",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VehicleCanSettings:
|
||||
"""Fail-closed channel/timing contract imported from the verified CAN_DEV branch."""
|
||||
|
||||
repository_root: Path
|
||||
enabled: bool
|
||||
approval_required: bool
|
||||
approval_token: str
|
||||
backend: str
|
||||
channel: int
|
||||
arbitration_bitrate: int
|
||||
data_bitrate: int
|
||||
termination_enabled: bool
|
||||
clock_hz: int
|
||||
arbitration_tseg1: int
|
||||
arbitration_tseg2: int
|
||||
arbitration_sjw: int
|
||||
arbitration_brp: int
|
||||
data_tseg1: int
|
||||
data_tseg2: int
|
||||
data_sjw: int
|
||||
data_brp: int
|
||||
feedback_timeout_ms: int
|
||||
device_type: int
|
||||
device_index: int
|
||||
zlg_python: Path
|
||||
zlg_dll: Path
|
||||
bridge_script: Path
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, repository_root: Path) -> "VehicleCanSettings":
|
||||
return cls(
|
||||
repository_root=repository_root,
|
||||
enabled=_env_bool(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ENABLED",
|
||||
default=_env_bool("COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED"),
|
||||
),
|
||||
approval_required=_env_bool(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
|
||||
),
|
||||
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
|
||||
backend=os.getenv(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_BACKEND", "zlgcan"
|
||||
).strip().lower(),
|
||||
channel=_env_int("COGNITIVE_AGENT_VEHICLE_CAN_CHANNEL", 1),
|
||||
arbitration_bitrate=_env_int(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_ARBITRATION_BITRATE", 500_000
|
||||
),
|
||||
data_bitrate=_env_int(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_DATA_BITRATE", 2_000_000
|
||||
),
|
||||
termination_enabled=_env_bool(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_TERMINATION_ENABLED", default=False
|
||||
),
|
||||
clock_hz=60_000_000,
|
||||
arbitration_tseg1=14,
|
||||
arbitration_tseg2=3,
|
||||
arbitration_sjw=2,
|
||||
arbitration_brp=5,
|
||||
data_tseg1=10,
|
||||
data_tseg2=2,
|
||||
data_sjw=2,
|
||||
data_brp=1,
|
||||
feedback_timeout_ms=_env_int(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS", 1_500
|
||||
),
|
||||
device_type=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_TYPE", 41),
|
||||
device_index=_env_int("COGNITIVE_AGENT_ZLGCAN_DEVICE_INDEX", 0),
|
||||
zlg_python=Path(
|
||||
os.getenv(
|
||||
"COGNITIVE_AGENT_ZLGCAN_PYTHON",
|
||||
r"C:\Program Files (x86)\ZCANPRO\python38\python.exe",
|
||||
)
|
||||
),
|
||||
zlg_dll=Path(
|
||||
os.getenv(
|
||||
"COGNITIVE_AGENT_ZLGCAN_DLL",
|
||||
r"C:\Program Files (x86)\ZCANPRO\zlgcan.dll",
|
||||
)
|
||||
),
|
||||
bridge_script=repository_root
|
||||
/ "src"
|
||||
/ "cognitive_agent"
|
||||
/ "infrastructure"
|
||||
/ "can"
|
||||
/ "zlgcan_bridge.py",
|
||||
)
|
||||
|
||||
@property
|
||||
def adapter_configured(self) -> bool:
|
||||
return bool(
|
||||
self.backend == "zlgcan"
|
||||
and self.zlg_python.is_file()
|
||||
and self.zlg_dll.is_file()
|
||||
and self.bridge_script.is_file()
|
||||
)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
self.enabled
|
||||
and (not self.approval_required or self.approval_token)
|
||||
and self.adapter_configured
|
||||
and self.timing_is_verified
|
||||
)
|
||||
|
||||
@property
|
||||
def timing_is_verified(self) -> bool:
|
||||
return (
|
||||
self.arbitration_bitrate == 500_000
|
||||
and self.data_bitrate == 2_000_000
|
||||
and 60_000_000
|
||||
// ((self.arbitration_brp + 1) * (self.arbitration_tseg1 + self.arbitration_tseg2 + 3))
|
||||
== 500_000
|
||||
and 60_000_000
|
||||
// ((self.data_brp + 1) * (self.data_tseg1 + self.data_tseg2 + 3))
|
||||
== 2_000_000
|
||||
)
|
||||
|
||||
def authorize(self, supplied_token: str | None) -> None:
|
||||
if not self.enabled:
|
||||
raise HardwareSafetyError(
|
||||
"vehicle CAN execution is disabled; set COGNITIVE_AGENT_VEHICLE_CAN_ENABLED=true"
|
||||
)
|
||||
if self.approval_required:
|
||||
if not self.approval_token:
|
||||
raise HardwareSafetyError(
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN is not configured"
|
||||
)
|
||||
if not supplied_token or not hmac.compare_digest(
|
||||
supplied_token, self.approval_token
|
||||
):
|
||||
raise HardwareSafetyError("hardware approval token is invalid")
|
||||
if self.backend != "zlgcan":
|
||||
raise HardwareSafetyError(
|
||||
"vehicle CAN FD currently requires the zlgcan multi-channel backend"
|
||||
)
|
||||
if self.channel < 0:
|
||||
raise HardwareSafetyError("vehicle CAN channel cannot be negative")
|
||||
if not self.timing_is_verified:
|
||||
raise HardwareSafetyError(
|
||||
"vehicle CAN timing must remain ISO CAN FD 500 kbit/s / 2 Mbit/s at 80% sample point"
|
||||
)
|
||||
if not 100 <= self.feedback_timeout_ms <= 10_000:
|
||||
raise HardwareSafetyError(
|
||||
"COGNITIVE_AGENT_VEHICLE_CAN_FEEDBACK_TIMEOUT_MS must be between 100 and 10000"
|
||||
)
|
||||
if not self.adapter_configured:
|
||||
raise HardwareSafetyError("the configured ZLG CAN FD runtime is not ready")
|
||||
|
||||
def channel_config(self) -> ZlgChannelConfig:
|
||||
return ZlgChannelConfig(
|
||||
channel=self.channel,
|
||||
arbitration_bitrate=self.arbitration_bitrate,
|
||||
data_bitrate=self.data_bitrate,
|
||||
can_fd_iso=True,
|
||||
termination_enabled=self.termination_enabled,
|
||||
)
|
||||
|
||||
def bridge(self, channels: list[ZlgChannelConfig]) -> ZlgCanBridgeProcess:
|
||||
return ZlgCanBridgeProcess(
|
||||
python_executable=self.zlg_python,
|
||||
bridge_script=self.bridge_script,
|
||||
dll_path=self.zlg_dll,
|
||||
device_type=self.device_type,
|
||||
device_index=self.device_index,
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
def public_info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"configured": self.configured,
|
||||
"backend": self.backend,
|
||||
"platform": "t2g_cabin_bench",
|
||||
"channel": self.channel,
|
||||
"mode": 0,
|
||||
"work_mode": "normal",
|
||||
"can_fd_standard": "ISO",
|
||||
"termination_enabled": self.termination_enabled,
|
||||
"clock_hz": self.clock_hz,
|
||||
"arbitration": {
|
||||
"bitrate": self.arbitration_bitrate,
|
||||
"tseg1": self.arbitration_tseg1,
|
||||
"tseg2": self.arbitration_tseg2,
|
||||
"sjw": self.arbitration_sjw,
|
||||
"smp": 0,
|
||||
"brp": self.arbitration_brp,
|
||||
"total_tq": 20,
|
||||
"sample_point_percent": 80,
|
||||
},
|
||||
"data": {
|
||||
"bitrate": self.data_bitrate,
|
||||
"tseg1": self.data_tseg1,
|
||||
"tseg2": self.data_tseg2,
|
||||
"sjw": self.data_sjw,
|
||||
"smp": 0,
|
||||
"brp": self.data_brp,
|
||||
"total_tq": 15,
|
||||
"sample_point_percent": 80,
|
||||
},
|
||||
"source_branch": "project-code/CAN_DEV",
|
||||
"source_commit": "6e7ed04a468dfcde7efda9e30bc15144bf02faf9",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""32-bit JSON-line bridge for the ZLG zlgcan.dll API.
|
||||
"""32-bit JSON-line bridge for a multi-channel ZLG CAN FD adapter.
|
||||
|
||||
This module deliberately has no project or third-party imports so it can run under
|
||||
the 32-bit Python bundled with ZCANPRO while the web service uses 64-bit Python.
|
||||
The module deliberately has no project or third-party imports. It runs under
|
||||
the 32-bit Python bundled with ZCANPRO while the application uses 64-bit Python.
|
||||
One bridge owns the physical adapter once and initializes every requested
|
||||
channel, which avoids two processes racing to open the same USB device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,10 +12,12 @@ import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ZCAN_TYPE_CANFD = 1
|
||||
ZCAN_STATUS_OK = 1
|
||||
CANFD_BRS = 0x01
|
||||
|
||||
|
||||
class ZcanClassicInit(ctypes.Structure):
|
||||
@@ -62,6 +66,17 @@ class ZcanFrame(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class ZcanFdFrame(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("can_id", ctypes.c_uint32),
|
||||
("len", ctypes.c_uint8),
|
||||
("flags", ctypes.c_uint8),
|
||||
("reserved0", ctypes.c_uint8),
|
||||
("reserved1", ctypes.c_uint8),
|
||||
("data", ctypes.c_uint8 * 64),
|
||||
]
|
||||
|
||||
|
||||
class ZcanTransmitData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFrame), ("transmit_type", ctypes.c_uint32)]
|
||||
|
||||
@@ -70,6 +85,14 @@ class ZcanReceiveData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFrame), ("timestamp", ctypes.c_uint64)]
|
||||
|
||||
|
||||
class ZcanTransmitFdData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFdFrame), ("transmit_type", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class ZcanReceiveFdData(ctypes.Structure):
|
||||
_fields_ = [("frame", ZcanFdFrame), ("timestamp", ctypes.c_uint64)]
|
||||
|
||||
|
||||
class ZcanChannelErrorInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("error_code", ctypes.c_uint32),
|
||||
@@ -87,45 +110,85 @@ class Driver:
|
||||
self.transmit_type = args.transmit_type
|
||||
self.dll = ctypes.WinDLL(args.dll)
|
||||
self._declare_functions()
|
||||
self.device = self.dll.ZCAN_OpenDevice(
|
||||
args.device_type, args.device_index, 0
|
||||
)
|
||||
self.device = self.dll.ZCAN_OpenDevice(args.device_type, args.device_index, 0)
|
||||
if not self.device:
|
||||
raise RuntimeError(
|
||||
f"ZCAN_OpenDevice failed for device_type={args.device_type}, "
|
||||
f"device_index={args.device_index}; connect the adapter and close "
|
||||
"ZCANPRO first"
|
||||
"ZCAN_OpenDevice failed for device_type={}, device_index={}; "
|
||||
"connect the adapter and close ZCANPRO first".format(
|
||||
args.device_type, args.device_index
|
||||
)
|
||||
)
|
||||
self.channel = None
|
||||
self.channels = {}
|
||||
try:
|
||||
bitrate = str(args.bitrate).encode("ascii")
|
||||
channel_prefix = str(args.channel)
|
||||
path = (channel_prefix + "/canfd_abit_baud_rate").encode("ascii")
|
||||
if self.dll.ZCAN_SetValue(self.device, path, bitrate) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue(canfd_abit_baud_rate) failed")
|
||||
data_path = (channel_prefix + "/canfd_dbit_baud_rate").encode("ascii")
|
||||
if self.dll.ZCAN_SetValue(self.device, data_path, b"2000000") != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue(canfd_dbit_baud_rate) failed")
|
||||
init = ZcanChannelInit()
|
||||
# USBCANFD devices require a CANFD channel handle even when the
|
||||
# application only transmits classic CAN frames through ZCAN_Transmit.
|
||||
init.can_type = ZCAN_TYPE_CANFD
|
||||
init.canfd.acc_code = 0
|
||||
init.canfd.acc_mask = 0xFFFFFFFF
|
||||
init.canfd.filter = 0
|
||||
init.canfd.mode = 0
|
||||
self.channel = self.dll.ZCAN_InitCAN(
|
||||
self.device, args.channel, ctypes.byref(init)
|
||||
)
|
||||
if not self.channel:
|
||||
raise RuntimeError("ZCAN_InitCAN failed")
|
||||
if self.dll.ZCAN_StartCAN(self.channel) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_StartCAN failed")
|
||||
self.dll.ZCAN_ClearBuffer(self.channel)
|
||||
for config in self._channel_configs(args):
|
||||
self._initialize_channel(config)
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _channel_configs(args):
|
||||
if args.channels_json:
|
||||
configs = json.loads(args.channels_json)
|
||||
else:
|
||||
configs = [
|
||||
{
|
||||
"channel": args.channel,
|
||||
"arbitration_bitrate": args.bitrate,
|
||||
"data_bitrate": args.data_bitrate,
|
||||
"can_fd_iso": True,
|
||||
"termination_enabled": True,
|
||||
}
|
||||
]
|
||||
if not isinstance(configs, list) or not configs:
|
||||
raise ValueError("channels-json must be a non-empty JSON array")
|
||||
channel_ids = [int(item["channel"]) for item in configs]
|
||||
if len(channel_ids) != len(set(channel_ids)):
|
||||
raise ValueError("channel numbers must be unique")
|
||||
return configs
|
||||
|
||||
def _set_value(self, path, value):
|
||||
result = self.dll.ZCAN_SetValue(
|
||||
self.device, path.encode("ascii"), str(value).encode("ascii")
|
||||
)
|
||||
if result != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_SetValue({}) failed".format(path))
|
||||
|
||||
def _initialize_channel(self, config):
|
||||
channel_id = int(config["channel"])
|
||||
arbitration_bitrate = int(config.get("arbitration_bitrate", 500000))
|
||||
data_bitrate = int(config.get("data_bitrate", 2000000))
|
||||
can_fd_iso = bool(config.get("can_fd_iso", True))
|
||||
if channel_id < 0:
|
||||
raise ValueError("channel number cannot be negative")
|
||||
if arbitration_bitrate <= 0 or data_bitrate <= 0:
|
||||
raise ValueError("CAN bitrates must be positive")
|
||||
|
||||
prefix = str(channel_id)
|
||||
self._set_value(prefix + "/protocol", 1)
|
||||
self._set_value(prefix + "/canfd_standard", 0 if can_fd_iso else 1)
|
||||
self._set_value(prefix + "/canfd_exp", 1)
|
||||
self._set_value(prefix + "/canfd_abit_baud_rate", arbitration_bitrate)
|
||||
self._set_value(prefix + "/canfd_dbit_baud_rate", data_bitrate)
|
||||
|
||||
init = ZcanChannelInit()
|
||||
init.can_type = ZCAN_TYPE_CANFD
|
||||
init.canfd.acc_code = 0
|
||||
init.canfd.acc_mask = 0xFFFFFFFF
|
||||
init.canfd.filter = 0
|
||||
init.canfd.mode = 0
|
||||
handle = self.dll.ZCAN_InitCAN(self.device, channel_id, ctypes.byref(init))
|
||||
if not handle:
|
||||
raise RuntimeError("ZCAN_InitCAN failed for channel {}".format(channel_id))
|
||||
self.channels[channel_id] = handle
|
||||
self._set_value(
|
||||
prefix + "/initenal_resistance",
|
||||
1 if bool(config.get("termination_enabled", True)) else 0,
|
||||
)
|
||||
if self.dll.ZCAN_StartCAN(handle) != ZCAN_STATUS_OK:
|
||||
raise RuntimeError("ZCAN_StartCAN failed for channel {}".format(channel_id))
|
||||
self.dll.ZCAN_ClearBuffer(handle)
|
||||
|
||||
def _declare_functions(self):
|
||||
handle = ctypes.c_void_p
|
||||
self.dll.ZCAN_OpenDevice.argtypes = [
|
||||
@@ -168,10 +231,60 @@ class Driver:
|
||||
ctypes.c_int32,
|
||||
]
|
||||
self.dll.ZCAN_Receive.restype = ctypes.c_uint32
|
||||
self.dll.ZCAN_TransmitFD.argtypes = [
|
||||
handle,
|
||||
ctypes.POINTER(ZcanTransmitFdData),
|
||||
ctypes.c_uint32,
|
||||
]
|
||||
self.dll.ZCAN_TransmitFD.restype = ctypes.c_uint32
|
||||
self.dll.ZCAN_ReceiveFD.argtypes = [
|
||||
handle,
|
||||
ctypes.POINTER(ZcanReceiveFdData),
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_int32,
|
||||
]
|
||||
self.dll.ZCAN_ReceiveFD.restype = ctypes.c_uint32
|
||||
|
||||
def send(self, arbitration_id, data):
|
||||
def _handle(self, channel):
|
||||
channel_id = int(channel)
|
||||
try:
|
||||
return self.channels[channel_id]
|
||||
except KeyError:
|
||||
raise ValueError("channel {} was not initialized".format(channel_id))
|
||||
|
||||
def _transmit_error(self, handle, operation):
|
||||
error = ZcanChannelErrorInfo()
|
||||
read_ok = self.dll.ZCAN_ReadChannelErrInfo(handle, ctypes.byref(error))
|
||||
detail = (
|
||||
"; channel_error=0x{:08X}".format(error.error_code)
|
||||
if read_ok == ZCAN_STATUS_OK
|
||||
else "; channel error info unavailable"
|
||||
)
|
||||
raise RuntimeError(operation + " did not accept the frame" + detail)
|
||||
|
||||
def send(self, channel, arbitration_id, data, is_fd=False, bitrate_switch=False):
|
||||
if not 0 <= arbitration_id <= 0x7FF:
|
||||
raise ValueError("only 11-bit standard CAN identifiers are allowed")
|
||||
handle = self._handle(channel)
|
||||
if is_fd:
|
||||
valid_lengths = set(range(9)) | {12, 16, 20, 24, 32, 48, 64}
|
||||
if len(data) not in valid_lengths:
|
||||
raise ValueError(
|
||||
"CAN FD payload length must be 0-8, 12, 16, 20, 24, 32, 48 or 64 bytes"
|
||||
)
|
||||
transmit = ZcanTransmitFdData()
|
||||
transmit.frame.can_id = arbitration_id
|
||||
transmit.frame.len = len(data)
|
||||
transmit.frame.flags = CANFD_BRS if bitrate_switch else 0
|
||||
for index, byte in enumerate(data):
|
||||
transmit.frame.data[index] = byte
|
||||
transmit.transmit_type = self.transmit_type
|
||||
if self.dll.ZCAN_TransmitFD(handle, ctypes.byref(transmit), 1) != 1:
|
||||
self._transmit_error(handle, "ZCAN_TransmitFD")
|
||||
return
|
||||
|
||||
if bitrate_switch:
|
||||
raise ValueError("bitrate switching is only valid for CAN FD frames")
|
||||
if len(data) > 8:
|
||||
raise ValueError("classic CAN payload exceeds 8 bytes")
|
||||
transmit = ZcanTransmitData()
|
||||
@@ -180,38 +293,63 @@ class Driver:
|
||||
for index, byte in enumerate(data):
|
||||
transmit.frame.data[index] = byte
|
||||
transmit.transmit_type = self.transmit_type
|
||||
if self.dll.ZCAN_Transmit(self.channel, ctypes.byref(transmit), 1) != 1:
|
||||
error = ZcanChannelErrorInfo()
|
||||
read_ok = self.dll.ZCAN_ReadChannelErrInfo(
|
||||
self.channel, ctypes.byref(error)
|
||||
)
|
||||
detail = (
|
||||
f"; channel_error=0x{error.error_code:08X}"
|
||||
if read_ok == ZCAN_STATUS_OK
|
||||
else "; channel error info unavailable"
|
||||
)
|
||||
raise RuntimeError("ZCAN_Transmit did not accept the frame" + detail)
|
||||
if self.dll.ZCAN_Transmit(handle, ctypes.byref(transmit), 1) != 1:
|
||||
self._transmit_error(handle, "ZCAN_Transmit")
|
||||
|
||||
def receive(self, timeout_ms):
|
||||
receive = ZcanReceiveData()
|
||||
count = self.dll.ZCAN_Receive(
|
||||
self.channel, ctypes.byref(receive), 1, int(timeout_ms)
|
||||
)
|
||||
if count == 0:
|
||||
return None
|
||||
if count != 1:
|
||||
raise RuntimeError("ZCAN_Receive returned an unexpected frame count")
|
||||
@staticmethod
|
||||
def _classic_public(receive):
|
||||
frame = receive.frame
|
||||
return {
|
||||
"arbitration_id": int(frame.can_id & 0x1FFFFFFF),
|
||||
"data_hex": bytes(frame.data[: frame.can_dlc]).hex(),
|
||||
"timestamp_us": int(receive.timestamp),
|
||||
"is_fd": False,
|
||||
"bitrate_switch": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _fd_public(receive):
|
||||
frame = receive.frame
|
||||
return {
|
||||
"arbitration_id": int(frame.can_id & 0x1FFFFFFF),
|
||||
"data_hex": bytes(frame.data[: frame.len]).hex(),
|
||||
"timestamp_us": int(receive.timestamp),
|
||||
"is_fd": True,
|
||||
"bitrate_switch": bool(frame.flags & CANFD_BRS),
|
||||
}
|
||||
|
||||
def receive(self, channel, timeout_ms, include_fd=False):
|
||||
handle = self._handle(channel)
|
||||
deadline = time.monotonic() + max(0, int(timeout_ms)) / 1000.0
|
||||
while True:
|
||||
classic = ZcanReceiveData()
|
||||
count = self.dll.ZCAN_Receive(handle, ctypes.byref(classic), 1, 0)
|
||||
if count == 1:
|
||||
return self._classic_public(classic)
|
||||
if count != 0:
|
||||
raise RuntimeError("ZCAN_Receive returned an unexpected frame count")
|
||||
|
||||
if include_fd:
|
||||
fd_receive = ZcanReceiveFdData()
|
||||
count = self.dll.ZCAN_ReceiveFD(
|
||||
handle, ctypes.byref(fd_receive), 1, 0
|
||||
)
|
||||
if count == 1:
|
||||
return self._fd_public(fd_receive)
|
||||
if count != 0:
|
||||
raise RuntimeError(
|
||||
"ZCAN_ReceiveFD returned an unexpected frame count"
|
||||
)
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
time.sleep(min(0.002, remaining))
|
||||
|
||||
def close(self):
|
||||
if self.channel:
|
||||
self.dll.ZCAN_ResetCAN(self.channel)
|
||||
self.channel = None
|
||||
for channel_id in sorted(self.channels, reverse=True):
|
||||
handle = self.channels.pop(channel_id)
|
||||
self.dll.ZCAN_ResetCAN(handle)
|
||||
if self.device:
|
||||
self.dll.ZCAN_CloseDevice(self.device)
|
||||
self.device = None
|
||||
@@ -222,8 +360,10 @@ def parse_args():
|
||||
parser.add_argument("--dll", required=True)
|
||||
parser.add_argument("--device-type", type=int, required=True)
|
||||
parser.add_argument("--device-index", type=int, default=0)
|
||||
parser.add_argument("--channels-json")
|
||||
parser.add_argument("--channel", type=int, default=0)
|
||||
parser.add_argument("--bitrate", type=int, default=500000)
|
||||
parser.add_argument("--data-bitrate", type=int, default=2000000)
|
||||
parser.add_argument(
|
||||
"--transmit-type",
|
||||
type=int,
|
||||
@@ -247,6 +387,8 @@ def main():
|
||||
"ZCAN_StartCAN",
|
||||
"ZCAN_Transmit",
|
||||
"ZCAN_Receive",
|
||||
"ZCAN_TransmitFD",
|
||||
"ZCAN_ReceiveFD",
|
||||
)
|
||||
missing = [name for name in required if not hasattr(dll, name)]
|
||||
emit(
|
||||
@@ -256,9 +398,12 @@ def main():
|
||||
"missing_exports": missing,
|
||||
"structure_sizes": {
|
||||
"init": ctypes.sizeof(ZcanChannelInit),
|
||||
"frame": ctypes.sizeof(ZcanFrame),
|
||||
"tx": ctypes.sizeof(ZcanTransmitData),
|
||||
"rx": ctypes.sizeof(ZcanReceiveData),
|
||||
"classic_frame": ctypes.sizeof(ZcanFrame),
|
||||
"classic_tx": ctypes.sizeof(ZcanTransmitData),
|
||||
"classic_rx": ctypes.sizeof(ZcanReceiveData),
|
||||
"fd_frame": ctypes.sizeof(ZcanFdFrame),
|
||||
"fd_tx": ctypes.sizeof(ZcanTransmitFdData),
|
||||
"fd_rx": ctypes.sizeof(ZcanReceiveFdData),
|
||||
"error": ctypes.sizeof(ZcanChannelErrorInfo),
|
||||
},
|
||||
}
|
||||
@@ -268,27 +413,43 @@ def main():
|
||||
driver = None
|
||||
try:
|
||||
driver = Driver(args)
|
||||
emit({"ok": True, "event": "ready"})
|
||||
emit({"ok": True, "event": "ready", "channels": sorted(driver.channels)})
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
request = json.loads(line)
|
||||
operation = request.get("operation")
|
||||
if operation == "send":
|
||||
channel = int(request.get("channel", args.channel))
|
||||
arbitration_id = int(request["arbitration_id"])
|
||||
data = bytes.fromhex(request["data_hex"])
|
||||
driver.send(arbitration_id, data)
|
||||
is_fd = bool(request.get("is_fd", False))
|
||||
bitrate_switch = bool(request.get("bitrate_switch", False))
|
||||
driver.send(
|
||||
channel,
|
||||
arbitration_id,
|
||||
data,
|
||||
is_fd=is_fd,
|
||||
bitrate_switch=bitrate_switch,
|
||||
)
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"channel": channel,
|
||||
"arbitration_id": arbitration_id,
|
||||
"data_hex": data.hex(),
|
||||
"is_fd": is_fd,
|
||||
"bitrate_switch": bitrate_switch,
|
||||
}
|
||||
)
|
||||
elif operation == "receive":
|
||||
emit(
|
||||
{
|
||||
"ok": True,
|
||||
"frame": driver.receive(int(request.get("timeout_ms", 0))),
|
||||
"frame": driver.receive(
|
||||
int(request.get("channel", args.channel)),
|
||||
int(request.get("timeout_ms", 0)),
|
||||
include_fd=bool(request.get("include_fd", False)),
|
||||
),
|
||||
}
|
||||
)
|
||||
elif operation == "close":
|
||||
|
||||
@@ -12,9 +12,11 @@ from typing import Any, Callable
|
||||
from cognitive_agent.infrastructure.can.gateway import CanBus
|
||||
|
||||
|
||||
FIRMWARE_PROTOCOL_SIGNATURE = "seat-controller-smv8-current-diagnostics-v6"
|
||||
FIRMWARE_PROTOCOL_FRAME_IDS = frozenset({0x301, 0x302, 0x303, 0x304, 0x305, 0x4FA})
|
||||
EXPECTED_FIRMWARE_REVISION = 6
|
||||
FIRMWARE_PROTOCOL_SIGNATURE = "seat-controller-smv8-dynamic-modes-v13"
|
||||
FIRMWARE_PROTOCOL_FRAME_IDS = frozenset(
|
||||
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
|
||||
)
|
||||
EXPECTED_FIRMWARE_REVISION = 13
|
||||
EXPECTED_MOTOR_MAX_HALL = (480, 374, 917, 647)
|
||||
|
||||
|
||||
@@ -168,9 +170,79 @@ class T2GFirmwareGuard:
|
||||
self.auto_flash = auto_flash
|
||||
self.probe_timeout_ms = probe_timeout_ms
|
||||
self.boot_wait_ms = boot_wait_ms
|
||||
self._last_probe_diagnostics: dict[str, Any] = {}
|
||||
|
||||
def _record_probe_diagnostics(
|
||||
self,
|
||||
*,
|
||||
observed_ids: set[int],
|
||||
motor_status_ids: set[int],
|
||||
motor_diagnostic: dict[int, int],
|
||||
motor_current_ids: set[int],
|
||||
firmware_revision: int | None,
|
||||
) -> None:
|
||||
expected_motors = {0, 1, 2, 3}
|
||||
max_hall_counts = [motor_diagnostic.get(motor) for motor in range(4)]
|
||||
mismatched_max_hall = [
|
||||
motor
|
||||
for motor, expected in enumerate(EXPECTED_MOTOR_MAX_HALL)
|
||||
if motor_diagnostic.get(motor) is not None
|
||||
and abs(int(motor_diagnostic[motor]) - expected)
|
||||
> max(4, round(expected * 0.05))
|
||||
]
|
||||
self._last_probe_diagnostics = {
|
||||
"observed_frame_ids": [
|
||||
f"0x{item:03X}" for item in sorted(observed_ids)
|
||||
],
|
||||
"missing_frame_ids": [
|
||||
f"0x{item:03X}"
|
||||
for item in sorted(FIRMWARE_PROTOCOL_FRAME_IDS - observed_ids)
|
||||
],
|
||||
"firmware_revision": firmware_revision,
|
||||
"expected_firmware_revision": EXPECTED_FIRMWARE_REVISION,
|
||||
"observed_motor_status_ids": sorted(motor_status_ids),
|
||||
"missing_motor_status_ids": sorted(expected_motors - motor_status_ids),
|
||||
"observed_motor_diagnostic_ids": sorted(motor_diagnostic),
|
||||
"missing_motor_diagnostic_ids": sorted(
|
||||
expected_motors - set(motor_diagnostic)
|
||||
),
|
||||
"observed_motor_current_ids": sorted(motor_current_ids),
|
||||
"missing_motor_current_ids": sorted(expected_motors - motor_current_ids),
|
||||
"max_hall_counts": max_hall_counts,
|
||||
"expected_max_hall_counts": list(EXPECTED_MOTOR_MAX_HALL),
|
||||
"mismatched_max_hall_ids": mismatched_max_hall,
|
||||
}
|
||||
|
||||
def _probe_failure_detail(self) -> str:
|
||||
diagnostics = self._last_probe_diagnostics
|
||||
if not diagnostics:
|
||||
return "no qualifying Seat_Controller_demo protocol frames were observed"
|
||||
|
||||
def joined(name: str) -> str:
|
||||
values = diagnostics.get(name) or []
|
||||
return ",".join(str(item) for item in values) or "none"
|
||||
|
||||
revision = diagnostics.get("firmware_revision")
|
||||
revision_text = "unknown" if revision is None else str(revision)
|
||||
max_hall = diagnostics.get("max_hall_counts") or []
|
||||
max_hall_text = ",".join(
|
||||
"?" if value is None else str(value) for value in max_hall
|
||||
)
|
||||
return (
|
||||
f"observed_frame_ids={joined('observed_frame_ids')}; "
|
||||
f"missing_frame_ids={joined('missing_frame_ids')}; "
|
||||
f"firmware_revision={revision_text} "
|
||||
f"(expected {EXPECTED_FIRMWARE_REVISION}); "
|
||||
f"missing_motor_status_ids={joined('missing_motor_status_ids')}; "
|
||||
"missing_motor_diagnostic_ids="
|
||||
f"{joined('missing_motor_diagnostic_ids')}; "
|
||||
f"missing_motor_current_ids={joined('missing_motor_current_ids')}; "
|
||||
f"max_hall_counts={max_hall_text}; "
|
||||
f"mismatched_max_hall_ids={joined('mismatched_max_hall_ids')}"
|
||||
)
|
||||
|
||||
def _probe(self, bus: CanBus) -> dict[str, Any] | None:
|
||||
"""Observe the cyclic 0x301..0x305/0x4FA contract without transmitting.
|
||||
"""Observe the cyclic 0x301..0x306/0x4FA contract without transmitting.
|
||||
|
||||
The 2026-07-30 firmware uses 0x304 and 0x305 for current diagnostics,
|
||||
so the previous request/response identity handshake would collide with
|
||||
@@ -185,12 +257,27 @@ class T2GFirmwareGuard:
|
||||
motor_diagnostic: dict[int, int] = {}
|
||||
motor_current_ids: set[int] = set()
|
||||
firmware_revision: int | None = None
|
||||
self._last_probe_diagnostics = {}
|
||||
while True:
|
||||
remaining_ms = round((deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
self._record_probe_diagnostics(
|
||||
observed_ids=observed_ids,
|
||||
motor_status_ids=motor_status_ids,
|
||||
motor_diagnostic=motor_diagnostic,
|
||||
motor_current_ids=motor_current_ids,
|
||||
firmware_revision=firmware_revision,
|
||||
)
|
||||
return None
|
||||
frame = bus.receive(remaining_ms)
|
||||
if frame is None:
|
||||
self._record_probe_diagnostics(
|
||||
observed_ids=observed_ids,
|
||||
motor_status_ids=motor_status_ids,
|
||||
motor_diagnostic=motor_diagnostic,
|
||||
motor_current_ids=motor_current_ids,
|
||||
firmware_revision=firmware_revision,
|
||||
)
|
||||
return None
|
||||
arbitration_id = int(frame.arbitration_id)
|
||||
if arbitration_id not in FIRMWARE_PROTOCOL_FRAME_IDS or len(frame.data) < 8:
|
||||
@@ -206,6 +293,14 @@ class T2GFirmwareGuard:
|
||||
elif arbitration_id == 0x304 and data[0] < 4:
|
||||
motor_current_ids.add(data[0])
|
||||
|
||||
self._record_probe_diagnostics(
|
||||
observed_ids=observed_ids,
|
||||
motor_status_ids=motor_status_ids,
|
||||
motor_diagnostic=motor_diagnostic,
|
||||
motor_current_ids=motor_current_ids,
|
||||
firmware_revision=firmware_revision,
|
||||
)
|
||||
|
||||
if (
|
||||
observed_ids == FIRMWARE_PROTOCOL_FRAME_IDS
|
||||
and firmware_revision == EXPECTED_FIRMWARE_REVISION
|
||||
@@ -254,6 +349,7 @@ class T2GFirmwareGuard:
|
||||
) from probe_error
|
||||
raise FirmwareProgrammingError(
|
||||
"matching Seat_Controller_demo passive protocol signature was not detected; "
|
||||
f"{self._probe_failure_detail()}; "
|
||||
"automatic programming is disabled"
|
||||
)
|
||||
if self.programmer is None:
|
||||
@@ -268,7 +364,8 @@ class T2GFirmwareGuard:
|
||||
verified = self._probe(verified_bus)
|
||||
if verified is None:
|
||||
raise FirmwareProgrammingError(
|
||||
"programming completed but passive firmware protocol verification failed"
|
||||
"programming completed but passive firmware protocol verification failed; "
|
||||
+ self._probe_failure_detail()
|
||||
)
|
||||
except Exception as exc:
|
||||
verified_bus.close()
|
||||
|
||||
Reference in New Issue
Block a user