优化前端,优化前端指令,优化自定义控制

This commit is contained in:
minghanchen
2026-08-24 09:40:16 +08:00
parent fff7657fea
commit 8d05815773
130 changed files with 5543 additions and 2206 deletions
@@ -89,6 +89,10 @@ def _new_state(
"absolute_position_available": target == "passenger",
"initialized": {},
},
"memory_recall": {
"slot": None,
"state": "idle",
},
}
for target in TARGETS
},
@@ -145,6 +149,15 @@ def _new_state(
},
"outlet": {"targets": {}},
"interior_light": {"power": "off"},
"front_light": {
"position_light": {"state": "unknown"},
"low_beam": {"state": "unknown"},
"high_beam": {"state": "unknown"},
"fog_light": {"state": "unknown"},
"brake_light": {"state": "unknown"},
"reverse_light": {"state": "unknown"},
"all_weather_light": {"state": "unknown"},
},
},
"presets": list(presets or []),
"recent_turns": [],
@@ -278,17 +291,21 @@ class ConversationStateStore:
conversation_id: str,
feedback_states: list[dict[str, Any]],
) -> dict[str, Any]:
"""Merge decoded, authoritative CH1 feedback into durable UI state."""
"""Merge decoded, authoritative wired or wireless CH1 feedback."""
with self._lock:
state = self._state(conversation_id)
synchronized = False
synchronized_sources: set[str] = set()
for feedback in feedback_states:
if not isinstance(feedback, dict):
continue
verification = feedback.get("verification")
feedback_source = str(feedback.get("source", ""))
wireless_mqtt = feedback_source == "wireless_mqtt_ch1"
if (
feedback.get("source") != "physical_can_ch1"
feedback_source
not in {"physical_can_ch1", "wireless_mqtt_ch1"}
or feedback.get("channel") != 1
or feedback.get("valid") is not True
or not isinstance(verification, dict)
@@ -306,6 +323,10 @@ class ConversationStateStore:
or verification.get("frame_id") != feedback.get("frame_id")
or verification.get("frame_id")
!= feedback.get("feedback_frame_id")
or (
wireless_mqtt
and verification.get("transport") != "wireless_mqtt"
)
):
continue
@@ -319,14 +340,15 @@ class ConversationStateStore:
for feature, feature_state in target_patch.items():
stored = state["devices"][target_name].get(feature)
if isinstance(feature_state, dict) and isinstance(stored, dict):
stored["feedback_source"] = "physical_can_ch1"
stored["feedback_source"] = feedback_source
synchronized = True
synchronized_sources.add(feedback_source)
vehicle_safety = feedback.get("vehicle_safety")
if isinstance(vehicle_safety, dict) and vehicle_safety:
vehicle_status = state["domain_devices"]["vehicle_status"]
_merge_mapping(vehicle_status, vehicle_safety)
vehicle_status["feedback_source"] = "physical_can_ch1"
vehicle_status["feedback_source"] = feedback_source
speed_kmh = vehicle_status.get("speed_kmh")
gear = vehicle_status.get("gear")
vehicle_status["stationary"] = (
@@ -337,6 +359,7 @@ class ConversationStateStore:
else None
)
synchronized = True
synchronized_sources.add(feedback_source)
domain_state = feedback.get("domain_state")
if not isinstance(domain_state, dict):
@@ -348,6 +371,7 @@ class ConversationStateStore:
"door",
"window",
"interior_light",
"front_light",
):
domain_patch = domain_state.get(domain)
target_domain = state["domain_devices"].get(domain)
@@ -358,27 +382,38 @@ class ConversationStateStore:
):
continue
_merge_mapping(target_domain, domain_patch)
target_domain["feedback_source"] = "physical_can_ch1"
target_domain["feedback_source"] = feedback_source
if domain == "ambient_light":
level = domain_patch.get("front_brightness_level")
if isinstance(level, int) and not isinstance(level, bool):
target_domain["brightness_pct"] = max(
0, min(100, round(level * 100 / 3))
0, min(100, round(level * 100 / 5))
)
if domain == "door":
for name in domain_patch.get("locks", {}):
lock = target_domain.get("locks", {}).get(name)
if isinstance(lock, dict):
lock["feedback_source"] = "physical_can_ch1"
lock["feedback_source"] = feedback_source
if domain == "window":
for name in domain_patch.get("positions", {}):
position = target_domain.get("positions", {}).get(name)
if isinstance(position, dict):
position["feedback_source"] = "physical_can_ch1"
position_patch = domain_patch["positions"].get(name)
if isinstance(position, dict) and isinstance(position_patch, dict):
# Remove virtual absolute-position defaults when
# a verified window patch carries only the
# matrix closed/not-closed flag and no percent.
if "position_pct" not in position_patch:
position.pop("position_pct", None)
position["feedback_source"] = feedback_source
synchronized = True
synchronized_sources.add(feedback_source)
if synchronized:
state["physical_ch1_state_updated_at"] = _now()
state["ch1_state_updated_at"] = _now()
if "physical_can_ch1" in synchronized_sources:
state["physical_ch1_state_updated_at"] = _now()
if "wireless_mqtt_ch1" in synchronized_sources:
state["wireless_mqtt_ch1_state_updated_at"] = _now()
state["updated_at"] = _now()
return deepcopy(state)
@@ -484,7 +519,10 @@ class ConversationStateStore:
parameters = action["parameters"]
feedback_source = str(action.get("feedback_source", "virtual"))
if domain == "seat" and capability.startswith("seat.set_driver_"):
if domain == "seat" and (
capability.startswith("seat.set_driver_")
or capability == "seat.recall_driver_memory_native"
):
driver = state["devices"]["driver"]
if capability == "seat.set_driver_ventilation_native":
value = str(parameters["state"])
@@ -514,6 +552,16 @@ class ConversationStateStore:
"motion_state": "stopped",
"feedback_source": feedback_source,
}
elif capability in {
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}:
driver["memory_recall"] = {
"slot": int(parameters["slot"]),
"state": "requested",
"absolute_position_confirmed": False,
"feedback_source": feedback_source,
}
return
device = state["domain_devices"][domain]
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
@@ -13,6 +14,7 @@ import yaml
_WHITESPACE = re.compile(r"\s+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
_MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _normalize_match_text(value: str) -> str:
@@ -28,6 +30,37 @@ def _clean_name(value: str) -> str:
return cleaned
def _clean_mode_id(value: str) -> str:
cleaned = str(value).strip()
if not _MODE_ID.fullmatch(cleaned):
raise ValueError(
"mode id must start with a lowercase letter or number and contain "
"only lowercase letters, numbers, underscores, or hyphens"
)
return cleaned
def _clean_instruction(value: str) -> str:
cleaned = str(value).strip()
if len(cleaned) > 1000:
raise ValueError("mode instruction must be at most 1000 characters")
return cleaned
def _clean_command_flow(value: Any) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("mode command_flow must be an object")
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True)
except (TypeError, ValueError) as exc:
raise ValueError("mode command_flow must be JSON serializable") from exc
if len(encoded.encode("utf-8")) > 1024 * 1024:
raise ValueError("mode command_flow must be at most 1 MiB")
return json.loads(encoded)
def _mode_aliases(name: str, aliases: list[str] | None = None) -> list[str]:
values = [name, *(aliases or [])]
if name.endswith("模式") and len(name) > 2:
@@ -304,6 +337,8 @@ class ModeProfile:
vehicle_ids: tuple[str, ...] = ()
timeline: ModeTimeline | None = None
user_defined: bool = False
instruction: str = ""
command_flow: dict[str, Any] | None = None
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -338,6 +373,8 @@ class ModeProfile:
else None
),
user_defined=_is_user_profile_path(path, profile_root),
instruction=_clean_instruction(document.get("instruction", "")),
command_flow=_clean_command_flow(document.get("command_flow")),
)
def public_dict(self) -> dict[str, Any]:
@@ -356,6 +393,8 @@ class ModeProfile:
"behavior": "cyclic" if self.timeline is not None else "static",
"origin": "user" if self.user_defined else "preset",
"deletable": self.user_defined,
"instruction": self.instruction,
"command_flow": self.command_flow,
}
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
@@ -397,10 +436,19 @@ class ModeProfileRegistry:
def public_modes(self, vehicle_id: str | None = None) -> list[dict[str, Any]]:
profiles = self._profiles_for_vehicle(vehicle_id)
profiles.sort(key=lambda profile: 0 if profile.user_defined else 1)
return [
profile.public_dict()
for profile in profiles
]
preset_ids = {
profile.id
for profile in self._visible_candidates_for_vehicle(vehicle_id)
if not profile.user_defined
}
public = []
for profile in profiles:
item = profile.public_dict()
if profile.user_defined and profile.id in preset_ids:
item["origin"] = "customized"
item["preset_override"] = True
public.append(item)
return public
def match_text(
self,
@@ -442,6 +490,12 @@ class ModeProfileRegistry:
mode_id: str | None = None,
aliases: list[str] | None = None,
vehicle_id: str | None = None,
*,
description: str | None = None,
icon: str = "bookmark",
instruction: str = "",
command_flow: dict[str, Any] | None = None,
timeline: ModeTimeline | None = None,
) -> ModeProfile:
clean_name = _clean_name(name)
canonical_operations = [
@@ -452,7 +506,9 @@ class ModeProfileRegistry:
]
if not canonical_operations:
raise ValueError("mode profile must contain at least one operation")
profile_id = mode_id or _slugify_mode_id(clean_name)
profile_id = _clean_mode_id(mode_id or _slugify_mode_id(clean_name))
clean_instruction = _clean_instruction(instruction)
clean_command_flow = _clean_command_flow(command_flow)
scope = (
{"vehicle_ids": [vehicle_id]}
if vehicle_id
@@ -462,13 +518,19 @@ class ModeProfileRegistry:
"id": profile_id,
"name": clean_name,
"aliases": _mode_aliases(clean_name, aliases),
"description": "用户固化的长期模式配置。",
"icon": "bookmark",
"description": str(description or "用户固化的长期模式配置。")[:500],
"icon": str(icon or "bookmark")[:64],
"scope": scope,
"operations": [
operation.to_yaml() for operation in canonical_operations
],
}
if clean_instruction:
document["instruction"] = clean_instruction
if clean_command_flow is not None:
document["command_flow"] = clean_command_flow
if timeline is not None:
document["timeline"] = timeline.to_yaml()
path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
@@ -653,6 +715,10 @@ def _mode_profile_document(
}
if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml()
if profile.instruction:
document["instruction"] = profile.instruction
if profile.command_flow is not None:
document["command_flow"] = profile.command_flow
return document
@@ -23,6 +23,9 @@ OTA_SEAT_AXIS_ALL = 0xFF
OTA_RESPONSE_TIMEOUT_MS = 1_500
OTA_HEARTBEAT_INTERVAL_MS = 500
OTA_DYNAMIC_DURATION_SECONDS = 300
# The seat domain's semantic mode IDs predate the seat-ota firmware enum.
# Keep the public meaning stable while sending the firmware selector exactly.
OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE = {1: 2, 2: 1}
OTA_STATUS_NAMES = {
0x00: "OK",
0x01: "BAD_MAGIC",
@@ -68,6 +71,28 @@ class MqttOtaSeatExecutor:
def read_physical_state(self) -> dict[str, Any]:
return self.status_executor.read_physical_state()
@staticmethod
def _operation_debug_command(operation: dict[str, Any]) -> str:
capability = str(operation["capability"])
parameters = operation.get("parameters", {})
axes = {
"seat.set_longitudinal_position": "longitudinal",
"seat.set_cushion_tilt": "height",
"seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest",
}
if capability in axes:
return (
f"seat passenger {axes[capability]} position "
f"{parameters['position_pct']}"
)
if capability == "seat.start_dynamic_mode":
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
return f"seat passenger dynamic start {mode}"
if capability == "seat.stop_dynamic_mode":
return "seat passenger dynamic stop"
return capability
@staticmethod
def _axis_state(frame: CanFrame) -> dict[str, Any] | None:
if frame.arbitration_id != 0x302 or len(frame.data) != 8:
@@ -290,7 +315,11 @@ class MqttOtaSeatExecutor:
stopping = operation["capability"] == "seat.stop_dynamic_mode"
mode_id = 0 if stopping else int(operation["parameters"]["mode_id"])
opcode = OTA_OPCODE_SEAT_STOP if stopping else OTA_OPCODE_SEAT_DYNAMIC_START
selector = OTA_SEAT_AXIS_ALL if stopping else mode_id
selector = (
OTA_SEAT_AXIS_ALL
if stopping
else OTA_DYNAMIC_SELECTOR_BY_SEMANTIC_MODE[mode_id]
)
duration = 0 if stopping else OTA_DYNAMIC_DURATION_SECONDS
command_frame, response_frame, job_state = self._send_control(
bus,
@@ -320,6 +349,7 @@ class MqttOtaSeatExecutor:
),
"telemetry": {
"dynamic_mode_id": mode_id,
"ota_dynamic_selector": None if stopping else selector,
"dynamic_mode_name": (
"" if stopping else "木马模式" if mode_id == 1 else "动态摇篮模式"
),
@@ -363,6 +393,17 @@ class MqttOtaSeatExecutor:
operations[item]
for item in command["operation_ref"].split(",")
]
publication_start = len(
getattr(bus, "published_messages", ())
)
set_debug_command = getattr(bus, "set_debug_command", None)
if callable(set_debug_command):
set_debug_command(
"; ".join(
self._operation_debug_command(item)
for item in referenced
)
)
if command["message"] != "T2G_Seat_Move_Command_300":
raise HardwareExecutionError(
"seat-ota firmware does not provide wireless thermal control; "
@@ -371,14 +412,27 @@ class MqttOtaSeatExecutor:
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
results.extend(
self._execute_dynamic(bus, command, referenced)
command_results = self._execute_dynamic(
bus, command, referenced
)
motion_armed = False
else:
motion_armed = True
results.extend(self._execute_move(bus, command, referenced))
command_results = self._execute_move(
bus, command, referenced
)
motion_armed = False
mqtt_messages = list(
getattr(bus, "published_messages", ())[
publication_start:
]
)
if mqtt_messages:
for item in command_results:
item.setdefault("telemetry", {})[
"mqtt_messages"
] = mqtt_messages
results.extend(command_results)
except Exception as exc: # noqa: BLE001 - normalize protocol failures
if bus is not None and motion_armed:
self._best_effort_stop(bus)
@@ -183,6 +183,10 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
names = ("front_left", "rear_left", "front_right", "rear_right")
positions = {
name: {
# The filled matrix defines the four closed flags in byte 7.
# Vehicle traces in project-code/canfd/window_native.txt and
# the head-unit envelope both independently show bytes 3..6
# tracking the four 0..100 opening percentages.
"position_pct": int(data[2 + index]) if data[2 + index] <= 100 else None,
"closed": {1: True, 2: False}.get(_bits(data, 48 + index * 2, 2)),
}
@@ -211,6 +215,7 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
"climate": {
"power": power,
"ac": power,
"front_defrost": _state(_bits(data, 14, 2), on=2, off=1),
"control_mode": control,
"cycle_mode": cycle,
"fan_level": _bits(data, 20, 4),
@@ -223,7 +228,12 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
return decoded
if frame.arbitration_id == 0x4FA and len(data) >= 8:
thermal = {1: ("off", 0), 2: ("on", 1), 3: ("on", 3)}
thermal = {
1: ("off", 0),
2: ("on", 1),
3: ("on", 2),
4: ("on", 3),
}
vent_state, vent_level = thermal.get(_bits(data, 0, 3), ("unknown", 0))
heat_state, heat_level = thermal.get(_bits(data, 3, 3), ("unknown", 0))
massage_mode_raw = _bits(data, 40, 4)
@@ -241,6 +251,8 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
5: "slack",
6: "relax",
}.get(massage_mode_raw, "unknown"),
# Strength is retained from the repository's verified HIL
# traces; the filled matrix only lists the adjacent mode.
"strength": {8: "off", 9: "weak", 10: "medium", 11: "strong"}.get(
massage_strength_raw, "unknown"
),
@@ -249,6 +261,24 @@ def decode_feedback(frame: CanFrame) -> dict[str, Any]:
}
return decoded
if frame.arbitration_id == 0x38A and len(data) >= 8:
decoded["domain_state"] = {
"front_light": {
"position_light": {"state": _state(_bits(data, 0, 2))},
"low_beam": {"state": _state(_bits(data, 2, 2))},
"high_beam": {"state": _state(_bits(data, 4, 2))},
"fog_light": {"state": _state(_bits(data, 8, 2))},
"brake_light": {
"state": {1: "on", 2: "flashing", 3: "off"}.get(
_bits(data, 10, 2), "unknown"
)
},
"reverse_light": {"state": _state(_bits(data, 12, 2))},
"all_weather_light": {"state": _state(_bits(data, 14, 2))},
}
}
return decoded
if frame.arbitration_id == 0x494 and len(data) >= 8:
decoded["actuator_state"] = {
"driver": {
@@ -375,6 +405,8 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None:
return climate["power"] == parts[1]
if family == "climate_control":
return climate["control_mode"] == {"1": "manual", "2": "auto"}.get(parts[1])
if family == "climate_cycle":
return climate["cycle_mode"] == parts[1]
if family == "climate_mode":
expected_mode = {
"1": "face",
@@ -534,6 +566,11 @@ def feedback_matches(expected: str, frame: CanFrame | None) -> bool | None:
)
return initialized.get(parts[1]) is True
if family == "driver_seat_memory_status":
# A post-command 0x494 proves only that the seat ECU is still reporting
# initialized axes; the frame has no absolute target-position signal.
return None
return None
@@ -70,6 +70,8 @@ CH1_DEFAULT_TARGETS = {
"seat.set_driver_heating_native": "driver",
"seat.set_driver_massage_native": "driver",
"seat.set_driver_axis_native": "driver",
"seat.set_driver_position_native": "driver",
"seat.recall_driver_memory_native": "driver",
"roof.set_shade_motion": "vehicle",
"roof.set_shade_position": "vehicle",
"interior_light.set_indoor_power": "vehicle",
@@ -159,6 +161,9 @@ def _action(
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"door vehicle (lock|unlock)", command)
if match:
return [_action("door", "door.set_vehicle_lock", "vehicle", {"state": match[1]}, "用户输入了受信任的整车门锁命令。")]
@@ -295,6 +300,29 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]:
"用户输入了受信任的 CH1 主驾座椅限时调节命令。",
)]
match = re.fullmatch(r"seat (driver|front-left) position ([1-3])", command)
if match:
target = "front_left" if match[1] == "front-left" else "driver"
return [_action(
"seat",
"seat.set_driver_position_native",
target,
{"slot": int(match[2])},
"用户输入了受信任的 CH1 主驾座椅记忆位置命令。",
)]
match = re.fullmatch(
r"seat (driver|front-left) memory recall ([1-3])", command
)
if match:
target = "front_left" if match[1] == "front-left" else "driver"
return [_action(
"seat",
"seat.recall_driver_memory_native",
target,
{"slot": int(match[2])},
"用户输入了受信任的 CH1 主驾座椅记忆唤醒命令。",
)]
match = re.fullmatch(r"light indoor (on|off)", command)
if match:
return [_action("interior_light", "interior_light.set_indoor_power", "vehicle", {"state": match[1]}, "用户输入了受信任的室内阅读灯总开关命令。")]
@@ -352,6 +380,24 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
default_reason = "用户未指定座椅目标,按驾驶台 CH1 安全默认选择主驾座椅。"
actions: list[dict[str, Any]] = []
memory = re.search(
r"(?:记忆(?:位置|位)?|记忆座椅(?:位置)?|座椅记忆(?:位置)?|位置记忆)"
r"(?:切换到|切换为|调整到|调到|唤醒|调用|恢复到|恢复为|设为|设置为)?"
r"([1-3])(?:号|档|位)?",
compact,
)
if memory is not None:
actions.append(_action(
"seat",
"seat.recall_driver_memory_native",
"driver",
{"slot": int(memory[1])},
(
"用户明确指定了 CH1 主驾座椅记忆位置。"
if explicit_driver
else default_reason
),
))
thermal_features = (
("通风", "seat.set_driver_ventilation_native"),
("加热", "seat.set_driver_heating_native"),
@@ -505,7 +551,11 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(compact)
if "空调" in compact or re.search(r"(?:AC|A/C)", text, re.IGNORECASE):
if (
"空调" in compact
or re.search(r"(?:AC|A/C)", text, re.IGNORECASE)
or re.search(r"温度|风量|风速|内循环|外循环|循环模式", compact)
):
if re.search(r"(?:打开|开启|启动)空调", compact):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
elif re.search(r"(?:关闭|关掉|关上)空调", compact):
@@ -533,6 +583,11 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
elif re.search(r"手动(?:控制|模式)", compact):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。"))
if re.search(r"内循环", compact):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "internal"}, "用户指定空调内循环。"))
elif re.search(r"外循环", compact):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
outlet_modes = [
("吹面吹脚除霜", "face_feet_defrost"),
("吹面除霜", "face_defrost"),
@@ -619,9 +674,26 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
for target in targets
)
vehicle_lock_request = bool(
rear_lock_request = bool(
re.search(
r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", compact
)
)
if rear_lock_request:
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}, "用户指定了后门门锁状态。"))
vehicle_lock_request = not rear_lock_request and bool(
re.search(
r"整车(?:门锁|车门)|全车(?:门锁|车门)|(?:所有|全部)(?:车门|门锁)|"
r"(?:车门|门锁)(?:上锁|落锁|锁上|解锁|开锁)|"
r"(?:锁车|整车解锁|全车解锁|解锁车辆)",
compact,
)
@@ -859,7 +931,11 @@ def merge_native_can_intent(
item for item in parsed.get(key, []) if not is_claimed(item)
]
clauses = [part for part in re.split(r"[,,。;;!!??]+", utterance) if part.strip()]
clauses = [
part
for part in re.split(r"[,,。;;::!!??]+", utterance)
if part.strip()
]
exact_clause_actions = [
parse_exact_native_can_command(clause) for clause in clauses
]
@@ -896,11 +972,14 @@ def merge_native_can_intent(
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"seat.set_driver_axis_native",
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}
]
implicit_driver_seat = _implicit_driver_seat_request(utterance)
has_default_driver_seat_action = any(
str(action.get("capability", "")).startswith("seat.set_driver_")
or action.get("capability") == "seat.recall_driver_memory_native"
for action in actions
)
if implicit_driver_seat:
@@ -914,9 +993,9 @@ def merge_native_can_intent(
result = dict(parsed)
if fully_exact:
# The trusted vocabulary covers every clause, so it is the authoritative
# execution plan. GPT is still called (and provider_used remains GPT),
# but model duplicates, contradictions, and unrelated hallucinations
# must never add physical operations to a byte-exact request.
# execution plan. Model duplicates, contradictions, and unrelated
# hallucinations must never add physical operations to a byte-exact
# request.
for key in (
"actions",
"domain_actions",
@@ -1027,7 +1106,7 @@ def merge_native_can_intent(
else {}
),
}
for item in unique_actions
for item in actions
],
]
result["model_requests"] = [
@@ -1049,7 +1128,7 @@ def merge_native_can_intent(
else {}
),
}
for item in unique_actions
for item in actions
],
]
constraints = list(result.get("semantic_constraints", []))
@@ -1091,6 +1170,8 @@ def merge_native_can_intent(
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"seat.set_driver_axis_native",
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}
for item in actions
)
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -61,11 +62,13 @@ DRIVER_SEAT_CAPABILITIES = frozenset(
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"seat.set_driver_axis_native",
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}
)
DRIVER_SEAT_TARGETS = frozenset({"driver", "front_left"})
UNSUPPORTED_CH1_DOOR_LOCK_CAPABILITIES = frozenset(
{"door.set_vehicle_lock", "door.set_rear_lock"}
{"door.set_vehicle_lock"}
)
@@ -76,18 +79,14 @@ def _unsupported_ch1_door_lock_message(capability: str) -> str:
"current CH1; the 0x496 smoke request caused no 0x407 vehicle-lock or "
"four-door transition, so CH1 door-lock support is observational only"
)
return (
"door.set_rear_lock is unsupported for physical execution on the current "
"CH1; 0x32B is second-row-only and is not a verified whole-vehicle lock "
"control, so CH1 door-lock support is observational only"
)
return f"{capability} is unsupported for physical execution on the current CH1"
class NativeCanRuntime:
"""Compile named capabilities into byte-exact CAN_DEV frames."""
compiler_version = "1.0.0"
mapping_version = "can-dev-verified-body-can-v4"
mapping_version = "can-dev-verified-body-can-v5"
def __init__(self, repository_root: Path):
self.root = repository_root
@@ -122,6 +121,8 @@ class NativeCanRuntime:
"climate.set_outlet_mode",
"climate.set_temperature",
"climate.set_control_mode",
"climate.set_cycle_mode",
"door.set_rear_lock",
"outlet.set_position_percent",
"outlet.set_calibrated_position",
*DRIVER_SEAT_CAPABILITIES,
@@ -185,7 +186,12 @@ class NativeCanRuntime:
"timeout_ms": (
20_000
if capability_name
in {"window.set_position", "roof.set_shade_position"}
in {
"window.set_position",
"roof.set_shade_position",
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}
else 4_000
),
"failure_policy": "abort",
@@ -232,6 +238,7 @@ class NativeCanRuntime:
f"0x{frame_id:03X}" for frame_id in spec.feedback_ids
),
"expected_feedback": spec.expected_feedback,
"debug_command": self.operation_debug_command(operation),
}
if spec.special_handler:
signals["special_handler"] = spec.special_handler
@@ -337,8 +344,103 @@ class NativeCanRuntime:
"stop_repeat_count": special["stop_repeat_count"],
"stop_period_ms": special["stop_period_ms"],
}
elif signals.get("special_handler") == "driver_seat_memory_recall":
special = json.loads(str(signals["special_parameters"]))
public["sequence"] = {
"frame_count": len(special["frame_sequence"]),
"period_ms": special["period_ms"],
"data_hex": list(special["frame_sequence"]),
"requires_fresh_initialized_0x494": True,
}
return public
@staticmethod
def operation_debug_command(operation: dict[str, Any]) -> str:
"""Return the trusted canonical command represented by one operation."""
capability = str(operation["capability"])
parameters = operation.get("parameters", {})
target = str(operation.get("target", {}).get("zone", "vehicle")).replace(
"_", "-"
)
if capability == "window.set_motion":
return f"window {target} {parameters['motion']}"
if capability == "window.set_position":
return f"window {target} position {parameters['position_pct']}"
if capability == "climate.set_power":
return f"ac {parameters['state']}"
if capability == "climate.set_ac":
return f"ac compressor {parameters['state']}"
if capability == "climate.set_fan_level":
return f"ac fan {parameters['level']}"
if capability == "climate.set_outlet_mode":
return f"ac mode {str(parameters['mode']).replace('_', '-')}"
if capability == "climate.set_temperature":
return f"ac temp {target} {parameters['temperature_c']}"
if capability == "climate.set_control_mode":
return f"ac control {parameters['mode']}"
if capability == "climate.set_cycle_mode":
return f"ac cycle {parameters['mode']}"
if capability == "door.set_rear_lock":
return f"door rear {parameters['state']}"
if capability == "outlet.set_position_percent":
return (
f"outlet {target} percent {parameters['horizontal_pct']} "
f"{parameters['vertical_pct']}"
)
if capability == "outlet.set_calibrated_position":
return (
f"outlet {target} position {parameters['horizontal_value']} "
f"{parameters['vertical_value']}"
)
if capability in {
"seat.set_driver_heating_native",
"seat.set_driver_ventilation_native",
}:
feature = (
"heat"
if capability == "seat.set_driver_heating_native"
else "vent"
)
setting = "off"
if parameters["state"] == "on":
setting = {1: "high", 2: "low"}[int(parameters.get("level", 2))]
return f"seat driver {feature} {setting}"
if capability == "seat.set_driver_massage_native":
action = str(parameters["action"])
if action == "off":
return "seat driver massage off"
if action == "strength":
strength = {1: "weak", 2: "medium", 3: "strong"}[
int(parameters["strength"])
]
return f"seat driver massage strength {strength}"
return f"seat driver massage mode {parameters['mode']}"
if capability == "seat.set_driver_axis_native":
return (
f"seat driver {parameters['axis']} {parameters['direction']} "
f"{parameters.get('duration_ms', 500)}"
)
if capability == "seat.set_driver_position_native":
return f"seat driver position {parameters['slot']}"
if capability == "seat.recall_driver_memory_native":
return f"seat driver memory recall {parameters['slot']}"
if capability == "interior_light.set_indoor_power":
return f"light indoor {parameters['state']}"
if capability == "interior_light.set_reading_power":
return f"light reading all {parameters['state']}"
if capability == "ambient_light.set_power":
return f"light ambient {parameters['state']}"
if capability == "ambient_light.set_color_index":
return f"light ambient color {parameters['color_index']}"
if capability == "ambient_light.set_brightness_level":
return f"light ambient brightness {parameters['brightness_level']}"
if capability == "roof.set_shade_motion":
return f"shade {parameters['motion']}"
if capability == "roof.set_shade_position":
return f"shade position {parameters['position_pct']}"
return capability
@staticmethod
def _report(
function_config: dict[str, Any],
@@ -380,6 +482,23 @@ class NativeCanRuntime:
target = operation["target"]["zone"]
parameters = operation["parameters"]
if capability == "door.set_rear_lock":
self._reject_unexpected_parameters(parameters, {"state"})
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,
1,
0,
(0x407,),
f"rear_lock:{parameters['state']}",
)
]
if capability in {"climate.set_power", "climate.set_ac"}:
state = self._enum(parameters, "state", {"on": 1, "off": 2})
payload = bytearray(8)
@@ -412,6 +531,21 @@ class NativeCanRuntime:
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_cycle_mode":
self._reject_unexpected_parameters(parameters, {"mode"})
mode = self._enum(parameters, "mode", {"internal": 1, "external": 2})
payload = bytearray(8)
_set_bits(payload, 19, 2, mode)
return [
self._classic(
0x1DE,
payload,
3,
100,
(0x404,),
f"climate_cycle:{parameters['mode']}",
)
]
if capability == "climate.set_temperature":
temperature = self._integer(parameters, "temperature_c", 18, 32)
starts = {
@@ -478,6 +612,12 @@ class NativeCanRuntime:
if capability == "seat.set_driver_axis_native":
self._require_driver_seat_target(target)
return [self._driver_seat_axis_frame(parameters)]
if capability in {
"seat.set_driver_position_native",
"seat.recall_driver_memory_native",
}:
self._require_driver_seat_target(target)
return [self._driver_seat_memory_frame(parameters)]
if capability in {"outlet.set_position_percent", "outlet.set_calibrated_position"}:
return [self._outlet_frame(capability, target, parameters)]
@@ -810,6 +950,78 @@ class NativeCanRuntime:
},
)
def _driver_seat_memory_frame(
self, parameters: dict[str, Any]
) -> NativeFrameSpec:
"""Compile one learned PAD memory slot without inventing payload bytes."""
self._reject_unexpected_parameters(parameters, {"slot"})
slot = self._integer(parameters, "slot", 1, 3)
configured = os.getenv(
"COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH", ""
).strip()
path = (
Path(configured).expanduser()
if configured
else Path("/home/orin/project511/project-code/canfd/driver_seat_memory.json")
)
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise NativeCanCompileError(
f"cannot load learned driver-seat memory file {path}: {exc}"
) from exc
if document.get("version") != 1 or not isinstance(
document.get("positions"), dict
):
raise NativeCanCompileError(
"driver-seat memory file must use version 1 with positions"
)
learned = document["positions"].get(str(slot))
if not isinstance(learned, dict):
raise NativeCanCompileError(
f"driver-seat memory slot {slot} has not been learned from PAD"
)
def learned_payload(name: str, sub_id: int) -> bytes:
raw = learned.get(name)
try:
payload = bytes.fromhex(str(raw))
except ValueError as exc:
raise NativeCanCompileError(
f"driver-seat memory slot {slot} has invalid {name}"
) from exc
if len(payload) != 8 or payload[0] != sub_id:
raise NativeCanCompileError(
f"driver-seat memory slot {slot} {name} must be an 8-byte "
f"0x{sub_id:02X} learned frame"
)
return payload
payload_15 = learned_payload("payload_15", 0x15)
payload_16 = learned_payload("payload_16", 0x16)
sequence = [
value.hex(" ").upper()
for _ in range(10)
for value in (payload_16, payload_15)
]
return NativeFrameSpec(
frame_id=0x4C1,
payload=payload_16,
is_fd=True,
bitrate_switch=True,
feedback_ids=(0x494,),
expected_feedback=f"driver_seat_memory_status:{slot}",
special_handler="driver_seat_memory_recall",
special_parameters={
"slot": slot,
"fresh_status_timeout_ms": 2_500,
"post_status_timeout_ms": 1_200,
"period_ms": 50,
"frame_sequence": sequence,
},
)
def _all_reading_light_frame(
self, parameters: dict[str, Any], feedback_family: str
) -> NativeFrameSpec:
@@ -876,9 +1088,11 @@ class NativeCanExecutor:
bus_factory: Callable[[], CanBus],
*,
feedback_timeout_ms: int = 1_200,
feedback_required: bool = True,
):
self.bus_factory = bus_factory
self.feedback_timeout_ms = feedback_timeout_ms
self.feedback_required = feedback_required
def execute(
self,
@@ -896,7 +1110,13 @@ class NativeCanExecutor:
try:
for command in command_plan["spec"]["commands"]:
operation = operations[command["operation_ref"]]
publication_start = len(
getattr(bus, "published_messages", ())
)
try:
set_debug_command = getattr(bus, "set_debug_command", None)
if callable(set_debug_command):
set_debug_command(command["signals"].get("debug_command", ""))
raw_feedback_ids = str(
command["signals"].get("feedback_ids", "")
).strip()
@@ -915,6 +1135,10 @@ class NativeCanExecutor:
sent_frames, feedback = self._execute_driver_seat_axis(
bus, command
)
elif special_handler == "driver_seat_memory_recall":
sent_frames, feedback = self._execute_driver_seat_memory(
bus, command
)
elif special_handler == "shade_preset":
sent_frames, feedback = self._execute_shade_preset(
bus, command
@@ -927,8 +1151,10 @@ class NativeCanExecutor:
sent_frames = self._send_command(bus, command)
feedback = self._wait_feedback(bus, command)
feedback_match = self._feedback_matches(command, feedback)
feedback_required = True
feedback_required = self.feedback_required
verified = feedback is not None and feedback_match is True
delivered = bool(sent_frames)
succeeded = verified or (delivered and not feedback_required)
physical_channel = (
feedback.channel
if feedback is not None
@@ -936,13 +1162,24 @@ class NativeCanExecutor:
if sent_frames
else None
)
mqtt_messages = list(
getattr(bus, "published_messages", ())[
publication_start:
]
)
results.append(
{
"ref": operation["id"],
"domain": function_config["spec"]["domain"],
"status": "succeeded" if verified else "failed",
"status": "succeeded" if succeeded else "failed",
"message": (
"CAN 盒已提交车端控制帧并观察到匹配反馈。"
"MQTT 白名单控制帧已发布,并收到匹配的车辆状态反馈。"
if not feedback_required and feedback_match is True
else "MQTT 白名单控制帧已发布;已收到状态帧,但目标值尚未确认。"
if not feedback_required and feedback is not None
else "MQTT 白名单控制帧已发布;车辆状态反馈尚未确认。"
if not feedback_required
else "CAN 盒已提交车端控制帧并观察到匹配反馈。"
if feedback_match is True
else "CAN 盒已提交车端控制帧,但反馈字段与目标值不一致。"
if feedback_match is False
@@ -960,8 +1197,24 @@ class NativeCanExecutor:
"feedback_match": feedback_match,
"feedback_state": self._feedback_state(feedback),
"feedback_required": feedback_required,
"delivery_status": (
"published"
if not feedback_required and delivered
else "submitted"
),
"physical_effect_confirmed": verified,
"control_gate": (
"compiled_action_and_frame_whitelist"
if not feedback_required
else "stationary_vehicle_and_feedback"
),
"channel": physical_channel,
"simulated": False,
**(
{"mqtt_messages": mqtt_messages}
if mqtt_messages
else {}
),
},
}
)
@@ -1062,6 +1315,62 @@ class NativeCanExecutor:
raise RuntimeError("; ".join(details))
return sent, self._wait_feedback(bus, command)
def _execute_driver_seat_memory(
self, bus: CanBus, command: dict[str, Any]
) -> tuple[list[CanFrame], CanFrame | None]:
"""Require fresh initialization, then replay the learned 16/15 sequence."""
signals = command["signals"]
special = json.loads(str(signals["special_parameters"]))
before = self._wait_for_id(
bus, 0x494, int(special["fresh_status_timeout_ms"])
)
initialized = (
decode_feedback(before)
.get("actuator_state", {})
.get("driver", {})
.get("position_feedback", {})
.get("initialized", {})
if before is not None
else {}
)
axes = ("horizontal", "backrest", "height", "basin")
if not all(initialized.get(axis) is True for axis in axes):
raise RuntimeError(
"no fresh 0x494 proving all four driver-seat axes initialized; "
"refusing memory recall"
)
frame_id = int(str(signals["frame_id"]), 0)
sequence = [
bytes.fromhex(str(value)) for value in special["frame_sequence"]
]
if (
len(sequence) != 20
or any(len(payload) != 8 for payload in sequence)
or any(
payload[0] != (0x16 if index % 2 == 0 else 0x15)
for index, payload in enumerate(sequence)
)
):
raise RuntimeError("compiled driver-seat memory sequence is invalid")
sent: list[CanFrame] = []
for index, payload in enumerate(sequence):
sent.append(
bus.send(
frame_id,
payload,
is_fd=True,
bitrate_switch=True,
)
)
if index + 1 < len(sequence):
time.sleep(int(special["period_ms"]) / 1_000)
feedback = self._wait_for_id(
bus, 0x494, int(special["post_status_timeout_ms"])
)
return sent, feedback
def _execute_shade_preset(
self, bus: CanBus, command: dict[str, Any]
) -> tuple[list[CanFrame], CanFrame | None]:
+604 -120
View File
@@ -6,7 +6,6 @@ import json
import os
import re
import sys
import tempfile
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
@@ -121,6 +120,7 @@ def _clean_vehicle_number(value: float | None) -> int | float | None:
CONVERSATION_ID_PATTERN = re.compile(r"^conv-[a-z0-9-]{6,80}$")
MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$")
SEAT_SCOPE_PATTERN = re.compile(r"\bseats?\b|座椅|坐椅", re.IGNORECASE)
DRIVER_SEAT_SCOPE_PATTERN = re.compile(
r"\bdriver\b|\bfront[-_ ]?left\b|\bleft[-_ ]?front\b|主驾|驾驶位|司机位|左前",
@@ -137,6 +137,18 @@ VEHICLE_CONTROL_TRANSPORTS = frozenset(
)
def _wireless_mqtt_publish_preflight() -> dict[str, Any]:
"""Describe the non-physical gate used before an approved MQTT publish."""
return {
"transport": WIRELESS_MQTT_TRANSPORT,
"status": "delegated",
"policy": "mqtt_whitelist_publish",
"control_gate": "capability_parameter_and_frame_whitelist",
"required_vehicle_frames": [],
}
def _has_mixed_driver_passenger_seat_scope(utterance: str) -> bool:
"""Detect one request that crosses the CH1-driver and CH0-passenger boundary."""
@@ -267,13 +279,19 @@ class SeatDemoService:
}
self.model_plan_adapter = ModelPlanAdapter(self.capability_context)
self.vehicle_profiles = VehicleProfileRegistry(repository_root / "config" / "vehicles")
self._mode_session_directory = tempfile.TemporaryDirectory(
prefix="cognitive-agent-mode-session-",
ignore_cleanup_errors=True,
configured_mode_root = os.getenv(
"COGNITIVE_AGENT_MODE_PROFILE_ROOT", ""
).strip()
self.mode_profile_user_root = (
Path(configured_mode_root).expanduser()
if configured_mode_root
else repository_root / "artifacts" / "mode-profiles"
)
if not self.mode_profile_user_root.is_absolute():
self.mode_profile_user_root = repository_root / self.mode_profile_user_root
self.mode_profiles = ModeProfileRegistry(
repository_root / "config" / "modes",
user_root=Path(self._mode_session_directory.name),
user_root=self.mode_profile_user_root,
)
self._artifact_root = repository_root / "artifacts" / "runs"
self.conversations = ConversationStateStore(
@@ -425,7 +443,8 @@ class SeatDemoService:
"vehicle": default_vehicle.public_dict(),
"vehicles": self.vehicle_profiles.public_profiles(),
"modes": self.mode_profiles.public_modes(default_vehicle.id),
"mode_profile_storage": "runtime_session_volatile",
"mode_profile_storage": "persistent_yaml",
"mode_profile_user_root": str(self.mode_profile_user_root),
"preset_storage": "conversation_durable",
"inferred_level_range": [1, 3],
"motion_position_range_pct": [0, 100],
@@ -476,6 +495,204 @@ class SeatDemoService:
"vehicle_id": resolved_vehicle_id,
}
def save_mode_profile_card(
self,
mode_id: str,
name: str,
instruction: str,
source_request_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Persist one edited mode from a server-generated CAN command flow."""
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
clean_mode_id = str(mode_id).strip()
clean_name = str(name).strip()
clean_instruction = str(instruction).strip()
clean_request_id = str(source_request_id).strip()
if not clean_name:
raise ValueError("模式名称不能为空。")
if len(clean_name) > 64:
raise ValueError("模式名称不能超过 64 个字符。")
if not clean_instruction:
raise ValueError("自然语言指令不能为空。")
if len(clean_instruction) > 1000:
raise ValueError("自然语言指令不能超过 1000 个字符。")
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id):
raise ValueError("CAN 指令流来源请求编号无效。")
visible_profiles = self.mode_profiles._profiles_for_vehicle( # noqa: SLF001
resolved_vehicle_id
)
profile = next(
(item for item in visible_profiles if item.id == clean_mode_id),
None,
)
if profile is None:
raise KeyError(clean_mode_id)
normalized_name = re.sub(r"\s+", "", clean_name).casefold()
if any(
item.id != profile.id
and re.sub(r"\s+", "", item.name).casefold() == normalized_name
for item in visible_profiles
):
raise ValueError("该模式名称已被其他模式使用。")
result_path = self.artifact_root / clean_request_id / "result.json"
if not result_path.is_file():
raise ValueError("未找到执行按钮生成的 CAN 指令流,请重新执行。")
try:
source_result = json.loads(result_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("CAN 指令流来源结果无法读取,请重新执行。") from exc
if not isinstance(source_result, dict) or source_result.get(
"request_id"
) != clean_request_id:
raise ValueError("CAN 指令流来源结果不完整,请重新执行。")
recorded_instruction = str(source_result.get("utterance", "")).strip()
if not recorded_instruction:
source_spans = {
str(item.get("source_span", "")).strip()
for item in (
(source_result.get("intent_ir") or {}).get("spec", {}).get(
"requests", []
)
)
if isinstance(item, dict) and str(item.get("source_span", "")).strip()
}
if len(source_spans) == 1:
recorded_instruction = next(iter(source_spans))
if recorded_instruction and recorded_instruction != clean_instruction:
raise ValueError("自然语言指令已修改,请重新点击执行生成 CAN 指令流。")
plans = source_result.get("command_plans") or {}
configs = source_result.get("function_configs") or {}
if not isinstance(plans, dict) or not isinstance(configs, dict):
raise ValueError("执行结果中没有可永久化的 CAN 指令流。")
executable_plan_keys = {
key
for key, plan in plans.items()
if isinstance(plan, dict)
and isinstance(plan.get("spec"), dict)
and isinstance(plan["spec"].get("commands"), list)
and plan["spec"]["commands"]
}
if not executable_plan_keys:
raise ValueError("请先点击执行并成功生成 CAN 控制指令流。")
operations: list[dict[str, Any]] = []
signatures: set[tuple[str, str, str, str]] = set()
for key in plans:
if key not in executable_plan_keys:
continue
config = configs.get(key)
if not isinstance(config, dict):
continue
spec = config.get("spec") or {}
domain = str(spec.get("domain", "")).strip()
for operation in spec.get("operations", []):
if not isinstance(operation, dict):
continue
capability = str(operation.get("capability", "")).strip()
target = str((operation.get("target") or {}).get("zone", "")).strip()
parameters = operation.get("parameters") or {}
if not domain or not capability or not target or not isinstance(
parameters, dict
):
continue
signature = (
domain,
capability,
target,
json.dumps(parameters, ensure_ascii=False, sort_keys=True),
)
if signature in signatures:
continue
signatures.add(signature)
operations.append(
{
"domain": domain,
"capability": capability,
"target_scope": target,
"operation": "set",
"parameters": deepcopy(parameters),
"reason": f"由模式编辑器请求 {clean_request_id} 固化。",
}
)
if not operations:
raise ValueError("CAN 指令流没有关联到可复用的模式操作。")
execution_steps = []
for item in (
(source_result.get("execution_report") or {}).get("spec", {}).get(
"results", []
)
):
if not isinstance(item, dict):
continue
telemetry = item.get("telemetry") or {}
command_evidence = {
key: deepcopy(telemetry[key])
for key in (
"command_frame",
"sent_frames",
"mqtt_messages",
"response_frame",
"requested_parameters",
)
if key in telemetry
}
if command_evidence:
execution_steps.append(
{
"ref": item.get("ref"),
"domain": item.get("domain"),
"status": item.get("status"),
**command_evidence,
}
)
command_flow = {
"schema": "cognitive-agent-mode-command-flow/v1",
"source_request_id": clean_request_id,
"generated_at": _now(),
"execution_mode": source_result.get("execution_mode"),
"control_transport": source_result.get("control_transport"),
"function_configs": {
key: deepcopy(configs[key])
for key in plans
if key in executable_plan_keys and key in configs
},
"plans": {key: deepcopy(plans[key]) for key in plans if key in executable_plan_keys},
"execution_steps": execution_steps,
}
saved = self.mode_profiles.save_mode(
clean_name,
operations,
mode_id=profile.id,
aliases=list(profile.aliases),
vehicle_id=resolved_vehicle_id,
description=profile.description,
icon=profile.icon,
instruction=clean_instruction,
command_flow=command_flow,
timeline=profile.timeline,
)
public_profile = next(
(
item
for item in self.mode_profiles.public_modes(resolved_vehicle_id)
if item["id"] == saved.id
),
saved.public_dict(),
)
return {
"status": "succeeded",
"mode_profile": public_profile,
"vehicle_id": resolved_vehicle_id,
"storage": "persistent_yaml",
"source_request_id": clean_request_id,
}
def synchronize_hardware_state(
self,
conversation_id: str,
@@ -999,6 +1216,7 @@ class SeatDemoService:
traces: list[dict[str, Any]] = []
result: dict[str, Any] = {
"request_id": request_id,
"utterance": utterance,
"conversation_id": conversation_id,
"conversation_turn": context["turn_count"],
"status": "processing",
@@ -1118,6 +1336,12 @@ class SeatDemoService:
),
)
if hardware_request and not control_plane_request:
if (
control_transport == WIRELESS_MQTT_TRANSPORT
and parsed.get("domain_actions")
and (parsed.get("actions") or parsed.get("native_can_actions"))
):
parsed = self._defer_nonwhitelisted_wireless_actions(parsed)
if parsed.get("domain_actions"):
unsupported = sorted(
{
@@ -1180,7 +1404,7 @@ class SeatDemoService:
if provider_notice:
parsed["decision_summary"].append(provider_notice)
result["provider_used"] = provider_used
if provider_used.startswith("openai:"):
if "openai:" in provider_used:
result["reasoning_effort_used"] = effective_reasoning_effort
result["decision_summary"] = parsed["decision_summary"]
result["deferred_requests"] = parsed.get("deferred_requests", [])
@@ -1598,21 +1822,7 @@ class SeatDemoService:
mqtt_selected = control_transport == WIRELESS_MQTT_TRANSPORT
vehicle_safety_preflight = None
if mqtt_selected:
try:
vehicle_safety_preflight = observe_vehicle_safety(
self._vehicle_mqtt_transport.bus(channel=1),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_mqtt_settings.feedback_timeout_ms),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
raise HardwareSafetyError(
"CAN1 vehicle safety preflight refused wireless passenger-seat execution: "
f"{exc}"
) from exc
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
hardware_executor = self._new_hardware_executor(
lambda: self._vehicle_mqtt_transport.bus(channel=0),
remote_transport=True,
@@ -3122,19 +3332,69 @@ class SeatDemoService:
)
]
for item in deferred_requests:
if item.get("capability") in {
"door.set_vehicle_lock",
"door.set_rear_lock",
}:
if item.get("capability") == "door.set_vehicle_lock":
item["execution_backend"] = "unsupported"
item["rejection_code"] = "CH1_DOOR_LOCK_CONTROL_UNSUPPORTED"
item["reason"] = (
"Current CH1 door-lock control is unsupported; lock feedback "
"is observational only and no verified physical command will be built."
"Current CH1 whole-vehicle door-lock control is unsupported; "
"lock feedback is observational only and no verified physical "
"command will be built."
)
result["deferred_requests"] = deferred_requests
return result
@staticmethod
def _defer_nonwhitelisted_wireless_actions(
parsed: dict[str, Any],
) -> dict[str, Any]:
"""Keep approved MQTT actions executable when a model adds virtual ones."""
result = deepcopy(parsed)
deferred_requests = list(result.get("deferred_requests", []))
existing = {
(
str(item.get("domain", "")),
str(item.get("capability", "")),
str(item.get("target", item.get("target_scope", "vehicle"))),
)
for item in deferred_requests
}
for action in result.get("domain_actions", []):
target = str(
action.get("target", action.get("target_scope", "vehicle"))
)
signature = (
str(action.get("domain", "unknown")),
str(action.get("capability", "unknown")),
target,
)
if signature in existing:
continue
parameters = dict(action.get("parameters", {}))
requested_value: Any = parameters
if len(parameters) == 1:
requested_value = next(iter(parameters.values()))
deferred_requests.append(
{
"domain": signature[0],
"capability": signature[1],
"target_scope": target,
"desired_change": str(action.get("operation", "set")),
"requested_value": requested_value,
"parameters": parameters,
"reason": (
"该模型动作不在无线 MQTT CAN 控制白名单中,已单独跳过;"
"同一句中的白名单动作继续发布。"
),
"execution_backend": "unsupported",
"rejection_code": "WIRELESS_MQTT_CONTROL_NOT_WHITELISTED",
}
)
existing.add(signature)
result["domain_actions"] = []
result["deferred_requests"] = deferred_requests
return result
def _process_orchestrated(
self,
utterance: str,
@@ -3415,7 +3675,12 @@ class SeatDemoService:
"safety_check",
"succeeded",
(
"所有动作已通过 CH1 车身 CAN 白名单和物理反馈门禁。"
"所有动作已通过无线 MQTT 能力、参数及 CAN 帧白名单门禁。"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
)
else "所有动作已通过 CH1 车身 CAN 白名单和物理反馈门禁。"
if hardware_request and native_config_keys and "seat" not in configs
else "副驾 T2G 台架动作已通过独立硬件安全门。"
if hardware_request
@@ -3526,7 +3791,14 @@ class SeatDemoService:
"labels": {
"simulation": "mixed" if hardware_request else "true",
"seat_feedback": (
"physical_can_ch1"
"wireless_mqtt_ch1"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
and native_config_keys
and "seat" not in configs
)
else "physical_can_ch1"
if hardware_request and native_config_keys and "seat" not in configs
else "physical_can"
if hardware_request
@@ -3570,7 +3842,14 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded and not report_issues else "failed",
(
"CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
"无线 MQTT 白名单控制帧已发布;车辆实际状态以状态话题回读为准。"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
and native_config_keys
and execution_succeeded
)
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
if hardware_request and native_config_keys and execution_succeeded
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
if hardware_request and execution_succeeded
@@ -3800,35 +4079,39 @@ class SeatDemoService:
seat_channel = int(self.hardware_settings.channel, 0)
seat_bus_factory = lambda: bridge.channel_bus(seat_channel)
# The bridge is open but no control frame has been submitted yet.
# Require two independent, fresh CH1 status frames before entering
# any actuator executor; CH0 traffic is ignored by the observer.
try:
vehicle_safety_preflight = observe_vehicle_safety(
native_bus_factory(),
timeout_ms=min(
5_000,
max(
1_000,
self.vehicle_mqtt_settings.feedback_timeout_ms
if mqtt_selected
else self.vehicle_can_settings.feedback_timeout_ms,
if mqtt_selected:
# The head unit is the physical CAN owner. Once an action has
# passed the capability/parameter compiler, MqttCanTransport
# applies the final channel, ID and frame-format allow-list at
# publish time. Missing 0x342/0x4EB state frames must not
# suppress an otherwise approved MQTT command.
vehicle_safety_preflight = _wireless_mqtt_publish_preflight()
else:
# The wired bridge is open but no control frame has been
# submitted yet. Require two independent, fresh CH1 status
# frames before entering any actuator executor; CH0 traffic is
# ignored by the observer.
try:
vehicle_safety_preflight = observe_vehicle_safety(
native_bus_factory(),
timeout_ms=min(
5_000,
max(1_000, self.vehicle_can_settings.feedback_timeout_ms),
),
),
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
if bridge is not None:
bridge.close()
raise HardwareSafetyError(
"CH1 vehicle safety preflight refused physical execution: "
f"{exc}"
) from exc
except Exception:
if bridge is not None:
bridge.close()
raise
required_channel=1,
)
require_stationary_vehicle(vehicle_safety_preflight)
except VehicleSafetyPreflightError as exc:
if bridge is not None:
bridge.close()
raise HardwareSafetyError(
"CH1 vehicle safety preflight refused physical execution: "
f"{exc}"
) from exc
except Exception:
if bridge is not None:
bridge.close()
raise
try:
def execute_native_configs() -> dict[str, dict[str, Any]]:
@@ -3846,6 +4129,7 @@ class SeatDemoService:
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.feedback_timeout_ms
),
feedback_required=not mqtt_selected,
)
native_reports[key] = executor.execute(
self.native_can_runtime,
@@ -3959,6 +4243,148 @@ class SeatDemoService:
},
}
@staticmethod
def _split_intent_clauses(utterance: str) -> list[str]:
fragments = [
item.strip()
for item in re.split(r"[,,。;;::!!??]+", utterance)
if item.strip()
]
clauses: list[str] = []
for fragment in fragments:
# An exclusion is a qualifier of the control immediately before it,
# not an independent command. Keeping both pieces together lets
# the deterministic window parser remove the excluded targets
# before the command list is finalized.
if clauses and re.search(r"除外|除去|不包括|不含", fragment):
clauses[-1] = f"{clauses[-1]},{fragment}"
else:
clauses.append(fragment)
return clauses or [utterance.strip()]
@staticmethod
def _local_clause_status(parsed: dict[str, Any], clause: str) -> str:
"""Classify one deterministic parse as complete, partial, or unparsed."""
controls = any(
parsed.get(key)
for key in (
"actions",
"domain_actions",
"native_can_actions",
"preset_actions",
"preset_events",
"semantic_constraints",
)
)
real_ambiguities = [
item
for item in parsed.get("ambiguities", [])
if str(item.get("field", "")) != "capability"
]
unresolved = bool(parsed.get("unresolved_spans"))
# Scene/fuzzy-goal language may contain valid atomic controls and still
# ask for additional orchestration. Keep the trusted local controls but
# give only that clause to the model for the uncovered meaning.
fuzzy_goal = bool(
re.search(
r"观影|影院|看电影|休息|睡眠|露营|播放|音乐|场景|"
r"舒适(?:点|一些)?|凉快(?:点|一些)?|暖和(?:点|一些)?",
clause,
re.IGNORECASE,
)
)
if controls and not real_ambiguities and not unresolved and not fuzzy_goal:
return "complete"
if controls:
return "partial"
return "unparsed"
@classmethod
def _merge_parsed_results(
cls, results: list[dict[str, Any]]
) -> dict[str, Any]:
"""Merge clause-level parses while retaining their source order."""
merged: dict[str, Any] = {
"actions": [],
"domain_actions": [],
"native_can_actions": [],
"preset_actions": [],
"preset_events": [],
"deferred_requests": [],
"model_requests": [],
"ambiguities": [],
"decision_summary": [],
"semantic_constraints": [],
"unresolved_spans": [],
"detected_domains": [],
"confidence": 0.0,
}
list_fields = tuple(
key
for key in merged
if key not in {"detected_domains", "confidence"}
)
for parsed in results:
for key in list_fields:
merged[key] = cls._merge_unique(
merged[key], list(parsed.get(key, []))
)
merged["detected_domains"] = cls._merge_domains(
merged["detected_domains"],
list(parsed.get("detected_domains", [])),
)
merged["confidence"] = max(
float(merged["confidence"]), float(parsed.get("confidence", 0))
)
return merged
@classmethod
def _merge_local_and_model_intent(
cls,
local: dict[str, Any],
model: dict[str, Any],
) -> dict[str, Any]:
"""Merge a fallback model plan without overriding local controls."""
merged = deepcopy(model)
for key in ("actions", "domain_actions", "native_can_actions"):
local_items = list(local.get(key, []))
local_signatures = {
cls._control_signature(item) for item in local_items
}
model_items = [
item
for item in model.get(key, [])
if cls._control_signature(item) not in local_signatures
]
merged[key] = cls._merge_unique(local_items, model_items)
for key in (
"preset_actions",
"preset_events",
"deferred_requests",
"model_requests",
"ambiguities",
"semantic_constraints",
"unresolved_spans",
):
merged[key] = cls._merge_unique(
list(local.get(key, [])), list(model.get(key, []))
)
merged["decision_summary"] = cls._merge_unique(
list(local.get("decision_summary", [])),
list(model.get("decision_summary", [])),
)
merged["detected_domains"] = cls._merge_domains(
list(local.get("detected_domains", [])),
list(model.get("detected_domains", [])),
)
merged["confidence"] = max(
float(local.get("confidence", 0)), float(model.get("confidence", 0))
)
return merged
def _interpret(
self,
utterance: str,
@@ -3969,69 +4395,127 @@ class SeatDemoService:
reasoning_effort: str | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
if provider == "local":
return parse_local_intent(utterance, context), "local_rules", None
if provider == "openai":
if not self.openai.available:
raise ValueError("GPT 模式需要设置 OPENAI_API_KEY")
try:
interpret_options = {}
if reasoning_effort is not None:
interpret_options["reasoning_effort"] = reasoning_effort
plan = self.openai.interpret(
utterance,
context,
self.capability_context,
**interpret_options,
clause_results: list[tuple[str, dict[str, Any], str]] = []
def parse_clause(clause: str) -> dict[str, Any]:
local = parse_local_intent(clause, context)
relative = (
self.native_can_relative_resolver.resolve(clause, context)
if self.native_can_relative_resolver.matches(clause)
else None
)
local = merge_native_can_intent(
local,
clause,
relative_actions=relative.actions if relative else None,
relative_ambiguities=relative.ambiguities if relative else None,
relative_claimed_controls=(
relative.claimed_controls if relative else None
),
)
return local
def has_seat_domain(parsed: dict[str, Any]) -> bool:
if parsed.get("actions") or "seat" in parsed.get("detected_domains", []):
return True
for key in (
"domain_actions",
"native_can_actions",
"deferred_requests",
"model_requests",
):
if any(item.get("domain") == "seat" for item in parsed.get(key, [])):
return True
return any(
str(item.get("field", "")).startswith("seat.")
for item in parsed.get("ambiguities", [])
)
inherited_seat_scope: str | None = None
for raw_clause in self._split_intent_clauses(utterance):
clause = raw_clause
local = parse_clause(clause)
clause_has_seat = has_seat_domain(local)
explicit_driver = bool(DRIVER_SEAT_SCOPE_PATTERN.search(raw_clause))
explicit_passenger = bool(PASSENGER_SEAT_SCOPE_PATTERN.search(raw_clause))
if clause_has_seat and explicit_driver != explicit_passenger:
inherited_seat_scope = "driver" if explicit_driver else "passenger"
elif clause_has_seat and inherited_seat_scope:
# Chinese compound controls commonly state the seat once and
# then list axes or thermal functions after commas. Carry the
# last explicit seat scope only into later seat clauses; this
# prevents a passenger request from silently falling through
# to the CH1 driver default while leaving climate/door/window
# clauses untouched.
scope_label = (
"主驾座椅"
if inherited_seat_scope == "driver"
else "副驾座椅"
)
return (
self.model_plan_adapter.adapt(
plan,
context,
vehicle_profile,
utterance=utterance,
),
f"openai:{self.openai.model}",
None,
)
except OpenAIIntentError:
return (
parse_local_intent(utterance, context),
"local_rules_fallback",
"GPT 调用失败,已降级到本地规则解析;CAN 编译和执行路径不变。",
)
if self.openai.available:
try:
interpret_options = {}
if reasoning_effort is not None:
interpret_options["reasoning_effort"] = reasoning_effort
plan = self.openai.interpret(
utterance,
context,
self.capability_context,
**interpret_options,
)
return (
self.model_plan_adapter.adapt(
plan,
context,
vehicle_profile,
utterance=utterance,
),
f"openai:{self.openai.model}",
None,
)
except OpenAIIntentError:
return (
parse_local_intent(utterance, context),
"local_rules_fallback",
"GPT 调用失败,已降级到本地规则解析;CAN 编译和执行路径不变。",
)
return (
parse_local_intent(utterance, context),
"local_rules",
"未配置 OPENAI_API_KEY,本次使用可重复的本地规则解析。",
clause = f"{scope_label},{raw_clause}"
local = parse_clause(clause)
clause_results.append(
(clause, local, self._local_clause_status(local, clause))
)
trusted_local = self._merge_parsed_results(
[parsed for _, parsed, status in clause_results if status != "unparsed"]
)
all_local = self._merge_parsed_results(
[parsed for _, parsed, _ in clause_results]
)
fallback_clauses = [
clause for clause, _, status in clause_results if status != "complete"
]
trusted_controls = any(
trusted_local.get(key)
for key in ("actions", "domain_actions", "native_can_actions")
)
safe_fallback = trusted_local if trusted_controls else all_local
if provider == "local" or not fallback_clauses:
return (
all_local if provider == "local" else trusted_local,
"local_rules",
None,
)
if not self.openai.available:
if provider == "openai":
raise ValueError("存在本地规则无法完整解析的子句;GPT 模式需要设置 OPENAI_API_KEY")
return (
safe_fallback,
"local_rules",
"未配置 OPENAI_API_KEY;已保留本地规则结果,未解析子句没有下发控制。",
)
fallback_utterance = ",".join(fallback_clauses)
try:
interpret_options = {}
if reasoning_effort is not None:
interpret_options["reasoning_effort"] = reasoning_effort
plan = self.openai.interpret(
fallback_utterance,
context,
self.capability_context,
**interpret_options,
)
model = self.model_plan_adapter.adapt(
plan,
context,
vehicle_profile,
utterance=fallback_utterance,
)
return (
self._merge_local_and_model_intent(trusted_local, model),
f"local_rules+openai:{self.openai.model}",
"本地规则先完成确定性解析;仅未覆盖或有歧义的子句交由 GPT 补充。",
)
except OpenAIIntentError:
return (
safe_fallback,
"local_rules_fallback",
"GPT 调用失败,已降级到本地规则解析(逐子句);已保留确定性结果,CAN 编译和执行路径不变。",
)
@staticmethod
def _context_for_vehicle(
@@ -18,7 +18,7 @@ REQUIRED_SEAT_IDS = frozenset({0x4FA, 0x494})
REQUIRED_BODY_IDS = frozenset({0x342, 0x4EB, 0x407})
REQUIRED_UI_IDS = REQUIRED_SEAT_IDS | REQUIRED_BODY_IDS
OBSERVABLE_IDS = REQUIRED_UI_IDS | frozenset(
{0x388, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E}
{0x388, 0x38A, 0x3F3, 0x3FF, 0x404, 0x41A, 0x42E}
)
PERSISTENT_MONITOR_POLL_INTERVAL_MS = 500
@@ -767,7 +767,7 @@ class VehicleCanSettings:
@dataclass(frozen=True)
class VehicleMqttSettings:
"""Wireless CAN0/CAN1 relay settings with the same physical approval gate."""
"""Wireless CAN0/CAN1 relay settings for the local head-unit broker."""
enabled: bool
approval_required: bool
@@ -789,7 +789,11 @@ class VehicleMqttSettings:
return cls(
enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True),
approval_required=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED", default=True
"COGNITIVE_AGENT_VEHICLE_MQTT_APPROVAL_TOKEN_REQUIRED",
default=_env_bool(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED",
default=False,
),
),
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN", ""),
host=os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_HOST", "127.0.0.1").strip(),
@@ -800,7 +804,7 @@ class VehicleMqttSettings:
).strip(),
control_topic=os.getenv(
"COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC",
"n7/can_control",
"n7/n7-headunit/can/command",
).strip(),
qos=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_QOS", 1),
keepalive_s=_env_int("COGNITIVE_AGENT_VEHICLE_MQTT_KEEPALIVE_S", 30),
@@ -871,6 +875,8 @@ class VehicleMqttSettings:
"channels": {"seat": 0, "vehicle": 1},
"target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle",
"frame_contract": "n7.can.frame.v1",
"state_contract": "n7-can-signals/v1",
"control_contract": "n7-headunit-can-command/v1",
"can0_seat_protocol": "seat-ota-runtime",
"can0_state_contract": "0x6A0-SEAT_INFO-plus-0x6A2-to-0x302",
"can0_control_id": "0x6A0",
@@ -27,7 +27,6 @@ from typing import Any
from cognitive_agent.application.native_can_feedback import decode_feedback
from cognitive_agent.application.vehicle_state_monitor import (
OBSERVABLE_IDS,
REQUIRED_UI_IDS,
VehicleStateSnapshot,
)
from cognitive_agent.infrastructure.can.gateway import (
@@ -38,16 +37,25 @@ from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_POLL_INTERVAL_MS = 500
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
{*range(9), 12, 16, 20, 24, 32, 48, 64}
)
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
MQTT_SEAT_FEEDBACK_IDS = frozenset({*range(0x301, 0x308), 0x4FA, 0x6A2})
# Frames accepted from the Android head unit are the seat-ota wire protocol.
# ``0x302`` is an internal compatibility frame synthesized below so the
# existing seat-domain state reader can consume the four firmware axes; it is
# deliberately not accepted as authoritative MQTT input.
MQTT_SEAT_WIRE_FEEDBACK_IDS = frozenset({0x6A0, 0x6A2})
MQTT_SEAT_FEEDBACK_IDS = frozenset({0x302, 0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}),
1: frozenset({0x1DE, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
_ID_KEYS = ("arbitration_id", "can_id", "canId", "frame_id", "frameId", "id")
@@ -184,8 +192,8 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
The seat-ota runtime returns a selector value in a generic response frame:
0x6A0 is ``C7 sequence 60 selector ...`` and 0x6A2 is
``7C sequence 60 status uint32-le``. Only the non-mutating SEAT_INFO
opcode is paired here. Neither OTA identifier is added to an outbound
allow-list or exposed to the ordinary seat executor.
opcode is paired here. The original 0x6A2 response remains available to
command-response waiters; the generated 0x302 frame is internal only.
"""
selectors_by_request: dict[tuple[int, int], int] = {}
@@ -220,19 +228,30 @@ def _ota_seat_status_frames(frames: list[CanFrame]) -> list[CanFrame]:
for axis in range(4):
position_entry = values.get(1 + axis)
flags_entry = values.get(5 + axis)
if position_entry is None or flags_entry is None:
if position_entry is None:
continue
packed_position, position_frame = position_entry
flags, flags_frame = flags_entry
position = packed_position & 0xFFFF
hall_range = (packed_position >> 16) & 0xFFFF
if hall_range <= 0 or position > hall_range:
continue
value_x100 = min(10_000, round(position * 10_000 / hall_range))
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
if flags_entry is None:
# The production Android poller intentionally requests selectors
# 1..4 only. A valid position/range pair is sufficient to expose
# position, but it does not prove motion or a driver fault.
flags = 0
flags_frame = position_frame
moving = False
position_valid = True
driver_fault = False
direction = 0
else:
flags, flags_frame = flags_entry
moving = bool(flags & (1 << 1))
position_valid = bool(flags & (1 << 0))
driver_fault = bool(flags & (1 << 3))
direction = (flags >> 8) & 0x03
task_state = 1 if moving else 0
result = 7 if driver_fault else 0
state_flags = direction
@@ -323,29 +342,76 @@ def parse_mqtt_can_payload(payload: bytes | str) -> list[CanFrame]:
item_count = sum(len(items) for items, _ in batches)
if not 1 <= item_count <= 256:
raise ValueError("MQTT CAN frame batch must contain between 1 and 256 frames")
frames = [
parsed_frames = [
_parse_one_frame(item, default_channel=default_channel)
for items, default_channel in batches
for item in items
]
frames = [
frame
for frame in parsed_frames
if frame.arbitration_id
in (
MQTT_SEAT_WIRE_FEEDBACK_IDS
if frame.channel == 0
else MQTT_VEHICLE_FEEDBACK_IDS
)
]
return [*frames, *_ota_seat_status_frames(frames)]
def mqtt_control_payload(frame: CanFrame) -> str:
"""Serialize one compiled outbound frame using the documented wire contract."""
def _validate_headunit_command_frame(frame: CanFrame) -> None:
"""Mirror the Android head unit's strict ``CanCommand`` frame checks."""
if frame.channel not in {0, 1}:
raise CanGatewayError("MQTT CAN command channel must be CAN0 or CAN1")
if frame.is_fd:
if not frame.bitrate_switch:
raise CanGatewayError("MQTT CAN FD command requires BRS")
if len(frame.data) not in MQTT_VALID_FD_PAYLOAD_LENGTHS:
raise CanGatewayError(
"MQTT CAN FD command length must be 0..8/12/16/20/24/32/48/64"
)
elif frame.bitrate_switch:
raise CanGatewayError("MQTT classic CAN command cannot enable BRS")
elif len(frame.data) > 8:
raise CanGatewayError("MQTT classic CAN command cannot exceed 8 bytes")
def mqtt_control_payload(
frame: CanFrame,
*,
response_timeout_ms: int = 1_500,
request_id: str | None = None,
command: str | None = None,
) -> str:
"""Serialize a frame as the Android head unit's strict ``CanCommand`` JSON."""
_validate_headunit_command_frame(frame)
command_request_id = request_id or f"cognitive-agent-{uuid.uuid4().hex}"
if not command_request_id.strip() or len(command_request_id) > 128:
raise CanGatewayError("MQTT CAN command requestId must contain 1..128 characters")
debug_command = "" if command is None else str(command).strip()
if len(debug_command) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
bounded_timeout_ms = max(100, min(5_000, int(response_timeout_ms)))
return json.dumps(
{
"schema": MQTT_CAN_SCHEMA,
"arbitration_id": frame.arbitration_id,
"frame_id": f"0x{frame.arbitration_id:03X}",
"channel": frame.channel,
"data_hex": frame.data.hex(" ").upper(),
"data": list(frame.data),
"payload_size": len(frame.data),
"is_fd": frame.is_fd,
"bitrate_switch": frame.bitrate_switch,
"timestamp": time.time(),
"requestId": command_request_id,
# Debug-only semantic metadata. The head unit continues to execute
# only the validated channel/ID/frame/data fields below.
"command": debug_command,
"channel": f"can{frame.channel}",
"canId": f"0x{frame.arbitration_id:03X}",
"frameType": "fd" if frame.is_fd else "classic",
"brs": frame.bitrate_switch,
"extended": False,
"remote": False,
"data": frame.data.hex(" ").upper(),
"repeat": 1,
"intervalMs": 0,
"responseTimeoutMs": bounded_timeout_ms,
},
ensure_ascii=False,
separators=(",", ":"),
@@ -587,6 +653,11 @@ class MqttCanTransport:
for key in ("actuator_state", "domain_state", "vehicle_safety")
):
return
# The matrix decoder is shared with the wired CH1 monitor and therefore
# emits its wired source marker by default. Preserve the common byte
# decoder while making the actual transport explicit before this state
# reaches the conversation store or browser.
decoded["source"] = WIRELESS_MQTT_CH1_SOURCE
frame_id = str(decoded.get("frame_id", ""))
captured_at = (
datetime.fromtimestamp(frame.timestamp, UTC)
@@ -683,7 +754,12 @@ class MqttCanTransport:
)
return MqttCanBus(self, cursor, channel, initial_frames=initial_frames)
def publish(self, frame: CanFrame) -> None:
def publish(
self,
frame: CanFrame,
*,
command: str | None = None,
) -> dict[str, Any]:
if (
frame.channel not in MQTT_APPROVED_CONTROL_IDS
or frame.arbitration_id not in MQTT_APPROVED_CONTROL_IDS[frame.channel]
@@ -691,6 +767,7 @@ class MqttCanTransport:
raise CanGatewayError("MQTT CAN frame is outside the approved channel policy")
if frame.channel == 0 and (frame.is_fd or frame.bitrate_switch):
raise CanGatewayError("CAN0 passenger-seat control requires classic CAN")
_validate_headunit_command_frame(frame)
if frame.channel == 0:
_validate_ota_seat_runtime_control(frame.data)
with self._condition:
@@ -698,9 +775,14 @@ class MqttCanTransport:
state = self._state
if client is None or state != "online":
raise CanGatewayError("MQTT CAN relay is not connected")
raw_payload = mqtt_control_payload(
frame,
response_timeout_ms=self.settings.feedback_timeout_ms,
command=command,
)
result = client.publish(
self.settings.control_topic,
mqtt_control_payload(frame),
raw_payload,
qos=self.settings.qos,
retain=False,
)
@@ -710,6 +792,12 @@ class MqttCanTransport:
published = result.wait_for_publish(timeout=2.0)
if published is False:
raise CanGatewayError("MQTT CAN publish confirmation timed out")
return {
"topic": self.settings.control_topic,
"qos": self.settings.qos,
"retain": False,
"payload": json.loads(raw_payload),
}
def _receive_after(
self,
@@ -782,8 +870,24 @@ class MqttCanBus:
self._cursor = cursor
self.channel = channel
self._initial_frames = deque(initial_frames)
self._debug_command = ""
self._published_messages: list[dict[str, Any]] = []
self._closed = False
@property
def published_messages(self) -> tuple[dict[str, Any], ...]:
"""Return this bus view's confirmed MQTT publications in send order."""
return tuple(deepcopy(self._published_messages))
def set_debug_command(self, command: str | None) -> None:
"""Attach semantic debug metadata to subsequent MQTT publications."""
value = "" if command is None else str(command).strip()
if len(value) > 256:
raise CanGatewayError("MQTT CAN debug command must contain at most 256 characters")
self._debug_command = value
def send(
self,
arbitration_id: int,
@@ -809,7 +913,8 @@ class MqttCanBus:
is_fd=is_fd,
bitrate_switch=bitrate_switch,
)
self._transport.publish(frame)
publication = self._transport.publish(frame, command=self._debug_command)
self._published_messages.append(publication)
return frame
def receive(
+40 -2
View File
@@ -359,6 +359,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path.startswith("/api/self-repair/"):
self._handle_self_repair_post(path)
return
if path.startswith("/api/mode-profiles/"):
self._handle_mode_profile_post(path)
return
if path != "/api/requests":
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
@@ -519,6 +522,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
def _handle_mode_profile_post(self, path: str) -> None:
mode_id = path.removeprefix("/api/mode-profiles/").strip()
if not mode_id:
self._json(
{"error": "invalid_request", "message": "模式编号不能为空。"},
HTTPStatus.BAD_REQUEST,
)
return
try:
payload = self._read_json_body(max_bytes=16 * 1024)
result = self.service.save_mode_profile_card(
mode_id,
str(payload.get("name", "")),
str(payload.get("instruction", "")),
str(payload.get("source_request_id", "")),
str(payload.get("vehicle_id", "")) or None,
)
self._json(result, HTTPStatus.OK)
except KeyError:
self._json(
{"error": "not_found", "message": "未找到需要编辑的模式卡片。"},
HTTPStatus.NOT_FOUND,
)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
def _handle_extension_post(self, path: str) -> None:
action_map = {
"/api/extension/gscu/learn": "learn",
@@ -1234,12 +1266,18 @@ def main() -> None:
)
else:
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
hardware = SeatDemoHandler.service.hardware_settings
hardware_execution_ready = any(
(
SeatDemoHandler.service.hardware_settings.configured,
SeatDemoHandler.service.vehicle_can_settings.configured,
SeatDemoHandler.service.vehicle_mqtt_settings.configured,
)
)
print(
"Execution modes: dry_run + simulated_hardware "
f"({'enabled' if SeatDemoHandler.service.simulated_hardware_enabled else 'locked'}) "
"+ hardware "
f"({'configured' if hardware.configured else 'locked'})",
f"({'configured' if hardware_execution_ready else 'locked'})",
flush=True,
)
try:
-2
View File
@@ -70,8 +70,6 @@ MODULE_ENDPOINTS = {
RETIRED_DOOR_COMMANDS = {
"REJECT-VEHICLE-LOCK": "door vehicle lock",
"REJECT-VEHICLE-UNLOCK": "door vehicle unlock",
"REJECT-REAR-LOCK": "door rear lock",
"REJECT-REAR-UNLOCK": "door rear unlock",
}