feat: integrate cabin CAN and verified dynamic seat modes

This commit is contained in:
vee
2026-08-04 17:51:38 +08:00
parent 155fbb59ba
commit dc7c91800e
99 changed files with 51897 additions and 3618 deletions
@@ -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"])
+207 -2
View File
@@ -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
+438 -48
View File
@@ -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,
+181 -39
View File
@@ -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()