feat: improve mode and peripheral orchestration
This commit is contained in:
+19
-1
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -39,13 +39,13 @@
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||
<script src="/network.js?v=android-pad-2" defer></script>
|
||||
<script src="/app.js?v=dual-seat-145-n7-dispatch" defer></script>
|
||||
<script src="/app.js?v=dual-seat-147-video-media" defer></script>
|
||||
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||
<script src="/console-v2.js?v=local-first-77-performance" defer></script>
|
||||
<script src="/self-extension.js?v=nfc-mqtt-56" defer></script>
|
||||
<script src="/assistant-chat.js?v=codex-assistant-35" defer></script>
|
||||
<script src="/console-v2.js?v=local-first-79-cinema-status" defer></script>
|
||||
<script src="/self-extension.js?v=nfc-persistent-57" defer></script>
|
||||
<script src="/assistant-chat.js?v=codex-assistant-39-video-media" defer></script>
|
||||
<script src="/energy-monitor.js?v=energy-monitor-5-performance" defer></script>
|
||||
<script src="/firmware-workbench-entry.js?v=3" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -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 @@
|
||||
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
|
||||
</article>`).join("") : nfcPet ? "" : `
|
||||
<div class="v2-extension-empty">
|
||||
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
|
||||
<i data-lucide="link-2-off"></i><div><strong>当前没有已绑定外设</strong><span>外设绑定 NFC 后将持续显示,解除绑定后移除</span></div>
|
||||
</div>`}
|
||||
</div>
|
||||
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
|
||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
|
||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "暂无外设联动")}</strong></div>
|
||||
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
|
||||
</footer>`;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
+641
-105
@@ -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,
|
||||
|
||||
+145
-54
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})()
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"] == "已下发 · 物理效果未确认"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 '<h2>NFC 设备授权</h2>' 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
|
||||
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user