完善模式卡片、NFC及多设备控制流程

This commit is contained in:
minghanchen
2026-09-01 16:18:57 +08:00
parent ca26259e63
commit cbf230182d
15 changed files with 1092 additions and 191 deletions
@@ -112,10 +112,16 @@ class ModeOperation:
)
def to_request(self, mode_name: str) -> dict[str, Any]:
target_scope = self.target_scope
if self.domain == "seat" and target_scope == "front_right":
# Compiled seat operations use the physical zone name, while the
# model-plan adapter exposes the logical passenger target. Normalize
# persisted cards so a validated target is not queried again.
target_scope = "passenger"
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"target_scope": target_scope,
"operation": self.operation,
"parameters": [
{"name": name, "value": value}
@@ -450,6 +456,26 @@ class ModeProfileRegistry:
public.append(item)
return public
def get_profile(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
"""Resolve the effective preset or user override by its stable ID."""
clean_mode_id = str(mode_id).strip()
profile = next(
(
item
for item in self._profiles_for_vehicle(vehicle_id)
if item.id == clean_mode_id
),
None,
)
if profile is None:
raise KeyError(clean_mode_id)
return profile
def match_text(
self,
utterance: str,
+152 -47
View File
@@ -608,6 +608,59 @@ class SeatDemoService:
"vehicle_id": resolved_vehicle_id,
}
def get_mode_profile_card(
self,
mode_id: str,
vehicle_id: str | None = None,
) -> ModeProfile:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
def execute_mode_profile_card(
self,
mode_id: str,
*,
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]:
"""Execute a persisted mode by ID without interpreting natural language."""
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
if not profile.command_flow or not profile.command_flow.get("plans"):
raise ValueError("该模式尚未生成可直接执行的控制指令流。")
result = self.process(
profile.instruction or f"进入{profile.name}",
provider="local",
conversation_id=conversation_id,
execution_mode=execution_mode,
approval_token=approval_token,
vehicle_id=resolved_vehicle_id,
simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass,
control_transport=control_transport,
_direct_mode_profile_id=profile.id,
)
public_profile = profile.public_dict()
result["provider_used"] = "saved_mode_command_flow"
result["utterance"] = profile.name
result["matched_modes"] = [public_profile]
result["mode_profile"] = public_profile
result["orchestration_route"] = "saved_mode_command_flow"
result["decision_summary"] = [
f"直接载入已保存的模式“{profile.name}”,未再次解析自然语言。",
*list(result.get("decision_summary") or []),
]
self._persist(result)
return result
def save_mode_profile_card(
self,
mode_id: str,
@@ -616,7 +669,7 @@ class SeatDemoService:
source_request_id: str,
vehicle_id: str | None = None,
) -> dict[str, Any]:
"""Persist one edited mode from a server-generated CAN command flow."""
"""Persist one edited mode from a server-generated control flow."""
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
clean_mode_id = str(mode_id).strip()
@@ -632,7 +685,7 @@ class SeatDemoService:
if len(clean_instruction) > 1000:
raise ValueError("自然语言指令不能超过 1000 个字符。")
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id):
raise ValueError("CAN 指令流来源请求编号无效。")
raise ValueError("控制指令流来源请求编号无效。")
visible_profiles = self.mode_profiles._profiles_for_vehicle( # noqa: SLF001
resolved_vehicle_id
@@ -653,15 +706,15 @@ class SeatDemoService:
result_path = self.artifact_root / clean_request_id / "result.json"
if not result_path.is_file():
raise ValueError("未找到执行按钮生成的 CAN 指令流,请重新执行。")
raise ValueError("未找到“生成指令”按钮产生的控制指令流,请重新生成。")
try:
source_result = json.loads(result_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("CAN 指令流来源结果无法读取,请重新执行。") from exc
raise ValueError("控制指令流来源结果无法读取,请重新生成。") from exc
if not isinstance(source_result, dict) or source_result.get(
"request_id"
) != clean_request_id:
raise ValueError("CAN 指令流来源结果不完整,请重新执行。")
raise ValueError("控制指令流来源结果不完整,请重新生成。")
recorded_instruction = str(source_result.get("utterance", "")).strip()
if not recorded_instruction:
source_spans = {
@@ -676,12 +729,12 @@ class SeatDemoService:
if len(source_spans) == 1:
recorded_instruction = next(iter(source_spans))
if recorded_instruction and recorded_instruction != clean_instruction:
raise ValueError("自然语言指令已修改,请重新点击执行生成 CAN 指令流。")
raise ValueError("自然语言指令已修改,请重新点击“生成指令”。")
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 指令流。")
raise ValueError("解析结果中没有可永久化的控制指令流。")
executable_plan_keys = {
key
for key, plan in plans.items()
@@ -691,7 +744,7 @@ class SeatDemoService:
and plan["spec"]["commands"]
}
if not executable_plan_keys:
raise ValueError("请先点击执行并成功生成 CAN 控制指令流。")
raise ValueError("请先点击“生成指令”并成功生成控制指令流。")
operations: list[dict[str, Any]] = []
signatures: set[tuple[str, str, str, str]] = set()
@@ -707,7 +760,12 @@ class SeatDemoService:
if not isinstance(operation, dict):
continue
capability = str(operation.get("capability", "")).strip()
target = str((operation.get("target") or {}).get("zone", "")).strip()
operation_target = operation.get("target") or {}
target = str(
operation_target.get("instance")
or operation_target.get("zone")
or ""
).strip()
parameters = operation.get("parameters") or {}
if not domain or not capability or not target or not isinstance(
parameters, dict
@@ -733,37 +791,8 @@ class SeatDemoService:
}
)
if not operations:
raise ValueError("CAN 指令流没有关联到可复用的模式操作。")
raise ValueError("控制指令流没有关联到可复用的模式操作。")
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,
@@ -776,7 +805,10 @@ class SeatDemoService:
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,
# A mode card stores only the validated, deterministic plan. Live
# delivery evidence belongs to each later execution, not to the
# generation workflow.
"execution_steps": [],
}
saved = self.mode_profiles.save_mode(
clean_name,
@@ -1677,6 +1709,7 @@ class SeatDemoService:
_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,
_direct_mode_profile_id: str | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1702,6 +1735,7 @@ class SeatDemoService:
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -1723,6 +1757,7 @@ class SeatDemoService:
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1744,6 +1779,7 @@ class SeatDemoService:
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1764,6 +1800,7 @@ class SeatDemoService:
_allow_empty_control_result,
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
)
def _process_once(
@@ -1783,6 +1820,7 @@ class SeatDemoService:
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,
direct_mode_profile_id: str | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1831,6 +1869,14 @@ class SeatDemoService:
if not CONVERSATION_ID_PATTERN.fullmatch(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)
direct_mode_profile = (
self.mode_profiles.get_profile(
direct_mode_profile_id,
vehicle_profile.id,
)
if direct_mode_profile_id
else None
)
if mode_create_name is not None:
return self._process_mode_profile_create(
@@ -1852,7 +1898,9 @@ class SeatDemoService:
)
context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id)
matched_modes = (
[]
[direct_mode_profile]
if direct_mode_profile is not None
else []
if direct_can_batch is not None
else self.mode_profiles.match_text(
utterance,
@@ -1941,7 +1989,56 @@ class SeatDemoService:
result,
traces,
)
if direct_can_batch is not None:
if direct_mode_profile is not None:
mode_requests = self.mode_profiles.requests_for_profiles(
[direct_mode_profile]
)
detected_domains = list(
dict.fromkeys(
str(item.get("domain") or "")
for item in mode_requests
if str(item.get("domain") or "")
)
)
parsed = self.model_plan_adapter.adapt(
{
"detected_domains": detected_domains or ["seat"],
"requests": mode_requests,
"ambiguities": [],
"decision_summary": [
f"已载入模式“{direct_mode_profile.name}”保存的结构化操作。"
],
"confidence": 1.0,
},
context,
vehicle_profile,
)
saved_native_actions: list[dict[str, Any]] = []
for request in mode_requests:
parameters = {
str(item.get("name")): deepcopy(item.get("value"))
for item in request.get("parameters", [])
if isinstance(item, dict) and str(item.get("name") or "")
}
action = {
"domain": str(request.get("domain") or ""),
"capability": str(request.get("capability") or ""),
"target": str(request.get("target_scope") or "vehicle"),
"parameters": parameters,
"operation": str(request.get("operation") or "set"),
"reason": str(request.get("reason") or ""),
"execution_backend": "native_can",
"parse_source": "saved_mode_command_flow",
}
if self.native_can_runtime.supports(action):
saved_native_actions.append(action)
parsed["native_can_actions"] = [
*list(parsed.get("native_can_actions", [])),
*saved_native_actions,
]
provider_used = "saved_mode_command_flow"
provider_notice = None
elif direct_can_batch is not None:
parsed = deepcopy(direct_can_batch[1])
provider_used = "direct_can_control"
provider_notice = None
@@ -1980,6 +2077,11 @@ class SeatDemoService:
relative_resolution.claimed_controls if relative_resolution else None
),
)
if direct_mode_profile is not None:
parsed = self._mark_control_parse_source(
parsed,
"saved_mode_command_flow",
)
if local_preview:
parsed["ambiguities"] = []
parsed["unresolved_spans"] = []
@@ -1993,11 +2095,14 @@ class SeatDemoService:
parsed = self._partition_native_actions(
parsed,
promote_supported=(
hardware_request
and (
self.vehicle_mqtt_settings.enabled
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.enabled
direct_mode_profile is not None
or (
hardware_request
and (
self.vehicle_mqtt_settings.enabled
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings.enabled
)
)
),
)
@@ -35,6 +35,7 @@ MAX_PLAN_STEPS = 12
MIN_PLAN_DELAY_MS = 1
MAX_PLAN_DELAY_MS = 30_000
MAX_TOTAL_PLAN_DELAY_MS = 60_000
ADJACENT_INFRARED_COMMAND_DELAY_SECONDS = 0.8
DEFAULT_GSCU_MQTT_HOST = "127.0.0.1"
DEFAULT_GSCU_MQTT_PORT = 1883
DEFAULT_GSCU_COMMAND_TOPIC = "topic/from_n7"
@@ -2542,7 +2543,7 @@ class SelfExtensionService:
and isinstance(next_step, dict)
and next_step.get("kind") == "peripheral"
):
time.sleep(0.25)
time.sleep(ADJACENT_INFRARED_COMMAND_DELAY_SECONDS)
succeeded = not interrupted and completed_steps == len(steps)
status = "succeeded" if succeeded else (
+220 -3
View File
@@ -795,15 +795,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_id: str | None,
execution_mode: str,
) -> dict[str, object]:
"""Execute already validated local-rule infrared capabilities."""
"""Execute already validated infrared capabilities."""
capabilities = [
{
**dict(entry["capability"]),
"parse_source": "local_rule",
**capability,
"parse_source": str(capability.get("parse_source") or "local_rule"),
}
for entry in entries
if isinstance(entry, dict) and isinstance(entry.get("capability"), dict)
for capability in [dict(entry["capability"])]
]
if not capabilities:
raise ExtensionIntegrationError("没有可执行的已学习红外能力。")
@@ -1454,6 +1455,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
def _handle_mode_profile_post(self, path: str) -> None:
execute_match = re.fullmatch(
r"/api/mode-profiles/(?P<mode_id>[a-z0-9][a-z0-9_-]{0,63})/execute",
path,
)
if execute_match is not None:
self._handle_mode_profile_execute(execute_match.group("mode_id"))
return
mode_id = path.removeprefix("/api/mode-profiles/").strip()
if not mode_id:
self._json(
@@ -1482,6 +1491,214 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
def _saved_mode_infrared_entries(
self,
profile: object,
) -> list[dict[str, object]]:
command_flow = getattr(profile, "command_flow", None)
if not isinstance(command_flow, dict):
return []
configurations = command_flow.get("function_configs") or {}
extension_config = None
if isinstance(configurations, dict):
extension_config = configurations.get("self_extension")
if not isinstance(extension_config, dict):
extension_config = next(
(
config
for config in configurations.values()
if isinstance(config, dict)
and str((config.get("spec") or {}).get("domain"))
== "self_extension"
),
None,
)
operations = (
(extension_config.get("spec") or {}).get("operations") or []
if isinstance(extension_config, dict)
else []
)
if not operations:
return []
snapshot = self.nfc.snapshot()
card = snapshot.get("card") if isinstance(snapshot, dict) else None
active_uid = re.sub(
r"[^0-9A-Fa-f]",
"",
str((card or {}).get("uid") or ""),
).upper()
if not active_uid or str((card or {}).get("action") or "present") != "present":
raise ExtensionIntegrationError(
"该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。"
)
catalog = self.extension.list_peripheral_capabilities()
entries: list[dict[str, object]] = []
for index, operation in enumerate(operations):
if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit":
continue
parameters = operation.get("parameters") or {}
target = operation.get("target") or {}
try:
slot = int(parameters.get("slot"))
except (TypeError, ValueError) as exc:
raise ExtensionIntegrationError(
"已保存模式包含无效的红外槽位。"
) from exc
device_id = str(target.get("device") or "").strip()
capability = next(
(
item
for item in catalog
if item.get("status") == "learned"
and item.get("enabled", True) is True
and int(item.get("slot") or 0) == slot
and str(item.get("device_id") or "") == device_id
and re.sub(
r"[^0-9A-Fa-f]",
"",
str(item.get("card_uid") or ""),
).upper()
== active_uid
),
None,
)
if capability is None:
raise ExtensionIntegrationError(
f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。"
)
entries.append(
{
"index": index,
"clause": str(parameters.get("action") or f"IR {slot}"),
"capability": {
**capability,
"parse_source": "saved_mode_command_flow",
},
}
)
return entries
def _handle_mode_profile_execute(self, mode_id: str) -> None:
utterance = ""
source = "web_console_mode_card"
try:
payload = self._read_json_body(max_bytes=16 * 1024)
vehicle_id = str(payload.get("vehicle_id", "")) or None
profile = self.service.get_mode_profile_card(mode_id, vehicle_id)
utterance = profile.name
source = str(payload.get("client_type") or source)
execution_mode = str(payload.get("execution_mode", "dry_run"))
control_transport = str(
payload.get("control_transport", "wired_can")
)
conversation_id = (
str(payload["conversation_id"])
if payload.get("conversation_id")
else None
)
hardware_client_id = (
str(payload["hardware_client_id"])
if payload.get("hardware_client_id")
else None
)
self.vehicle_display_sync.update_task(
status="running",
utterance=utterance,
source=source,
)
# Validate the complete saved flow, including the live NFC
# binding, before any transport is allowed to execute.
entries = self._saved_mode_infrared_entries(profile)
can_result = self.service.execute_mode_profile_card(
mode_id,
conversation_id=conversation_id,
execution_mode=execution_mode,
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
vehicle_id=vehicle_id,
simulation_fault=str(payload.get("simulation_fault", "normal")),
hardware_client_id=hardware_client_id,
hardware_access_bypass=self._is_unproxied_loopback_request(),
control_transport=control_transport,
)
if entries:
infrared_result = self._execute_local_peripheral_actions(
utterance,
entries,
provider="saved_mode_command_flow",
conversation_id=conversation_id,
execution_mode=execution_mode,
)
result = self._merge_can_and_peripheral_results(
utterance,
can_result,
infrared_result,
entries,
)
result["provider_used"] = "saved_mode_command_flow"
result["orchestration_route"] = "saved_mode_command_flow"
result["mode_profile"] = profile.public_dict()
self.service.persist_composed_result(result)
else:
result = can_result
self.vehicle_display_sync.update_task(
status=str(result.get("status") or "succeeded"),
utterance=utterance,
source=source,
result=result,
)
self._json(result, HTTPStatus.OK)
except KeyError:
self._json(
{"error": "not_found", "message": "未找到需要执行的模式卡片。"},
HTTPStatus.NOT_FOUND,
)
except HardwareAccessRequiredError as exc:
self._json(
{
"error": "hardware_access_required",
"message": str(exc),
"access_state": exc.state,
},
HTTPStatus.FORBIDDEN,
)
except HardwareBusyError as exc:
self._json(
{
"error": "hardware_busy",
"message": str(exc),
"retry_after_seconds": exc.retry_after_seconds,
},
HTTPStatus.CONFLICT,
)
except (HardwareSafetyError, CanGatewayError, GatewayError) as exc:
if utterance:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "mode_execution_failed", "message": str(exc)},
)
self._json(
{"error": "mode_execution_failed", "message": str(exc)},
HTTPStatus.SERVICE_UNAVAILABLE,
)
except (ExtensionIntegrationError, ValueError, json.JSONDecodeError) as exc:
if utterance:
self.vehicle_display_sync.update_task(
status="failed",
utterance=utterance,
source=source,
error={"code": "invalid_mode_command_flow", "message": str(exc)},
)
self._json(
{"error": "invalid_mode_command_flow", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
def _handle_extension_post(self, path: str) -> None:
action_map = {
"/api/extension/gscu/info": "info",