优化了副驾座椅控制

This commit is contained in:
minghanchen
2026-08-25 11:39:41 +08:00
parent 33c92d1352
commit 9ac8fdface
34 changed files with 31714 additions and 289 deletions
@@ -239,6 +239,9 @@ class ConversationStateStore:
with self._lock:
state = self._state(conversation_id)
passenger = state["devices"]["passenger"]
feedback_source = str(physical_state.get("source", "physical_can"))
if feedback_source not in {"physical_can", "wireless_mqtt_ch0"}:
feedback_source = "physical_can"
for axis, feedback in physical_state.get("position", {}).items():
if axis not in MOTION_INITIAL_POSITIONS or not isinstance(
feedback, dict
@@ -260,7 +263,7 @@ class ConversationStateStore:
position_state["position_valid"] = bool(
feedback["position_valid"]
)
position_state["feedback_source"] = "physical_can"
position_state["feedback_source"] = feedback_source
passenger["position"][str(axis)] = position_state
for feature in THERMAL_FUNCTIONS:
@@ -277,7 +280,20 @@ class ConversationStateStore:
passenger[feature] = {
"state": state_value,
"level": level,
"feedback_source": "physical_can",
"feedback_source": feedback_source,
}
dynamic_mode = physical_state.get("dynamic_mode")
if isinstance(dynamic_mode, dict):
dynamic_state = str(dynamic_mode.get("state", "idle"))
passenger["dynamic_mode"] = {
**deepcopy(dynamic_mode),
"state": (
"running"
if dynamic_state in {"entry", "running"}
else dynamic_state
),
"feedback_source": feedback_source,
}
state["physical_state_updated_at"] = str(
@@ -0,0 +1,285 @@
"""Deterministic batches submitted by the vehicle-device control panel.
The browser sends only canonical commands from the checked CAN vocabulary.
This module validates the complete batch and builds the same intermediate
actions used by the normal compiler, without natural-language or model parsing.
"""
from __future__ import annotations
import re
from typing import Any
from cognitive_agent.application.native_can_intent import (
parse_exact_native_can_command,
)
MAX_DIRECT_CAN_COMMANDS = 32
MAX_DIRECT_CAN_COMMAND_LENGTH = 160
_PASSENGER_POSITION_COMMAND = re.compile(
r"seat passenger "
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
r"position (\d+)"
)
_PASSENGER_RELATIVE_COMMAND = re.compile(
r"seat passenger "
r"(horizontal|basin|backrest|legrest|longitudinal|height) "
r"relative (forward|backward|up|down) (\d+)"
)
_PASSENGER_THERMAL_COMMAND = re.compile(
r"seat passenger (vent|heat) (off|low|medium|high)"
)
_PASSENGER_DYNAMIC_COMMAND = re.compile(
r"seat passenger dynamic (?:(?:start )?(rocking-horse|cradle)|(stop))"
)
_PASSENGER_AXES = {
"horizontal": "longitudinal",
"basin": "cushion_tilt",
"longitudinal": "longitudinal",
"height": "cushion_tilt",
"backrest": "backrest_recline",
"legrest": "legrest_height",
}
_PASSENGER_RELATIVE_DIRECTIONS = {
"horizontal": {"forward": "decrease", "backward": "increase"},
"longitudinal": {"forward": "decrease", "backward": "increase"},
"basin": {"up": "decrease", "down": "increase"},
"height": {"up": "decrease", "down": "increase"},
"backrest": {"forward": "decrease", "backward": "increase"},
"legrest": {"up": "increase", "down": "decrease"},
}
_PASSENGER_THERMAL_LEVELS = {"low": 1, "medium": 2, "high": 3}
def _empty_direct_intent() -> dict[str, Any]:
return {
"actions": [],
"domain_actions": [],
"native_can_actions": [],
"preset_actions": [],
"preset_events": [],
"deferred_requests": [],
"model_requests": [],
"ambiguities": [],
"decision_summary": [],
"semantic_constraints": [],
"unresolved_spans": [],
"detected_domains": [],
"confidence": 1.0,
}
def build_direct_can_intent(
commands: object,
context: dict[str, Any] | None = None,
) -> tuple[list[str], dict[str, Any]]:
"""Validate a canonical command batch and return compiler-ready actions."""
if not isinstance(commands, list) or not commands:
raise ValueError("can_commands must be a non-empty array")
if len(commands) > MAX_DIRECT_CAN_COMMANDS:
raise ValueError(
f"can_commands must contain at most {MAX_DIRECT_CAN_COMMANDS} commands"
)
normalized_commands: list[str] = []
parsed = _empty_direct_intent()
seen_commands: set[str] = set()
for raw_command in commands:
if not isinstance(raw_command, str):
raise ValueError("every can command must be a string")
command = " ".join(raw_command.strip().lower().split())
if not command or len(command) > MAX_DIRECT_CAN_COMMAND_LENGTH:
raise ValueError(
"every can command must contain 1 to "
f"{MAX_DIRECT_CAN_COMMAND_LENGTH} characters"
)
if command in seen_commands:
continue
native_actions = parse_exact_native_can_command(command)
if native_actions:
for action in native_actions:
direct_action = dict(action)
direct_action["parse_source"] = "direct_control"
direct_action["source_span"] = command
parsed["native_can_actions"].append(direct_action)
parsed["decision_summary"].append(
f"设备控件直接提交白名单命令:{command}"
)
domain = str(direct_action.get("domain", "vehicle"))
if domain not in parsed["detected_domains"]:
parsed["detected_domains"].append(domain)
else:
passenger_position = _PASSENGER_POSITION_COMMAND.fullmatch(command)
if passenger_position:
position = int(passenger_position[2])
if not 0 <= position <= 100:
raise ValueError(
f"CAN0 passenger-seat position is out of range: {command}"
)
parsed["actions"].append(
{
"target": "passenger",
"action_type": "position",
"axis": _PASSENGER_AXES[passenger_position[1]],
"position_pct": position,
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
relative = _PASSENGER_RELATIVE_COMMAND.fullmatch(command)
if relative:
semantic_axis = relative[1]
axis = _PASSENGER_AXES[semantic_axis]
direction = relative[2]
operation = _PASSENGER_RELATIVE_DIRECTIONS.get(
semantic_axis, {}
).get(direction)
delta = int(relative[3])
if operation is None:
raise ValueError(
f"CAN0 passenger-seat direction is invalid: {command}"
)
if not 1 <= delta <= 100:
raise ValueError(
f"CAN0 passenger-seat relative amount is out of range: {command}"
)
position_state = (
(context or {})
.get("devices", {})
.get("passenger", {})
.get("position", {})
.get(axis, {})
)
current = position_state.get("position_pct")
if (
isinstance(current, bool)
or not isinstance(current, (int, float))
or not 0 <= float(current) <= 100
):
raise ValueError(
"CAN0 passenger-seat relative control requires a valid "
f"current {axis} position"
)
signed_delta = delta if operation == "increase" else -delta
raw_target = round(float(current)) + signed_delta
target = max(0, min(100, raw_target))
applied_delta = abs(target - round(float(current)))
parsed["actions"].append(
{
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": target,
"motion_intent": {
"mode": "relative",
"direction": operation,
"start_position_pct": round(float(current)),
"delta_position_pct": applied_delta,
**(
{
"requested_delta_position_pct": delta,
"range_clamped": True,
}
if applied_delta != delta
else {}
),
},
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 相对移动白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
thermal = _PASSENGER_THERMAL_COMMAND.fullmatch(command)
if thermal:
setting = thermal[2]
action = {
"target": "passenger",
"function": (
"ventilation" if thermal[1] == "vent" else "heating"
),
"state": "off" if setting == "off" else "on",
"parse_source": "direct_control",
"source_span": command,
}
if setting != "off":
action["requested_level"] = _PASSENGER_THERMAL_LEVELS[
setting
]
parsed["actions"].append(action)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 热舒适白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
normalized_commands.append(command)
seen_commands.add(command)
continue
dynamic = _PASSENGER_DYNAMIC_COMMAND.fullmatch(command)
if dynamic and dynamic[1]:
mode = dynamic[1]
parsed["actions"].append(
{
"target": "passenger",
"action_type": "dynamic_mode",
"mode_id": 1 if mode == "rocking-horse" else 2,
"mode_name": (
"木马模式"
if mode == "rocking-horse"
else "动态摇篮模式"
),
"repeat": "until_stopped_or_timeout",
"max_runtime_ms": 600_000,
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
elif dynamic and dynamic[2] == "stop":
parsed["actions"].append(
{
"target": "passenger",
"action_type": "dynamic_mode_stop",
"parse_source": "direct_control",
"source_span": command,
}
)
parsed["decision_summary"].append(
f"设备控件直接提交 CAN0 白名单命令:{command}"
)
if "seat" not in parsed["detected_domains"]:
parsed["detected_domains"].append("seat")
else:
raise ValueError(
f"CAN control command is not in the exact whitelist: {command}"
)
normalized_commands.append(command)
seen_commands.add(command)
if not normalized_commands:
raise ValueError("can_commands must contain at least one unique command")
return normalized_commands, parsed
@@ -76,19 +76,43 @@ class MqttOtaSeatExecutor:
capability = str(operation["capability"])
parameters = operation.get("parameters", {})
axes = {
"seat.set_longitudinal_position": "longitudinal",
"seat.set_cushion_tilt": "height",
"seat.set_longitudinal_position": "horizontal",
"seat.set_cushion_tilt": "basin",
"seat.set_backrest_recline": "backrest",
"seat.set_legrest_height": "legrest",
}
if capability in axes:
motion = operation.get("motion", {})
relative_directions = {
"horizontal": {"increase": "backward", "decrease": "forward"},
"basin": {"increase": "down", "decrease": "up"},
"backrest": {"increase": "backward", "decrease": "forward"},
"legrest": {"increase": "up", "decrease": "down"},
}
direction = relative_directions[axes[capability]].get(
str(motion.get("direction", ""))
)
delta = motion.get("delta_position_pct")
if motion.get("mode") == "relative" and direction and delta is not None:
return (
f"seat passenger {axes[capability]} relative "
f"{direction} {int(delta)}"
)
return (
f"seat passenger {axes[capability]} position "
f"{parameters['position_pct']}"
)
if capability in {"seat.set_ventilation", "seat.set_heating"}:
feature = "vent" if capability.endswith("ventilation") else "heat"
if parameters.get("state") == "off":
return f"seat passenger {feature} off"
level = {1: "low", 2: "medium", 3: "high"}.get(
int(parameters.get("level", 1)), "low"
)
return f"seat passenger {feature} {level}"
if capability == "seat.start_dynamic_mode":
mode = "rocking-horse" if int(parameters["mode_id"]) == 1 else "cradle"
return f"seat passenger dynamic start {mode}"
return f"seat passenger dynamic {mode}"
if capability == "seat.stop_dynamic_mode":
return "seat passenger dynamic stop"
return capability
@@ -384,6 +408,7 @@ class MqttOtaSeatExecutor:
try:
bus = self.bus_factory()
previous_offset = 0
thermal_publication_start: int | None = None
for command in command_plan["spec"]["commands"]:
offset = int(command.get("send_offset_ms", 0))
if offset > previous_offset:
@@ -393,8 +418,10 @@ class MqttOtaSeatExecutor:
operations[item]
for item in command["operation_ref"].split(",")
]
publication_start = len(
getattr(bus, "published_messages", ())
publication_start = (
thermal_publication_start
if thermal_publication_start is not None
else len(getattr(bus, "published_messages", ()))
)
set_debug_command = getattr(bus, "set_debug_command", None)
if callable(set_debug_command):
@@ -404,24 +431,40 @@ class MqttOtaSeatExecutor:
for item in referenced
)
)
if command["message"] != "T2G_Seat_Move_Command_300":
raise HardwareExecutionError(
"seat-ota firmware does not provide wireless thermal control; "
f"{command['message']} was not sent"
if command["message"] == "T2G_Seat_Thermal_Mode_3CE":
encoded = self.status_executor.matrix.encode(
command["message"], command["signals"]
)
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
command_results = self._execute_dynamic(
bus.send(
int(encoded["frame_id"], 16),
bytes.fromhex(encoded["data_hex"]),
)
thermal_publication_start = publication_start
continue
if command["message"] == "T2G_Seat_Thermal_Control_29E":
command_results = self.status_executor._execute_thermal( # noqa: SLF001
bus, command, referenced
)
motion_armed = False
thermal_publication_start = None
elif command["message"] == "T2G_Seat_Move_Command_300":
opcode = int(command["signals"]["Opcode"])
if opcode in {0x0E, 0x0F}:
motion_armed = opcode == 0x0E
command_results = self._execute_dynamic(
bus, command, referenced
)
motion_armed = False
else:
motion_armed = True
command_results = self._execute_move(
bus, command, referenced
)
motion_armed = False
else:
motion_armed = True
command_results = self._execute_move(
bus, command, referenced
raise HardwareExecutionError(
"wireless CAN0 command is not in the passenger-seat runtime "
f"whitelist: {command['message']}"
)
motion_armed = False
mqtt_messages = list(
getattr(bus, "published_messages", ())[
publication_start:
@@ -491,6 +534,7 @@ class MqttOtaSeatExecutor:
"labels": {
"simulation": "false",
"feedback": "wireless_mqtt_ch0",
"seat_feedback": "wireless_mqtt_ch0",
},
},
"spec": spec,
@@ -60,6 +60,65 @@ LIGHT_TARGETS = {
"右后": "rear_right",
}
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
OUTLET_MODE_DISABLE_PATTERN = re.compile(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
r"(?:关闭|关掉|停止|取消)"
)
DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
_FEATURE_BOUNDARY = re.compile(
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
)
def _feature_context(text: str, feature: str, radius: int = 24) -> str:
"""Keep modifiers attached to one seat feature instead of a neighbour."""
match = re.search(feature, text)
if match is None:
return ""
prefix = text[max(0, match.start() - radius) : match.start()]
suffix = text[match.end() : match.end() + radius]
prefix = _FEATURE_BOUNDARY.split(prefix)[-1]
suffix = _FEATURE_BOUNDARY.split(suffix)[0]
return f"{prefix}{match.group(0)}{suffix}"
def _grade_alias(text: str, *, include_numeric: bool = True) -> str | None:
"""Normalize common low/middle/high wording without guessing a value."""
low = (
r"最小(?:档|级)?|最低(?:档|级)?|最弱(?:档|级)?|弱(?:档|级)?|"
r"低(?:档|级)?|调低"
)
middle = (
r"中等(?:档|级)?|中档|中级|中间(?:档|级)?|适中|"
r"(?:调到|调为|调|设为|设置为|调整到|调整为)中(?:档|级)?"
)
high = (
r"最大(?:档|级)?|最高(?:档|级)?|最强(?:档|级)?|"
r"强(?!度)(?:档|级)?|高(?:档|级)?|调高"
)
if re.search(low, text):
return "low"
if re.search(high, text):
return "high"
if re.search(middle, text):
return "medium"
if include_numeric:
if re.search(r"(?:1|一)(?:档|级)", text):
return "low"
if re.search(r"(?:2|二)(?:档|级)", text):
return "medium"
if re.search(r"(?:3|三)(?:档|级)", text):
return "high"
return None
# Defaults are intentionally scoped to the driver console and verified CH1
# body-CAN vocabulary. A missing target must never become the legacy
@@ -463,6 +522,36 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
through to the legacy CH0/T2G passenger-seat implementation.
"""
seat_clauses = [
clause for clause in CLAUSE_SEPARATOR.split(compact) if clause
]
if len(seat_clauses) > 1:
inherited_driver_scope = False
combined_actions: list[dict[str, Any]] = []
seen: set[tuple[str, str, tuple[tuple[str, str], ...]]] = set()
for clause in seat_clauses:
if re.search(r"主驾|驾驶位|司机位|左前座椅", clause):
inherited_driver_scope = True
elif re.search(r"副驾|副驾驶|乘客|右前", clause):
inherited_driver_scope = False
elif inherited_driver_scope:
clause = f"主驾座椅{clause}"
for action in _parse_chinese_driver_seat_commands(clause):
signature = (
str(action.get("capability", "")),
str(action.get("target", "")),
tuple(
sorted(
(str(key), repr(value))
for key, value in action.get("parameters", {}).items()
)
),
)
if signature not in seen:
seen.add(signature)
combined_actions.append(action)
return combined_actions
if re.search(
r"副驾(?:驶|位)?|乘客(?:位|座|座椅|座位)?|右前(?:座|座椅|座位)?|"
r"前排|后排|左后|右后|所有座椅|全部座椅|全车座椅|"
@@ -508,14 +597,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
present_thermal = [item for item in thermal_features if item[0] in compact]
if len(present_thermal) == 1:
label, capability = present_thermal[0]
thermal_text = _feature_context(compact, label)
thermal_grade = _grade_alias(thermal_text, include_numeric=False)
parameters: dict[str, Any] | None = None
if re.search(r"关闭|关掉|停止|取消|不要|别开", compact):
if re.search(r"关闭|关掉|停止|取消|不要|别开", thermal_text):
parameters = {"state": "off"}
elif re.search(r"高档|高速|最高|强档|调高|1档", compact):
elif thermal_grade == "high" or re.search(
r"高速|(?:1|一)(?:档|级)", thermal_text
):
parameters = {"state": "on", "level": 1}
elif re.search(r"低档|低速|最低|弱档|调低|2档", compact):
elif thermal_grade == "low" or re.search(
r"低速|(?:2|二)(?:档|级)", thermal_text
):
parameters = {"state": "on", "level": 2}
elif re.search(r"打开|开启|启动", compact):
elif re.search(r"打开|开启|启动|启用", thermal_text):
parameters = {"state": "on", "level": 2}
if parameters is not None:
actions.append(_action(
@@ -531,9 +626,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
))
if "按摩" in compact:
massage: dict[str, Any] | None = None
if re.search(r"关闭|关掉|停止|取消", compact):
massage = {"action": "off"}
massage_text = _feature_context(compact, "按摩")
massage_actions: list[dict[str, Any]] = []
if re.search(r"关闭|关掉|停止|取消|不要|别开", massage_text):
massage_actions.append({"action": "off"})
else:
modes = (
(r"波浪", "wave"),
@@ -543,67 +639,122 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
(r"舒展|放松", "relax"),
)
mode = next(
(value for pattern, value in modes if re.search(pattern, compact)),
(
value
for pattern, value in modes
if re.search(pattern, massage_text)
),
None,
)
if mode is not None:
massage = {"action": "mode", "mode": mode}
elif re.search(r"强度|力度|弱|中等|中档|强|[123]档", compact):
strength = (
1
if re.search(r"弱|低|1档", compact)
else 2
if re.search(r"中等|中档|2档", compact)
else 3
if re.search(r"强(?!度)|高|3档", compact)
else 2
massage_actions.append({"action": "mode", "mode": mode})
elif re.search(r"打开|开启|启动|启用", massage_text):
# The verified vocabulary has no state-only ON frame. Starting
# the conservative relax mode is the deterministic ON action.
massage_actions.append({"action": "mode", "mode": "relax"})
massage_grade = _grade_alias(massage_text)
strength_requested = bool(
re.search(
r"强度|力度|档位|等级|最小|最大|最低|最高|最弱|最强|"
r"弱|中等|中档|中级|中间|适中|强(?!度)|"
r"(?:[123一二三])(?:档|级)",
massage_text,
)
massage = {"action": "strength", "strength": strength}
if massage is not None:
actions.append(_action(
"seat",
"seat.set_driver_massage_native",
"driver",
massage,
(
"用户明确指定了 CH1 主驾座椅按摩控制。"
if explicit_driver
else default_reason
),
))
)
if strength_requested and massage_grade is not None:
massage_actions.append(
{
"action": "strength",
"strength": {"low": 1, "medium": 2, "high": 3}[
massage_grade
],
}
)
for massage in massage_actions:
actions.append(
_action(
"seat",
"seat.set_driver_massage_native",
"driver",
massage,
(
"用户明确指定了 CH1 主驾座椅按摩控制。"
if explicit_driver
else default_reason
),
)
)
stop_all_axes = bool(
re.search(
r"(?:停止|停下|暂停)(?:主驾)?(?:座椅)?(?:所有)?(?:移动|调节|动作)|"
r"(?:主驾)?座椅(?:停止|停下|暂停)(?:移动|调节|动作)",
compact,
)
)
if stop_all_axes:
for stopped_axis in ("horizontal", "height", "backrest", "basin"):
actions.append(
_action(
"seat",
"seat.set_driver_axis_native",
"driver",
{
"axis": stopped_axis,
"direction": "stop",
"duration_ms": 500,
},
(
"用户明确要求停止 CH1 主驾座椅全部四轴动作。"
if explicit_driver
else default_reason
),
)
)
return actions
axis: str | None = None
direction: str | None = None
if re.search(r"坐盆|坐垫", compact):
if re.search(r"坐盆|坐垫|座垫", compact):
axis = "basin"
direction = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "up" if re.search(r"抬高|升高|向上|往上", compact)
else "down" if re.search(r"降低|下降|向下|往下", compact)
else "up" if re.search(r"抬高|抬起|升高|翘起|上调|向上|往上", compact)
else "down" if re.search(r"降低|下降|降下|放低|下调|向下|往下", compact)
else None
)
elif "靠背" in compact:
elif re.search(r"靠背|椅背", compact):
axis = "backrest"
direction = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "backward" if re.search(r"后仰|向后|往后", compact)
else "forward" if re.search(r"前倾|向前|往前|立直", compact)
else "backward" if re.search(r"后仰|放倒|躺倒|调躺|后倾|向后|往后|朝后", compact)
else "forward" if re.search(r"前倾|调直|立直|竖直|竖起|立起|向前|往前|朝前", compact)
else None
)
elif re.search(r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低", compact):
elif re.search(
r"座椅高度|整椅|座椅升高|座椅降低|座椅调高|座椅调低|"
r"座椅(?:向|往|朝)?(?:上|下)(?:移|移动|调|调整)?|座椅上移|座椅下移",
compact,
):
axis = "height"
direction = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "up" if re.search(r"抬高|升高|上升|向上|往上|调高", compact)
else "down" if re.search(r"降低|下降|向下|往下|调低", compact)
else "up" if re.search(r"抬高|抬起|升高|上升|上移|向上|往上|朝上|调高", compact)
else "down" if re.search(r"降低|下降|降下|下移|向下|往下|朝下|调低|放低", compact)
else None
)
elif re.search(r"座椅前后|水平|前移|后移|向前移动|向后移动|座椅(?:向|往)?(?:前|后)", compact):
elif re.search(
r"座椅前后|水平|滑轨|前移|后移|前进|后退|前推|后拉|"
r"(?:向|往|朝)(?:前|后)(?:移动|挪动|挪|调节|调整|调|滑动)?|"
r"座椅(?:向|往|朝)?(?:前|后)",
compact,
):
axis = "horizontal"
direction = (
"stop" if re.search(r"停止|停下|暂停", compact)
else "forward" if re.search(r"前移|向前|往前", compact)
else "backward" if re.search(r"后移|向后|往后", compact)
else "forward" if re.search(r"前移|前进|前推|向前|往前|朝前", compact)
else "backward" if re.search(r"后移|后退|后拉|向后|往后|朝后", compact)
else None
)
if axis is not None and direction is not None:
@@ -624,14 +775,22 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
second_match = re.search(
r"(\d+(?:\.\d+)?)(?:秒|s)", compact, re.IGNORECASE
)
chinese_second_match = re.search(r"(半|一|二|两)(?:秒|s)", compact)
unsupported_duration = "分钟" in compact
if millisecond_match and second_match:
if sum(
bool(item)
for item in (millisecond_match, second_match, chinese_second_match)
) > 1:
unsupported_duration = True
duration = 500
if direction != "stop" and millisecond_match:
duration = int(millisecond_match[1])
elif direction != "stop" and second_match:
duration = round(float(second_match[1]) * 1000)
elif direction != "stop" and chinese_second_match:
duration = {"半": 500, "一": 1000, "二": 2000, "两": 2000}[
chinese_second_match[1]
]
if (
not unsupported_absolute_goal
and not unsupported_duration
@@ -652,6 +811,111 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
return actions
def _positive_outlet_feature(text: str, pattern: str) -> bool:
"""Return whether an outlet feature is requested rather than disabled."""
for match in re.finditer(pattern, text):
prefix = text[max(0, match.start() - 16) : match.start()]
suffix = text[match.end() : match.end() + 12]
disabled_before = re.search(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?$",
prefix,
)
disabled_after = re.match(
r"(?:出风口|出风|模式)?(?:关闭|关掉|停止|取消)",
suffix,
)
if disabled_before is None and disabled_after is None:
return True
return False
def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
"""Parse each window clause independently so targets and values cannot leak."""
actions: list[dict[str, Any]] = []
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
exclusion_pattern = re.compile(
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前|前左|右前|前右|左后|后左|右后|后右)"
r"(?:车窗)?(?:除外|除去|不包括|不含)$"
)
for index, clause in enumerate(clauses):
if "车窗" not in clause:
continue
window_text = clause
if index + 1 < len(clauses) and exclusion_pattern.search(
clauses[index + 1]
):
window_text = f"{window_text},{clauses[index + 1]}"
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)"
r"(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = (
int(position[1]) if position else 50 if half_position else None
)
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action(
"window",
"window.set_position",
target,
{"position_pct": position_pct},
reason,
)
for target in targets
)
continue
if position_pct is not None:
# Absolute feedback is verified for one window only.
continue
motion = (
"stop"
if re.search(r"停止|停下|暂停", window_text)
else "close"
if re.search(r"关闭|关上|升起|关窗", window_text)
else "open"
if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action(
"window",
"window.set_motion",
target,
{"motion": motion},
reason,
)
for target in targets
)
return actions
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text)
seat_text = _scoped_text(
@@ -726,8 +990,14 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"([1-7])(?:档|级)?(?![\d.])",
climate_text,
)
if fan:
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
fan_level = int(fan[1]) if fan else None
if fan_level is None and re.search(r"风量|风速", climate_text):
fan_grade = _grade_alias(climate_text, include_numeric=False)
fan_level = {"low": 1, "medium": 4, "high": 7}.get(
fan_grade or ""
)
if fan_level is not None:
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": fan_level}, "用户指定了空调风量。"))
if re.search(r"自动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
@@ -739,9 +1009,9 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
has_face = bool(re.search(r"吹面|吹脸", climate_text))
has_feet = "吹脚" in climate_text
has_defrost = "除霜" in climate_text
has_face = _positive_outlet_feature(climate_text, r"吹面|吹脸")
has_feet = _positive_outlet_feature(climate_text, r"吹脚")
has_defrost = _positive_outlet_feature(climate_text, r"除霜")
outlet_mode = (
"face_feet_defrost"
if has_face and has_feet and has_defrost
@@ -770,60 +1040,7 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
)
)
window_text = _scoped_text(
compact,
r"车窗",
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?(?:除外|除去|不包括|不含)$",
)
if window_text:
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = int(position[1]) if position else 50 if half_position else None
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action("window", "window.set_position", target, {"position_pct": position_pct}, reason)
for target in targets
)
elif position_pct is not None:
# Absolute position feedback is verified for one window only. Do
# not turn a half-open request for a broad scope into full motion.
pass
else:
motion = (
"stop" if re.search(r"停止|停下|暂停", window_text)
else "close" if re.search(r"关闭|关上|升起|关窗", window_text)
else "open" if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
_action("window", "window.set_motion", target, {"motion": motion}, reason)
for target in targets
)
actions.extend(_parse_chinese_window_commands(compact))
door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
rear_lock_request = bool(
@@ -931,8 +1148,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
)
if color_match and 1 <= int(color_match[1]) <= 127:
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
if brightness_match:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。"))
brightness_level = int(brightness_match[1]) if brightness_match else None
if brightness_level is None and re.search(r"亮度|强度", ambient_text):
brightness_grade = _grade_alias(
ambient_text, include_numeric=False
)
brightness_level = {
"low": 1,
"medium": 3,
"high": 5,
}.get(brightness_grade or "")
if brightness_level is not None:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": brightness_level}, "用户指定了已验证的氛围灯亮度档位。"))
outlet_text = _scoped_text(compact, r"风口")
outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
@@ -1204,6 +1431,89 @@ def merge_native_can_intent(
unresolved=not has_default_driver_seat_action,
)
if not actions and not relative_ambiguities:
unsupported_thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE_PATTERN.search(
utterance
)
if unsupported_thermal_middle is not None and not re.search(
r"副驾|副驾驶|乘客|右前", utterance
):
result = dict(parsed)
feature = (
"通风"
if "通风" in unsupported_thermal_middle.group(0)
else "加热"
)
capability = (
"seat.set_driver_ventilation_native"
if feature == "通风"
else "seat.set_driver_heating_native"
)
constraint = {
"type": "unsupported_discrete_level",
"domain": "seat",
"capability": capability,
"parameters": {"requested_level": "medium"},
"source_span": {
"text": unsupported_thermal_middle.group(0),
"start": unsupported_thermal_middle.start(),
"end": unsupported_thermal_middle.end(),
},
}
constraints = list(result.get("semantic_constraints", []))
if not any(
item.get("type") == constraint["type"]
and item.get("capability") == capability
and item.get("parameters") == constraint["parameters"]
for item in constraints
):
constraints.append(constraint)
result["semantic_constraints"] = constraints
result["ambiguities"] = [
item
for item in result.get("ambiguities", [])
if not (
str(item.get("field", "")) in {"target", "target_scope"}
and "仅控制副驾" in str(item.get("question", ""))
)
]
result["decision_summary"] = [
item
for item in result.get("decision_summary", [])
if not _is_stale_passenger_seat_summary(item)
]
domains = list(result.get("detected_domains", []))
if "seat" not in domains:
domains.append("seat")
result["detected_domains"] = domains
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
return result
disabled_outlet = OUTLET_MODE_DISABLE_PATTERN.search(utterance)
if disabled_outlet is not None:
feature = (
disabled_outlet.group("feature")
or disabled_outlet.group("feature_after")
)
result = dict(parsed)
result["semantic_constraints"] = [
*result.get("semantic_constraints", []),
{
"type": "requires_absolute_outlet_mode",
"domain": "climate",
"capability": "climate.set_outlet_mode",
"parameters": {"disabled_feature": feature},
"source_span": {
"text": disabled_outlet.group(0),
"start": disabled_outlet.start(),
"end": disabled_outlet.end(),
},
},
]
domains = list(result.get("detected_domains", []))
if "climate" not in domains:
domains.append("climate")
result["detected_domains"] = domains
result["confidence"] = max(float(result.get("confidence", 0)), 0.99)
return result
return parsed
result = dict(parsed)
@@ -1391,6 +1701,20 @@ def merge_native_can_intent(
}
for item in actions
)
resolved_separate_thermal_and_massage = (
any(
item["capability"]
in {
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
}
for item in actions
)
and any(
item["capability"] == "seat.set_driver_massage_native"
for item in actions
)
)
result["ambiguities"] = [
item
for item in result.get("ambiguities", [])
@@ -1410,6 +1734,10 @@ def merge_native_can_intent(
)
and re.search(r"主驾|driver|座椅", str(item.get("question", "")), re.IGNORECASE)
)
and not (
resolved_separate_thermal_and_massage
and str(item.get("field", "")) == "passenger.thermal.state"
)
]
existing_summaries = list(result.get("decision_summary", []))
if unique_actions and not forbidden_seat_scope and not result.get("actions"):
+151 -58
View File
@@ -57,6 +57,9 @@ from cognitive_agent.application.mqtt_ota_seat_executor import ( # noqa: E402
MqttOtaSeatExecutor,
)
from cognitive_agent.application.native_can_intent import merge_native_can_intent # noqa: E402
from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
from cognitive_agent.application.native_can_relative import ( # noqa: E402
NativeCanRelativeResolver,
)
@@ -1401,6 +1404,47 @@ class SeatDemoService:
}
return merged
def process_can_commands(
self,
commands: object,
conversation_id: str | None = None,
execution_mode: str = "dry_run",
approval_token: str | None = None,
vehicle_id: str | None = None,
simulation_fault: str = "normal",
*,
hardware_client_id: str | None = None,
hardware_access_bypass: bool = True,
control_transport: str = WIRED_CAN_TRANSPORT,
) -> dict[str, Any]:
"""Compile and execute an exact UI command batch without intent parsing."""
resolved_conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
if not CONVERSATION_ID_PATTERN.fullmatch(resolved_conversation_id):
raise ValueError(
"conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
)
vehicle_profile = self.vehicle_profiles.get(vehicle_id)
context = self._context_for_vehicle(
self.conversations.snapshot(resolved_conversation_id),
vehicle_profile,
)
direct_can_batch = build_direct_can_intent(commands, context)
normalized_commands, _parsed = direct_can_batch
return self.process(
";".join(normalized_commands),
provider="local",
conversation_id=resolved_conversation_id,
execution_mode=execution_mode,
approval_token=approval_token,
vehicle_id=vehicle_id,
simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
control_transport=control_transport,
_direct_can_batch=direct_can_batch,
)
def process(
self,
utterance: str,
@@ -1420,6 +1464,7 @@ class SeatDemoService:
_model_parse_source: str = "cloud_model",
_allow_empty_control_result: bool = False,
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1444,6 +1489,28 @@ class SeatDemoService:
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
# Local T2G/ZLG hardware access, firmware profiles and the local
# physical-bench lease are unrelated to MQTT relay execution.
return self._process_once(
utterance,
provider,
conversation_id,
execution_mode,
approval_token,
vehicle_id,
simulation_fault,
reasoning_effort,
control_transport,
_local_preview,
_excluded_control_signatures,
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1464,6 +1531,7 @@ class SeatDemoService:
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1483,6 +1551,7 @@ class SeatDemoService:
_model_parse_source,
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
)
def _process_once(
@@ -1501,6 +1570,7 @@ class SeatDemoService:
model_parse_source: str = "cloud_model",
allow_empty_control_result: bool = False,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1569,9 +1639,13 @@ class SeatDemoService:
vehicle_profile,
)
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
matched_modes = self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
matched_modes = (
[]
if direct_can_batch is not None
else self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
)
)
traces: list[dict[str, Any]] = []
result: dict[str, Any] = {
@@ -1614,6 +1688,9 @@ class SeatDemoService:
for item in context.get("presets", [])
],
"trace_events": traces,
"can_commands": (
list(direct_can_batch[0]) if direct_can_batch is not None else []
),
}
started = time.perf_counter()
@@ -1652,40 +1729,45 @@ class SeatDemoService:
result,
traces,
)
parsed, provider_used, provider_notice = self._interpret(
utterance,
provider,
context,
vehicle_profile,
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
if direct_can_batch is not None:
parsed = deepcopy(direct_can_batch[1])
provider_used = "direct_can_control"
provider_notice = None
else:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
parsed, provider_used, provider_notice = self._interpret(
utterance,
provider,
context,
vehicle_profile,
reasoning_effort=reasoning_effort,
local_preview=local_preview,
model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
else:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
else None
)
parsed = merge_native_can_intent(
parsed,
utterance,
relative_actions=(
relative_resolution.actions if relative_resolution else None
),
relative_ambiguities=(
relative_resolution.ambiguities if relative_resolution else None
),
relative_claimed_controls=(
relative_resolution.claimed_controls if relative_resolution else None
),
)
parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
else None
)
parsed = merge_native_can_intent(
parsed,
utterance,
relative_actions=(
relative_resolution.actions if relative_resolution else None
),
relative_ambiguities=(
relative_resolution.ambiguities if relative_resolution else None
),
relative_claimed_controls=(
relative_resolution.claimed_controls if relative_resolution else None
),
)
if local_preview:
parsed["ambiguities"] = []
parsed["unresolved_spans"] = []
@@ -2136,7 +2218,8 @@ class SeatDemoService:
compile_started = time.perf_counter()
try:
if hardware_request:
self.hardware_settings.validate_function_config(function_config)
if control_transport != WIRELESS_MQTT_TRANSPORT:
self.hardware_settings.validate_function_config(function_config)
command_plan = self.hardware_compiler.compile(
function_config, context["devices"]
)
@@ -2199,7 +2282,11 @@ class SeatDemoService:
"safety_check",
"succeeded",
(
(
"无线 MQTT CAN0 座椅语义白名单与 seat-ota 报文白名单已通过;"
"本地 T2G 固件 profile、USB-CAN 与物理台架连接不参与校验。"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else (
"硬件启用、授权令牌、固件 profile 与目标座椅配置门禁已通过;"
if self.hardware_settings.approval_required
else "硬件启用、调试免令牌、固件 profile 与目标座椅配置门禁已通过;"
@@ -2220,15 +2307,26 @@ class SeatDemoService:
simulation_fault if simulated_hardware_request else None
),
"position_calibration_gate": (
"runtime_can_preflight" if hardware_request else "not_applicable"
"remote_seat_ota_feedback"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "runtime_can_preflight"
if hardware_request
else "not_applicable"
),
"mapping_status": (
"protocol_hil_verified_combined_firmware_hil_pending"
"mqtt_can0_seat_ota_whitelist"
if hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
else "protocol_hil_verified_combined_firmware_hil_pending"
if hardware_request
else "draft"
),
"firmware_profiles": (
sorted(self.hardware_settings.profiles) if hardware_request else []
sorted(self.hardware_settings.profiles)
if hardware_request
and control_transport != WIRELESS_MQTT_TRANSPORT
else []
),
},
)
@@ -2249,9 +2347,6 @@ class SeatDemoService:
command_plan, function_config, context["devices"]
)
if vehicle_safety_preflight is not None:
execution_report["spec"]["vehicle_safety_preflight"] = (
vehicle_safety_preflight
)
for item in execution_report["spec"].get("results", []):
item.setdefault("telemetry", {})[
"vehicle_safety_preflight"
@@ -4045,10 +4140,15 @@ class SeatDemoService:
compile_started = time.perf_counter()
plans: dict[str, dict[str, Any]] = {}
wireless_hardware_request = (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
)
for domain, config in configs.items():
if domain == "seat":
if hardware_request:
self.hardware_settings.validate_function_config(config)
if not wireless_hardware_request:
self.hardware_settings.validate_function_config(config)
plans[domain] = self.hardware_compiler.compile(
config,
context["devices"],
@@ -4225,13 +4325,10 @@ class SeatDemoService:
"labels": {
"simulation": "mixed" if hardware_request else "true",
"seat_feedback": (
"wireless_mqtt_ch1"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
and native_config_keys
and "seat" not in configs
)
"wireless_mqtt_ch0"
if wireless_hardware_request and "seat" in configs
else "wireless_mqtt_ch1"
if wireless_hardware_request and native_config_keys
else "physical_can_ch1"
if hardware_request and native_config_keys and "seat" not in configs
else "physical_can"
@@ -4276,13 +4373,9 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded and not report_issues else "failed",
(
"无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
and native_config_keys
and execution_succeeded
)
"无线 MQTT CAN0/CAN1 白名单控制帧已提交;"
"车辆实际状态以状态话题回读为准。"
if wireless_hardware_request and execution_succeeded
else "CH1 车身 CAN 动作已按目标字段物理回读闭环执行。"
if hardware_request and native_config_keys and execution_succeeded
else "副驾座椅已按独立 T2G 物理 CAN 回读闭环执行。"
@@ -23,6 +23,22 @@ FUZZY_UNMAPPED_CONTROL = re.compile(
r"(?P<text>(?:座椅|靠背|腿托|空调|车窗|风口|灯光?)"
r"(?:调|弄|搞)(?:得|到)?(?:舒服|合适|好)(?:一点|点|些)?)"
)
OUTLET_MODE_DISABLE = re.compile(
r"(?:关闭|关掉|停止|取消)(?:一下)?(?:空调)?(?:的)?"
r"(?P<feature>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?|"
r"(?P<feature_after>吹面|吹脸|吹脚|除霜)(?:出风口|出风|模式)?"
r"(?:关闭|关掉|停止|取消)"
)
POSITIVE_OUTLET_MODE = re.compile(
r"(?:打开|开启|启用|切换到|切换为|改为|设为|设置为|调整为|调为)"
r"(?:空调)?(?:的)?(?:吹面|吹脸|吹脚|除霜)"
)
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
)
def _span(utterance: str, match: re.Match[str]) -> dict[str, Any]:
@@ -114,6 +130,21 @@ def _remove_seat_target(parsed: dict[str, Any], target: str) -> None:
]
def _remove_capability(parsed: dict[str, Any], capability: str) -> None:
for key in (
"actions",
"domain_actions",
"native_can_actions",
"deferred_requests",
"model_requests",
):
parsed[key] = [
request
for request in parsed.get(key, [])
if request.get("capability") != capability
]
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
"""Preserve negation and unresolved control language without producing CAN."""
@@ -162,6 +193,100 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
disabled_outlets = list(OUTLET_MODE_DISABLE.finditer(utterance))
if disabled_outlets and POSITIVE_OUTLET_MODE.search(utterance) is None:
_remove_capability(result, "climate.set_outlet_mode")
for match in disabled_outlets:
feature = match.group("feature") or match.group("feature_after")
constraints.append(
{
"type": "prohibit_action",
"domain": "climate",
"capability": "climate.set_outlet_mode",
"parameters": {"disabled_feature": feature},
"source_span": _span(utterance, match),
}
)
unresolved.append(
{
"text": match.group(0),
"reason": "outlet_feature_disable_requires_absolute_mode",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": "climate.outlet_mode",
"question": (
"当前CAN白名单只能设置完整出风模式;请明确改为吹脚、除霜等目标模式。"
),
"reason": "outlet_feature_disable_requires_absolute_mode",
},
]
result["decision_summary"] = [
*result.get("decision_summary", []),
"检测到关闭单一出风方向的请求;未将其错误转换为开启该出风模式。",
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
if thermal_middle is not None and not re.search(
r"副驾|副驾驶|乘客|右前", utterance
):
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
native_capability = (
"seat.set_driver_ventilation_native"
if feature == "通风"
else "seat.set_driver_heating_native"
)
canonical_capability = (
"seat.set_ventilation"
if feature == "通风"
else "seat.set_heating"
)
_remove_capability(result, native_capability)
_remove_capability(result, canonical_capability)
constraint = {
"type": "unsupported_discrete_level",
"domain": "seat",
"capability": native_capability,
"parameters": {"requested_level": "medium"},
"source_span": _span(utterance, thermal_middle),
}
if not any(
item.get("type") == constraint["type"]
and item.get("capability") == native_capability
and item.get("parameters") == constraint["parameters"]
for item in constraints
):
constraints.append(constraint)
unresolved.append(
{
"text": thermal_middle.group(0),
"reason": "driver_seat_thermal_middle_level_unsupported",
}
)
result["ambiguities"] = [
*result.get("ambiguities", []),
{
"field": (
"seat.driver.ventilation.level"
if feature == "通风"
else "seat.driver.heating.level"
),
"question": (
f"主驾座椅{feature}实车白名单只有低档和高档,"
"请明确选择低档或高档。"
),
"reason": "driver_seat_thermal_middle_level_unsupported",
},
]
result["decision_summary"] = [
*result.get("decision_summary", []),
f"识别到主驾座椅{feature}中档请求;实车协议不支持中档,未降级为其他档位。",
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
if not result.get("actions") and not result.get("domain_actions"):
for match in FUZZY_UNMAPPED_CONTROL.finditer(utterance):
if not any(item.get("text") == match.group("text") for item in unresolved):
@@ -43,18 +43,24 @@ MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
)
MQTT_SAFETY_FEEDBACK_IDS = frozenset({0x342, 0x4EB})
MQTT_VEHICLE_FEEDBACK_IDS = OBSERVABLE_IDS | frozenset({0x2F5, 0x37B})
# 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})
# CAN0 accepts both state formats currently emitted by the passenger-seat
# stack. The production firmware publishes its authoritative classic-CAN
# status directly on 0x301..0x306/0x4FA. Older head-unit builds expose the
# read-only seat-ota request/response exchange on 0x6A0/0x6A2; that exchange is
# retained and translated to an internal 0x302 position frame below.
MQTT_SEAT_NATIVE_FEEDBACK_IDS = frozenset(
{0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x4FA}
)
MQTT_SEAT_WIRE_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset(
{0x6A0, 0x6A2}
)
MQTT_SEAT_FEEDBACK_IDS = MQTT_SEAT_NATIVE_FEEDBACK_IDS | frozenset({0x6A2})
MQTT_ACCEPTED_FEEDBACK_IDS = {
0: MQTT_SEAT_FEEDBACK_IDS,
1: MQTT_VEHICLE_FEEDBACK_IDS,
}
MQTT_APPROVED_CONTROL_IDS = {
0: frozenset({0x6A0}),
0: frozenset({0x29E, 0x3CE, 0x6A0}),
1: frozenset({0x1DE, 0x32B, 0x3FC, 0x431, 0x4C1, 0x4EF, 0x4F5}),
}
MQTT_OTA_SEAT_RUNTIME_OPCODES = frozenset({0x63, 0x64, 0x65, 0x66})
@@ -449,6 +455,39 @@ def _validate_ota_seat_runtime_control(data: bytes) -> None:
)
def _validate_native_seat_thermal_control(frame: CanFrame) -> None:
"""Validate the two runtime thermal frames implemented by the CAN0 firmware."""
if len(frame.data) != 8:
raise CanGatewayError("CAN0 passenger-seat thermal control requires DLC 8")
if frame.arbitration_id == 0x3CE:
mode = (frame.data[0] >> 4) & 0x03
if mode not in {1, 2} or frame.data[0] & 0xCF or any(frame.data[1:]):
raise CanGatewayError("CAN0 0x3CE thermal mode payload is invalid")
return
if frame.arbitration_id != 0x29E:
raise CanGatewayError("CAN0 native thermal frame ID is invalid")
fan_on = frame.data[0] & 0x03
fan_mode = (frame.data[0] >> 2) & 0x03
heating_on = (frame.data[0] >> 4) & 0x03
fan_level = frame.data[2] & 0x0F
heating_level = (frame.data[2] >> 4) & 0x0F
if (
fan_on not in {1, 2}
or fan_mode != 1
or heating_on not in {1, 2}
or fan_level not in range(4)
or heating_level not in range(4)
or (fan_on == 1 and fan_level == 0)
or (fan_on == 2 and fan_level != 0)
or (heating_on == 1 and heating_level == 0)
or (heating_on == 2 and heating_level != 0)
or frame.data[1] != 0
or any(frame.data[3:])
):
raise CanGatewayError("CAN0 0x29E thermal control payload is invalid")
class MqttCanTransport:
"""One process-wide MQTT connection serving snapshots and executor buses."""
@@ -633,7 +672,7 @@ class MqttCanTransport:
discriminator = (
frame.data[0]
if frame.channel == 0
and frame.arbitration_id == 0x302
and frame.arbitration_id in {0x302, 0x303, 0x304}
and frame.data
else 0
)
@@ -751,7 +790,15 @@ class MqttCanTransport:
initial_frames = tuple(
frame
for (seen_channel, _frame_id, _discriminator),
(captured_monotonic, frame) in sorted(self._latest_bus_frames.items())
(captured_monotonic, frame) in sorted(
self._latest_bus_frames.items(),
key=lambda item: (
0 if item[0][1] == 0x306 else
1 if item[0][1] == 0x4FA else
2,
item[0],
),
)
if replay_latest
and seen_channel == channel
and now - captured_monotonic <= self.freshness_s
@@ -772,8 +819,10 @@ class MqttCanTransport:
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:
if frame.channel == 0 and frame.arbitration_id == 0x6A0:
_validate_ota_seat_runtime_control(frame.data)
elif frame.channel == 0:
_validate_native_seat_thermal_control(frame)
with self._condition:
client = self._client
state = self._state
+47 -4
View File
@@ -367,7 +367,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
path,
)
if (
path not in {"/api/requests", "/api/requests/preview"}
path not in {
"/api/requests",
"/api/requests/preview",
"/api/can/commands",
}
and not supplement_match
):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -402,7 +406,17 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if self.auth.config.enabled:
self.auth.api_requests.record(username)
payload = self._read_json_body()
utterance = str(payload.get("utterance", ""))
direct_can_commands = (
payload.get("commands") if path == "/api/can/commands" else None
)
utterance = (
";".join(
item if isinstance(item, str) else repr(item)
for item in direct_can_commands
)
if isinstance(direct_can_commands, list)
else str(payload.get("utterance", ""))
)
source = str(payload.get("client_type", "head_unit"))
if utterance:
self.vehicle_display_sync.update_task(
@@ -451,7 +465,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload.get("control_transport", "wired_can")
),
}
if "application/x-ndjson" in self.headers.get("Accept", ""):
if (
path != "/api/can/commands"
and "application/x-ndjson" in self.headers.get("Accept", "")
):
self._stream_control_request(
path,
supplement_match,
@@ -460,7 +477,33 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
source=source,
)
return
if path == "/api/requests/preview":
if path == "/api/can/commands":
result = self.service.process_can_commands(
direct_can_commands,
conversation_id=(
str(payload["conversation_id"])
if payload.get("conversation_id")
else None
),
execution_mode=str(payload.get("execution_mode", "dry_run")),
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
vehicle_id=(
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
simulation_fault=str(
payload.get("simulation_fault", "normal")
),
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
control_transport=str(
payload.get("control_transport", "wired_can")
),
)
elif path == "/api/requests/preview":
result = self.service.preview_native_can(**common_arguments)
elif supplement_match:
result = self.service.supplement_native_can(