diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 0f6fc58..c5d7847 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -17,6 +17,8 @@ function createConversationId() {
return createOpaqueId("conv");
}
+const HARDWARE_CLIENT_ID_PATTERN = /^hwc-[a-z0-9-]{8,80}$/;
+
function loadConversationId() {
const stored = sessionStorage.getItem("cognitive-agent.conversation-id");
// A browser runtime restart is a new virtual run. Do not reuse the old
@@ -33,12 +35,19 @@ function loadConversationId() {
function loadHardwareClientId() {
const key = "cognitive-agent.hardware-client-id";
const stored = localStorage.getItem(key);
- if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
+ if (HARDWARE_CLIENT_ID_PATTERN.test(stored || "")) return stored;
const clientId = createOpaqueId("hwc");
localStorage.setItem(key, clientId);
return clientId;
}
+function validatedHardwareClientId() {
+ if (!HARDWARE_CLIENT_ID_PATTERN.test(state.hardwareClientId || "")) {
+ state.hardwareClientId = loadHardwareClientId();
+ }
+ return state.hardwareClientId;
+}
+
function loadControlTransport() {
const stored = localStorage.getItem("cognitive-agent.control-transport");
return ["wired_can", "wireless_mqtt"].includes(stored) ? stored : "wired_can";
@@ -5131,6 +5140,7 @@ const MODE_DOMAIN_LABELS = {
window: "车窗",
front_light: "车灯",
vehicle_status: "车辆状态",
+ android: "车机媒体",
};
const MODE_TARGET_LABELS = {
@@ -5140,6 +5150,7 @@ const MODE_TARGET_LABELS = {
front_right: "右前 / 副驾",
rear_left: "左后",
rear_right: "右后",
+ headunit: "车机",
front_row: "前排",
rear_row: "后排",
vehicle: "全车",
@@ -8241,6 +8252,7 @@ const CONFIG_DOMAIN_META = {
window: { label: "车窗", icon: "panels-top-left", className: "generic" },
outlet: { label: "电动风口", icon: "wind", className: "climate" },
interior_light: { label: "车内灯", icon: "lightbulb", className: "ambient" },
+ android: { label: "车机媒体", icon: "clapperboard", className: "generic" },
};
const CONFIG_CAPABILITY_LABELS = {
@@ -8286,6 +8298,7 @@ const CONFIG_CAPABILITY_LABELS = {
"roof.set_shade_position": "设置遮阳帘位置",
"roof.set_shade_motion": "控制遮阳帘动作",
"roof.set_automatic_dimming": "设置自动调光",
+ "android.launch_app": "打开视频媒体",
};
const CONFIG_PARAMETER_LABELS = {
@@ -8309,6 +8322,8 @@ const CONFIG_PARAMETER_LABELS = {
outlet_mode: "出风模式",
mode: "工作模式",
mode_id: "固件模式编号",
+ package_name: "应用包名",
+ activity_name: "启动页面",
};
function makeConfigElement(tagName, className, textValue) {
@@ -9412,6 +9427,9 @@ window.CognitiveAgentConsole = {
getExecutionMode() {
return state.executionMode;
},
+ getHardwareClientId() {
+ return validatedHardwareClientId();
+ },
getConversationId() {
return state.conversationId;
},
diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js
index 192abd2..e74c5f9 100644
--- a/apps/web-console/assistant-chat.js
+++ b/apps/web-console/assistant-chat.js
@@ -548,6 +548,7 @@
"door.set_rear_lock": "后排门锁",
"door.set_vehicle_lock": "整车门锁",
"infrared.emit": "红外外设",
+ "android.launch_app": "打开视频媒体",
};
function assistantControlCategory(operation = {}, fallback = "控制指令") {
@@ -745,6 +746,22 @@
const brightness = numericValue(parameters.brightness_level);
if (brightness !== null) return `${brightness}级`;
const level = numericValue(parameters.level ?? parameters.fan_level);
+ const capability = String(item.operation?.capability || "");
+ if (capability === "android.launch_app") {
+ const packageName = String(parameters.package_name || "").trim();
+ const activityName = String(parameters.activity_name || "").trim();
+ return [
+ packageName ? `应用 ${packageName}` : "视频应用",
+ activityName ? `页面 ${activityName}` : "",
+ ].filter(Boolean).join(" · ");
+ }
+ if (
+ level !== null
+ && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"]
+ .includes(capability)
+ ) {
+ return ({ 1: "3档", 2: "1档" })[level] || "档位待确认";
+ }
if (level !== null) return `${level}档`;
const state = parameters.motion
?? parameters.state
@@ -769,6 +786,7 @@
if (capability.startsWith("seat.") || category.includes("座椅")) return "armchair";
if (capability.startsWith("door.") || category.includes("门锁")) return "lock-keyhole";
if (capability === "infrared.emit") return "radio-tower";
+ if (capability === "android.launch_app") return "clapperboard";
return "binary";
}
@@ -985,6 +1003,7 @@
"ambient_light.set_power": "氛围灯",
"ambient_light.set_mode": "氛围灯模式",
"infrared.emit": "红外外设",
+ "android.launch_app": "打开视频媒体",
};
const zeroGravityIndexes = actions
@@ -1166,9 +1185,10 @@
? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}`
: motionLabel(params.state || params.mode_id);
if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
- // CH1 driver controls encode high as 1 and low as 2; passenger
- // comfort levels use a different scale and must not reuse this map.
- const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认";
+ // CH1 encodes high as 1 and low as 2 internally. Present the
+ // occupant-facing three-speed scale instead of leaking CAN enums.
+ const level = { 1: "3档(高)", 2: "1档(低)" }[params.level ?? 2]
+ || "档位待确认";
value = params.state === "off" ? "关闭"
: params.state === "on" ? `开启 · ${level}` : "开关状态待确认";
} else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) {
@@ -1529,10 +1549,23 @@
return /(?:构建|创建|新建|新增|生成|添加)(?:一下)?(?:一个|个|一套|套)?[^,。!?!?]{1,64}?模式/.test(compact);
}
+ function validatedHardwareClientId() {
+ const sharedClientId = window.CognitiveAgentConsole?.getHardwareClientId?.();
+ let storedClientId = null;
+ try {
+ storedClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
+ } catch (_error) {
+ // The shared console identity remains authoritative when storage is unavailable.
+ }
+ return [sharedClientId, storedClientId]
+ .map((value) => String(value || "").trim())
+ .find((value) => /^hwc-[a-z0-9-]{8,80}$/.test(value)) || null;
+ }
+
function requestBody(message) {
const configuredExecutionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run";
const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can";
- const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
+ const hardwareClientId = validatedHardwareClientId();
const vehicleId = $("#vehicleSelect")?.value || null;
const modeProfileDraftContext = requestModeDraftContext(message);
const modeConfigurationPreview = Boolean(modeProfileDraftContext)
@@ -1967,6 +2000,11 @@
&& result?.schedule_execution?.status === "running";
const dryRun = result?.execution_mode === "dry_run";
const waitingForFeedback = result?.execution_outcome === "submitted_unverified";
+ const delegatedCount = Array.isArray(result?.deferred_requests)
+ ? result.deferred_requests.filter(
+ (item) => item?.execution_backend === "n7_vehicle_transfer",
+ ).length
+ : 0;
let content = `${modeName}执行未完成,请查看控制指令状态。`;
let statusText = "功能模式执行未完成";
@@ -1991,8 +2029,15 @@
: "功能模式执行已完成";
statusState = "success";
} else if (status === "partially_succeeded") {
- content = `${modeName}仅部分完成,请查看未完成的控制指令。`;
- statusText = "功能模式部分完成";
+ content = delegatedCount > 0
+ ? `${modeName}指令已提交,未由 PC 执行的 ${delegatedCount} 项动作将由车机继续处理。`
+ : waitingForFeedback
+ ? `${modeName}指令已提交,正在等待车辆状态确认。`
+ : `${modeName}执行结果已更新,请查看各控制指令状态。`;
+ statusText = waitingForFeedback || delegatedCount > 0
+ ? "功能模式指令已提交"
+ : "功能模式执行结果已更新";
+ statusState = waitingForFeedback || delegatedCount > 0 ? "working" : "success";
}
setMessageContent(message, content, { final: true });
diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js
index 73be120..d820f4d 100644
--- a/apps/web-console/console-v2.js
+++ b/apps/web-console/console-v2.js
@@ -571,9 +571,20 @@
const explicitlyBlocked = executionStatus === "blocked"
|| verificationStatus === "blocked"
|| Boolean(telemetry.authorization_reason);
- const executionFailed = executionStatus === "failed"
+ // Closed-loop evidence is authoritative. Some MQTT window executors
+ // retain a legacy `failed` operation status even after fresh 0x388
+ // feedback confirms the requested target; do not render that as a
+ // failed dispatch once the physical result is verified.
+ const verified = !aborted && (
+ verificationStatus === "verified_success"
+ || telemetry.physical_effect_confirmed === true
+ || telemetry.feedback_match === true
+ );
+ const executionFailed = !verified && (
+ executionStatus === "failed"
|| explicitlyBlocked
- || verificationStatus === "failed_or_blocked";
+ || verificationStatus === "failed_or_blocked"
+ );
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
|| /未收到.*(?:有效|新鲜).*车窗位置/.test(failureMessage)
@@ -591,11 +602,6 @@
|| /thermal protection|热保护/i.test(failureMessage)
);
const dispatchFailed = executionFailed && !submitted && !safetyBlocked;
- const verified = !executionFailed && !aborted && (
- verificationStatus === "verified_success"
- || telemetry.physical_effect_confirmed === true
- || telemetry.feedback_match === true
- );
const deliveryState = aborted
? "aborted"
: safetyBlocked
@@ -1325,7 +1331,7 @@
}
const levelOptions = [
- ["off", "关闭"], ["high", "1 档(高)"], ["low", "2 档(低)"],
+ ["off", "关闭"], ["low", "1 档(低)"], ["high", "3 档(高)"],
];
const toggleOptions = [["off", "关闭"], ["on", "开启"]];
const massageModeOptions = [
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index 98eea6e..d3bc96f 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -39,13 +39,13 @@
-
+
-
-
-
+
+
+
diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js
index 3f87a0e..6427c58 100644
--- a/apps/web-console/self-extension.js
+++ b/apps/web-console/self-extension.js
@@ -382,6 +382,8 @@
};
let peripheralCatalogRevision = null;
let peripheralCatalogSyncQueue = Promise.resolve();
+ let durablePeripheralCatalog = [];
+ let durablePeripheralCatalogHydrated = false;
let petCardSnapshot = {
revision: -1,
cards: [],
@@ -1973,11 +1975,40 @@
}).filter((item) => item.device_id && item.card_uid && validSlot(item.slot));
}
+ function applyDurablePeripheralCatalog(
+ catalog,
+ revision = null,
+ { render = true } = {},
+ ) {
+ const nextCatalog = (Array.isArray(catalog) ? catalog : [])
+ .filter((item) => item && typeof item === "object"
+ && item.id && item.device_id && normalizeCardUid(item.card_uid)
+ && validSlot(item.slot))
+ .map((item) => ({ ...item, card_uid: normalizeCardUid(item.card_uid) }));
+ const changed = JSON.stringify(durablePeripheralCatalog) !== JSON.stringify(nextCatalog)
+ || !durablePeripheralCatalogHydrated;
+ durablePeripheralCatalog = nextCatalog;
+ durablePeripheralCatalogHydrated = true;
+ if (revision != null && String(revision).trim()) {
+ peripheralCatalogRevision = String(revision).trim();
+ }
+ if (changed) unifiedExtensionDirty = true;
+ if (render && changed) renderUnifiedExtensionView?.({ force: true });
+ return changed;
+ }
+
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
- peripheralCatalogRevision = payload.catalog_revision || peripheralCatalogRevision;
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
- if (!catalog.length) return 0;
+ applyDurablePeripheralCatalog(
+ catalog,
+ payload.catalog_revision,
+ { render: false },
+ );
+ if (!catalog.length) {
+ renderAll();
+ return 0;
+ }
const restoredDeviceIds = new Set();
catalog.forEach((item) => {
if (!item || typeof item !== "object" || !item.id || !validSlot(item.slot)) return;
@@ -3806,12 +3837,13 @@
}
async function syncPeripheralCatalog(device = null, { throwOnError = false } = {}) {
+ const nextCatalog = peripheralCatalog();
const operation = peripheralCatalogSyncQueue
.catch(() => null)
.then(() => api("display-sync", {
method: "POST",
body: JSON.stringify({
- peripherals: peripheralCatalog(),
+ peripherals: nextCatalog,
device,
catalog_revision: peripheralCatalogRevision,
}),
@@ -3819,7 +3851,10 @@
peripheralCatalogSyncQueue = operation.catch(() => null);
try {
const result = await operation;
- peripheralCatalogRevision = result.catalog_revision || peripheralCatalogRevision;
+ applyDurablePeripheralCatalog(
+ nextCatalog,
+ result.catalog_revision,
+ );
return result;
} catch (error) {
console.warn("外设能力同步失败", error);
@@ -4169,6 +4204,18 @@
status: item.status,
enabled: item.enabled,
})),
+ durableCatalogHydrated: durablePeripheralCatalogHydrated,
+ durableCatalog: durablePeripheralCatalog.map((item) => ({
+ id: item.id,
+ deviceId: item.device_id,
+ deviceName: item.device_name,
+ deviceType: item.device_type,
+ typeLabel: item.type_label,
+ action: item.action,
+ cardUid: item.card_uid,
+ status: item.status,
+ enabled: item.enabled,
+ })),
lastAction: snapshot?.extension?.last_action ? {
command: snapshot.extension.last_action.command,
type: snapshot.extension.last_action.type,
@@ -4249,27 +4296,28 @@
const sharedCatalog = Array.isArray(snapshot?.extension?.catalog)
? snapshot.extension.catalog
: [];
- const mergedCatalog = [...sharedCatalog, ...peripheralCatalog()]
+ const fallbackCatalog = [...sharedCatalog, ...peripheralCatalog()]
.filter((item, index, catalog) => {
const identity = item.id || `${item.device_id}:${item.action}`;
return catalog.findIndex((candidate) => (
candidate.id || `${candidate.device_id}:${candidate.action}`
) === identity) === index;
});
+ const persistentCatalog = (
+ durablePeripheralCatalogHydrated
+ ? durablePeripheralCatalog
+ : fallbackCatalog
+ ).filter((item) => (
+ normalizeCardUid(item.card_uid)
+ && item.status === "learned"
+ ));
const nfcReader = nfcSnapshot.reader || {};
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
const nfcPet = linkedPetForCard(sensedCardUid);
- const activeCatalog = sensedCardUid
- ? mergedCatalog.filter((item) => (
- normalizeCardUid(item.card_uid) === sensedCardUid
- && item.status === "learned"
- && item.enabled !== false
- ))
- : [];
const grouped = new Map();
- activeCatalog.forEach((item) => {
+ persistentCatalog.forEach((item) => {
const deviceId = item.device_id || "external-device";
if (!grouped.has(deviceId)) grouped.set(deviceId, {
id: deviceId,
@@ -4278,7 +4326,10 @@
cardUid: item.card_uid || null,
actions: [],
});
- grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
+ const actionName = item.action || item.name || "已学习能力";
+ grouped.get(deviceId).actions.push(
+ item.enabled === false ? `${actionName}(已停用)` : actionName,
+ );
});
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
@@ -4294,8 +4345,10 @@
: petCardSnapshot.publisher?.state === "burst_failed"
? "宠物消息发送失败"
: "已感应宠物 NFC"
- : devices.length
+ : nfcDevice
? "已感应 NFC 外设"
+ : devices.length
+ ? `${devices.length} 个外设已绑定`
: sensedCardUid
? "当前 NFC 暂无已授权命令"
: nfcReader.online
@@ -4361,11 +4414,11 @@
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : nfcPet ? "" : `
-
${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}
+
当前没有已绑定外设外设绑定 NFC 后将持续显示,解除绑定后移除
`}
`;
unifiedExtensionFingerprint = renderFingerprint;
@@ -4377,6 +4430,17 @@
window.addEventListener("cognitive-agent:display-sync", (event) => {
snapshot = event.detail || null;
+ const extensionSnapshot = snapshot?.extension;
+ if (
+ Array.isArray(extensionSnapshot?.catalog)
+ && extensionSnapshot?.result?.catalog_revision
+ ) {
+ applyDurablePeripheralCatalog(
+ extensionSnapshot.catalog,
+ extensionSnapshot.result.catalog_revision,
+ { render: false },
+ );
+ }
if (snapshot?.extension_health?.nfc) {
applyNfcSnapshot(snapshot.extension_health.nfc, { announce: false });
}
@@ -4402,8 +4466,8 @@
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
- })
- .finally(() => syncPeripheralCatalog());
+ return syncPeripheralCatalog();
+ });
return true;
}
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index 9304111..7b2e4d1 100644
--- a/apps/web-console/service-worker.js
+++ b/apps/web-console/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview";
+const CACHE_VERSION = "cognitive-agent-pad-v334-video-media";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,18 +6,18 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-24-dialog-size",
"/network.js?v=android-pad-2",
- "/app.js?v=dual-seat-145-n7-dispatch",
+ "/app.js?v=dual-seat-147-video-media",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
- "/console-v2.js?v=local-first-77-performance",
+ "/console-v2.js?v=local-first-79-cinema-status",
"/self-extension.css?v=pet-settings-54-dialog-size",
- "/self-extension.js?v=nfc-mqtt-56",
+ "/self-extension.js?v=nfc-persistent-57",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-30",
- "/assistant-chat.js?v=codex-assistant-35",
+ "/assistant-chat.js?v=codex-assistant-39-video-media",
"/energy-monitor.css?v=energy-monitor-5-dialog-size",
"/energy-monitor.js?v=energy-monitor-5-performance",
"/ui-consistency.css?v=spatial-ui-8",
diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py
index a48fc58..c3483fc 100644
--- a/src/cognitive_agent/application/assistant_capabilities.py
+++ b/src/cognitive_agent/application/assistant_capabilities.py
@@ -2465,8 +2465,19 @@ class AssistantCapabilityCatalog:
if isinstance(command_flow, Mapping)
else None
)
+ local_n7_transfer_actions = (
+ command_flow.get("n7_transfer_actions")
+ if isinstance(command_flow, Mapping)
+ else None
+ )
n7_transfer_ready = (
- raw.get("source_topic") == "from_n7/scenes"
+ (
+ raw.get("source_topic") == "from_n7/scenes"
+ or (
+ isinstance(local_n7_transfer_actions, list)
+ and bool(local_n7_transfer_actions)
+ )
+ )
and isinstance(command_plans, Mapping)
and bool(command_plans)
)
diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py
index 2856de6..2080ebe 100644
--- a/src/cognitive_agent/application/assistant_plan_guard.py
+++ b/src/cognitive_agent/application/assistant_plan_guard.py
@@ -726,6 +726,17 @@ def _level_matches(text: str) -> set[int]:
return values
+def _driver_native_thermal_level_matches(text: str) -> set[int]:
+ """Translate occupant-facing 1/3 gears to CH1 low/high enum values."""
+
+ values: set[int] = set()
+ if re.search(r"(?:1|一)\s*(?:档|级)|最低|低档|低速", text):
+ values.add(2)
+ if re.search(r"(?:3|三)\s*(?:档|级)|最高|高档|高速", text):
+ values.add(1)
+ return values
+
+
def _axis_number_matches(
text: str,
axis: str,
@@ -861,7 +872,16 @@ def _semantic_parameter_error(
return "temperature_c 没有原始文本数值依据"
for name in {"level", "brightness_level"} & values.keys():
- levels = _level_matches(text)
+ levels = (
+ _driver_native_thermal_level_matches(text)
+ if name == "level"
+ and capability
+ in {
+ "seat.set_driver_ventilation_native",
+ "seat.set_driver_heating_native",
+ }
+ else _level_matches(text)
+ )
value = values[name]
if levels:
if not any(_numeric_equal(value, expected) for expected in levels):
diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py
index a2a1740..3755fb3 100644
--- a/src/cognitive_agent/application/codex_assistant.py
+++ b/src/cognitive_agent/application/codex_assistant.py
@@ -2727,7 +2727,22 @@ class CodexAssistantService:
raise TypeError("vehicle control returned an invalid result")
safe_result = self._safe_vehicle_result(result)
action_state, successful, completion_text = _vehicle_action_state(result)
- if mode_profile_preview:
+ if (
+ saved_mode_execution
+ and result.get("status") == "partially_succeeded"
+ ):
+ successful = True
+ action_state = (
+ "simulated"
+ if result.get("execution_mode") != "hardware"
+ else "submitted"
+ )
+ completion_text = (
+ "功能模式模拟编排已完成"
+ if action_state == "simulated"
+ else "功能模式指令已提交"
+ )
+ elif mode_profile_preview:
successful = _mode_profile_result_succeeded(result)
action_state = "simulated" if successful else "failed"
completion_text = (
@@ -4232,6 +4247,7 @@ class CodexAssistantService:
"local_control_elapsed_ms",
"local_control_within_budget",
"natural_language_routing",
+ "parallel_dispatch",
):
if result.get(key) is not None:
payload[key] = CodexAssistantService._safe_result_value(
@@ -4444,6 +4460,11 @@ class CodexAssistantService:
}
if result.get("orchestration_route") is not None:
safe["orchestration_route"] = str(result["orchestration_route"])
+ if isinstance(result.get("parallel_dispatch"), Mapping):
+ safe["parallel_dispatch"] = CodexAssistantService._safe_result_value(
+ result["parallel_dispatch"],
+ secret=secret,
+ )
validation = result.get("validation")
if isinstance(validation, Sequence) and not isinstance(
validation,
diff --git a/src/cognitive_agent/application/extension_control.py b/src/cognitive_agent/application/extension_control.py
index 37ab1d1..27c0ed7 100644
--- a/src/cognitive_agent/application/extension_control.py
+++ b/src/cognitive_agent/application/extension_control.py
@@ -17,6 +17,11 @@ from typing import Any
from cognitive_agent.application.extension_toy_publisher import (
normalize_extension_nfc_id,
)
+from cognitive_agent.application.self_extension import (
+ is_peripheral_color_capability,
+ is_peripheral_power_off_capability,
+ is_peripheral_power_on_capability,
+)
EXTENSION_CONTROL_TOPIC = "extension/ctl"
@@ -194,7 +199,7 @@ def authorize_extension_control_actions(
request: Mapping[str, Any],
catalog: object,
) -> list[dict[str, Any]]:
- """Authorize every action before any TCP command may be sent."""
+ """Authorize every action and inject mandatory light power prerequisites."""
actions = request.get("actions")
if not isinstance(actions, list):
@@ -251,9 +256,86 @@ def authorize_extension_control_actions(
"action": deepcopy(dict(action)),
"capability_id": str(capability.get("id") or ""),
"extension_name": str(capability.get("device_name") or ""),
+ "capability": capability,
+ "auto_inserted": False,
+ "sequence_order": index * 2 + 1,
}
)
- return authorized
+
+ allowed_by_device: dict[str, list[dict[str, Any]]] = {}
+ for capability in allowlist.values():
+ device_id = str(capability.get("device_id") or "").strip()
+ if device_id:
+ allowed_by_device.setdefault(device_id, []).append(capability)
+ expanded: list[dict[str, Any]] = []
+ powered_devices: set[str] = set()
+ next_index = len(actions)
+ for item in sorted(
+ authorized,
+ key=lambda value: (value["at_ms"], value["sequence_order"]),
+ ):
+ capability = item["capability"]
+ device_id = str(capability.get("device_id") or "").strip()
+ if is_peripheral_power_on_capability(capability):
+ powered_devices.add(device_id)
+ elif is_peripheral_power_off_capability(capability):
+ powered_devices.discard(device_id)
+ elif (
+ is_peripheral_color_capability(capability)
+ and device_id not in powered_devices
+ ):
+ prerequisite = next(
+ (
+ candidate
+ for candidate in sorted(
+ allowed_by_device.get(device_id, []),
+ key=lambda value: str(value.get("id") or ""),
+ )
+ if is_peripheral_power_on_capability(candidate)
+ ),
+ None,
+ )
+ if prerequisite is None:
+ raise ExtensionControlAuthorizationError(
+ "extension/ctl color action requires an enabled, NFC-bound "
+ f"power-on action for the same device: {device_id}"
+ )
+ prerequisite_slot = int(prerequisite["slot"])
+ prerequisite_action = str(prerequisite.get("action") or "").strip()
+ expanded.append(
+ {
+ "index": next_index,
+ "at_ms": item["at_ms"],
+ "action": {
+ "type": "infrared_emit",
+ "at_ms": item["at_ms"],
+ "domain": "self_extension",
+ "capability": "infrared.emit",
+ "target_scope": device_id,
+ "operation": "emit",
+ "parameters": {
+ "action": prerequisite_action,
+ "slot": prerequisite_slot,
+ },
+ },
+ "capability_id": str(prerequisite.get("id") or ""),
+ "extension_name": str(
+ prerequisite.get("device_name") or ""
+ ),
+ "capability": prerequisite,
+ "auto_inserted": True,
+ "dependency": "power_on_before_color",
+ "sequence_order": int(item["sequence_order"]) - 1,
+ }
+ )
+ next_index += 1
+ powered_devices.add(device_id)
+ expanded.append(item)
+ if len(expanded) > MAX_EXTENSION_CONTROL_ACTIONS:
+ raise ExtensionControlAuthorizationError(
+ "extension/ctl exceeds the action limit after adding power-on prerequisites"
+ )
+ return expanded
class ExtensionControlConsumer:
@@ -477,7 +559,14 @@ class ExtensionControlConsumer:
self._record_rejection(exc, request=request)
return
- ordered_actions = sorted(actions, key=lambda item: (item["at_ms"], item["index"]))
+ ordered_actions = sorted(
+ actions,
+ key=lambda item: (
+ item["at_ms"],
+ item.get("sequence_order", item["index"]),
+ item["index"],
+ ),
+ )
action_statuses = [
{
"index": authorized["index"],
@@ -488,6 +577,8 @@ class ExtensionControlConsumer:
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
+ "auto_inserted": authorized.get("auto_inserted") is True,
+ "dependency": authorized.get("dependency"),
"state": "pending",
"state_label": "等待下发",
"state_detail": f"计划在接收后 {authorized['at_ms']} ms 下发",
@@ -583,6 +674,8 @@ class ExtensionControlConsumer:
"target_scope": authorized["action"]["target_scope"],
"action": authorized["action"]["parameters"]["action"],
"slot": authorized["action"]["parameters"]["slot"],
+ "auto_inserted": authorized.get("auto_inserted") is True,
+ "dependency": authorized.get("dependency"),
"transport": str(result.get("transport") or "tcp"),
"gateway_acknowledged": gateway_acknowledged,
"physical_state_confirmed": physical_state_confirmed,
diff --git a/src/cognitive_agent/application/mode_scene_catalog.py b/src/cognitive_agent/application/mode_scene_catalog.py
index 03b07c9..802401e 100644
--- a/src/cognitive_agent/application/mode_scene_catalog.py
+++ b/src/cognitive_agent/application/mode_scene_catalog.py
@@ -267,6 +267,33 @@ def _scene_from_profile(
for operation, target, operation_ref in effective_operations
]
)
+ local_transfer_actions = (
+ profile.command_flow.get("n7_transfer_actions")
+ if not profile.sync_origin
+ and isinstance(profile.command_flow, dict)
+ and isinstance(profile.command_flow.get("n7_transfer_actions"), list)
+ else []
+ )
+ transfer_by_identity: dict[str, dict[str, Any]] = {}
+ for raw_action in local_transfer_actions:
+ if not isinstance(raw_action, dict):
+ continue
+ try:
+ transfer_operation = ModeOperation.from_dict(raw_action)
+ except (KeyError, TypeError, ValueError):
+ continue
+ transfer_by_identity[_operation_identity(transfer_operation)] = deepcopy(
+ raw_action
+ )
+ if transfer_by_identity:
+ actions = [
+ transfer_by_identity.get(_operation_identity(operation), action)
+ for (operation, _, _), action in zip(
+ effective_operations,
+ actions,
+ strict=True,
+ )
+ ]
scene = {
"id": profile.id,
"label": profile.name,
diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py
index 4a59b9e..fb9a895 100644
--- a/src/cognitive_agent/application/native_can_intent.py
+++ b/src/cognitive_agent/application/native_can_intent.py
@@ -106,9 +106,13 @@ OUTLET_MODE_DISABLE_PATTERN = re.compile(
)
DRIVER_SEAT_THERMAL_MIDDLE_PATTERN = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
- r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
+ r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:2|二)(?:档|级)|"
+ r"(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
- r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
+ r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)|"
+ r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}[,,;;]"
+ r"(?:挡位|档位)?(?:调到|调为|设为|设置为|调整到|调整为)?"
+ r"(?:2|二)(?:档|级)"
)
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN = re.compile(
r"^(?:(?:然后|随后|接着|同时|并且|再|并|以及)?(?:把|将)?(?:按摩)?)"
@@ -116,6 +120,12 @@ DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN = re.compile(
r"(?:切换|改成|改为|调整|调成|调为|设置|设为)"
r".{0,8}(?:波浪|脉冲|伸展|拉伸|松弛|舒展|放松)(?:模式)?)"
)
+DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN = re.compile(
+ r"^(?:(?:然后|随后|接着|同时|并且|再|并|以及)?(?:把|将)?)"
+ r"(?:通风|加热)?(?:挡位|档位)?"
+ r"(?:切换|改成|改为|调整|调到|调成|调为|设置|设为)?"
+ r"(?:[1-3一二三](?:档|级)|最低|低档|最高|高档|中档|中等|适中)$"
+)
_FEATURE_BOUNDARY = re.compile(
r"并且|同时|然后|随后|接着|以及|并|再|和|与|、|,"
)
@@ -775,6 +785,23 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
clause = f"{clause}{continuation}"
continuation_index += 1
index = continuation_index - 1
+ if "通风" in clause or "加热" in clause:
+ continuation_index = index + 1
+ while (
+ continuation_index < len(seat_clauses)
+ and DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN.search(
+ seat_clauses[continuation_index]
+ )
+ ):
+ continuation = re.sub(
+ r"^(?:然后|随后|接着|同时|并且|再|并|以及)?"
+ r"(?:把|将)?(?:通风|加热)?",
+ "",
+ seat_clauses[continuation_index],
+ )
+ clause = f"{clause}{continuation}"
+ continuation_index += 1
+ index = continuation_index - 1
scope = _seat_target_scope(clause)
if scope == "driver":
inherited_driver_scope = True
@@ -864,11 +891,13 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
if re.search(r"关闭|关掉|停止|取消|不要|别开", thermal_text):
parameters = {"state": "off"}
elif thermal_grade == "high" or re.search(
- r"高速|(?:1|一)(?:档|级)", thermal_text
+ r"高速|(?:3|三)(?:档|级)", thermal_text
):
parameters = {"state": "on", "level": 1}
+ elif re.search(r"(?:2|二)(?:档|级)|中档|中等|适中", thermal_text):
+ parameters = None
elif thermal_grade == "low" or re.search(
- r"低速|(?:2|二)(?:档|级)", thermal_text
+ r"低速|(?:1|一)(?:档|级)", thermal_text
) or re.search(r"打开|开启|启动|启用", thermal_text):
parameters = {"state": "on", "level": 2}
elif shared_thermal_state == "off":
@@ -1177,7 +1206,8 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact,
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
r"通风|加热|前移|后移|向前移动|向后移动",
- DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.pattern,
+ rf"(?:{DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.pattern})|"
+ rf"(?:{DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN.pattern})",
)
actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(
seat_text
@@ -1794,7 +1824,7 @@ def merge_native_can_intent(
utterance
)
if unsupported_thermal_middle is not None and not re.search(
- r"副驾|副驾驶|乘客|右前", utterance
+ r"副驾|副驾驶|乘客|乘员|右前", utterance
):
result = dict(parsed)
feature = (
diff --git a/src/cognitive_agent/application/native_can_runtime.py b/src/cognitive_agent/application/native_can_runtime.py
index a33cd30..a51624e 100644
--- a/src/cognitive_agent/application/native_can_runtime.py
+++ b/src/cognitive_agent/application/native_can_runtime.py
@@ -1334,7 +1334,10 @@ class NativeCanExecutor:
# separate evidence level in ``verification_status`` and
# ``physical_effect_confirmed``. A feedback timeout must
# not be rewritten as a transport/orchestration failure.
- succeeded = delivered
+ # A closed-loop target may already be satisfied before a
+ # new frame is needed. Fresh matching feedback is a
+ # successful no-op, not a failed dispatch.
+ succeeded = delivered or verified
verification_status = (
"verified_success"
if verified
@@ -1354,7 +1357,11 @@ class NativeCanExecutor:
publication_start:
]
)
- delivery_status = "submitted"
+ delivery_status = (
+ "not_required"
+ if verified and not delivered
+ else "submitted"
+ )
if not feedback_required and delivered:
delivery_status = (
"published"
@@ -1371,7 +1378,9 @@ class NativeCanExecutor:
"domain": function_config["spec"]["domain"],
"status": "succeeded" if succeeded else "failed",
"message": (
- "MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
+ "车辆反馈已确认目标状态,无需重复下发控制帧。"
+ if verified and not delivered
+ else "MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
if not feedback_required and delivery_status == "published"
else "MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。"
if not feedback_required
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 48b3439..30b2586 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -104,6 +104,7 @@ from cognitive_agent.application.native_can_feedback import ( # noqa: E402
)
from cognitive_agent.application.native_can_intent import ( # noqa: E402
DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN,
+ DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN,
merge_native_can_intent,
)
from cognitive_agent.application.native_can_relative import ( # noqa: E402
@@ -202,6 +203,39 @@ LOCAL_CONTROL_BUDGET_MS = 1000
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
+N7_CINEMA_RELATED_PATTERN = re.compile(
+ r"影院|观影|电影|看电影|cinema|movie|theater|theatre",
+ re.IGNORECASE,
+)
+N7_CINEMA_BASE_NAMES = frozenset(
+ {"影院", "影院模式", "观影", "观影模式", "cinema", "cinemamode"}
+)
+N7_FLASHING_LIGHT_PATTERN = re.compile(
+ r"闪烁灯|闪光灯|闪灯玩具|flashing\s*light",
+ re.IGNORECASE,
+)
+N7_LIGHT_STRIP_PATTERN = re.compile(
+ r"灯带|灯条|light\s*strip|led\s*strip",
+ re.IGNORECASE,
+)
+N7_PERIPHERAL_ACTIVATE_PATTERN = re.compile(
+ r"打开|开启|启动|点亮|\bon\b|power\s*on",
+ re.IGNORECASE,
+)
+N7_PERIPHERAL_OFF_PATTERN = re.compile(
+ r"关闭|关掉|熄灭|\boff\b|power\s*off",
+ re.IGNORECASE,
+)
+N7_PERIPHERAL_COLOR_PATTERN = re.compile(
+ r"红色|绿色|蓝色|白色|紫色|黄色|橙色|粉色|"
+ r"\bred\b|\bgreen\b|\bblue\b|\bwhite\b|\bpurple\b|"
+ r"\byellow\b|\borange\b|\bpink\b",
+ re.IGNORECASE,
+)
+N7_CINEMA_VIDEO_APP_PATTERN = re.compile(
+ r"视频|媒体|爱奇艺|video|media|videoplay|iqiyi",
+ re.IGNORECASE,
+)
def _wireless_mqtt_publish_preflight() -> dict[str, Any]:
@@ -472,6 +506,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
lambda scene: self._vehicle_mqtt_transport.publish_mode_scene(scene),
topic=MQTT_MODE_SCENE_TOPIC,
)
+ self._mode_scene_peripheral_catalog_provider: Callable[
+ [], list[dict[str, Any]]
+ ] = lambda: []
self._mode_scene_ingress = ModeSceneIngressConsumer(
self.import_n7_mode_scene,
)
@@ -637,6 +674,378 @@ class SeatDemoService(ModeScheduleServiceMixin):
return self.publish_saved_mode_scene(mode_id, vehicle_id)
+ @staticmethod
+ def _n7_scene_identity_text(scene: Mapping[str, Any]) -> str:
+ aliases = scene.get("aliases", [])
+ return " ".join(
+ str(value or "")
+ for value in (
+ scene.get("id"),
+ scene.get("name"),
+ scene.get("label"),
+ scene.get("description"),
+ *(aliases if isinstance(aliases, list) else []),
+ )
+ )
+
+ @classmethod
+ def _is_n7_cinema_scene(cls, scene: Mapping[str, Any]) -> bool:
+ return N7_CINEMA_RELATED_PATTERN.search(
+ cls._n7_scene_identity_text(scene)
+ ) is not None
+
+ @staticmethod
+ def _is_n7_cinema_base_scene(scene: Mapping[str, Any]) -> bool:
+ scene_id = re.sub(r"\s+", "", str(scene.get("id") or "")).casefold()
+ names = {
+ re.sub(r"\s+", "", str(scene.get(field) or "")).casefold()
+ for field in ("name", "label")
+ }
+ return scene_id == "cinema" or bool(names & N7_CINEMA_BASE_NAMES)
+
+ @staticmethod
+ def _n7_scene_action_selector(action: Mapping[str, Any]) -> tuple[object, ...]:
+ parameters = action.get("parameters")
+ slot = (
+ parameters.get("slot")
+ if isinstance(parameters, Mapping)
+ and action.get("capability") == "infrared.emit"
+ else None
+ )
+ return (
+ str(action.get("domain") or ""),
+ str(action.get("capability") or ""),
+ str(action.get("target_scope") or ""),
+ str(action.get("operation") or "set"),
+ int(action.get("at_ms") or 0),
+ slot,
+ )
+
+ @classmethod
+ def _merge_n7_scene_actions(
+ cls,
+ base_actions: object,
+ overriding_actions: object,
+ ) -> list[dict[str, Any]]:
+ merged: list[dict[str, Any]] = []
+ positions: dict[tuple[object, ...], int] = {}
+ for source in (base_actions, overriding_actions):
+ if not isinstance(source, list):
+ continue
+ for raw_action in source:
+ if not isinstance(raw_action, Mapping):
+ continue
+ action = deepcopy(dict(raw_action))
+ selector = cls._n7_scene_action_selector(action)
+ existing = positions.get(selector)
+ if existing is None:
+ positions[selector] = len(merged)
+ merged.append(action)
+ else:
+ merged[existing] = action
+ return merged
+
+ def _n7_cinema_base_scene(
+ self,
+ incoming_scene: Mapping[str, Any],
+ ) -> dict[str, Any] | None:
+ if self._is_n7_cinema_base_scene(incoming_scene):
+ return None
+ vehicle_id = self.vehicle_profiles.default().id
+ candidates: list[tuple[int, dict[str, Any]]] = []
+ for public_profile in self.mode_profiles.public_modes(vehicle_id):
+ try:
+ profile = self.mode_profiles.get_profile(
+ str(public_profile.get("id") or ""),
+ vehicle_id,
+ )
+ except (KeyError, ValueError):
+ continue
+ if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
+ continue
+ command_flow = profile.command_flow
+ source_scene = (
+ command_flow.get("source_scene")
+ if isinstance(command_flow, Mapping)
+ else None
+ )
+ if not isinstance(source_scene, Mapping) or not self._is_n7_cinema_base_scene(
+ source_scene
+ ):
+ continue
+ priority = 0 if str(source_scene.get("id") or "") == "cinema" else 1
+ candidates.append((priority, deepcopy(dict(source_scene))))
+ if not candidates:
+ return None
+ candidates.sort(key=lambda item: (item[0], str(item[1].get("id") or "")))
+ return candidates[0][1]
+
+ @staticmethod
+ def _is_n7_cinema_video_launch_action(action: Mapping[str, Any]) -> bool:
+ parameters = action.get("parameters")
+ if (
+ action.get("domain") != "android"
+ or action.get("capability") != "android.launch_app"
+ or action.get("target_scope") != "headunit"
+ or action.get("operation") != "launch"
+ or not isinstance(parameters, Mapping)
+ ):
+ return False
+ package_name = str(parameters.get("package_name") or "").strip()
+ activity_name = str(parameters.get("activity_name") or "").strip()
+ if (
+ not package_name
+ or not activity_name
+ or len(package_name) > 256
+ or len(activity_name) > 256
+ ):
+ return False
+ return N7_CINEMA_VIDEO_APP_PATTERN.search(
+ " ".join(
+ (
+ str(action.get("type") or ""),
+ package_name,
+ activity_name,
+ )
+ )
+ ) is not None
+
+ def _local_cinema_n7_transfer_actions(
+ self,
+ mode_name: str,
+ instruction: str = "",
+ ) -> list[dict[str, Any]]:
+ """Reuse the N7 cinema card's video launch for a local cinema variant."""
+
+ scene_identity = {
+ "id": "local-user-mode-variant",
+ "name": f"本地扩展 {str(mode_name or '').strip()}",
+ "label": f"本地扩展 {str(mode_name or '').strip()}",
+ "description": str(instruction or ""),
+ }
+ if not self._is_n7_cinema_scene(scene_identity):
+ return []
+ base_scene = self._n7_cinema_base_scene(scene_identity)
+ if base_scene is None:
+ return []
+ actions = base_scene.get("actions", [])
+ if not isinstance(actions, list):
+ return []
+ return [
+ deepcopy(dict(action))
+ for action in actions
+ if isinstance(action, Mapping)
+ and self._is_n7_cinema_video_launch_action(action)
+ ][:1]
+
+ @staticmethod
+ def _n7_transfer_operation_selector(
+ domain: object,
+ capability: object,
+ target_scope: object,
+ operation: object,
+ parameters: object,
+ ) -> tuple[str, str, str, str, str]:
+ clean_parameters = dict(parameters) if isinstance(parameters, Mapping) else {}
+ return (
+ str(domain or ""),
+ str(capability or ""),
+ str(target_scope or ""),
+ str(operation or "set"),
+ json.dumps(
+ clean_parameters,
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ),
+ )
+
+ @classmethod
+ def _cinema_preferred_color_patterns(
+ cls,
+ scene: Mapping[str, Any],
+ ) -> list[re.Pattern[str]]:
+ text = cls._n7_scene_identity_text(scene)
+ colors = (
+ (r"蓝色|\bblue\b",),
+ (r"紫色|\bpurple\b",),
+ (r"红色|\bred\b",),
+ (r"白色|\bwhite\b",),
+ (r"绿色|\bgreen\b",),
+ (r"黄色|\byellow\b",),
+ (r"橙色|\borange\b",),
+ (r"粉色|\bpink\b",),
+ )
+ explicit = [
+ re.compile(patterns[0], re.IGNORECASE)
+ for patterns in colors
+ if re.search(patterns[0], text, re.IGNORECASE)
+ ]
+ defaults = [re.compile(patterns[0], re.IGNORECASE) for patterns in colors]
+ return [*explicit, *(item for item in defaults if item not in explicit)]
+
+ def _n7_cinema_peripheral_actions(
+ self,
+ scene: Mapping[str, Any],
+ ) -> list[dict[str, Any]]:
+ try:
+ raw_catalog = self._mode_scene_peripheral_catalog_provider()
+ except Exception: # noqa: BLE001 - an unavailable catalog must not block import
+ return []
+ if not isinstance(raw_catalog, list):
+ return []
+ grouped: dict[str, list[dict[str, Any]]] = {}
+ identities: dict[str, str] = {}
+ for raw in raw_catalog:
+ if not isinstance(raw, Mapping):
+ continue
+ device_id = str(raw.get("device_id") or "").strip()
+ card_uid = str(raw.get("card_uid") or "").strip()
+ slot = raw.get("slot")
+ if (
+ not device_id
+ or not card_uid
+ or raw.get("status") != "learned"
+ or raw.get("enabled", True) is False
+ or isinstance(slot, bool)
+ or not isinstance(slot, int)
+ or not 1 <= slot <= 248
+ ):
+ continue
+ item = deepcopy(dict(raw))
+ grouped.setdefault(device_id, []).append(item)
+ names = [
+ raw.get("device_name"),
+ *(raw.get("device_aliases", []) if isinstance(raw.get("device_aliases"), list) else []),
+ *(raw.get("object_names", []) if isinstance(raw.get("object_names"), list) else []),
+ ]
+ identities[device_id] = " ".join(str(value or "") for value in names)
+
+ def action_text(item: Mapping[str, Any]) -> str:
+ return " ".join(
+ [
+ str(item.get("action") or ""),
+ *(item.get("aliases", []) if isinstance(item.get("aliases"), list) else []),
+ *(item.get("action_names", []) if isinstance(item.get("action_names"), list) else []),
+ ]
+ )
+
+ def as_scene_action(
+ item: Mapping[str, Any],
+ *,
+ category: str,
+ kind: str,
+ ) -> dict[str, Any]:
+ device_id = str(item.get("device_id") or "")
+ device_name = str(item.get("device_name") or device_id)
+ slot = int(item["slot"])
+ action_name = str(item.get("action") or "").strip()
+ return {
+ "type": "infrared_emit",
+ "at_ms": 0,
+ "domain": "self_extension",
+ "capability": "infrared.emit",
+ "target_scope": device_id,
+ "target": {
+ "device": device_id,
+ "name": device_name,
+ "card_uid": str(item.get("card_uid") or ""),
+ },
+ "operation": "emit",
+ "parameters": {"action": action_name, "slot": slot},
+ "operation_ref": f"pc-cinema-{category}-{kind}-{slot}",
+ }
+
+ selected_devices: set[str] = set()
+ result: list[dict[str, Any]] = []
+ color_patterns = self._cinema_preferred_color_patterns(scene)
+ for category, identity_pattern in (
+ ("flashing-light", N7_FLASHING_LIGHT_PATTERN),
+ ("light-strip", N7_LIGHT_STRIP_PATTERN),
+ ):
+ device_id = next(
+ (
+ key
+ for key in sorted(grouped)
+ if key not in selected_devices
+ and identity_pattern.search(identities.get(key, key))
+ ),
+ None,
+ )
+ if device_id is None:
+ continue
+ selected_devices.add(device_id)
+ actions = sorted(grouped[device_id], key=lambda item: int(item["slot"]))
+ activation = next(
+ (
+ item
+ for item in actions
+ if N7_PERIPHERAL_ACTIVATE_PATTERN.search(action_text(item))
+ and N7_PERIPHERAL_OFF_PATTERN.search(action_text(item)) is None
+ and N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item)) is None
+ ),
+ None,
+ )
+ color = next(
+ (
+ item
+ for pattern in color_patterns
+ for item in actions
+ if pattern.search(action_text(item))
+ and N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item))
+ ),
+ None,
+ )
+ if color is None:
+ color = next(
+ (
+ item
+ for item in actions
+ if N7_PERIPHERAL_COLOR_PATTERN.search(action_text(item))
+ ),
+ None,
+ )
+ if activation is not None:
+ result.append(
+ as_scene_action(activation, category=category, kind="activate")
+ )
+ if color is not None and (
+ activation is None or color.get("id") != activation.get("id")
+ ):
+ result.append(as_scene_action(color, category=category, kind="color"))
+ return result
+
+ def _augment_n7_cinema_scene(
+ self,
+ scene: dict[str, Any],
+ ) -> dict[str, object] | None:
+ if not self._is_n7_cinema_scene(scene):
+ return None
+ base_scene = self._n7_cinema_base_scene(scene)
+ if base_scene is None:
+ return None
+ base_actions = base_scene.get("actions", [])
+ incoming_actions = scene.get("actions", [])
+ inherited = self._merge_n7_scene_actions(base_actions, incoming_actions)
+ peripheral_actions = self._n7_cinema_peripheral_actions(scene)
+ augmented = self._merge_n7_scene_actions(inherited, peripheral_actions)
+ if len(augmented) > 256:
+ raise ValueError("影院相关模式合并后的动作数量超过 256 项。")
+ scene["actions"] = augmented
+ scene["operation_count"] = len(augmented)
+ scene["domains"] = list(
+ dict.fromkeys(str(action.get("domain") or "") for action in augmented)
+ )
+ return {
+ "base_scene_id": str(base_scene.get("id") or ""),
+ "base_action_count": len(base_actions) if isinstance(base_actions, list) else 0,
+ "incoming_action_count": (
+ len(incoming_actions) if isinstance(incoming_actions, list) else 0
+ ),
+ "peripheral_action_count": len(peripheral_actions),
+ "merged_action_count": len(augmented),
+ }
+
@staticmethod
def _imported_scene_function_configs(
scene: dict[str, Any],
@@ -694,6 +1103,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
"""Upsert one validated N7 card without publishing it back to the broker."""
document = deepcopy(dict(scene))
+ cinema_augmentation = self._augment_n7_cinema_scene(document)
actions = document.get("actions")
if not isinstance(actions, list) or not actions:
raise ValueError("from_n7/scenes card must contain executable actions")
@@ -764,7 +1174,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
sync_origin=MQTT_MODE_SCENE_INGRESS_TOPIC,
upstream_origin=str(document.get("origin") or ""),
)
- return {
+ result = {
"status": "imported",
"scene_id": saved.id,
"scene_name": saved.name,
@@ -772,6 +1182,9 @@ class SeatDemoService(ModeScheduleServiceMixin):
"profile_revision": self.mode_profiles.profile_revision(saved),
"storage": "persistent_yaml",
}
+ if cinema_augmentation is not None:
+ result["cinema_augmentation"] = cinema_augmentation
+ return result
@property
def driver_only_ch1_execution_active(self) -> bool:
@@ -1015,14 +1428,42 @@ class SeatDemoService(ModeScheduleServiceMixin):
requests = self.mode_profiles.requests_for_profiles([profile])
if profile.sync_origin != MQTT_MODE_SCENE_INGRESS_TOPIC:
- return [
- request
- for operation, request in zip(profile.operations, requests, strict=True)
- if not (
+ transfer_actions = self._local_cinema_n7_transfer_actions(
+ profile.name,
+ profile.instruction,
+ )
+ transfer_selectors = {
+ self._n7_transfer_operation_selector(
+ action.get("domain"),
+ action.get("capability"),
+ action.get("target_scope"),
+ action.get("operation"),
+ action.get("parameters"),
+ )
+ for action in transfer_actions
+ }
+ local_requests = []
+ for operation, request in zip(
+ profile.operations,
+ requests,
+ strict=True,
+ ):
+ if (
operation.domain == "self_extension"
and operation.capability == "infrared.emit"
+ ):
+ continue
+ selector = self._n7_transfer_operation_selector(
+ operation.domain,
+ operation.capability,
+ operation.target_scope,
+ operation.operation,
+ operation.parameters,
)
- ], []
+ if selector in transfer_selectors:
+ continue
+ local_requests.append(request)
+ return local_requests, transfer_actions
source_scene = (
profile.command_flow.get("source_scene")
if isinstance(profile.command_flow, dict)
@@ -1109,7 +1550,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
execution_mode: str,
local_preview: bool,
) -> dict[str, Any]:
- """Forward only N7-originated actions never submitted by this PC."""
+ """Forward validated N7-only actions never submitted by this PC."""
actions = result.get("n7_transfer_candidates")
scene = result.get("n7_source_scene")
@@ -1579,6 +2020,48 @@ class SeatDemoService(ModeScheduleServiceMixin):
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
+ n7_transfer_actions = (
+ self._local_cinema_n7_transfer_actions(
+ clean_name,
+ clean_instruction,
+ )
+ if schedule is None
+ else []
+ )
+ if n7_transfer_actions:
+ command_flow["n7_transfer_actions"] = deepcopy(n7_transfer_actions)
+ existing_operation_selectors = {
+ self._n7_transfer_operation_selector(
+ operation.get("domain"),
+ operation.get("capability"),
+ operation.get("target_scope"),
+ operation.get("operation"),
+ operation.get("parameters"),
+ )
+ for operation in operations
+ if isinstance(operation, Mapping)
+ }
+ for action in n7_transfer_actions:
+ selector = self._n7_transfer_operation_selector(
+ action.get("domain"),
+ action.get("capability"),
+ action.get("target_scope"),
+ action.get("operation"),
+ action.get("parameters"),
+ )
+ if selector in existing_operation_selectors:
+ continue
+ operations.append(
+ {
+ "domain": str(action.get("domain") or ""),
+ "capability": str(action.get("capability") or ""),
+ "target_scope": str(action.get("target_scope") or ""),
+ "operation": str(action.get("operation") or "launch"),
+ "parameters": deepcopy(dict(action.get("parameters") or {})),
+ "reason": "继承车机基础影院模式的视频媒体启动动作。",
+ }
+ )
+ existing_operation_selectors.add(selector)
saved = self.mode_profiles.save_mode(
clean_name,
operations,
@@ -1843,6 +2326,16 @@ class SeatDemoService(ModeScheduleServiceMixin):
self._vehicle_mqtt_transport.set_extension_control_rejection_handler(handler)
+ def set_mode_scene_peripheral_catalog_provider(
+ self,
+ provider: Callable[[], list[dict[str, Any]]] | None,
+ ) -> None:
+ """Supply the durable NFC-authorized IR catalog for N7 scene imports."""
+
+ if provider is not None and not callable(provider):
+ raise TypeError("mode scene peripheral catalog provider must be callable")
+ self._mode_scene_peripheral_catalog_provider = provider or (lambda: [])
+
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
@@ -8372,6 +8865,11 @@ class SeatDemoService(ModeScheduleServiceMixin):
and "按摩" in clauses[-1]
and DRIVER_SEAT_MASSAGE_CONTINUATION_PATTERN.search(fragment)
)
+ thermal_continuation = bool(
+ clauses
+ and re.search(r"通风|加热", clauses[-1])
+ and DRIVER_SEAT_THERMAL_CONTINUATION_PATTERN.search(fragment)
+ )
# 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
@@ -8379,7 +8877,7 @@ class SeatDemoService(ModeScheduleServiceMixin):
exclusion_qualifier = bool(
clauses and re.search(r"除外|除去|不包括|不含", fragment)
)
- if massage_continuation or exclusion_qualifier:
+ if massage_continuation or thermal_continuation or exclusion_qualifier:
clauses[-1] = f"{clauses[-1]},{fragment}"
else:
clauses.append(fragment)
diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py
index 0421b2c..322785d 100644
--- a/src/cognitive_agent/application/self_extension.py
+++ b/src/cognitive_agent/application/self_extension.py
@@ -1050,6 +1050,152 @@ def _peripheral_execution_identity_authorized(
return bool(_device_match_score(_compact_match_text(utterance), capability))
+_PERIPHERAL_COLOR_ACTION_PATTERN = re.compile(
+ r"颜色|色彩|红色|红灯|红光|绿色|绿灯|绿光|蓝色|蓝灯|蓝光|白色|白灯|白光|"
+ r"紫色|紫灯|紫光|黄色|黄灯|黄光|橙色|橙灯|橙光|粉色|粉灯|粉光|"
+ r"\b(?:color|red|green|blue|white|purple|yellow|orange|pink)\b",
+ re.IGNORECASE,
+)
+_PERIPHERAL_POWER_ON_ACTION_PATTERN = re.compile(
+ r"打开|开启|启动|点亮|开灯|\b(?:on|power\s*on|turn\s*on)\b",
+ re.IGNORECASE,
+)
+_PERIPHERAL_POWER_OFF_ACTION_PATTERN = re.compile(
+ r"关闭|关掉|停止|关灯|熄灭|\b(?:off|power\s*off|turn\s*off)\b",
+ re.IGNORECASE,
+)
+
+
+def peripheral_capability_action_text(capability: Mapping[str, Any]) -> str:
+ """Return the complete learned action vocabulary used for dependencies."""
+
+ values: list[str] = [str(capability.get("action") or "")]
+ for field in ("aliases", "action_names"):
+ aliases = capability.get(field, [])
+ if isinstance(aliases, str):
+ values.append(aliases)
+ elif isinstance(aliases, list):
+ values.extend(str(value or "") for value in aliases)
+ return " ".join(value.strip() for value in values if value.strip())
+
+
+def is_peripheral_color_capability(capability: Mapping[str, Any]) -> bool:
+ """Return whether an infrared ability changes a light-like device color."""
+
+ return _PERIPHERAL_COLOR_ACTION_PATTERN.search(
+ peripheral_capability_action_text(capability)
+ ) is not None
+
+
+def is_peripheral_power_on_capability(capability: Mapping[str, Any]) -> bool:
+ """Return whether an infrared ability is a stable device activation."""
+
+ text = peripheral_capability_action_text(capability)
+ return bool(
+ _PERIPHERAL_POWER_ON_ACTION_PATTERN.search(text)
+ and _PERIPHERAL_POWER_OFF_ACTION_PATTERN.search(text) is None
+ and _PERIPHERAL_COLOR_ACTION_PATTERN.search(text) is None
+ )
+
+
+def is_peripheral_power_off_capability(capability: Mapping[str, Any]) -> bool:
+ """Return whether an infrared ability explicitly powers a device off."""
+
+ return _PERIPHERAL_POWER_OFF_ACTION_PATTERN.search(
+ peripheral_capability_action_text(capability)
+ ) is not None
+
+
+def add_peripheral_activation_prerequisites(
+ steps: list[dict[str, Any]],
+ catalog: list[dict[str, Any]],
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Insert an authorized power-on step before each device's first color.
+
+ Infrared color commands do not prove that the target is powered. The
+ executor therefore treats a learned power-on command for the same durable
+ device binding as a mandatory dependency. The returned catalog carries
+ the original parse source onto an injected prerequisite so saved modes can
+ still pass their immutable-flow authorization check.
+ """
+
+ ordered_ids: list[str] = []
+ catalog_by_id: dict[str, dict[str, Any]] = {}
+ for raw in catalog[:MAX_CATALOG_ITEMS]:
+ if not isinstance(raw, Mapping):
+ continue
+ capability_id = str(raw.get("id") or "").strip()
+ if not capability_id:
+ continue
+ if capability_id not in catalog_by_id:
+ ordered_ids.append(capability_id)
+ catalog_by_id[capability_id] = dict(raw)
+
+ prepared_steps: list[dict[str, Any]] = []
+ powered_devices: set[str] = set()
+ for raw_step in steps:
+ step = dict(raw_step) if isinstance(raw_step, Mapping) else raw_step
+ if not isinstance(step, dict) or step.get("kind") != "peripheral":
+ prepared_steps.append(step)
+ continue
+ capability_id = str(step.get("capability_id") or "").strip()
+ capability = catalog_by_id.get(capability_id)
+ if capability is None:
+ prepared_steps.append(step)
+ continue
+ device_id = str(capability.get("device_id") or "").strip()
+ if is_peripheral_power_on_capability(capability):
+ if device_id:
+ powered_devices.add(device_id)
+ prepared_steps.append(step)
+ continue
+ if is_peripheral_power_off_capability(capability):
+ if device_id:
+ powered_devices.discard(device_id)
+ prepared_steps.append(step)
+ continue
+ if not is_peripheral_color_capability(capability) or not device_id:
+ prepared_steps.append(step)
+ continue
+ if device_id not in powered_devices:
+ prerequisite = next(
+ (
+ item
+ for item in catalog_by_id.values()
+ if str(item.get("device_id") or "").strip() == device_id
+ and item.get("status") == "learned"
+ and item.get("enabled", True) is not False
+ and bool(_normalized_card_uid(item.get("card_uid")))
+ and is_peripheral_power_on_capability(item)
+ ),
+ None,
+ )
+ if prerequisite is None:
+ raise ExtensionIntegrationError(
+ f"外设 {capability.get('device_name') or device_id} 设置颜色前需要先打开,"
+ "但当前没有已学习、已启用且已绑定 NFC 的打开指令。"
+ )
+ prerequisite_id = str(prerequisite.get("id") or "").strip()
+ parse_source = str(capability.get("parse_source") or "").strip()
+ if parse_source and not prerequisite.get("parse_source"):
+ prerequisite = {**prerequisite, "parse_source": parse_source}
+ catalog_by_id[prerequisite_id] = prerequisite
+ prepared_steps.append(
+ {
+ "kind": "peripheral",
+ "capability_id": prerequisite_id,
+ "dependency": "power_on_before_color",
+ "auto_inserted": True,
+ }
+ )
+ powered_devices.add(device_id)
+ prepared_steps.append(step)
+
+ if len(prepared_steps) > MAX_PLAN_STEPS * 2:
+ raise ExtensionIntegrationError("补齐外设打开前置指令后,动作数量超过安全上限。")
+ return prepared_steps, [catalog_by_id[item_id] for item_id in ordered_ids]
+
+
def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
"""Validate one browser-synchronized learned infrared capability."""
@@ -2615,6 +2761,36 @@ class SelfExtensionService:
raise ExtensionIntegrationError(
"外设名称未在原始指令中被明确提及,已拒绝红外执行。"
)
+ if is_peripheral_color_capability(capability):
+ device_id = str(capability.get("device_id") or "").strip()
+ dependency_catalog = [
+ dict(item)
+ for item in self.list_peripheral_capabilities()
+ if isinstance(item, Mapping)
+ and str(item.get("device_id") or "").strip() == device_id
+ and str(item.get("id") or "").strip()
+ != str(capability.get("id") or "").strip()
+ ]
+ dependency_catalog.append(dict(capability))
+ return self.execute_peripheral_plan_request(
+ utterance,
+ {
+ "provider_used": "infrared_dependency_guard",
+ "summary": "设置外设颜色前自动补齐同设备的打开指令。",
+ "steps": [
+ {
+ "kind": "peripheral",
+ "capability_id": str(capability.get("id") or ""),
+ }
+ ],
+ },
+ dependency_catalog,
+ conversation_id=conversation_id,
+ conversation_turn=conversation_turn,
+ execution_mode=execution_mode,
+ provider_requested=provider_requested,
+ on_commands_validated=on_commands_validated,
+ )
slot = validate_slot(capability.get("slot"))
request_id = f"req-extension-{uuid.uuid4().hex}"
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
@@ -2819,6 +2995,39 @@ class SelfExtensionService:
raise ExtensionIntegrationError("GPT 没有生成可执行的外设动作。")
if any(step.get("kind") == "vehicle" for step in steps if isinstance(step, dict)):
raise ExtensionIntegrationError("当前主编排只允许执行持久绑定的 NFC 外设计划。")
+ selected_device_ids = {
+ str(item.get("device_id") or "").strip()
+ for item in catalog
+ if isinstance(item, Mapping)
+ and str(item.get("device_id") or "").strip()
+ }
+ effective_catalog: list[dict[str, Any]] = [
+ dict(item) for item in catalog if isinstance(item, Mapping)
+ ]
+ known_ids = {
+ str(item.get("id") or "").strip()
+ for item in effective_catalog
+ if str(item.get("id") or "").strip()
+ }
+ for raw_item in self.list_peripheral_capabilities():
+ if not isinstance(raw_item, Mapping):
+ continue
+ item = dict(raw_item)
+ capability_id = str(item.get("id") or "").strip()
+ if (
+ not capability_id
+ or capability_id in known_ids
+ or str(item.get("device_id") or "").strip()
+ not in selected_device_ids
+ ):
+ continue
+ effective_catalog.append(dict(item))
+ known_ids.add(capability_id)
+ steps, catalog = add_peripheral_activation_prerequisites(
+ [dict(step) if isinstance(step, Mapping) else step for step in steps],
+ effective_catalog,
+ )
+ plan_result = {**plan_result, "steps": steps}
catalog_by_id = {
str(item.get("id")): item
for item in catalog
@@ -2884,6 +3093,7 @@ class SelfExtensionService:
function_operations.append(
{
"id": operation_ref,
+ "capability_id": capability_id,
"capability": "infrared.emit",
"target": {
"device": capability.get("device_id"),
@@ -2897,6 +3107,8 @@ class SelfExtensionService:
"parse_source": capability.get(
"parse_source", "local_rule"
),
+ "auto_inserted": step.get("auto_inserted") is True,
+ "dependency": step.get("dependency"),
"timeout_ms": int(self.command_timeout * 1000),
"failure_policy": "abort",
}
diff --git a/src/cognitive_agent/application/semantic_ir.py b/src/cognitive_agent/application/semantic_ir.py
index 37771af..8c8e6d7 100644
--- a/src/cognitive_agent/application/semantic_ir.py
+++ b/src/cognitive_agent/application/semantic_ir.py
@@ -36,9 +36,13 @@ POSITIVE_OUTLET_MODE = re.compile(
)
DRIVER_SEAT_THERMAL_MIDDLE = re.compile(
r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}"
- r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
+ r"(?:中等(?:档|级)?|中档|中级|中间档|适中|(?:2|二)(?:档|级)|"
+ r"(?:调到|调为|调|设为|设置为|调整到|调整为)中)|"
r"(?:中等(?:档|级)?|中档|中级|中间档|适中)"
- r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)"
+ r"[^,,;;。!?!?\n]{0,16}(?:通风|加热)|"
+ r"(?:通风|加热)[^,,;;。!?!?\n]{0,16}[,,;;]"
+ r"(?:挡位|档位)?(?:调到|调为|设为|设置为|调整到|调整为)?"
+ r"(?:2|二)(?:档|级)"
)
META_NON_EXECUTION = re.compile(
r"复述|引用|翻译|改写|总结|解释|分析|造句|"
@@ -542,7 +546,7 @@ def apply_language_safety_guards(
thermal_middle = DRIVER_SEAT_THERMAL_MIDDLE.search(utterance)
if thermal_middle is not None and not re.search(
- r"副驾|副驾驶|乘客|右前", utterance
+ r"副驾|副驾驶|乘客|乘员|右前", utterance
):
feature = "通风" if "通风" in thermal_middle.group(0) else "加热"
native_capability = (
diff --git a/src/cognitive_agent/infrastructure/speech/understanding.py b/src/cognitive_agent/infrastructure/speech/understanding.py
index a27382d..b00c818 100644
--- a/src/cognitive_agent/infrastructure/speech/understanding.py
+++ b/src/cognitive_agent/infrastructure/speech/understanding.py
@@ -864,6 +864,19 @@ class UnderstandingTranscriptProcessor:
while index < len(commands):
current = commands[index]
following = commands[index + 1] if index + 1 < len(commands) else None
+ if (
+ following is not None
+ and current.capability == following.capability
+ and current.target_scope == following.target_scope
+ and current.property_name == following.property_name
+ and current.property_name in {"seat.ventilation", "seat.heating"}
+ and "level" in current.qualifiers.get("implicit_parameters", ())
+ and "level" not in following.qualifiers.get("implicit_parameters", ())
+ ):
+ obsolete_issues.update(current.validation_issues)
+ output.append(following)
+ index += 2
+ continue
if (
following is not None
and current.property_name == following.property_name == "ambient.color"
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index 7973ca8..413a416 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
+import hashlib
import hmac
import ipaddress
import json
@@ -14,6 +15,7 @@ import sys
import threading
import time
from collections.abc import Callable, Mapping
+from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from http import HTTPStatus
from http.cookies import CookieError, SimpleCookie
@@ -95,6 +97,7 @@ from cognitive_agent.auth import ( # noqa: E402
verify_password,
)
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
+ HARDWARE_CLIENT_ID_PATTERN,
HardwareAccessRequiredError,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
@@ -147,6 +150,25 @@ PUBLIC_OTA_DOWNLOAD_PATH = re.compile(
NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
+def _compatible_hardware_client_id(value: object) -> str | None:
+ """Map legacy browser IDs onto the current opaque hardware-client contract.
+
+ Older cached consoles used UUIDs or identifiers containing underscores.
+ They cannot have a valid lease under the current controller contract, but
+ rejecting them before the access workflow runs leaves every hardware mode
+ unusable. A one-way, stable mapping lets those consoles request and reuse
+ an ordinary lease without granting access or exposing the legacy value.
+ """
+
+ raw_value = str(value or "").strip().lower()
+ if not raw_value:
+ return None
+ if HARDWARE_CLIENT_ID_PATTERN.fullmatch(raw_value):
+ return raw_value
+ digest = hashlib.sha256(raw_value.encode("utf-8")).hexdigest()[:32]
+ return f"hwc-legacy-{digest}"
+
+
class VehicleDisplaySync:
"""Process-local snapshot consumed by passive vehicle display clients."""
@@ -272,6 +294,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
catalog_provider=extension.list_peripheral_capabilities,
executor=extension.execute_bound_infrared_action,
)
+ service.set_mode_scene_peripheral_catalog_provider(
+ extension.list_peripheral_capabilities
+ )
service.set_extension_control_handler(extension_control.submit)
service.set_extension_control_rejection_handler(
extension_control.reject_transport_message
@@ -361,7 +386,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
try:
self._json(
self.service.hardware_access.status(
- str(query.get("client_id", [""])[0])
+ _compatible_hardware_client_id(
+ query.get("client_id", [""])[0]
+ )
)
)
except ValueError as exc:
@@ -478,7 +505,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
control_transport = str(
query.get("control_transport", ["wired_can"])[0]
)
- hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
+ hardware_client_id = _compatible_hardware_client_id(
+ query.get("hardware_client_id", [""])[0]
+ )
try:
self._json(
(
@@ -769,10 +798,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
utterance=utterance,
source=source,
)
- hardware_client_id = (
- str(payload["hardware_client_id"])
- if payload.get("hardware_client_id")
- else None
+ hardware_client_id = _compatible_hardware_client_id(
+ payload.get("hardware_client_id")
)
reasoning_effort = (
str(payload["reasoning_effort"])
@@ -1231,6 +1258,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_id: str | None,
execution_mode: str,
on_commands_validated: Callable[[dict[str, object]], None] | None = None,
+ update_display: bool = True,
) -> dict[str, object]:
"""Execute already validated infrared capabilities."""
@@ -1281,17 +1309,257 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
on_commands_validated=on_commands_validated,
)
result["provider_used"] = "infrared_local_rules"
- self.vehicle_display_sync.update_extension(
- action={
- "type": "emit_sequence" if len(capabilities) > 1 else "emit",
- "source": "main_orchestration_local_rule",
- "capability_ids": [item["id"] for item in capabilities],
- "slots": [item["slot"] for item in capabilities],
- },
- result=result,
- )
+ if update_display:
+ self.vehicle_display_sync.update_extension(
+ action={
+ "type": "emit_sequence" if len(capabilities) > 1 else "emit",
+ "source": "main_orchestration_local_rule",
+ "capability_ids": [item["id"] for item in capabilities],
+ "slots": [item["slot"] for item in capabilities],
+ },
+ result=result,
+ )
return result
+ @staticmethod
+ def _run_parallel_control_branches(
+ can_branch: Callable[[], dict[str, object]],
+ peripheral_branch: Callable[[], dict[str, object]],
+ ) -> tuple[dict[str, object], dict[str, object], dict[str, object]]:
+ """Start two independent transports together and retain timing evidence."""
+
+ barrier = threading.Barrier(2)
+ timing_lock = threading.Lock()
+ starts: dict[str, float] = {}
+ durations: dict[str, float] = {}
+
+ def run(
+ name: str,
+ branch: Callable[[], dict[str, object]],
+ ) -> dict[str, object]:
+ barrier.wait(timeout=5)
+ started = time.perf_counter()
+ with timing_lock:
+ starts[name] = started
+ try:
+ return branch()
+ finally:
+ with timing_lock:
+ durations[name] = round(
+ (time.perf_counter() - started) * 1000,
+ 3,
+ )
+
+ total_started = time.perf_counter()
+ with ThreadPoolExecutor(
+ max_workers=2,
+ thread_name_prefix="can-ir-dispatch",
+ ) as pool:
+ can_future = pool.submit(run, "can", can_branch)
+ peripheral_future = pool.submit(run, "peripheral", peripheral_branch)
+ can_result = can_future.result()
+ peripheral_result = peripheral_future.result()
+ started_values = list(starts.values())
+ timing = {
+ "started_skew_ms": round(
+ (max(started_values) - min(started_values)) * 1000,
+ 3,
+ ) if len(started_values) == 2 else None,
+ "total_ms": round(
+ (time.perf_counter() - total_started) * 1000,
+ 3,
+ ),
+ "branches": {
+ name: {"duration_ms": duration}
+ for name, duration in sorted(durations.items())
+ },
+ }
+ return can_result, peripheral_result, timing
+
+ @staticmethod
+ def _parallel_branch_succeeded(result: Mapping[str, object]) -> bool:
+ if str(result.get("status") or "") not in {
+ "succeeded",
+ "partially_succeeded",
+ "no_action",
+ }:
+ return False
+ if str(result.get("execution_outcome") or "") in {
+ "failed",
+ "failed_or_blocked",
+ }:
+ return False
+ validation = result.get("validation", [])
+ return not any(
+ isinstance(issue, Mapping) and issue.get("severity") == "error"
+ for issue in (
+ validation
+ if isinstance(validation, list)
+ else []
+ )
+ )
+
+ def _ensure_parallel_hardware_authorized(
+ self,
+ *,
+ execution_mode: str,
+ control_transport: str,
+ hardware_access_bypass: bool,
+ hardware_client_id: str | None,
+ ) -> None:
+ """Resolve local-bench access before an external branch can start.
+
+ Wireless MQTT is executed by the head unit and is deliberately outside
+ the local T2G/ZLG lease. Requiring a browser hardware client ID there
+ contradicts :meth:`SeatDemoService.process` and rejects every mixed
+ CAN/IR mode because wireless requests correctly omit that identifier.
+ """
+
+ if (
+ execution_mode == "hardware"
+ and control_transport != WIRELESS_MQTT_TRANSPORT
+ and not hardware_access_bypass
+ ):
+ self.service.hardware_access.ensure_authorized(hardware_client_id)
+
+ def _execute_can_and_peripheral_branches(
+ self,
+ *,
+ utterance: str,
+ entries: list[dict[str, object]],
+ execution_mode: str,
+ provider: str,
+ conversation_id: str | None,
+ can_execute: Callable[[], dict[str, object]],
+ can_preflight: Callable[[], dict[str, object]],
+ parallel_eligible: bool,
+ on_preflight_validated: Callable[[dict[str, object]], None] | None = None,
+ ) -> dict[str, object]:
+ """Preflight both branches, then dispatch hardware concurrently."""
+
+ if not entries:
+ return can_execute()
+ if not parallel_eligible or execution_mode != "hardware":
+ can_result = can_execute()
+ infrared_result = self._execute_local_peripheral_actions(
+ utterance,
+ entries,
+ provider=provider,
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ )
+ return self._merge_can_and_peripheral_results(
+ utterance,
+ can_result,
+ infrared_result,
+ entries,
+ )
+
+ can_preview, infrared_preview, preflight_timing = (
+ self._run_parallel_control_branches(
+ can_preflight,
+ lambda: self._execute_local_peripheral_actions(
+ utterance,
+ entries,
+ provider=provider,
+ conversation_id=None,
+ execution_mode="dry_run",
+ update_display=False,
+ ),
+ )
+ )
+
+ if not (
+ self._parallel_branch_succeeded(can_preview)
+ and self._parallel_branch_succeeded(infrared_preview)
+ ):
+ rejected = self._merge_can_and_peripheral_results(
+ utterance,
+ can_preview,
+ infrared_preview,
+ entries,
+ )
+ rejected.update(
+ {
+ "status": "validation_failed",
+ "success": False,
+ "execution_mode": "dry_run",
+ "hardware_execution_allowed": False,
+ "parallel_dispatch": {
+ "enabled": False,
+ "preflight_status": "failed",
+ "preflight": preflight_timing,
+ },
+ }
+ )
+ rejected["decision_summary"] = [
+ *list(rejected.get("decision_summary") or []),
+ "CAN 或外设预校验未通过;两个物理传输分支均未启动。",
+ ]
+ return rejected
+
+ if on_preflight_validated is not None:
+ validated = self._merge_can_and_peripheral_results(
+ utterance,
+ can_preview,
+ infrared_preview,
+ entries,
+ )
+ validated["status"] = "processing"
+ validated["phase"] = "commands_validated"
+ validated["execution_mode"] = "hardware"
+ validated["parallel_dispatch"] = {
+ "enabled": False,
+ "preflight_status": "succeeded",
+ "preflight": preflight_timing,
+ }
+ on_preflight_validated(validated)
+
+ can_result, infrared_result, dispatch_timing = (
+ self._run_parallel_control_branches(
+ can_execute,
+ lambda: self._execute_local_peripheral_actions(
+ utterance,
+ entries,
+ provider=provider,
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ ),
+ )
+ )
+ result = self._merge_can_and_peripheral_results(
+ utterance,
+ can_result,
+ infrared_result,
+ entries,
+ )
+ result["parallel_dispatch"] = {
+ "enabled": True,
+ "preflight_status": "succeeded",
+ "preflight": preflight_timing,
+ "dispatch": dispatch_timing,
+ }
+ result["decision_summary"] = [
+ *list(result.get("decision_summary") or []),
+ "CAN 与外设控制在完整预校验通过后并行启动。",
+ ]
+ return result
+
+ @staticmethod
+ def _mixed_request_requires_transport_order(utterance: str) -> bool:
+ """Keep explicit sequencing and delays out of concurrent dispatch."""
+
+ return bool(
+ re.search(
+ r"然后|随后|接着|之后|"
+ r"先.{0,120}?(?:再|然后|随后|接着)|"
+ r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
+ r"(?:毫秒|秒(?:钟)?|分钟|小时|ms(?![A-Za-z])|s(?![A-Za-z]))",
+ utterance,
+ flags=re.IGNORECASE,
+ )
+ )
+
@staticmethod
def _split_scene_peripheral_plan(
assistant_plan: Mapping[str, object],
@@ -1800,24 +2068,111 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
labels = metadata.setdefault("labels", {})
labels["route"] = "unified_can_infrared"
- infrared_actions = [
- {
- "index": entry.get("index"),
- "clause": entry.get("clause"),
- "capability_id": capability.get("id"),
- "device_id": capability.get("device_id"),
- "device_name": capability.get("device_name"),
- "action": capability.get("action"),
- "slot": capability.get("slot"),
- "parse_source": str(
- capability.get("parse_source") or "local_rule"
- ),
- "transport": str(infrared_result.get("control_transport") or "gscu_tcp"),
- }
- for entry in entries
- for capability in [entry.get("capability")]
- if isinstance(capability, dict)
- ]
+ entry_lookup: dict[tuple[str, object, str], dict[str, object]] = {}
+ for entry in entries:
+ capability = entry.get("capability") if isinstance(entry, dict) else None
+ if not isinstance(capability, dict):
+ continue
+ entry_lookup[
+ (
+ str(capability.get("device_id") or ""),
+ capability.get("slot"),
+ str(capability.get("action") or ""),
+ )
+ ] = entry
+ extension_config = (infrared_result.get("function_configs") or {}).get(
+ "self_extension"
+ )
+ extension_spec = (
+ extension_config.get("spec")
+ if isinstance(extension_config, Mapping)
+ else None
+ )
+ compiled_operations = (
+ extension_spec.get("operations")
+ if isinstance(extension_spec, Mapping)
+ and isinstance(extension_spec.get("operations"), list)
+ else []
+ )
+ infrared_actions: list[dict[str, object]] = []
+ for operation_index, operation in enumerate(compiled_operations):
+ if not isinstance(operation, Mapping):
+ continue
+ target = operation.get("target")
+ parameters = operation.get("parameters")
+ if not isinstance(target, Mapping) or not isinstance(parameters, Mapping):
+ continue
+ device_id = str(target.get("device") or "")
+ slot = parameters.get("slot")
+ action_name = str(parameters.get("action") or "")
+ matched_entry = entry_lookup.get((device_id, slot, action_name), {})
+ matched_capability = (
+ matched_entry.get("capability")
+ if isinstance(matched_entry, dict)
+ else None
+ )
+ capability_id = str(operation.get("capability_id") or "") or str(
+ matched_capability.get("id")
+ if isinstance(matched_capability, Mapping)
+ else ""
+ )
+ auto_inserted = operation.get("auto_inserted") is True
+ infrared_actions.append(
+ {
+ "index": (
+ matched_entry.get("index")
+ if isinstance(matched_entry, dict) and matched_entry
+ else operation_index
+ ),
+ "clause": (
+ matched_entry.get("clause")
+ if isinstance(matched_entry, dict) and matched_entry
+ else "自动前置:打开设备"
+ if auto_inserted
+ else action_name
+ ),
+ "capability_id": capability_id,
+ "device_id": device_id,
+ "device_name": str(target.get("name") or device_id),
+ "action": action_name,
+ "slot": slot,
+ "parse_source": str(
+ operation.get("parse_source") or "local_rule"
+ ),
+ **(
+ {
+ "auto_inserted": True,
+ "dependency": operation.get("dependency"),
+ }
+ if auto_inserted
+ else {}
+ ),
+ "transport": str(
+ infrared_result.get("control_transport") or "gscu_tcp"
+ ),
+ }
+ )
+ if not infrared_actions:
+ infrared_actions = [
+ {
+ "index": entry.get("index"),
+ "clause": entry.get("clause"),
+ "capability_id": capability.get("id"),
+ "device_id": capability.get("device_id"),
+ "device_name": capability.get("device_name"),
+ "action": capability.get("action"),
+ "slot": capability.get("slot"),
+ "parse_source": str(
+ capability.get("parse_source") or "local_rule"
+ ),
+ "transport": str(
+ infrared_result.get("control_transport") or "gscu_tcp"
+ ),
+ }
+ for entry in entries
+ for capability in [entry.get("capability")]
+ if isinstance(capability, dict)
+ ]
preview_only = (
str(can_result.get("execution_mode") or "dry_run") != "hardware"
and str(infrared_result.get("execution_mode") or "dry_run")
@@ -2262,47 +2617,78 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else None
)
entries = self._saved_mode_infrared_entries(profile)
- can_result = self.service.execute_mode_profile_card(
- str(getattr(profile, "id")),
- conversation_id=conversation_id,
- execution_mode=execution_mode,
- approval_token=(
- str(common_arguments["approval_token"])
- if common_arguments.get("approval_token")
- else None
- ),
- vehicle_id=vehicle_id,
- simulation_fault=str(
- common_arguments.get("simulation_fault") or "normal"
- ),
- hardware_client_id=(
- str(common_arguments["hardware_client_id"])
- if common_arguments.get("hardware_client_id")
- else None
- ),
- hardware_access_bypass=bool(
- common_arguments.get("hardware_access_bypass", True)
- ),
- control_transport=str(
- common_arguments.get("control_transport") or "wired_can"
- ),
+ mode_id = str(getattr(profile, "id"))
+ simulation_fault = str(common_arguments.get("simulation_fault") or "normal")
+ control_transport = str(
+ common_arguments.get("control_transport") or "wired_can"
)
- if not entries:
- can_result["utterance"] = utterance
- return can_result
- infrared_result = self._execute_local_peripheral_actions(
- utterance,
- entries,
+ hardware_client_id = _compatible_hardware_client_id(
+ common_arguments.get("hardware_client_id")
+ )
+ hardware_access_bypass = bool(
+ common_arguments.get("hardware_access_bypass", True)
+ )
+ command_flow = getattr(profile, "command_flow", None)
+ execution_steps = (
+ command_flow.get("execution_steps", [])
+ if isinstance(command_flow, Mapping)
+ else []
+ )
+ parallel_eligible = bool(
+ getattr(profile, "schedule", None) is None
+ and not execution_steps
+ )
+ if entries and parallel_eligible:
+ self._ensure_parallel_hardware_authorized(
+ execution_mode=execution_mode,
+ control_transport=control_transport,
+ hardware_access_bypass=hardware_access_bypass,
+ hardware_client_id=hardware_client_id,
+ )
+
+ def execute_can() -> dict[str, object]:
+ return self.service.execute_mode_profile_card(
+ mode_id,
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ approval_token=(
+ str(common_arguments["approval_token"])
+ if common_arguments.get("approval_token")
+ else None
+ ),
+ vehicle_id=vehicle_id,
+ simulation_fault=simulation_fault,
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass,
+ control_transport=control_transport,
+ )
+
+ def preflight_can() -> dict[str, object]:
+ return self.service.execute_mode_profile_card(
+ mode_id,
+ conversation_id=None,
+ execution_mode="dry_run",
+ approval_token=None,
+ vehicle_id=vehicle_id,
+ simulation_fault="normal",
+ hardware_client_id=None,
+ hardware_access_bypass=True,
+ control_transport=control_transport,
+ )
+
+ result = self._execute_can_and_peripheral_branches(
+ utterance=utterance,
+ entries=entries,
+ execution_mode=execution_mode,
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,
+ can_execute=execute_can,
+ can_preflight=preflight_can,
+ parallel_eligible=parallel_eligible,
)
+ if not entries:
+ result["utterance"] = utterance
+ return result
result["provider_used"] = "saved_mode_command_flow"
result["orchestration_route"] = "saved_mode_command_flow"
result["mode_profile"] = profile.public_dict()
@@ -2516,6 +2902,125 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
return result
+ parallel_mixed_control = bool(
+ path != "/api/requests/preview"
+ and execution_mode == "hardware"
+ and entries
+ and residual
+ and not unresolved
+ and not denied
+ and not self._mixed_request_requires_transport_order(utterance)
+ )
+ if parallel_mixed_control:
+ can_arguments = {
+ **common_arguments,
+ "utterance": residual,
+ "_cloud_instruction_context": natural_language_routing,
+ }
+
+ def execute_can() -> dict[str, object]:
+ return self.service.process(
+ **can_arguments,
+ _on_commands_validated=None,
+ )
+
+ preflight_arguments = {
+ **can_arguments,
+ "execution_mode": "dry_run",
+ "approval_token": None,
+ "hardware_client_id": None,
+ "hardware_access_bypass": True,
+ }
+
+ def preflight_can() -> dict[str, object]:
+ return self.service.preview_native_can(
+ **preflight_arguments,
+ on_commands_validated=None,
+ )
+
+ def publish_parallel_validation(
+ validated: dict[str, object],
+ ) -> None:
+ nonlocal peripheral_validation_elapsed_ms
+ if callback is None:
+ return
+ elapsed_ms = round(
+ (time.perf_counter() - local_stage_started) * 1000,
+ 3,
+ )
+ peripheral_validation_elapsed_ms = elapsed_ms
+ validated["natural_language_routing"] = deepcopy(
+ natural_language_routing
+ )
+ validated.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": elapsed_ms,
+ "local_control_within_budget": (
+ elapsed_ms < LOCAL_CONTROL_BUDGET_MS
+ ),
+ }
+ )
+ callback(validated)
+
+ hardware_client_id = _compatible_hardware_client_id(
+ common_arguments.get("hardware_client_id")
+ )
+ hardware_access_bypass = bool(
+ common_arguments.get("hardware_access_bypass", True)
+ )
+ self._ensure_parallel_hardware_authorized(
+ execution_mode=execution_mode,
+ control_transport=str(
+ common_arguments.get("control_transport") or "wired_can"
+ ),
+ hardware_access_bypass=hardware_access_bypass,
+ hardware_client_id=hardware_client_id,
+ )
+ result = self._execute_can_and_peripheral_branches(
+ utterance=utterance,
+ entries=entries,
+ execution_mode=execution_mode,
+ provider=provider,
+ conversation_id=conversation_id,
+ can_execute=execute_can,
+ can_preflight=preflight_can,
+ parallel_eligible=True,
+ on_preflight_validated=publish_parallel_validation,
+ )
+ routed = result.get("natural_language_routing")
+ if isinstance(routed, dict):
+ natural_language_routing = deepcopy(routed)
+ result["natural_language_routing"] = deepcopy(
+ natural_language_routing
+ )
+ if peripheral_validation_elapsed_ms is not None:
+ result.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": (
+ peripheral_validation_elapsed_ms
+ ),
+ "local_control_within_budget": (
+ peripheral_validation_elapsed_ms
+ < LOCAL_CONTROL_BUDGET_MS
+ ),
+ }
+ )
+ for key in (
+ "parsed_clauses",
+ "resolved_clauses",
+ "unresolved_clauses",
+ ):
+ value = natural_language_routing.get(key)
+ if isinstance(value, list):
+ result[key] = deepcopy(value)
+ result = self._apply_nfc_command_denials(result, denied)
+ self.service.persist_composed_result(result)
+ if callback is not None:
+ callback(result)
+ return result
+
# Compile, publish and execute every deterministic IR action before
# any hosted planner is contacted.
if entries:
@@ -2892,10 +3397,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
approval_token=os.getenv(
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
),
- hardware_client_id=(
- str(payload["hardware_client_id"])
- if payload.get("hardware_client_id")
- else None
+ hardware_client_id=_compatible_hardware_client_id(
+ payload.get("hardware_client_id")
),
hardware_access_bypass=self._hardware_access_bypass_allowed(),
reasoning_effort=(
@@ -3006,8 +3509,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
history_payload = payload.get("history")
history = history_payload if isinstance(history_payload, list) else []
source = str(payload.get("client_type") or "web_console_assistant")
- hardware_client_id = (
- str(payload["hardware_client_id"]) if payload.get("hardware_client_id") else None
+ hardware_client_id = _compatible_hardware_client_id(
+ payload.get("hardware_client_id")
)
reasoning_effort = (
str(payload["reasoning_effort"])
@@ -3815,10 +4318,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if payload.get("conversation_id")
else None
)
- hardware_client_id = (
- str(payload["hardware_client_id"])
- if payload.get("hardware_client_id")
- else None
+ hardware_client_id = _compatible_hardware_client_id(
+ payload.get("hardware_client_id")
)
self.vehicle_display_sync.update_task(
status="running",
@@ -3828,39 +4329,69 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
# Validate the complete saved flow against the durable 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._hardware_access_bypass_allowed(),
- control_transport=control_transport,
+ simulation_fault = str(payload.get("simulation_fault", "normal"))
+ hardware_access_bypass = self._hardware_access_bypass_allowed()
+ command_flow = getattr(profile, "command_flow", None)
+ execution_steps = (
+ command_flow.get("execution_steps", [])
+ if isinstance(command_flow, Mapping)
+ else []
)
- if entries:
- infrared_result = self._execute_local_peripheral_actions(
- utterance,
- entries,
- provider="saved_mode_command_flow",
+ parallel_eligible = bool(
+ getattr(profile, "schedule", None) is None
+ and not execution_steps
+ )
+ if entries and parallel_eligible:
+ self._ensure_parallel_hardware_authorized(
+ execution_mode=execution_mode,
+ control_transport=control_transport,
+ hardware_access_bypass=hardware_access_bypass,
+ hardware_client_id=hardware_client_id,
+ )
+
+ def execute_can() -> dict[str, object]:
+ return 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=simulation_fault,
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass,
+ control_transport=control_transport,
)
- result = self._merge_can_and_peripheral_results(
- utterance,
- can_result,
- infrared_result,
- entries,
+
+ def preflight_can() -> dict[str, object]:
+ return self.service.execute_mode_profile_card(
+ mode_id,
+ conversation_id=None,
+ execution_mode="dry_run",
+ approval_token=None,
+ vehicle_id=vehicle_id,
+ simulation_fault="normal",
+ hardware_client_id=None,
+ hardware_access_bypass=True,
+ control_transport=control_transport,
)
+
+ result = self._execute_can_and_peripheral_branches(
+ utterance=utterance,
+ entries=entries,
+ execution_mode=execution_mode,
+ provider="saved_mode_command_flow",
+ conversation_id=conversation_id,
+ can_execute=execute_can,
+ can_preflight=preflight_can,
+ parallel_eligible=parallel_eligible,
+ )
+ if 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,
@@ -4035,6 +4566,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self._json(result)
return
if path == "/api/extension/vehicle/execute":
+ payload["hardware_client_id"] = _compatible_hardware_client_id(
+ payload.get("hardware_client_id")
+ )
result = self.extension.execute_vehicle(
payload,
self.service,
@@ -4362,7 +4896,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
try:
payload = self._read_json_body(max_bytes=4096)
- client_id = str(payload.get("client_id", ""))
+ client_id = _compatible_hardware_client_id(
+ payload.get("client_id")
+ )
if path == "/api/hardware/access/request":
status = self.service.hardware_access.request_access(
client_id,
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 55e3b92..f157dc8 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -997,14 +997,14 @@ operations:
def runtime_url(file_name: str, source_url: str) -> str:
hashed = runtime_assets.get(file_name)
return f"/{hashed}" if hashed else source_url
- with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-145-n7-dispatch", timeout=3) as response:
+ with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/console-v2.js?v=local-first-77-performance", timeout=3
+ f"{self.base_url}/console-v2.js?v=local-first-79-cinema-status", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
@@ -1024,7 +1024,7 @@ operations:
) as response:
speech_worklet = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/self-extension.js?v=nfc-mqtt-56", timeout=3
+ f"{self.base_url}/self-extension.js?v=nfc-persistent-57", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
@@ -1032,7 +1032,7 @@ operations:
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
- f"{self.base_url}/assistant-chat.js?v=codex-assistant-35", timeout=3
+ f"{self.base_url}/assistant-chat.js?v=codex-assistant-39-video-media", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -1084,13 +1084,13 @@ operations:
self.assertIn("CMD 0x431 · FB 0x388 · CH1", template)
self.assertNotIn("透明车体 · 实体设备", template)
self.assertIn(
- f'src="{runtime_url("app.js", "/app.js?v=dual-seat-145-n7-dispatch")}"',
+ f'src="{runtime_url("app.js", "/app.js?v=dual-seat-147-video-media")}"',
html,
)
self.assertIn('import "../styles.css";', vite_entry)
self.assertNotIn('class="vehicle-system-hud"', template)
self.assertIn(
- f'src="{runtime_url("console-v2.js", "/console-v2.js?v=local-first-77-performance")}"',
+ f'src="{runtime_url("console-v2.js", "/console-v2.js?v=local-first-79-cinema-status")}"',
html,
)
self.assertIn('import "../console-v2.css";', vite_entry)
@@ -1105,7 +1105,7 @@ operations:
self.assertIn('id="v2BootScreen"', template)
self.assertIn('正在加载车机控制与实时状态', template)
self.assertIn(
- f'src="{runtime_url("self-extension.js", "/self-extension.js?v=nfc-mqtt-56")}"',
+ f'src="{runtime_url("self-extension.js", "/self-extension.js?v=nfc-persistent-57")}"',
html,
)
self.assertIn('import "../self-extension.css";', vite_entry)
@@ -1117,7 +1117,7 @@ operations:
self.assertIn('id="assistantTranscript"', template)
self.assertIn('import "../assistant-chat.css";', vite_entry)
self.assertIn(
- f'src="{runtime_url("assistant-chat.js", "/assistant-chat.js?v=codex-assistant-35")}"',
+ f'src="{runtime_url("assistant-chat.js", "/assistant-chat.js?v=codex-assistant-39-video-media")}"',
html,
)
self.assertIn('import "../energy-monitor.css";', vite_entry)
@@ -1433,16 +1433,16 @@ operations:
self.assertIn(f'/{runtime_assets[asset_name]}', service_worker)
else:
self.assertIn(
- 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"',
+ 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"',
service_worker,
)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
- self.assertIn('/console-v2.js?v=local-first-77-performance', service_worker)
+ self.assertIn('/console-v2.js?v=local-first-79-cinema-status', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-30', service_worker)
- self.assertIn('/assistant-chat.js?v=codex-assistant-35', service_worker)
+ self.assertIn('/assistant-chat.js?v=codex-assistant-39-video-media', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-5-dialog-size', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-5-performance', service_worker)
- self.assertIn('"/app.js?v=dual-seat-145-n7-dispatch"', service_worker)
+ self.assertIn('"/app.js?v=dual-seat-147-video-media"', service_worker)
self.assertIn('/firmware-workbench-entry.css?v=2', service_worker)
self.assertIn('/firmware-workbench-entry.js?v=3', service_worker)
self.assertIn('/firmware-workbench-theme.js?v=1', service_worker)
@@ -1910,7 +1910,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/app.js?v=dual-seat-145-n7-dispatch", timeout=3
+ f"{self.base_url}/app.js?v=dual-seat-147-video-media", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -2904,47 +2904,51 @@ operations:
).encode("utf-8"),
)
- def fail_can_after_validation(*_args, **kwargs):
- callback = kwargs.get("_on_commands_validated")
- self.assertTrue(callable(callback))
- callback(
- {
- "request_id": "req-can-validation-only",
- "utterance": "打开空调,温度设置为24度,风速调整为3档,打开遮阳帘",
- "status": "processing",
- "execution_mode": "hardware",
- "control_transport": "wired_can",
- "provider_used": "local_rules",
- "decision_summary": [],
- "function_configs": {
- "native_climate": {
- "spec": {
- "operations": [
- {
- "id": "vehicle_set_power_1",
- "capability": "climate.set_power",
- "target": {"zone": "vehicle"},
- "parameters": {"state": "on"},
- "parse_source": "local_rule",
- }
- ]
+ can_preview = {
+ "request_id": "req-can-validation-only",
+ "utterance": "打开空调,温度设置为24度,风速调整为3档,打开遮阳帘",
+ "status": "succeeded",
+ "execution_mode": "dry_run",
+ "control_transport": "wired_can",
+ "provider_used": "local_rules",
+ "decision_summary": [],
+ "function_configs": {
+ "native_climate": {
+ "spec": {
+ "operations": [
+ {
+ "id": "vehicle_set_power_1",
+ "capability": "climate.set_power",
+ "target": {"zone": "vehicle"},
+ "parameters": {"state": "on"},
+ "parse_source": "local_rule",
}
- }
- },
- "command_plans": {
- "native_climate": {"spec": {"commands": []}}
- },
- "operation_results": [],
- "execution_report": None,
+ ]
+ }
}
- )
+ },
+ "command_plans": {
+ "native_climate": {"spec": {"commands": []}}
+ },
+ "operation_results": [],
+ "execution_report": None,
+ }
+
+ def fail_can_after_validation(*_args, **_kwargs):
raise CanGatewayError("simulated CAN transport failure")
try:
- with patch.object(
- self.service,
- "process",
- side_effect=fail_can_after_validation,
+ with (
+ patch.object(
+ self.service,
+ "preview_native_can",
+ return_value=can_preview,
+ ),
+ patch.object(
+ self.service,
+ "process",
+ side_effect=fail_can_after_validation,
+ ),
):
with self.opener.open(request, timeout=3) as response:
events = [
@@ -2963,11 +2967,8 @@ operations:
for item in events
if item["type"] == "validated_commands"
]
- self.assertEqual(set(validated_payloads[0]["function_configs"]), {"self_extension"})
- validated = next(
- payload for payload in validated_payloads
- if {"native_climate", "self_extension"}.issubset(payload["function_configs"])
- )
+ self.assertEqual(len(validated_payloads), 1)
+ validated = validated_payloads[0]
self.assertEqual(validated["status"], "processing")
self.assertEqual(
set(validated["function_configs"]),
@@ -2977,6 +2978,10 @@ operations:
[item["slot"] for item in validated["infrared_actions"]],
[1, 4],
)
+ self.assertEqual(
+ validated["parallel_dispatch"]["preflight_status"],
+ "succeeded",
+ )
def test_disabled_nfc_ir_command_does_not_discard_allowed_can_and_ir(
self,
@@ -3173,6 +3178,18 @@ operations:
)
extension.sync_peripheral_catalog(
[
+ {
+ "id": "toy-001-light-on",
+ "status": "learned",
+ "enabled": True,
+ "device_id": "toy-001",
+ "device_name": "闪烁灯玩具",
+ "object_names": ["闪烁灯玩具", "闪烁灯"],
+ "card_uid": "2CC8B103",
+ "action": "打开",
+ "action_names": ["打开", "开启", "启动"],
+ "slot": 1,
+ },
{
"id": "toy-001-light-red",
"status": "learned",
@@ -3239,6 +3256,11 @@ operations:
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "cloud_model"
)
+ self.assertEqual(
+ [action["slot"] for action in result["infrared_actions"]],
+ [1, 3],
+ )
+ self.assertTrue(result["infrared_actions"][0]["auto_inserted"])
self.assertFalse(result["supplement_required"])
def test_main_request_executes_gpt_nfc_sequence_without_vehicle_can(self) -> None:
@@ -4113,6 +4135,75 @@ operations:
self.assertEqual(released["state"], "none")
self.assertIsNone(self.service.hardware_access.admin_snapshot()["active"])
+ def test_legacy_hardware_client_id_can_request_access_and_execute_mode_card(
+ self,
+ ) -> None:
+ legacy_client_id = "legacy_mode_client_with_underscores"
+
+ def post(path: str, payload: dict, *, public: bool = False):
+ headers = {"Content-Type": "application/json"}
+ if public:
+ headers["X-Forwarded-For"] = "203.0.113.31"
+ request = urllib.request.Request(
+ f"{self.base_url}{path}",
+ method="POST",
+ headers=headers,
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
+ )
+ return self.opener.open(request, timeout=3)
+
+ with post(
+ "/api/hardware/access/request",
+ {"client_id": legacy_client_id, "label": "旧版模式终端"},
+ public=True,
+ ) as response:
+ requested = json.loads(response.read().decode("utf-8"))
+ canonical_client_id = requested["client_id"]
+ self.assertRegex(canonical_client_id, r"^hwc-legacy-[0-9a-f]{32}$")
+
+ with post(
+ "/api/local/hardware-access/approve",
+ {"client_id": canonical_client_id},
+ ):
+ pass
+
+ fake_result = {
+ "status": "succeeded",
+ "request_id": "req-legacy-mode-client",
+ "execution_mode": "hardware",
+ }
+ with patch.object(
+ self.service,
+ "execute_mode_profile_card",
+ return_value=fake_result,
+ ) as execute:
+ with post(
+ "/api/mode-profiles/office/execute",
+ {
+ "conversation_id": "conv-legacy-mode-client",
+ "vehicle_id": "denza_n7",
+ "execution_mode": "hardware",
+ "hardware_client_id": legacy_client_id,
+ "client_type": "web_console_mode_card",
+ },
+ public=True,
+ ) as response:
+ result = json.loads(response.read().decode("utf-8"))
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(
+ execute.call_args.kwargs["hardware_client_id"],
+ canonical_client_id,
+ )
+
+ with post(
+ "/api/hardware/access/release",
+ {"client_id": legacy_client_id},
+ public=True,
+ ) as response:
+ released = json.loads(response.read().decode("utf-8"))
+ self.assertEqual(released["state"], "none")
+
def test_local_hardware_decision_page_is_not_exposed_through_proxy(self) -> None:
with self.opener.open(
f"{self.base_url}/hardware-control", timeout=3
diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py
index 59a8dc3..efbb82d 100644
--- a/tests/integration/test_assistant_capability_execution_matrix.py
+++ b/tests/integration/test_assistant_capability_execution_matrix.py
@@ -271,7 +271,7 @@ CAPABILITY_CASES = (
),
_CapabilityCase(
"seat.set_driver_heating_native",
- "打开主驾座椅加热2档",
+ "打开主驾座椅加热低档",
"driver",
{"state": "on", "level": 2},
"activate",
@@ -291,7 +291,7 @@ CAPABILITY_CASES = (
),
_CapabilityCase(
"seat.set_driver_ventilation_native",
- "打开主驾座椅通风2档",
+ "打开主驾座椅通风低档",
"driver",
{"state": "on", "level": 2},
"activate",
diff --git a/tests/integration/test_scene_peripheral_mode_flow.py b/tests/integration/test_scene_peripheral_mode_flow.py
index 828f208..7bc6294 100644
--- a/tests/integration/test_scene_peripheral_mode_flow.py
+++ b/tests/integration/test_scene_peripheral_mode_flow.py
@@ -48,6 +48,23 @@ def _learned_light() -> dict[str, object]:
}
+def _learned_light_power_on() -> dict[str, object]:
+ return {
+ "id": "ir-slot:romance-light:1",
+ "status": "learned",
+ "enabled": True,
+ "device_id": "romance-light",
+ "device_name": "氛围灯带",
+ "object_names": ["氛围灯带", "灯带"],
+ "device_type": "light",
+ "card_uid": "2CC8B103",
+ "action": "打开",
+ "action_names": ["打开", "开启", "点亮"],
+ "risk": "low",
+ "slot": 1,
+ }
+
+
def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
tmp_path: Path,
) -> None:
@@ -61,13 +78,19 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
SimulationGateway(),
catalog_db_path=tmp_path / "infrared.sqlite3",
)
- extension.sync_peripheral_catalog([_learned_light()])
+ extension.sync_peripheral_catalog(
+ [_learned_light_power_on(), _learned_light()]
+ )
catalog = AssistantCapabilityCatalog(
service,
peripheral_provider=extension.list_peripheral_routing_capabilities,
)
context = catalog.scene_composition_context(catalog.snapshot())
- capability_ref = context["peripheral_actions"][0]["ref"]
+ capability_ref = next(
+ item["ref"]
+ for item in context["peripheral_actions"]
+ if "红色" in str(item.get("action_label") or "")
+ )
encoded_context = json.dumps(context, ensure_ascii=False)
assert "2CC8B103" not in encoded_context
assert '"slot":7' not in encoded_context
@@ -126,13 +149,19 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
"climate",
"self_extension",
]
- assert preview["mode_profile_draft"]["operation_count"] == 2
+ assert preview["mode_profile_draft"]["operation_count"] == 3
assert set(preview["function_configs"]) == {"native_climate", "self_extension"}
- infrared_command = preview["command_plans"]["self_extension"]["spec"][
+ infrared_commands = preview["command_plans"]["self_extension"]["spec"][
"commands"
- ][0]
- assert infrared_command["message"] == "infrared.emit"
- assert infrared_command["signals"] == {"slot": 7}
+ ]
+ assert [command["message"] for command in infrared_commands] == [
+ "infrared.emit",
+ "infrared.emit",
+ ]
+ assert [command["signals"] for command in infrared_commands] == [
+ {"slot": 1},
+ {"slot": 7},
+ ]
assert preview["infrared_actions"][0]["parse_source"] == (
"scene_composer_capability_ref"
)
@@ -156,9 +185,10 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
assert [operation.capability for operation in profile.operations] == [
"climate.set_temperature",
"infrared.emit",
+ "infrared.emit",
]
entries = handler._saved_mode_infrared_entries(profile)
- assert [entry["capability"]["slot"] for entry in entries] == [7]
+ assert [entry["capability"]["slot"] for entry in entries] == [1, 7]
native_execution = service.execute_mode_profile_card(
profile.id,
conversation_id="conv-saved-scene-peripheral",
@@ -179,7 +209,7 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
entries,
)
assert executed["status"] == "succeeded"
- assert [action["slot"] for action in executed["infrared_actions"]] == [7]
+ assert [action["slot"] for action in executed["infrared_actions"]] == [1, 7]
invoked = handler._execute_request_with_peripherals(
"/api/requests/preview",
{
@@ -196,7 +226,7 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
assert invoked is not None
assert invoked["status"] == "succeeded"
assert invoked["execution_mode"] == "dry_run"
- assert [action["slot"] for action in invoked["infrared_actions"]] == [7]
+ assert [action["slot"] for action in invoked["infrared_actions"]] == [1, 7]
template_plan = _catalog_exact_scene_template_plan(
catalog.snapshot(),
@@ -301,8 +331,8 @@ def test_scene_composer_mixed_can_ir_preview_save_and_revalidate(
assert explicit is not None
assert explicit["status"] == "succeeded"
assert explicit["execution_mode"] == "dry_run"
- assert explicit["mode_profile_draft"]["operation_count"] == 2
- assert explicit["infrared_actions"][0]["slot"] == 7
+ assert explicit["mode_profile_draft"]["operation_count"] == 3
+ assert [action["slot"] for action in explicit["infrared_actions"]] == [1, 7]
# Removing the durable NFC whitelist invalidates both cached refs and saved
# mode execution before any transport can be reached.
diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs
index 9d0f133..665d3e4 100644
--- a/tests/unit/assistant_stream_runtime.test.cjs
+++ b/tests/unit/assistant_stream_runtime.test.cjs
@@ -560,6 +560,64 @@ test("mode creation always requests dry-run even while the console is in hardwar
assert.equal(Object.hasOwn(request, "hardware_client_id"), false);
});
+test("hardware requests use only the console's validated hardware client id", async () => {
+ const events = [
+ { type: "message.accepted", route: "mode_profile_execute", engine: "n7web-mode-profile-runner" },
+ { type: "answer.completed", route: "mode_profile_execute", answer: "阅读模式执行完成。" },
+ ];
+ const validHarness = createHarness(events, "hardware");
+ validHarness.sandbox.CognitiveAgentConsole.getHardwareClientId = (
+ () => "hwc-runtime-client-1234"
+ );
+ validHarness.input.value = "进入阅读模式";
+
+ await validHarness.sandbox.CognitiveAgentAssistant.submit();
+
+ const validRequest = JSON.parse(validHarness.requestCalls[0].options.body);
+ assert.equal(validRequest.hardware_client_id, "hwc-runtime-client-1234");
+
+ const invalidHarness = createHarness(events, "hardware");
+ invalidHarness.sandbox.CognitiveAgentConsole.getHardwareClientId = (
+ () => "legacy_invalid_client"
+ );
+ invalidHarness.input.value = "进入浪漫观影模式";
+
+ await invalidHarness.sandbox.CognitiveAgentAssistant.submit();
+
+ const invalidRequest = JSON.parse(invalidHarness.requestCalls[0].options.body);
+ assert.equal(Object.hasOwn(invalidRequest, "hardware_client_id"), false);
+});
+
+test("partially completed imported mode uses neutral delegated wording", () => {
+ const harness = createHarness([]);
+
+ harness.sandbox.CognitiveAgentAssistant.announceModeExecution(
+ { id: "cinema", name: "影院模式", command_flow: { function_configs: {}, plans: {} } },
+ {
+ status: "partially_succeeded",
+ execution_mode: "hardware",
+ execution_outcome: "submitted_unverified",
+ deferred_requests: [
+ { execution_backend: "n7_vehicle_transfer" },
+ { execution_backend: "n7_vehicle_transfer" },
+ ],
+ function_configs: {},
+ command_plans: {},
+ execution_report: null,
+ operation_results: [],
+ },
+ { command: "进入影院模式" },
+ );
+
+ const assistantMessage = harness.messages.children.at(-1);
+ assert.equal(
+ assistantMessage.dataset.messageContent,
+ "影院模式指令已提交,未由 PC 执行的 2 项动作将由车机继续处理。",
+ );
+ const actionStatus = assistantMessage.querySelector(".assistant-action-status").children.at(-1);
+ assert.equal(actionStatus.textContent, "功能模式指令已提交");
+});
+
test("a follow-up turn carries only the active authoritative draft reference", async () => {
const events = modeDraftEvents("req-draft-one", 24);
const harness = createHarness(events);
diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py
index d85d622..cfba60f 100644
--- a/tests/unit/test_assistant_frontend_contract.py
+++ b/tests/unit/test_assistant_frontend_contract.py
@@ -21,17 +21,17 @@ def test_console_contains_ai_assistant_surface():
assert 'placeholder="向 AI助手提问或交代工作"' in template
assert 'aria-label="发送消息"' in template
assert 'import "../assistant-chat.css";' in vite_entry
- assert 'src="/assistant-chat.js?v=codex-assistant-35"' in html
+ assert 'src="/assistant-chat.js?v=codex-assistant-39-video-media"' in html
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert (
- 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"'
+ 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
in service_worker
)
assert '"/assistant-chat.css?v=codex-assistant-30"' in service_worker
- assert '"/assistant-chat.js?v=codex-assistant-35"' in service_worker
+ assert '"/assistant-chat.js?v=codex-assistant-39-video-media"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -95,6 +95,8 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
assert 'count.className = "assistant-dispatched-controls-count"' in script
assert 'list.className = "assistant-dispatched-controls-list"' in script
assert "value.textContent = assistantControlValue(item)" in script
+ assert 'return ({ 1: "3档", 2: "1档" })[level]' in script
+ assert '{ 1: "3档(高)", 2: "1档(低)" }' in script
assert "语义控制指令:" in script
assert "实际控制指令:" in script
assert "appendDispatchedControls(message, event.payload)" in script
@@ -105,6 +107,9 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
assert "appendDispatchedControls(message, modeExecutionPreviewResult(profile))" in script
assert "beginModeExecution," in script
assert "completeModeExecution," in script
+ assert "功能模式部分完成" not in script
+ assert "功能模式指令已提交" in script
+ assert "未由 PC 执行的" in script
assert "const operationByRef = new Map()" in script
assert 'deliveryState: "submitting"' in script
assert 'deliveryLabel: "正在下发"' in script
@@ -113,6 +118,10 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card():
assert '"seat.set_legrest_height": "腿托"' in script
assert '"seat.set_legrest_extension": "腿托"' in script
assert '"ambient_light.set_illumination": "氛围灯颜色与亮度"' in script
+ assert '"android.launch_app": "打开视频媒体"' in script
+ assert 'if (capability === "android.launch_app")' in script
+ assert "parameters.package_name" in script
+ assert "parameters.activity_name" in script
assert '.replaceAll("_", "-")' in script
assert ".assistant-dispatched-controls > summary" in css
assert ".assistant-dispatched-controls-list > li" in css
@@ -130,6 +139,16 @@ def test_explicit_nfc_authorization_block_is_rendered_as_safety_blocked():
assert '? "安全阻止"' in console
+def test_n7_video_launch_uses_chinese_mode_card_labels():
+ app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
+
+ assert 'android: "车机媒体"' in app
+ assert 'headunit: "车机"' in app
+ assert '"android.launch_app": "打开视频媒体"' in app
+ assert 'package_name: "应用包名"' in app
+ assert 'activity_name: "启动页面"' in app
+
+
def test_latest_dispatched_control_group_is_the_only_group_expanded():
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(
encoding="utf-8"
@@ -189,6 +208,18 @@ def test_codex_request_keeps_trusted_vehicle_context_without_legacy_model_routin
assert 'const executionMode = modeConfigurationPreview ? "dry_run"' in request_body
+def test_codex_request_reuses_only_the_console_validated_hardware_client_id():
+ app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
+ assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
+
+ assert "const HARDWARE_CLIENT_ID_PATTERN" in app
+ assert "function validatedHardwareClientId()" in app
+ assert "getHardwareClientId()" in app
+ assert "return validatedHardwareClientId();" in app
+ assert "window.CognitiveAgentConsole?.getHardwareClientId?.()" in assistant
+ assert "/^hwc-[a-z0-9-]{8,80}$/.test(value)" in assistant
+
+
def test_starting_a_new_codex_conversation_rotates_the_conversation_id():
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
assert 'const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id"' in script
diff --git a/tests/unit/test_assistant_validated_control_projection.py b/tests/unit/test_assistant_validated_control_projection.py
index 33650be..23c59a7 100644
--- a/tests/unit/test_assistant_validated_control_projection.py
+++ b/tests/unit/test_assistant_validated_control_projection.py
@@ -24,6 +24,14 @@ def compiled_mixed_result() -> dict[str, object]:
"provider_used": "local_rules+infrared_local_rules",
"execution_mode": "dry_run",
"control_transport": "wired_can",
+ "parallel_dispatch": {
+ "enabled": True,
+ "preflight_status": "succeeded",
+ "dispatch": {
+ "started_skew_ms": 1.2,
+ "total_ms": 120.5,
+ },
+ },
"function_configs": {
"native_climate": {
"spec": {
@@ -155,6 +163,10 @@ class ValidatedControlProjectionTest(unittest.TestCase):
"compile",
],
)
+ self.assertEqual(
+ projection["parallel_dispatch"]["preflight_status"],
+ "succeeded",
+ )
serialized = json.dumps(projection, ensure_ascii=False)
self.assertNotIn("command_plans", serialized)
self.assertNotIn("data_hex", serialized)
@@ -362,6 +374,10 @@ class ValidatedControlProjectionTest(unittest.TestCase):
)
self.assertNotIn("command_plans", safe_result)
self.assertNotIn("validated_control_commands", safe_result)
+ self.assertEqual(
+ safe_result["parallel_dispatch"]["preflight_status"],
+ "succeeded",
+ )
def test_agent_tool_path_queues_projection_for_the_browser_thread(self) -> None:
client = type("UnavailableClient", (), {"available": False})()
diff --git a/tests/unit/test_can1_semantic_whitelist.py b/tests/unit/test_can1_semantic_whitelist.py
index 7cdf8da..0525ccb 100644
--- a/tests/unit/test_can1_semantic_whitelist.py
+++ b/tests/unit/test_can1_semantic_whitelist.py
@@ -724,6 +724,50 @@ class Can1SemanticWhitelistTests(unittest.TestCase):
self.assertEqual(preview["unresolved_clauses"], [])
self.assertEqual(self._debug_commands(preview), [])
+ def test_driver_ventilation_numeric_gear_followup_uses_occupant_scale(self) -> None:
+ cases = (
+ (
+ "打开主驾座椅通风,挡位调到3档",
+ "seat driver vent high",
+ 1,
+ ),
+ (
+ "打开主驾座椅通风,挡位调到1档",
+ "seat driver vent low",
+ 2,
+ ),
+ )
+ for index, (utterance, command, internal_level) in enumerate(cases):
+ with self.subTest(utterance=utterance):
+ result = self.service.process(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-driver-vent-gear-{index}",
+ execution_mode="dry_run",
+ )
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(self._debug_commands(result), [command])
+ operation = result["function_configs"]["native_seat"]["spec"][
+ "operations"
+ ][0]
+ self.assertEqual(operation["parameters"]["level"], internal_level)
+
+ middle = self.service.process(
+ "打开主驾座椅通风,挡位调到2档",
+ provider="local",
+ conversation_id="conv-driver-vent-gear-middle",
+ execution_mode="dry_run",
+ )
+ self.assertEqual(middle["status"], "needs_confirmation")
+ self.assertEqual(self._debug_commands(middle), [])
+ self.assertIn(
+ "driver_seat_thermal_middle_level_unsupported",
+ {
+ item.get("reason")
+ for item in middle["intent_ir"]["spec"]["ambiguities"]
+ },
+ )
+
def test_vehicle_position_aliases_stay_scoped_to_the_device_domain(self) -> None:
cases = (
("打开左前车窗", ["window front-left open"]),
diff --git a/tests/unit/test_codex_assistant_service.py b/tests/unit/test_codex_assistant_service.py
index 8f881d2..a6b366d 100644
--- a/tests/unit/test_codex_assistant_service.py
+++ b/tests/unit/test_codex_assistant_service.py
@@ -523,6 +523,56 @@ def test_registered_mode_execution_uses_fast_local_route_without_codex(
assert "模拟编排" in result["answer"]
+def test_partially_supported_imported_mode_uses_submitted_not_partial_wording() -> None:
+ client = FakeCodexClient(available=False)
+ assistant = CodexAssistantService(client)
+ catalog = {
+ "modes": [
+ {
+ "id": "cinema",
+ "name": "影院模式",
+ "aliases": [],
+ "assistant_callable": True,
+ }
+ ],
+ "source_status": {"modes": "ready"},
+ }
+
+ def execute(instruction: str, progress: Callable[[dict[str, object]], None]):
+ result = {
+ "request_id": "req-imported-cinema-partial",
+ "utterance": instruction,
+ "status": "partially_succeeded",
+ "execution_mode": "hardware",
+ "execution_outcome": "submitted_unverified",
+ "deferred_requests": [
+ {
+ "execution_backend": "n7_vehicle_transfer",
+ "capability": "android.launch_app",
+ }
+ ],
+ }
+ progress(result)
+ return result
+
+ result, events = _stream(
+ assistant,
+ "进入影院模式",
+ execute_control=execute,
+ capability_snapshot=catalog,
+ )
+
+ assert result["route"] == "mode_profile_execute"
+ assert result["success"] is True
+ assert "已提交" in result["answer"]
+ assert "部分完成" not in result["answer"]
+ assert "未能完成" not in result["answer"]
+ assert any(
+ event.get("display_text") == "功能模式已提交,等待车辆状态确认"
+ for event in events
+ )
+
+
def test_registered_name_only_mode_keeps_the_fast_local_route_without_codex() -> None:
client = FakeCodexClient(available=False)
assistant = CodexAssistantService(client)
diff --git a/tests/unit/test_driver_seat_native_can.py b/tests/unit/test_driver_seat_native_can.py
index d65dfc6..2febb91 100644
--- a/tests/unit/test_driver_seat_native_can.py
+++ b/tests/unit/test_driver_seat_native_can.py
@@ -554,6 +554,48 @@ class DriverSeatNativeCanTests(unittest.TestCase):
self.assertEqual(bus.send_calls, 4)
self.assertEqual(len(bus.sent), 3)
+ def test_window_already_at_target_is_verified_success_without_resend(self) -> None:
+ config = self.runtime.build_function_config(
+ "window",
+ [
+ {
+ "domain": "window",
+ "capability": "window.set_position",
+ "target": "rear_left",
+ "operation": "set",
+ "parameters": {"position_pct": 0},
+ }
+ ],
+ "req-window-already-closed",
+ "local_rules",
+ )
+ plan = self.runtime.compile(config)
+ feedback = CanFrame(
+ 0x388,
+ bytes.fromhex("00 00 01 00 01 00 66 00"),
+ channel=1,
+ is_fd=True,
+ bitrate_switch=True,
+ )
+ bus = _FakeBus([feedback])
+
+ report = NativeCanExecutor(
+ lambda: bus,
+ feedback_timeout_ms=1,
+ feedback_required=False,
+ ).execute(self.runtime, plan, config)
+
+ self.assertEqual(report["spec"]["status"], "succeeded")
+ operation = report["spec"]["results"][0]
+ self.assertEqual(operation["status"], "succeeded")
+ self.assertEqual(operation["telemetry"]["sent_frames"], [])
+ self.assertEqual(
+ operation["telemetry"]["verification_status"],
+ "verified_success",
+ )
+ self.assertEqual(operation["telemetry"]["delivery_status"], "not_required")
+ self.assertTrue(operation["telemetry"]["physical_effect_confirmed"])
+
def test_memory_compile_rejects_missing_or_malformed_learned_slots(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "driver_memory.json"
diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py
index e8b0f39..e7762d6 100644
--- a/tests/unit/test_energy_monitor_frontend_contract.py
+++ b/tests/unit/test_energy_monitor_frontend_contract.py
@@ -37,11 +37,11 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
assert f'"{asset}"' in service_worker
assert 'import "../energy-monitor.css";' in vite_entry
assert "/energy-monitor.js?v=energy-monitor-5-performance" in html
- assert html.index("/assistant-chat.js?v=codex-assistant-35") < html.index(
+ assert html.index("/assistant-chat.js?v=codex-assistant-39-video-media") < html.index(
"/energy-monitor.js?v=energy-monitor-5-performance"
)
assert (
- 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"'
+ 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"'
in service_worker
)
diff --git a/tests/unit/test_extension_control.py b/tests/unit/test_extension_control.py
index a481b92..96435e9 100644
--- a/tests/unit/test_extension_control.py
+++ b/tests/unit/test_extension_control.py
@@ -157,6 +157,42 @@ def test_authorization_requires_exact_enabled_learned_bound_ability() -> None:
authorize_extension_control_actions(parsed, disallowed)
+def test_authorization_injects_same_device_power_on_before_color() -> None:
+ parsed = parse_extension_control_payload(
+ encoded(request(action(3, "切换为红色")))
+ )
+ catalog = [
+ capability("light-on", 2, "打开"),
+ capability("light-red", 3, "切换为红色"),
+ ]
+
+ authorized = authorize_extension_control_actions(parsed, catalog)
+
+ ordered = sorted(
+ authorized,
+ key=lambda item: (item["at_ms"], item["sequence_order"]),
+ )
+ assert [item["action"]["parameters"]["slot"] for item in ordered] == [2, 3]
+ assert ordered[0]["auto_inserted"] is True
+ assert ordered[0]["dependency"] == "power_on_before_color"
+ assert ordered[1]["auto_inserted"] is False
+
+
+def test_authorization_rejects_color_without_same_device_power_on() -> None:
+ parsed = parse_extension_control_payload(
+ encoded(request(action(3, "切换为红色")))
+ )
+
+ with pytest.raises(
+ ExtensionControlAuthorizationError,
+ match="power-on action",
+ ):
+ authorize_extension_control_actions(
+ parsed,
+ [capability("light-red", 3, "切换为红色")],
+ )
+
+
def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None:
catalog = [
capability("light-on", 2, "打开"),
@@ -184,15 +220,21 @@ def test_consumer_validates_entire_request_then_executes_by_at_ms() -> None:
)
)
assert wait_until(lambda: consumer.snapshot()["state"] == "completed")
- assert [item[1]["parameters"]["slot"] for item in executions] == [3, 2]
+ assert [item[1]["parameters"]["slot"] for item in executions] == [2, 3, 2]
status = consumer.snapshot()
assert status["processed_count"] == 1
- assert status["executed_action_count"] == 2
- assert [item["slot"] for item in status["last_results"]] == [3, 2]
- assert [item["slot"] for item in status["action_statuses"]] == [3, 2]
+ assert status["executed_action_count"] == 3
+ assert [item["slot"] for item in status["last_results"]] == [2, 3, 2]
+ assert [item["slot"] for item in status["action_statuses"]] == [2, 3, 2]
+ assert [item["auto_inserted"] for item in status["action_statuses"]] == [
+ True,
+ False,
+ False,
+ ]
assert [item["state"] for item in status["action_statuses"]] == [
"submitted_unverified",
"submitted_unverified",
+ "submitted_unverified",
]
assert all(
item["state_label"] == "已下发 · 物理效果未确认"
diff --git a/tests/unit/test_frontend_performance_contract.py b/tests/unit/test_frontend_performance_contract.py
index 22f55b3..d10be47 100644
--- a/tests/unit/test_frontend_performance_contract.py
+++ b/tests/unit/test_frontend_performance_contract.py
@@ -92,12 +92,12 @@ def test_performance_assets_have_a_new_cache_generation() -> None:
worker = source("service-worker.js")
for asset in (
- "/app.js?v=dual-seat-145-n7-dispatch",
- "/console-v2.js?v=local-first-77-performance",
- "/self-extension.js?v=nfc-mqtt-56",
+ "/app.js?v=dual-seat-147-video-media",
+ "/console-v2.js?v=local-first-79-cinema-status",
+ "/self-extension.js?v=nfc-persistent-57",
"/energy-monitor.js?v=energy-monitor-5-performance",
):
assert asset in html
assert f'"{asset}"' in worker
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"' in worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in worker
assert '"/codepen-glass-theme.css?v=creative-cloud-6-performance"' in worker
diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py
index 6d9222d..0fba051 100644
--- a/tests/unit/test_frontend_theme_contract.py
+++ b/tests/unit/test_frontend_theme_contract.py
@@ -54,7 +54,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
assert source_asset in main
assert f'"{cached_asset}"' in service_worker
assert 'import "./styles/design-system.css"' in main
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
diff --git a/tests/unit/test_mode_scene_ingress.py b/tests/unit/test_mode_scene_ingress.py
index 3fbfd51..bb3ba34 100644
--- a/tests/unit/test_mode_scene_ingress.py
+++ b/tests/unit/test_mode_scene_ingress.py
@@ -205,6 +205,55 @@ def cinema_scene_card() -> dict[str, object]:
}
+def cinema_peripheral_catalog() -> list[dict[str, object]]:
+ def capability(
+ identity: str,
+ device_id: str,
+ device_name: str,
+ action: str,
+ slot: int,
+ ) -> dict[str, object]:
+ return {
+ "id": identity,
+ "status": "learned",
+ "enabled": True,
+ "device_id": device_id,
+ "device_name": device_name,
+ "device_aliases": [device_name],
+ "object_names": [device_name],
+ "card_uid": "2CC8B103" if "flash" in device_id else "1A3AB103",
+ "action": action,
+ "aliases": [action],
+ "action_names": [action],
+ "risk": "low",
+ "slot": slot,
+ }
+
+ return [
+ capability("flash-on", "flash-light", "闪烁灯", "打开", 1),
+ capability("flash-blue", "flash-light", "闪烁灯", "切换为蓝色", 3),
+ capability("flash-red", "flash-light", "闪烁灯", "切换为红色", 4),
+ capability("strip-on", "light-strip", "灯带", "打开", 21),
+ capability("strip-blue", "light-strip", "灯带", "切换为蓝色", 23),
+ capability("strip-red", "light-strip", "灯带", "切换为红色", 24),
+ ]
+
+
+def cinema_video_launch_action() -> dict[str, object]:
+ return {
+ "type": "android_app_launch",
+ "at_ms": 3000,
+ "domain": "android",
+ "capability": "android.launch_app",
+ "target_scope": "headunit",
+ "operation": "launch",
+ "parameters": {
+ "activity_name": "com.byd.videoplay.activity.WelComeActivity",
+ "package_name": "com.byd.videoplay",
+ },
+ }
+
+
def wait_until(predicate, timeout: float = 1.5) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
@@ -337,6 +386,269 @@ def test_import_upserts_yaml_refreshes_catalog_and_does_not_echo(
assert "upstream_origin: user" in yaml_text
+def test_later_n7_cinema_mode_inherits_base_and_adds_external_light_colors(
+ tmp_path: Path,
+) -> None:
+ service = configured_service(tmp_path)
+ service.set_mode_scene_peripheral_catalog_provider(cinema_peripheral_catalog)
+ base = cinema_scene_card()
+ base_action_count = len(base["actions"])
+ base_result = service.import_n7_mode_scene(base)
+ assert "cinema_augmentation" not in base_result
+
+ variant = cinema_scene_card()
+ variant.update(
+ id="blue-cinema",
+ label="蓝色影院模式",
+ name="蓝色影院模式",
+ aliases=["蓝色观影模式"],
+ description="在车机影院模式基础上增加蓝色外设灯光。",
+ domains=["climate"],
+ operation_count=1,
+ actions=[
+ {
+ "type": "climate_temperature",
+ "at_ms": 0,
+ "domain": "climate",
+ "capability": "climate.set_temperature",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "parameters": {"temperature_c": 23},
+ }
+ ],
+ profile_revision="blue-cinema-revision",
+ )
+
+ imported = service.import_n7_mode_scene(variant)
+ augmentation = imported["cinema_augmentation"]
+ assert augmentation == {
+ "base_scene_id": "cinema",
+ "base_action_count": base_action_count,
+ "incoming_action_count": 1,
+ "peripheral_action_count": 4,
+ "merged_action_count": base_action_count + 4,
+ }
+
+ profile = service.get_mode_profile_card("blue-cinema", "denza_n7")
+ source_scene = profile.command_flow["source_scene"]
+ actions = source_scene["actions"]
+ assert source_scene["operation_count"] == base_action_count + 4
+ assert "self_extension" in source_scene["domains"]
+ temperatures = [
+ action["parameters"]["temperature_c"]
+ for action in actions
+ if action["capability"] == "climate.set_temperature"
+ ]
+ assert temperatures == [23]
+ peripheral_actions = [
+ action
+ for action in actions
+ if action["capability"] == "infrared.emit"
+ ]
+ assert [
+ (
+ action["target_scope"],
+ action["parameters"]["action"],
+ action["parameters"]["slot"],
+ )
+ for action in peripheral_actions
+ ] == [
+ ("flash-light", "打开", 1),
+ ("flash-light", "切换为蓝色", 3),
+ ("light-strip", "打开", 21),
+ ("light-strip", "切换为蓝色", 23),
+ ]
+
+ repeated = service.import_n7_mode_scene(variant)
+ assert (
+ repeated["cinema_augmentation"]["merged_action_count"]
+ == base_action_count + 4
+ )
+ reloaded = service.get_mode_profile_card("blue-cinema", "denza_n7")
+ assert (
+ len(reloaded.command_flow["source_scene"]["actions"])
+ == base_action_count + 4
+ )
+
+
+def test_n7_cinema_variant_is_not_augmented_before_base_arrives(
+ tmp_path: Path,
+) -> None:
+ service = configured_service(tmp_path)
+ service.set_mode_scene_peripheral_catalog_provider(cinema_peripheral_catalog)
+ variant = cinema_scene_card()
+ variant.update(
+ id="family-movie",
+ name="家庭影院模式",
+ label="家庭影院模式",
+ profile_revision="family-movie-revision",
+ )
+
+ imported = service.import_n7_mode_scene(variant)
+
+ assert "cinema_augmentation" not in imported
+ profile = service.get_mode_profile_card("family-movie", "denza_n7")
+ assert not any(
+ operation.capability == "infrared.emit"
+ for operation in profile.operations
+ )
+
+
+def test_created_romantic_cinema_persists_and_transfers_n7_video_launch(
+ tmp_path: Path,
+) -> None:
+ service = configured_service(tmp_path)
+ base = cinema_scene_card()
+ base["actions"] = [*base["actions"], cinema_video_launch_action()]
+ base["operation_count"] = len(base["actions"])
+ base["domains"] = [*base["domains"], "android"]
+ service.import_n7_mode_scene(base)
+
+ request_id = "req-romantic-cinema-video"
+ instruction = "进入浪漫观影模式"
+ result_dir = service.artifact_root / request_id
+ result_dir.mkdir(parents=True)
+ (result_dir / "result.json").write_text(
+ json.dumps(
+ {
+ "request_id": request_id,
+ "status": "succeeded",
+ "execution_mode": "dry_run",
+ "control_transport": "wireless_mqtt",
+ "utterance": instruction,
+ "vehicle": {"id": "denza_n7"},
+ "function_configs": {
+ "climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "vehicle-climate-power",
+ "capability": "climate.set_power",
+ "target": {"zone": "vehicle"},
+ "parameters": {"state": "on"},
+ }
+ ],
+ }
+ }
+ },
+ "command_plans": {
+ "climate": {
+ "spec": {
+ "commands": [
+ {
+ "operation_ref": "vehicle-climate-power",
+ "message": "climate.set_power",
+ }
+ ]
+ }
+ }
+ },
+ },
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+
+ saved = service.create_mode_profile_card(
+ "浪漫观影模式",
+ instruction,
+ request_id,
+ "denza_n7",
+ )
+ profile = service.get_mode_profile_card(
+ str(saved["mode_profile"]["id"]),
+ "denza_n7",
+ )
+
+ assert [
+ operation.capability for operation in profile.operations
+ ] == ["climate.set_power", "android.launch_app"]
+ assert profile.command_flow["n7_transfer_actions"] == [
+ cinema_video_launch_action()
+ ]
+ local_requests, transfer_actions = service._mode_requests_and_n7_transfers(
+ profile
+ )
+ assert [request["capability"] for request in local_requests] == [
+ "climate.set_power"
+ ]
+ assert transfer_actions == [cinema_video_launch_action()]
+
+ exported = next(
+ scene
+ for scene in service.mode_scene_catalog("denza_n7")["scenes"]
+ if scene["id"] == profile.id
+ )
+ video = next(
+ action
+ for action in exported["actions"]
+ if action["capability"] == "android.launch_app"
+ )
+ assert video == cinema_video_launch_action()
+
+
+def test_existing_local_cinema_mode_publishes_video_action_only_in_hardware(
+ tmp_path: Path,
+) -> None:
+ service = configured_service(tmp_path)
+ base = cinema_scene_card()
+ base["actions"] = [*base["actions"], cinema_video_launch_action()]
+ base["operation_count"] = len(base["actions"])
+ base["domains"] = [*base["domains"], "android"]
+ service.import_n7_mode_scene(base)
+ profile = service.mode_profiles.save_mode(
+ "浪漫观影模式",
+ [
+ {
+ "domain": "android",
+ "capability": "android.launch_app",
+ "target_scope": "headunit",
+ "operation": "launch",
+ "parameters": deepcopy(cinema_video_launch_action()["parameters"]),
+ }
+ ],
+ vehicle_id="denza_n7",
+ instruction="进入浪漫观影模式",
+ command_flow={
+ "schema": "cognitive-agent-mode-command-flow/v1",
+ "plans": {"saved": {"spec": {"commands": [{"message": "saved"}]}}},
+ },
+ )
+ publisher = MagicMock(
+ return_value={
+ "topic": "transto_n7/ctl",
+ "qos": 1,
+ "retain": False,
+ "delivery_status": "published",
+ "message_id": 19,
+ }
+ )
+ service._vehicle_mqtt_transport.publish_n7_control_transfer = publisher
+
+ preview = service.execute_mode_profile_card(
+ profile.id,
+ execution_mode="dry_run",
+ control_transport="wireless_mqtt",
+ )
+ publisher.assert_not_called()
+ assert preview["n7_transfer_candidates"] == [cinema_video_launch_action()]
+
+ executed = service.execute_mode_profile_card(
+ profile.id,
+ execution_mode="hardware",
+ control_transport="wireless_mqtt",
+ )
+
+ assert executed["n7_control_transfer"]["state"] == "published"
+ publisher.assert_called_once()
+ payload = publisher.call_args.args[0]
+ assert payload["scene_id"] == profile.id
+ assert payload["scene_name"] == "浪漫观影模式"
+ assert payload["operation_count"] == 1
+ assert payload["actions"] == [cinema_video_launch_action()]
+
+
def test_unknown_n7_action_is_forwarded_once_only_during_hardware_execution(
tmp_path: Path,
) -> None:
diff --git a/tests/unit/test_nfc_frontend_contract.py b/tests/unit/test_nfc_frontend_contract.py
index af96e54..29b78ab 100644
--- a/tests/unit/test_nfc_frontend_contract.py
+++ b/tests/unit/test_nfc_frontend_contract.py
@@ -17,7 +17,7 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
vite_entry = source("src/main.js")
assert 'data-open-nfc-binding' in script
- assert '靠近读卡器后' in script
+ assert '请将 NFC 卡靠近读卡器' in script
assert 'NFC 设备授权
' in script
assert 'NFC 设备授权' in script
assert 'api("nfc/status")' in script
@@ -55,23 +55,25 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '学习指令已下发,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
- assert 'const activeCatalog = sensedCardUid' in script
- assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script
+ assert 'const persistentCatalog = (' in script
+ assert 'durablePeripheralCatalogHydrated' in script
+ assert 'durablePeripheralCatalog' in script
+ assert 'normalizeCardUid(item.card_uid)' in script
assert '&& item.status === "learned"' in script
assert 'slot: capability.slot' in script
assert 'device_aliases: device?.aliases || []' in script
assert 'async function hydratePeripheralCatalog()' in script
assert 'api("peripherals")' in script
assert 'signalSource: "backend_catalog"' in script
- assert '当前未感应到外设' in script
- assert '将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令' in script
+ assert '当前没有已绑定外设' in script
+ assert '外设绑定 NFC 后将持续显示,解除绑定后移除' in script
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
- assert 'self-extension.js?v=nfc-mqtt-56' in html
+ assert 'self-extension.js?v=nfc-persistent-57' in html
assert 'import "../self-extension.css";' in vite_entry
@@ -219,5 +221,5 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
assert "下发控制指令" in console
assert "已解析控制指令" not in console
assert 'li[data-can-channel="IR"]' in styles
- assert "console-v2.js?v=local-first-77-performance" in html
+ assert "console-v2.js?v=local-first-79-cinema-status" in html
assert 'speech_semantic: "语音语义"' in console
diff --git a/tests/unit/test_parallel_can_peripheral_dispatch.py b/tests/unit/test_parallel_can_peripheral_dispatch.py
new file mode 100644
index 0000000..6c00dfe
--- /dev/null
+++ b/tests/unit/test_parallel_can_peripheral_dispatch.py
@@ -0,0 +1,305 @@
+from __future__ import annotations
+
+import threading
+import time
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+from cognitive_agent.server import SeatDemoHandler
+
+
+def _result(mode: str, *, status: str = "succeeded") -> dict[str, object]:
+ return {
+ "request_id": f"req-{mode}-{status}",
+ "status": status,
+ "success": status == "succeeded",
+ "execution_mode": mode,
+ "decision_summary": [],
+ "function_configs": {},
+ "command_plans": {},
+ "trace_events": [],
+ "operation_results": [],
+ "execution_report": None,
+ "control_transport": "test",
+ }
+
+
+def _entries() -> list[dict[str, object]]:
+ return [
+ {
+ "index": 0,
+ "clause": "打开闪烁灯",
+ "capability": {
+ "id": "ir-slot:flash:2",
+ "device_id": "flash",
+ "device_name": "闪烁灯",
+ "action": "打开",
+ "slot": 2,
+ "parse_source": "local_rule",
+ },
+ }
+ ]
+
+
+def test_hardware_branches_preflight_then_start_together() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ lock = threading.Lock()
+ preflight_completed: set[str] = set()
+ hardware_started: dict[str, float] = {}
+
+ def mark_preflight(name: str) -> dict[str, object]:
+ time.sleep(0.03)
+ with lock:
+ preflight_completed.add(name)
+ return _result("dry_run")
+
+ def execute_can() -> dict[str, object]:
+ with lock:
+ assert preflight_completed == {"can", "peripheral"}
+ hardware_started["can"] = time.perf_counter()
+ time.sleep(0.12)
+ return _result("hardware")
+
+ def execute_peripheral(
+ _utterance: str,
+ _entries_value: list[dict[str, object]],
+ *,
+ execution_mode: str,
+ **_kwargs: object,
+ ) -> dict[str, object]:
+ if execution_mode == "dry_run":
+ return mark_preflight("peripheral")
+ with lock:
+ assert preflight_completed == {"can", "peripheral"}
+ hardware_started["peripheral"] = time.perf_counter()
+ time.sleep(0.12)
+ return _result("hardware")
+
+ with patch.object(
+ handler,
+ "_execute_local_peripheral_actions",
+ side_effect=execute_peripheral,
+ ):
+ result = handler._execute_can_and_peripheral_branches(
+ utterance="打开空调和闪烁灯",
+ entries=_entries(),
+ execution_mode="hardware",
+ provider="local",
+ conversation_id="conv-parallel-unit",
+ can_execute=execute_can,
+ can_preflight=lambda: mark_preflight("can"),
+ parallel_eligible=True,
+ )
+
+ assert result["status"] == "succeeded"
+ assert result["parallel_dispatch"]["enabled"] is True
+ assert result["parallel_dispatch"]["preflight_status"] == "succeeded"
+ assert abs(hardware_started["can"] - hardware_started["peripheral"]) < 0.05
+ dispatch = result["parallel_dispatch"]["dispatch"]
+ branch_durations = [
+ item["duration_ms"] for item in dispatch["branches"].values()
+ ]
+ assert dispatch["total_ms"] < sum(branch_durations)
+
+
+def test_failed_preflight_starts_neither_hardware_branch() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ hardware_calls: list[str] = []
+
+ def execute_peripheral(
+ _utterance: str,
+ _entries_value: list[dict[str, object]],
+ *,
+ execution_mode: str,
+ **_kwargs: object,
+ ) -> dict[str, object]:
+ if execution_mode == "hardware":
+ hardware_calls.append("peripheral")
+ return _result(execution_mode)
+
+ with patch.object(
+ handler,
+ "_execute_local_peripheral_actions",
+ side_effect=execute_peripheral,
+ ):
+ result = handler._execute_can_and_peripheral_branches(
+ utterance="打开空调和闪烁灯",
+ entries=_entries(),
+ execution_mode="hardware",
+ provider="local",
+ conversation_id="conv-parallel-preflight-failure",
+ can_execute=lambda: hardware_calls.append("can") or _result("hardware"),
+ can_preflight=lambda: _result(
+ "dry_run",
+ status="validation_failed",
+ ),
+ parallel_eligible=True,
+ )
+
+ assert hardware_calls == []
+ assert result["status"] == "validation_failed"
+ assert result["execution_mode"] == "dry_run"
+ assert result["hardware_execution_allowed"] is False
+ assert result["parallel_dispatch"]["enabled"] is False
+ assert result["parallel_dispatch"]["preflight_status"] == "failed"
+
+
+def test_wireless_parallel_dispatch_does_not_require_local_bench_lease() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ authorization_calls: list[str | None] = []
+ handler.service = SimpleNamespace(
+ hardware_access=SimpleNamespace(
+ ensure_authorized=lambda client_id: authorization_calls.append(client_id)
+ )
+ )
+
+ handler._ensure_parallel_hardware_authorized(
+ execution_mode="hardware",
+ control_transport="wireless_mqtt",
+ hardware_access_bypass=False,
+ hardware_client_id=None,
+ )
+
+ assert authorization_calls == []
+
+
+def test_wired_parallel_dispatch_keeps_local_bench_lease_gate() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ authorization_calls: list[str | None] = []
+ handler.service = SimpleNamespace(
+ hardware_access=SimpleNamespace(
+ ensure_authorized=lambda client_id: authorization_calls.append(client_id)
+ )
+ )
+
+ handler._ensure_parallel_hardware_authorized(
+ execution_mode="hardware",
+ control_transport="wired_can",
+ hardware_access_bypass=False,
+ hardware_client_id="hwc-wired-parallel-test",
+ )
+
+ assert authorization_calls == ["hwc-wired-parallel-test"]
+
+
+@pytest.mark.parametrize(
+ "utterance",
+ (
+ "先打开空调,再打开闪烁灯",
+ "打开空调,然后打开闪烁灯",
+ "打开空调,2秒后打开闪烁灯",
+ ),
+)
+def test_explicit_cross_transport_order_disables_parallelism(
+ utterance: str,
+) -> None:
+ assert SeatDemoHandler._mixed_request_requires_transport_order(utterance)
+
+
+def test_ineligible_flow_keeps_existing_can_then_peripheral_order() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ calls: list[str] = []
+
+ def execute_peripheral(
+ _utterance: str,
+ _entries_value: list[dict[str, object]],
+ *,
+ execution_mode: str,
+ **_kwargs: object,
+ ) -> dict[str, object]:
+ calls.append(f"peripheral:{execution_mode}")
+ return _result(execution_mode)
+
+ def unexpected_preflight() -> dict[str, object]:
+ raise AssertionError("ordered flow must not run a duplicate preflight")
+
+ with patch.object(
+ handler,
+ "_execute_local_peripheral_actions",
+ side_effect=execute_peripheral,
+ ):
+ result = handler._execute_can_and_peripheral_branches(
+ utterance="先打开空调,再打开闪烁灯",
+ entries=_entries(),
+ execution_mode="hardware",
+ provider="local",
+ conversation_id="conv-ordered-unit",
+ can_execute=lambda: calls.append("can:hardware") or _result("hardware"),
+ can_preflight=unexpected_preflight,
+ parallel_eligible=False,
+ )
+
+ assert calls == ["can:hardware", "peripheral:hardware"]
+ assert result["status"] == "succeeded"
+ assert "parallel_dispatch" not in result
+
+
+def test_saved_static_mode_uses_parallel_can_and_peripheral_path() -> None:
+ handler = object.__new__(SeatDemoHandler)
+ can_modes: list[str] = []
+ peripheral_modes: list[str] = []
+ persisted: list[dict[str, object]] = []
+ profile = SimpleNamespace(
+ id="mode-parallel",
+ schedule=None,
+ command_flow={"execution_steps": []},
+ public_dict=lambda: {"id": "mode-parallel", "name": "并行模式"},
+ )
+
+ def execute_mode_profile_card(
+ _mode_id: str,
+ **kwargs: object,
+ ) -> dict[str, object]:
+ mode = str(kwargs.get("execution_mode") or "")
+ can_modes.append(mode)
+ return _result(mode)
+
+ handler.service = SimpleNamespace(
+ execute_mode_profile_card=execute_mode_profile_card,
+ persist_composed_result=lambda result: persisted.append(result),
+ )
+
+ def execute_peripheral(
+ _utterance: str,
+ _entries_value: list[dict[str, object]],
+ *,
+ execution_mode: str,
+ **_kwargs: object,
+ ) -> dict[str, object]:
+ peripheral_modes.append(execution_mode)
+ return _result(execution_mode)
+
+ with (
+ patch.object(
+ handler,
+ "_exact_local_peripheral_mode_invocation",
+ return_value=profile,
+ ),
+ patch.object(handler, "_exact_n7_mode_invocation", return_value=None),
+ patch.object(handler, "_saved_mode_infrared_entries", return_value=_entries()),
+ patch.object(
+ handler,
+ "_execute_local_peripheral_actions",
+ side_effect=execute_peripheral,
+ ),
+ ):
+ result = handler._execute_n7_named_mode_request(
+ "/api/requests",
+ {
+ "utterance": "进入并行模式",
+ "execution_mode": "hardware",
+ "provider": "local",
+ "conversation_id": "conv-saved-parallel",
+ "vehicle_id": "denza_n7",
+ "control_transport": "wired_can",
+ },
+ )
+
+ assert result is not None
+ assert can_modes == ["dry_run", "hardware"]
+ assert peripheral_modes == ["dry_run", "hardware"]
+ assert result["parallel_dispatch"]["enabled"] is True
+ assert result["provider_used"] == "saved_mode_command_flow"
+ assert persisted == [result]
diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py
index cf5a535..a7d76c7 100644
--- a/tests/unit/test_requested_frontend_enhancements.py
+++ b/tests/unit/test_requested_frontend_enhancements.py
@@ -937,6 +937,22 @@ def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() ->
"physical_effect_confirmed": True,
},
),
+ sample(
+ {
+ "id": "window-feedback-confirmed",
+ "capability": "window.set_position",
+ "target": {"zone": "rear-left"},
+ "parameters": {"position_pct": 0},
+ },
+ status="failed",
+ message="MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。",
+ telemetry={
+ "delivery_status": "submitted",
+ "verification_status": "verified_success",
+ "feedback_match": True,
+ "physical_effect_confirmed": True,
+ },
+ ),
sample(
{
"id": "climate-post-submit-failed",
@@ -970,6 +986,7 @@ def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() ->
"下发失败",
"已中止",
"执行已确认",
+ "执行已确认",
"执行失败",
]
assert [item["deliveryState"] for item in commands] == [
@@ -977,6 +994,7 @@ def test_delivery_status_distinguishes_blocked_failed_aborted_and_verified() ->
"failed",
"aborted",
"submitted",
+ "submitted",
"failed",
]
assert "0x388 实时位置" in commands[0]["deliveryDetail"]
diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py
index b8cf587..0965019 100644
--- a/tests/unit/test_self_extension.py
+++ b/tests/unit/test_self_extension.py
@@ -1406,6 +1406,15 @@ class SelfExtensionTests(unittest.TestCase):
def test_main_peripheral_sequence_executes_validated_actions_and_delays(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
+ {
+ "id": "on",
+ "status": "learned",
+ "device_id": "third-party-light",
+ "device_name": "第三方氛围灯",
+ "card_uid": "1A53DD95",
+ "action": "打开灯光",
+ "slot": 1,
+ },
{
"id": "red",
"status": "learned",
@@ -1457,16 +1466,24 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(
result["orchestration_route"], "nfc_peripheral_sequence"
)
- self.assertEqual(result["sequence"]["step_count"], 3)
- self.assertEqual(result["sequence"]["action_count"], 2)
- self.assertEqual(result["sequence"]["emit_count"], 2)
+ self.assertEqual(result["sequence"]["step_count"], 4)
+ self.assertEqual(result["sequence"]["action_count"], 3)
+ self.assertEqual(result["sequence"]["emit_count"], 3)
self.assertEqual(result["sequence"]["total_delay_ms"], 5000)
self.assertEqual(result["sequence"]["steady_blink_fallbacks"], 1)
self.assertEqual(
[item["status"] for item in result["operation_results"]],
- ["succeeded", "succeeded", "succeeded"],
+ ["succeeded", "succeeded", "succeeded", "succeeded"],
+ )
+ self.assertEqual(
+ [
+ item["parameters"]["slot"]
+ for item in result["function_config"]["spec"]["operations"]
+ ],
+ [1, 3, 4],
)
sleep.assert_any_call(5.0)
+ sleep.assert_any_call(0.8)
self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list])
def test_failed_first_emit_retains_complete_compiled_infrared_plan(self) -> None:
@@ -1630,6 +1647,107 @@ class SelfExtensionTests(unittest.TestCase):
)
)
+ def test_color_only_request_automatically_executes_power_on_first(self) -> None:
+ with TemporaryDirectory() as directory:
+ service = SelfExtensionService(
+ SimulationGateway(),
+ catalog_db_path=Path(directory, "infrared.sqlite3"),
+ )
+ service.sync_peripheral_catalog(
+ [
+ {
+ "id": "flash-on",
+ "status": "learned",
+ "enabled": True,
+ "device_id": "flash-light",
+ "device_name": "闪烁灯",
+ "object_names": ["闪烁灯"],
+ "card_uid": "2CC8B103",
+ "action": "打开",
+ "action_names": ["打开", "开启", "启动"],
+ "slot": 1,
+ },
+ {
+ "id": "flash-blue",
+ "status": "learned",
+ "enabled": True,
+ "device_id": "flash-light",
+ "device_name": "闪烁灯",
+ "object_names": ["闪烁灯"],
+ "card_uid": "2CC8B103",
+ "action": "切换为蓝色",
+ "action_names": ["蓝色", "设置为蓝色"],
+ "slot": 3,
+ },
+ ]
+ )
+ color = next(
+ item
+ for item in service.list_peripheral_capabilities()
+ if item["id"] == "flash-blue"
+ )
+
+ result = service.execute_peripheral_request(
+ "把闪烁灯设置为蓝色",
+ color,
+ conversation_id="conv-color-power-dependency",
+ execution_mode="dry_run",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(result["sequence"]["action_count"], 2)
+ self.assertEqual(
+ [
+ item["parameters"]["slot"]
+ for item in result["function_config"]["spec"]["operations"]
+ ],
+ [1, 3],
+ )
+ self.assertEqual(
+ [
+ item["signals"]["slot"]
+ for item in result["command_plan"]["spec"]["commands"]
+ ],
+ [1, 3],
+ )
+ service.close()
+
+ def test_color_request_is_rejected_when_power_on_is_not_authorized(self) -> None:
+ with TemporaryDirectory() as directory:
+ service = SelfExtensionService(
+ SimulationGateway(),
+ catalog_db_path=Path(directory, "infrared.sqlite3"),
+ )
+ service.sync_peripheral_catalog(
+ [
+ {
+ "id": "strip-red",
+ "status": "learned",
+ "enabled": True,
+ "device_id": "light-strip",
+ "device_name": "灯带",
+ "object_names": ["灯带"],
+ "card_uid": "1A3AB103",
+ "action": "切换为红色",
+ "action_names": ["红色", "设置为红色"],
+ "slot": 24,
+ }
+ ]
+ )
+ color = service.list_peripheral_capabilities()[0]
+
+ with self.assertRaisesRegex(
+ ExtensionIntegrationError,
+ "设置颜色前需要先打开",
+ ):
+ service.execute_peripheral_request(
+ "把灯带设置为红色",
+ color,
+ conversation_id="conv-color-missing-power",
+ execution_mode="dry_run",
+ )
+ service.close()
+
def test_color_sequence_never_cycles_the_power_off_capability(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
diff --git a/tests/unit/test_self_extension_nfc_sync_contract.py b/tests/unit/test_self_extension_nfc_sync_contract.py
index 9ac28c4..1976713 100644
--- a/tests/unit/test_self_extension_nfc_sync_contract.py
+++ b/tests/unit/test_self_extension_nfc_sync_contract.py
@@ -37,11 +37,29 @@ def test_peripheral_catalog_sync_is_serialized_and_tracks_backend_revision() ->
)
assert "peripheralCatalogSyncQueue" in sync
+ assert "const nextCatalog = peripheralCatalog();" in sync
assert "catalog_revision: peripheralCatalogRevision" in sync
assert "result.catalog_revision" in sync
+ assert "applyDurablePeripheralCatalog(" in sync
assert "if (throwOnError) throw error;" in sync
+def test_backend_catalog_is_authoritative_for_persistent_device_cards() -> None:
+ source = SOURCE_PATH.read_text(encoding="utf-8")
+ hydrate = _function_source(
+ source,
+ " async function hydratePeripheralCatalog() {",
+ "\n function vehicleCatalog",
+ )
+ bootstrap = source[source.index(" void hydratePeripheralCatalog()") :]
+
+ assert "applyDurablePeripheralCatalog(" in hydrate
+ assert "if (!catalog.length)" in hydrate
+ assert "renderAll();" in hydrate
+ assert ".finally(() => syncPeripheralCatalog())" not in bootstrap
+ assert "return syncPeripheralCatalog();" in bootstrap
+
+
def test_bound_card_can_be_reassigned_to_a_new_device() -> None:
source = SOURCE_PATH.read_text(encoding="utf-8")
render = _function_source(
diff --git a/tests/unit/test_speech_capability_alignment.py b/tests/unit/test_speech_capability_alignment.py
index d818e23..be295a8 100644
--- a/tests/unit/test_speech_capability_alignment.py
+++ b/tests/unit/test_speech_capability_alignment.py
@@ -429,23 +429,26 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
result = self.processor.process("打开座椅通风并调到三档")
self.assertEqual(
[command.capability for command in result.commands],
- [
- "seat.set_driver_ventilation_native",
- "seat.set_driver_ventilation_native",
- ],
+ ["seat.set_driver_ventilation_native"],
)
self.assertEqual(
[command.target_scope for command in result.commands],
- ["driver", "driver"],
+ ["driver"],
)
self.assertIn("3档", result.clean_text)
- self.assertEqual(result.semantic_status, "recognized_unsupported")
- self.assertFalse(result.execution_eligible)
+ self.assertEqual(result.commands[0].parameters, {"state": "on", "level": 1})
+ self.assertEqual(result.commands[0].qualifiers["display_level"], "3档")
+ self.assertEqual(result.semantic_status, "recognized_executable")
+ self.assertTrue(result.execution_eligible)
passenger = self.processor.process("打开副驾座椅通风并调到三档")
self.assertEqual(
[command.target_scope for command in passenger.commands],
- ["passenger", "passenger"],
+ ["passenger"],
+ )
+ self.assertEqual(
+ passenger.commands[0].parameters,
+ {"state": "on", "level": 3},
)
self.assertTrue(passenger.execution_eligible)
@@ -489,7 +492,11 @@ class SpeechCapabilityAlignmentTests(unittest.TestCase):
self.assertNotIn("设置为", result.clean_text)
explicit_level = self.processor.process("主驾座椅通风开到一档")
- self.assertEqual(explicit_level.commands[0].parameters["level"], 1)
+ self.assertEqual(explicit_level.commands[0].parameters["level"], 2)
+ self.assertEqual(
+ explicit_level.commands[0].qualifiers["display_level"],
+ "1档",
+ )
def test_discourse_connectors_preserve_actions_without_leaking_scope(self) -> None:
cases = (
diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py
index cef784c..92fd8fe 100644
--- a/tests/unit/test_vue_frontend_contract.py
+++ b/tests/unit/test_vue_frontend_contract.py
@@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None:
assert package["scripts"]["build"] == "vite build"
assert 'import vue from "@vitejs/plugin-vue"' in vite
assert 'outDir: outputRoot' in vite
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v329-mode-preview"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v334-video-media"' in service_worker
assert 'createHash("sha256")' in vite
assert 'runtime-assets.json' in vite
assert 'rewriteRuntimeReferences' in vite