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