优化前端,优化前端指令,优化自定义控制
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user