feat: harden seat intent and expand HIL coverage

This commit is contained in:
vee
2026-08-02 05:20:38 +08:00
parent 4aded5c21d
commit 55d0d400cb
62 changed files with 53112 additions and 1748 deletions
+25 -2
View File
@@ -17,6 +17,20 @@ UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模
make dev
```
Android PAD/模拟器联调使用:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\android_pad_dev.ps1
```
脚本会启动已配置的 Pixel Tablet AVD、建立 `adb reverse tcp:8000 tcp:8000`、
以真实硬件关闭/模拟台架开启的方式启动服务,并在 Android Chrome 中打开
`http://127.0.0.1:8000`。停止联调环境:
```powershell
powershell -ExecutionPolicy Bypass -File .\tools\android_pad_dev.ps1 -Stop
```
局域网演示使用:
```bash
@@ -31,7 +45,7 @@ make dev-lan
HTTPS。部署方式见
[`deploy/server/README.md`](../../deploy/server/README.md)。
页面支持 GPT/本地解析切换、六阶段流水线、主副驾热舒适、五轴位置状态和 3D 座椅孪生、GPT 结构化能力请求、决策与校验轨迹、FunctionConfig、CommandPlan、虚拟帧和 ExecutionReport。硬件执行在服务端固定关闭,不是仅靠前端按钮限制。
页面支持 GPT/本地解析切换、六阶段流水线、主副驾热舒适、五轴位置状态和 3D 座椅孪生、GPT 结构化能力请求、决策与校验轨迹、FunctionConfig、CommandPlan、虚拟帧和 ExecutionReport。真实硬件执行由服务端门禁控制,不是仅靠前端按钮限制;Android 联调脚本会显式关闭真实硬件执行。
## 手势资源与安全规则
@@ -42,7 +56,16 @@ assets/gesture/gesture_recognizer.task
../../config/gesture/default.yaml
```
后端提供 `/api/gesture/runtime` 和 `/api/gesture/model`,前端以二进制缓冲区加载模型,避免依赖外部 `seat_gesture_control` 目录。MediaPipe Web 运行时从 jsDelivr 按需加载;摄像头帧和手部关键点只在当前浏览器中处理。
后端提供 `/api/gesture/runtime` 和 `/api/gesture/model`,前端以二进制缓冲区加载模型,避免依赖外部 `seat_gesture_control` 目录。MediaPipe Web、Lucide 和 Three.js 均按固定版本保存在 `vendor/`,运行时不访问外部 CDN;摄像头帧和手部关键点只在当前浏览器中处理。
## PAD、PWA 与离线行为
- 竖屏 PAD 在 900 CSS px 以下切换为上下布局,横屏保留指令栏与结果区;触控目标最小 48 dp,并处理安全区和动态视口。
- 顶部连接徽标由 `/api/health` 周期探测驱动;GET 可有限重试,POST 控制请求不自动重试。
- Service Worker 缓存页面壳、固定版本依赖和按需访问的 3D/手势资源;`/api/requests` 等控制 API 始终走网络且不会写入缓存。
- 从 Android Chrome 的“安装应用”可进入 standalone PWA;首次完整在线加载后可离线打开控制台壳,实际编排仍要求 Windows 服务可达。
模拟台架需要 `COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED=true`。选择“模拟台架”后可注入反馈超时、适配器断开、电机堵转、固件不匹配和部分反馈。结果会标记 `simulation=true`、`seat_feedback=simulated_hardware` 和 `physical_can_opened=false`,不会调用真实 CAN、ZLG 或 IAR。
安全规则:张掌保持 1 秒激活;动作保持 600 ms 且至少连续 8 帧才执行;换动作不重新锁止;握拳立即停止但不解除激活;3 秒无有效动作自动锁止;单次连续运动最多 1.5 秒;检测到多只手时不执行。自定义映射保存在浏览器 `localStorage`。
+112 -25
View File
@@ -22,10 +22,22 @@ const state = {
seatActuatorState: {},
seatFeedbackSource: "simulated",
availableSeatAxes: null,
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(input, options);
// A four-axis hardware mode is executed serially. Each absolute move may wait
// up to 33 seconds, and firmware recovery or known-range homing can extend the
// request further. Keep the browser deadline above the backend's bounded HIL
// workflow so the UI does not report failure while the seat is still moving.
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 45 * 1000;
function requestTimeoutForExecutionMode(mode) {
return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
}
function refreshIcons() {
if (window.lucide) {
@@ -2519,6 +2531,10 @@ function updateSeatFeedbackModeLabel() {
label.textContent = "PHYSICAL CAN · SYNC UNAVAILABLE";
return;
}
if (state.seatFeedbackSource === "simulated_hardware") {
label.textContent = "SIMULATED HIL · NO PHYSICAL CAN";
return;
}
label.textContent = state.executionMode === "hardware"
? "PHYSICAL CAN FEEDBACK"
: "SIMULATED FEEDBACK";
@@ -2526,30 +2542,41 @@ function updateSeatFeedbackModeLabel() {
function selectExecutionMode(mode) {
if (mode === "hardware" && !state.runtime?.hardware?.configured) return;
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return;
state.executionMode = mode;
$$('[data-execution-mode]').forEach((button) => {
button.classList.toggle("active", button.dataset.executionMode === mode);
});
const hardware = mode === "hardware";
const simulatedHardware = mode === "simulated_hardware";
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
$("#approvalField").hidden = !hardware || !approvalRequired;
$("#executionBadge").className = `status-badge ${hardware ? "" : "neutral"}`.trim();
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
? '<i data-lucide="radio-tower"></i>HIL · 真实 CAN'
: '<i data-lucide="flask-conical"></i>DRY-RUN';
$("#safetyTitle").textContent = hardware ? "真实台架安全门禁" : "虚拟执行边界";
: simulatedHardware
? '<i data-lucide="bug-play"></i>SIM · 模拟台架'
: '<i data-lucide="flask-conical"></i>DRY-RUN';
$("#safetyTitle").textContent = hardware
? "真实台架安全门禁"
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
$("#safetyText").textContent = hardware
? "副驾座椅映射到统一实体执行器;绝对位置会实时检查板端标定,小幅相对移动可走受限定时/Hall 回读,异常会尝试 STOP_ALL。"
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
$("#executeStageLabel").textContent = hardware ? "台架执行" : "虚拟执行";
: simulatedHardware
? "故障只注入执行报告;物理 CAN、ZLG 适配器和 IAR 固件路径保持关闭。"
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
$("#feedbackModeLabel").textContent = hardware
? "T2G BENCH · PHYSICAL CAN FEEDBACK"
: "MATRIX-BACKED · SIMULATED FEEDBACK";
: simulatedHardware
? "SIMULATED HIL · FAULT INJECTION"
: "MATRIX-BACKED · SIMULATED FEEDBACK";
const runButtonLabel = $("#runButton span");
if (runButtonLabel) {
runButtonLabel.textContent = hardware
? (approvalRequired ? "授权并真实执行" : "真实执行")
: "生成并执行";
: simulatedHardware ? "运行故障仿真" : "生成并执行";
}
updateSeatFeedbackModeLabel();
refreshIcons();
@@ -2730,7 +2757,7 @@ function saveCurrentModeProfile() {
async function loadRuntime() {
try {
const response = await fetch("/api/runtime", { cache: "no-store" });
const response = await apiRequest("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
populateVehicleSelector();
@@ -2741,7 +2768,8 @@ async function loadRuntime() {
if (state.runtime.openai_available) {
badge.classList.remove("danger");
badge.innerHTML = `<i data-lucide="sparkles"></i>${state.runtime.openai_model}`;
$("#providerNote").textContent = `${state.runtime.openai_model} · Responses API · 失败时自动降级本地规则`;
const responsesEndpoint = state.runtime.openai_responses_endpoint || "Responses API";
$("#providerNote").textContent = `${state.runtime.openai_model} · ${responsesEndpoint} · 失败时自动降级本地规则`;
selectProvider("openai");
} else {
badge.classList.add("danger");
@@ -2754,6 +2782,11 @@ async function loadRuntime() {
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
const hardwareButton = $("#hardwareMode");
hardwareButton.disabled = !hardware.configured;
const simulation = state.runtime.simulated_hardware || {};
$("#simulationMode").disabled = !simulation.enabled;
$("#simulationMode").title = simulation.enabled
? "开发专用模拟台架,不连接物理 CAN"
: "通过安卓联调脚本或环境变量启用模拟台架";
const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
@@ -2772,7 +2805,12 @@ async function loadRuntime() {
? `可选 · ${approvalText}`
: "已阻断";
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
selectExecutionMode("dry_run");
const modeAvailable = state.executionMode === "hardware"
? hardware.configured
: state.executionMode === "simulated_hardware"
? simulation.enabled
: true;
selectExecutionMode(modeAvailable ? state.executionMode : "dry_run");
refreshIcons();
} catch (error) {
$("#modelBadge").textContent = "运行时离线";
@@ -2789,7 +2827,7 @@ async function synchronizePhysicalSeatState() {
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
});
try {
const response = await fetch(`/api/hardware/seat-state?${query}`, {
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
cache: "no-store",
});
if (response.status === 401) {
@@ -2814,7 +2852,7 @@ async function synchronizePhysicalSeatState() {
}
async function loadAuthStatus() {
const response = await fetch("/api/auth/status", { cache: "no-store" });
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
const auth = await response.json();
if (auth.enabled && !auth.authenticated) {
@@ -2825,7 +2863,7 @@ async function loadAuthStatus() {
}
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
await apiRequest("/api/auth/logout", { method: "POST" });
window.location.replace("/login");
}
@@ -3423,7 +3461,10 @@ function renderCode(result) {
const feedback = telemetry?.feedback_frame
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
: "NO DIRECT FEEDBACK";
const mode = result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN";
const simulatedHil = result.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
const mode = simulatedHil
? "HIL · SIMULATED"
: result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN";
summary.innerHTML = `<span>Frames</span><b>${frames.length}</b><span>First payload</span><code>${frames[0].frame_id} · ${frames[0].data_hex}</code><span>Feedback</span><code>${feedback}</code><span>Mode</span><b>${mode}</b>`;
$("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`;
} else if (telemetry?.operation === "save_posture_preset") {
@@ -3459,7 +3500,8 @@ function renderExecution(result) {
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
const presetSave = Boolean(result.preset_config);
const modeSave = Boolean(result.mode_profile);
const physical = report.spec.mode === "hil";
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
const physical = report.spec.mode === "hil" && !simulatedHil;
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
@@ -3468,6 +3510,8 @@ function renderExecution(result) {
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
: presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: simulatedHil
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
@@ -3480,7 +3524,9 @@ function renderExecution(result) {
if (!reportSucceeded) {
const failure = report.spec.results.find((item) => item.status === "failed");
const failureTelemetry = failure?.telemetry || {};
executionDetail = failureTelemetry.error_type === "CanGatewayError"
executionDetail = simulatedHil
? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。`
: failureTelemetry.error_type === "CanGatewayError"
? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}`
: failureTelemetry.error_type === "FirmwareProgrammingError"
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
@@ -3496,9 +3542,15 @@ function renderExecution(result) {
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
: failure?.message || "真实台架未返回满足目标的物理反馈。";
}
summary.innerHTML = `<i data-lucide="${reportSucceeded ? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big") : "circle-x"}"></i><div><strong>${reportSucceeded ? (modeSave ? "长期模式已保存" : presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}</strong><span>${executionDetail}</span></div>`;
const executionTitle = reportSucceeded
? modeSave ? "长期模式已保存"
: presetSave ? "姿态预设已保存"
: simulatedHil ? "模拟台架执行成功"
: physical ? "真实台架执行成功" : "虚拟执行成功"
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
summary.innerHTML = `<i data-lucide="${reportSucceeded ? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big") : "circle-x"}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${physical ? "HIL" : "DRY-RUN"}`
? `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
: "执行失败 · 查看回读";
$("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : "FAILED";
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : "failed"}`;
@@ -3513,10 +3565,19 @@ function renderExecution(result) {
detail.textContent = item.message;
text.append(title, detail);
const frame = document.createElement("code");
const firmwareTelemetry = item.telemetry?.firmware_verified
? [
item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED",
item.telemetry.firmware_revision == null ? null : `REV ${item.telemetry.firmware_revision}`,
item.telemetry.identity_frame_id && item.telemetry.identity_data_hex
? `${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
: item.telemetry.protocol_signature,
].filter(Boolean).join(" · ")
: null;
frame.textContent = item.telemetry?.command_frame
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
: item.telemetry?.firmware_verified
? `${item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED"} · ${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
: firmwareTelemetry
? firmwareTelemetry
: "CONTROL PLANE · NO CAN";
row.append(text, frame);
list.append(row);
@@ -3527,7 +3588,10 @@ function renderExecution(result) {
function renderResult(result) {
state.result = result;
const reportMode = result.execution_report?.spec?.mode;
if (reportMode === "hil") {
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
if (feedbackSource === "simulated_hardware") {
state.seatFeedbackSource = "simulated_hardware";
} else if (reportMode === "hil") {
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
? "physical_can"
: "physical_partial";
@@ -3597,8 +3661,9 @@ async function submitRequest(event) {
if (approvalTokenInput) approvalTokenInput.value = "";
try {
const response = await fetch("/api/requests", {
const response = await apiRequest("/api/requests", {
method: "POST",
timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
utterance,
@@ -3606,6 +3671,9 @@ async function submitRequest(event) {
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
...(approvalRequired
? { approval_token: approvalToken }
: {}),
@@ -3622,9 +3690,14 @@ async function submitRequest(event) {
await synchronizePhysicalSeatState();
}
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
showToast(completed
? (payload.execution_report?.spec?.mode === "hil" ? "真实台架执行与反馈确认已完成" : "可执行动作的 dry-run 已完成")
: "请求未执行");
? (simulated
? "模拟台架执行完成"
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
} catch (error) {
window.clearInterval(state.progressTimer);
resetPipeline();
@@ -3637,7 +3710,10 @@ async function submitRequest(event) {
const hardwareLabel = state.runtime?.hardware?.approval_required
? "授权并真实执行"
: "真实执行";
button.innerHTML = `<i data-lucide="play"></i><span>${state.executionMode === "hardware" ? hardwareLabel : "生成并执行"}</span>`;
const buttonLabel = state.executionMode === "hardware"
? hardwareLabel
: state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行";
button.innerHTML = `<i data-lucide="play"></i><span>${buttonLabel}</span>`;
refreshIcons();
}
}
@@ -3723,6 +3799,17 @@ window.CognitiveAgentConsole = {
},
};
window.addEventListener("cognitive-agent:connection", (event) => {
const previousStatus = state.connectionStatus;
state.connectionStatus = event.detail?.status || "offline";
if (["offline", "degraded"].includes(previousStatus)
&& state.connectionStatus === "online") {
Promise.all([loadAuthStatus(), loadRuntime()])
.then(() => showToast("服务连接已恢复"))
.catch(() => {});
}
});
document.addEventListener("DOMContentLoaded", async () => {
initJointTwinLayout();
initResultWorkspace();
+7 -4
View File
@@ -1,6 +1,6 @@
const VISION_VERSION = "0.10.14";
const VISION_MODULE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/vision_bundle.mjs`;
const VISION_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/wasm`;
const VISION_MODULE = `/vendor/mediapipe/tasks-vision/${VISION_VERSION}/vision_bundle.mjs`;
const VISION_WASM = `/vendor/mediapipe/tasks-vision/${VISION_VERSION}/wasm`;
const CONNECTIONS = [
[0, 1], [1, 2], [2, 3], [3, 4],
@@ -464,7 +464,7 @@ async function recognizeLoop() {
}
async function loadGestureRuntime() {
const response = await fetch("/api/gesture/runtime", { cache: "no-store" });
const response = await window.CognitiveAgentNetwork.request("/api/gesture/runtime", { cache: "no-store" });
if (!response.ok) throw new Error("无法读取手势能力配置");
state.runtime = await response.json();
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
@@ -477,7 +477,10 @@ async function loadRecognizer() {
$("#gestureSafetyText").textContent = "正在加载手势模型…";
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
const fileset = await FilesetResolver.forVisionTasks(VISION_WASM);
const modelResponse = await fetch("/api/gesture/model", { cache: "force-cache" });
const modelResponse = await window.CognitiveAgentNetwork.request("/api/gesture/model", {
cache: "force-cache",
timeoutMs: 60000,
});
if (!modelResponse.ok) throw new Error("内置手势模型加载失败");
const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer());
state.recognizer = await GestureRecognizer.createFromOptions(fileset, {
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+33 -9
View File
@@ -2,16 +2,22 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light" />
<meta name="theme-color" content="#146b65" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>智舱随行 · 一句话与手势控制</title>
<link rel="stylesheet" href="/styles.css" />
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/build/three.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/libs/fflate.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/loaders/FBXLoader.js" defer></script>
<script src="/app.js?v=unified-cabin-23" defer></script>
<script type="module" src="/gesture-control.js?v=gesture-core-3"></script>
<link rel="stylesheet" href="/styles.css?v=android-pad-2" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<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-1" defer></script>
<script src="/app.js?v=seat-hil-7" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-1"></script>
<script src="/pwa.js?v=android-pad-1" defer></script>
</head>
<body>
<header class="topbar">
@@ -26,6 +32,8 @@
<button class="gesture-entry" id="gestureToggleButton" type="button" aria-controls="gestureDrawer" aria-expanded="false">
<span class="gesture-entry-dot"></span><i data-lucide="scan-face"></i>手势控制
</button>
<span class="status-badge neutral connection-badge" id="connectionBadge"><i data-lucide="loader-circle"></i><span>连接中</span></span>
<button class="status-badge neutral pwa-badge" id="pwaBadge" type="button" hidden></button>
<span class="status-badge neutral" id="executionBadge"><i data-lucide="flask-conical"></i>DRY-RUN</span>
<span class="status-badge" id="modelBadge"><i data-lucide="sparkles"></i>模型检测中</span>
<span class="status-badge danger" id="hardwareBadge"><i data-lucide="shield-off"></i>硬件检测中</span>
@@ -83,18 +91,34 @@
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented" role="group" aria-label="执行目标">
<div class="segmented execution-mode-options" role="group" aria-label="执行目标">
<button type="button" class="mode-option active" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>虚拟 CAN
</button>
<button type="button" class="mode-option" id="hardwareMode" data-execution-mode="hardware" disabled>
<i data-lucide="radio-tower"></i>真实台架
</button>
<button type="button" class="mode-option" id="simulationMode" data-execution-mode="simulated_hardware" disabled>
<i data-lucide="bug-play"></i>模拟台架
</button>
</div>
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
<option value="normal">正常反馈</option>
<option value="feedback_timeout">反馈超时</option>
<option value="adapter_disconnect">适配器断开</option>
<option value="motor_stall">电机堵转</option>
<option value="firmware_mismatch">固件不匹配</option>
<option value="partial_feedback">部分反馈</option>
</select>
<p class="provider-note">仅生成模拟 HIL 结果,不会打开物理 CAN 适配器或调用 IAR。</p>
</div>
<div class="field-group vehicle-field">
<label class="field-label" for="vehicleSelect">车型配置</label>
<div class="vehicle-select-control">
+1
View File
@@ -6,6 +6,7 @@
<meta name="color-scheme" content="light" />
<title>登录 · Cognitive Agent</title>
<link rel="stylesheet" href="/login.css" />
<script src="/network.js?v=android-pad-1" defer></script>
<script src="/login.js" defer></script>
</head>
<body>
+2 -2
View File
@@ -4,7 +4,7 @@ function nextPath() {
}
async function readAuthStatus() {
const response = await fetch("/api/auth/status", { cache: "no-store" });
const response = await window.CognitiveAgentNetwork.request("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
const status = await response.json();
if (status.authenticated) window.location.replace(nextPath());
@@ -18,7 +18,7 @@ async function submitLogin(event) {
message.textContent = "";
try {
const response = await fetch("/api/auth/login", {
const response = await window.CognitiveAgentNetwork.request("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Cognitive Agent PAD Console",
"short_name": "Cognitive Agent",
"description": "车舱认知编排、模拟台架与手势控制 PAD 控制台",
"lang": "zh-CN",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#eef3f1",
"theme_color": "#146b65",
"icons": [
{
"src": "/icons/app-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/app-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+138
View File
@@ -0,0 +1,138 @@
(function initializeCognitiveAgentNetwork() {
const nativeFetch = window.fetch.bind(window);
const listeners = new Set();
const state = {
status: navigator.onLine === false ? "offline" : "connecting",
lastSuccessAt: null,
lastError: null,
};
let probeTimer = null;
function snapshot() {
return { ...state };
}
function renderBadge() {
const badge = document.querySelector("#connectionBadge");
if (!badge) return;
const presentation = {
connecting: ["neutral", "loader-circle", "连接中"],
online: ["", "wifi", "服务已连接"],
degraded: ["warning", "wifi-low", "连接不稳定"],
offline: ["danger", "wifi-off", "服务已断开"],
}[state.status];
badge.className = `status-badge connection-badge ${presentation[0]}`.trim();
badge.innerHTML = `<i data-lucide="${presentation[1]}"></i><span>${presentation[2]}</span>`;
badge.title = state.lastError || presentation[2];
window.lucide?.createIcons?.({
attrs: { "aria-hidden": "true" },
nodes: [badge],
});
}
function publish(status, error = null) {
const changed = state.status !== status || state.lastError !== error;
state.status = status;
state.lastError = error;
if (status === "online") state.lastSuccessAt = new Date().toISOString();
renderBadge();
if (!changed) return;
const detail = snapshot();
listeners.forEach((listener) => listener(detail));
window.dispatchEvent(new CustomEvent("cognitive-agent:connection", { detail }));
}
function sleep(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async function request(input, options = {}) {
const {
timeoutMs = 10000,
retries: configuredRetries,
signal: callerSignal,
...fetchOptions
} = options;
const method = String(fetchOptions.method || "GET").toUpperCase();
const retries = configuredRetries ?? (method === "GET" ? 1 : 0);
let lastError;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const controller = new AbortController();
const abortFromCaller = () => controller.abort(callerSignal?.reason);
callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
const timeout = window.setTimeout(() => controller.abort("timeout"), timeoutMs);
try {
const response = await nativeFetch(input, {
...fetchOptions,
signal: controller.signal,
});
publish(response.status >= 500 ? "degraded" : "online",
response.status >= 500 ? `服务返回 HTTP ${response.status}` : null);
return response;
} catch (error) {
lastError = error;
const message = controller.signal.aborted && !callerSignal?.aborted
? `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
: (error?.message || "网络请求失败");
publish(attempt < retries ? "degraded" : "offline", message);
if (callerSignal?.aborted || attempt >= retries || method !== "GET") {
throw new Error(message, { cause: error });
}
await sleep(300 * (attempt + 1));
} finally {
window.clearTimeout(timeout);
callerSignal?.removeEventListener("abort", abortFromCaller);
}
}
throw lastError;
}
async function probe() {
if (document.hidden) return;
if (navigator.onLine === false) {
publish("offline", "设备当前处于离线状态");
return;
}
try {
const response = await request("/api/health", {
cache: "no-store",
timeoutMs: 4000,
retries: 0,
});
if (!response.ok && response.status !== 401) {
publish("degraded", `健康检查返回 HTTP ${response.status}`);
}
} catch (_error) {
// request() already published the detailed state.
}
}
function startMonitoring() {
if (probeTimer !== null) return;
renderBadge();
probe();
probeTimer = window.setInterval(probe, 10000);
window.addEventListener("online", () => {
publish("connecting");
probe();
});
window.addEventListener("offline", () => publish("offline", "设备当前处于离线状态"));
document.addEventListener("visibilitychange", () => {
if (!document.hidden) probe();
});
}
window.CognitiveAgentNetwork = {
request,
probe,
subscribe(listener) {
listeners.add(listener);
listener(snapshot());
return () => listeners.delete(listener);
},
getState: snapshot,
nativeFetch,
};
document.addEventListener("DOMContentLoaded", startMonitoring, { once: true });
})();
+40
View File
@@ -0,0 +1,40 @@
(function initializePwa() {
function notify(message) {
window.CognitiveAgentConsole?.notify?.(message);
}
function showUpdate(registration) {
const badge = document.querySelector("#pwaBadge");
if (!badge) return;
badge.hidden = false;
badge.innerHTML = '<i data-lucide="refresh-cw"></i><span>发现更新</span>';
badge.onclick = () => registration.waiting?.postMessage({ type: "SKIP_WAITING" });
window.lucide?.createIcons?.({ attrs: { "aria-hidden": "true" }, nodes: [badge] });
}
async function register() {
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
try {
const registration = await navigator.serviceWorker.register("/service-worker.js", {
scope: "/",
});
if (registration.waiting) showUpdate(registration);
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
worker?.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdate(registration);
}
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => window.location.reload());
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "OFFLINE_READY") notify("PAD 离线资源已准备完成");
});
} catch (error) {
console.warn("PWA registration failed", error);
}
}
window.addEventListener("load", register, { once: true });
})();
+84
View File
@@ -0,0 +1,84 @@
const CACHE_VERSION = "cognitive-agent-pad-v7";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
"/",
"/index.html",
"/styles.css?v=android-pad-2",
"/network.js?v=android-pad-1",
"/app.js?v=seat-hil-7",
"/gesture-control.js?v=android-pad-1",
"/pwa.js?v=android-pad-1",
"/manifest.webmanifest",
"/icons/app-icon-192.png",
"/icons/app-icon-512.png",
"/vendor/lucide/0.468.0/lucide.min.js",
"/vendor/three/0.147.0/three.min.js",
"/vendor/three/0.147.0/fflate.min.js",
"/vendor/three/0.147.0/FBXLoader.js",
"/vendor/mediapipe/tasks-vision/0.10.14/vision_bundle.mjs",
"/vendor/mediapipe/tasks-vision/0.10.14/wasm/vision_wasm_internal.js",
"/vendor/mediapipe/tasks-vision/0.10.14/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/tasks-vision/0.10.14/wasm/vision_wasm_nosimd_internal.js",
"/vendor/mediapipe/tasks-vision/0.10.14/wasm/vision_wasm_nosimd_internal.wasm"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.clients.matchAll({ includeUncontrolled: true }))
.then((clients) => clients.forEach((client) => client.postMessage({ type: "OFFLINE_READY" })))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((key) => key.startsWith("cognitive-agent-pad-")
&& ![SHELL_CACHE, RUNTIME_CACHE].includes(key))
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
async function navigationResponse(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put("/index.html", response.clone());
}
return response;
} catch (_error) {
return (await caches.match("/index.html")) || Response.error();
}
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || event.request.method !== "GET") return;
if (event.request.mode === "navigate") {
event.respondWith(navigationResponse(event.request));
return;
}
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/gesture/model") return;
event.respondWith(cacheFirst(event.request));
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
+169
View File
@@ -4356,3 +4356,172 @@ pre {
grid-column: 2;
}
}
/* Android PAD, touch and installable-app refinements ---------------------- */
.execution-mode-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.simulation-fault-field select {
width: 100%;
min-height: 44px;
padding: 0 11px;
color: var(--ink);
background: var(--surface-muted);
border: 1px solid var(--line-strong);
border-radius: 6px;
font: inherit;
font-size: 13px;
font-weight: 700;
}
.status-badge.warning {
color: #7a5416;
background: var(--amber-soft);
border-color: #e9cc96;
}
.connection-badge svg[data-lucide="loader-circle"] {
animation: spin 0.9s linear infinite;
}
.pwa-badge {
cursor: pointer;
}
.pwa-badge[hidden] {
display: none;
}
@supports (min-height: 100dvh) {
body {
min-height: 100dvh;
}
.workspace {
min-height: calc(100dvh - 70px);
}
}
@media (pointer: coarse) {
button,
select,
input,
.segment,
.mode-option,
.tab,
.example-command,
.gesture-entry,
.status-badge.auth-logout {
min-height: 48px;
}
button:hover,
.example-command:hover,
.segment:hover,
.mode-option:hover {
transform: none;
}
textarea {
font-size: 16px;
}
}
@media (max-width: 900px) {
.topbar {
min-height: 64px;
align-items: center;
gap: 12px;
padding-top: max(10px, env(safe-area-inset-top));
padding-right: max(14px, env(safe-area-inset-right));
padding-left: max(14px, env(safe-area-inset-left));
}
.brand-block p,
#hardwareBadge,
#modelBadge {
display: none;
}
.runtime-badges {
flex: 1;
flex-wrap: nowrap;
justify-content: flex-end;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
}
.runtime-badges .status-badge#connectionBadge {
display: flex;
}
.runtime-badges::-webkit-scrollbar {
display: none;
}
.workspace {
display: block;
}
.command-panel {
width: auto;
max-height: none;
position: static;
padding: 22px max(18px, env(safe-area-inset-right)) 24px max(18px, env(safe-area-inset-left));
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.main-panel {
padding-right: max(16px, env(safe-area-inset-right));
padding-bottom: max(42px, env(safe-area-inset-bottom));
padding-left: max(16px, env(safe-area-inset-left));
}
.pipeline {
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.pipeline-step {
min-width: 96px;
}
.gesture-drawer {
width: min(520px, 100vw);
padding-right: max(18px, env(safe-area-inset-right));
padding-bottom: max(22px, env(safe-area-inset-bottom));
padding-left: max(18px, env(safe-area-inset-left));
}
}
@media (max-width: 620px) {
.execution-mode-options {
grid-template-columns: 1fr;
}
.runtime-badges #executionBadge,
.pwa-badge {
display: none;
}
}
@media (orientation: landscape) and (max-height: 720px) and (min-width: 901px) {
.topbar {
min-height: 58px;
padding-top: 8px;
padding-bottom: 8px;
}
.workspace {
min-height: calc(100dvh - 58px);
}
.command-panel,
.main-panel {
padding-top: 18px;
}
}
+15
View File
@@ -0,0 +1,15 @@
ISC License
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2022 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,36 @@
"tolerance": {"type": "number", "minimum": 0},
"timeout_ms": {"type": "integer", "minimum": 1, "maximum": 3600000}
}
},
"relative_fallback": {
"type": "object",
"additionalProperties": false,
"required": ["id", "operation_ref", "bus", "message", "signals", "completion"],
"properties": {
"id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{1,63}$"},
"operation_ref": {"type": "string", "minLength": 2},
"bus": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"},
"message": {"type": "string", "minLength": 1},
"signals": {
"type": "object",
"minProperties": 1,
"additionalProperties": {"type": ["number", "integer", "boolean", "string"]}
},
"send_offset_ms": {"type": "integer", "minimum": 0, "maximum": 3600000},
"completion": {
"type": "object",
"additionalProperties": false,
"required": ["message", "signal", "operator", "value", "timeout_ms"],
"properties": {
"message": {"type": "string", "minLength": 1},
"signal": {"type": "string", "minLength": 1},
"operator": {"type": "string", "enum": ["eq", "neq", "gte", "lte", "within"]},
"value": {"type": ["number", "integer", "boolean", "string"]},
"tolerance": {"type": "number", "minimum": 0},
"timeout_ms": {"type": "integer", "minimum": 1, "maximum": 3600000}
}
}
}
}
}
}
+26 -3
View File
@@ -17,13 +17,36 @@
"additionalProperties": false,
"required": ["mode", "direction", "start_position_pct", "delta_position_pct"],
"properties": {
"mode": {"const": "relative"},
"mode": {"type": "string", "enum": ["relative", "timed"]},
"direction": {"type": "string", "enum": ["increase", "decrease"]},
"start_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
"delta_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
"start_angle_deg": {"type": "number"},
"delta_angle_deg": {"type": "number", "minimum": 0}
}
"delta_angle_deg": {"type": "number", "minimum": 0},
"requested_delta_angle_deg": {"type": "number", "minimum": 0},
"requested_delta_position_pct": {"type": "integer", "minimum": 0},
"range_clamped": {"type": "boolean"},
"requested_duration_ms": {"type": "integer", "minimum": 1},
"applied_duration_ms": {"type": "integer", "minimum": 1},
"max_duration_ms": {"type": "integer", "minimum": 1},
"min_duration_ms": {"type": "integer", "minimum": 1},
"duration_clamped": {"type": "boolean"},
"endpoint_expected": {"type": "string", "enum": ["near", "far"]}
},
"allOf": [
{
"if": {"properties": {"mode": {"const": "timed"}}},
"then": {
"required": [
"requested_duration_ms",
"applied_duration_ms",
"max_duration_ms",
"min_duration_ms",
"duration_clamped"
]
}
}
]
},
"transition": {"$ref": "common.schema.json#/$defs/transition"},
"depends_on": {
+28 -5
View File
@@ -1,8 +1,8 @@
# T2G 座椅前端能力测试集
本测试集验证从前端自然语言输入、意图计划、`FunctionConfig`、CAN 编译到执行反馈展示的完整链路。机器自动化测试固定使用 `dry_run`,不会连接台架;真实台架部分必须由现场人员逐条执行并目视确认。
本测试集验证从前端自然语言输入、意图计划、`FunctionConfig`、CAN 编译到物理反馈和整车孪生同步的完整链路。离线回归固定使用 `dry_run`;仓库另含可续跑的真实 HIL 执行器,逐条提交请求、失败后继续,并保存物理 CAN 与故障电流证据。
可执行用例源位于 `tests/fixtures/seat_frontend_capabilities.json`,HTTP 回归测试位于 `tests/e2e/test_seat_web_api.py`。
离线用例源位于 `tests/fixtures/seat_frontend_capabilities.json`,真实 HIL 用例源位于 `tests/fixtures/seat_frontend_hil_suite.json`,HTTP 回归测试位于 `tests/e2e/test_seat_web_api.py`。
## 覆盖范围
@@ -14,6 +14,8 @@
| MOTION-02 | 副驾坐盆倾角35% | M1、`0x302≈3500`、COMPLETE/OK |
| MOTION-03 | 副驾靠背130度 | M2、角度标定和真实位置反馈 |
| MOTION-04 | 副驾腿托145度 | M3、角度标定和真实位置反馈 |
| MOTION-TIME | 靠背向后移动70秒、座椅向前移动1分钟 | 秒/分钟解析、四轴时长上限钳制、提前到端点按成功返回及可见提示 |
| MOTION-FALLBACK | 靠背向后移动70度、靠背200度、水平101% | 超量程意图钳制到对应机械端点,不因越界静默拒绝 |
| COMBINED-01 | 一句话同时设置热管理和四轴 | 多操作编排、逐项反馈与失败传播 |
| DEFAULT-01 | 未指定座位的通风请求 | 自动使用副驾,不生成主驾操作 |
| SAFE-01 | 显式主驾通风请求 | 返回仅支持副驾,不得重定向或发 CAN |
@@ -31,13 +33,34 @@ python -m unittest tests.e2e.test_seat_web_api
自动测试会逐条向 `/api/requests` 提交测试集中的自然语言输入,验证所有六项真实台架能力均生成副驾目标和正确参数,并确认执行模式保持为 `dry_run`。领域集成测试另行验证未指定座位时默认副驾,以及本地解析和模型输出中的显式主驾目标都被拒绝。
真实台架服务启用后运行:
```powershell
python .\tools\run_seat_frontend_hil_suite.py `
--confirm-hardware `
--output .\artifacts\hil\seat_frontend_hil_latest.json
```
可用 `--only-group thermal|motion|combined|mode|model|safety` 只执行一组,用 `--start-at CASE-ID` 从指定用例续跑,或重复传入 `--case CASE-ID` 精确复测一个有序子集。每次运行先强制执行四轴物理归零,重建霍尔位置记忆;失败时停止后续位置测试。当前清单共 186 条:129 条热管理/移动/组合输入、33 条模式输入、4 条 GPT-5.6-sol 模糊意图输入和 20 条安全拒绝输入。新增覆盖包括取暖/吹风口语、正常秒数/半秒/毫秒与最小时长钳制、四轴独立端点表达、坐垫与腿托超量程、靠背角度下溢,以及后排/左前/全部座椅、小数/负位置和零时长安全拒绝。每条用例均核对:所有座椅操作为 `passenger/front_right`、物理状态来源为 `physical_can`、目标位置误差不超过 2%、联合孪生值与物理状态相同;模型用例还会防止静默降级为本地规则,时长和超量程用例会核对界面可见的时长上限、实际执行时长和到达端点提示,拒绝用例会比较动作前后物理指纹。调试环境设置 `COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED=false`,执行器和前端均不要求逐次输入硬件令牌。
## 真实台架执行顺序
1. 固定座椅、清空行程、准备急停和限流电源;保持 `COGNITIVE_AGENT_SEAT_AUTO_HOME_ENABLED=false`。
1. 固定座椅、清空行程、准备急停和限流电源;普通失败继续下一条,真实高电流且无 Hall 活动等安全故障停止对应危险方向。
2. 先执行 RECOVERY-01。可使用未烧写控制器,或仅在确认允许覆盖当前固件后进行;前端结果必须显示 `programmed_this_request=true`、`firmware_verified=true`,随后才出现动作反馈。
3. 依次执行 THERM-01~04,确认开、关和档位回读。
4. 让四轴处于有效位置后,依次执行 MOTION-01~04。每次只观察一个物理轴,映射或方向错误立即急停。
4. 让四轴处于有效位置后,执行 M0~M3 双向中间位置,并分别验证安全轴的 `0%` 会编译为 `HOME_KNOWN_RANGE (0x0B)`,而不是把累计 Hall 计数清零。
5. 单项全部通过后再执行 COMBINED-01;逐项核对 ACK、TaskState、位置、Hall、电流诊断及最终报告。
6. 最后执行 SAFE-01~03,确认所有安全用例均未产生 CAN 动作。
普通一句话请求绝不触发全行程标定。若 `PositionValid=0`,保持自动归零关闭时动作应安全失败;只有确认台架无遮挡且允许回最近端后,才能单独启用已知量程归零。
普通一句话请求绝不触发全行程标定。显式 `0%`/“归零”会触发已知量程回最近端,并要求 `Hall≤2` 后才通过。若其他绝对目标遇到 `PositionValid=0`,保持自动归零关闭时动作应安全失败;只有确认台架无遮挡且允许回最近端后,才能单独启用自动归零。
## 时长与超量程容错规则
- 支持“毫秒/秒/分钟”、阿拉伯数字、中文整数、全角字符和半分钟/半秒表达。按时长运动仍必须明确轴和方向;“座椅向前/向后”默认指水平轴。
- 实际下发时长为 `min(max(用户时长, 300 ms), 轴最大允许时长)`。当前 M0/M2 为 30 秒、M1 为 20 秒、M3 为 25 秒;钳制不会中止请求。
- 按时长运动提前到端点时,只有 `Near/Far Endpoint`、有效归一化位置或 Hall 端点证据与指令方向一致,才把固件的 `ENDPOINT_REACHED`、`STALL` 或 `TIMEOUT` 归一化为成功;无端点证据的堵转继续失败并执行 `STOP_ALL`。
- 相对角度、相对百分比、绝对角度和绝对百分比超出剩余行程时,语义层保留原始请求并把实际目标钳制到相应端点。界面同时显示备用目标;真实执行后显示“轴最大允许时长、已执行秒数、到达最前/最后/最上/最下方”。
2026-08-01 的 M0/M2 无 Hall 与 M1 高电流无 Hall 记录最终定位为水平、坐盆和靠背 Hall 地线断开。2026-08-02 地线恢复、四轴手工归零后,revision 6 的完整自动 HIL 结果为 33/33 通过、0 失败、0 `safety_blocked`:覆盖加热/通风全部档位、M0~M3 双向中间位置、单轴及四轴归零、组合指令和全部 11 个预设模式。临时轴向安全阻塞已从测试清单移除;报告保存于 `artifacts/hil/seat_frontend_hil_20260802_full_retest_run1.json`。
2026-08-02 补齐时长和超量程容错后,扩充后的完整 HIL 为 161/161 通过、0 失败、0 `safety_blocked`,最终清理通过;独立 CAN 复核确认加热/通风均为 0 档、M0~M3 均为 0% 且四轴 `Position_Valid=1`。其中“靠背向后移动70度”、四轴双向超长时长、水平101%、靠背200度以及“打开零重力模式”均在完整序列中通过。完整报告为 `artifacts/hil/seat_frontend_hil_20260802_full_timed_overflow.json`,新增规则的定向 14/14 报告为 `artifacts/hil/seat_frontend_hil_20260802_timed_overflow_targeted.json`。
+18 -4
View File
@@ -31,7 +31,7 @@ powershell -ExecutionPolicy Bypass -File .\tools\build_seat_firmware.ps1
| MotorID | 前端能力 | 物理执行器 | 最近端 → 最远端 | MaxHall | 全量程 |
| ---: | --- | --- | --- | ---: | ---: |
| M0 | `seat.set_longitudinal_position` | 水平前后 | 最前 → 最后 | 480 | 215 mm |
| M0 | `seat.set_longitudinal_position` | 水平前后 | 最前 → 最后 | 499(本台架最新实测;标称 480) | 215 mm |
| M1 | `seat.set_cushion_tilt` | 坐盆斜上下 | 最高 → 斜向下 | 374 | 水平 51 mm / 垂直 56 mm |
| M2 | `seat.set_backrest_recline` | 靠背 | 竖直 → 后躺 | 917 | 68° |
| M3 | `seat.set_legrest_height` | 腿托 | 最下 → 抬起 | 647 | 72° |
@@ -43,11 +43,12 @@ powershell -ExecutionPolicy Bypass -File .\tools\build_seat_firmware.ps1
- 热管理命令:`0x29E/0x3CE`;档位反馈:`0x4FA`。
- 移动命令:`0x300`;系统 ACK:`0x301`;归一化位置:`0x302`;Hall/量程:`0x303`。
- 电流诊断:`0x304` 四轴轮询,`0x305` 通风/加热;这些值用于台架可见性,当前保守阈值不能替代外部限流或量产保护。
- Opcode 1 的 `ParamA` 为 0–10000 绝对位置,`ParamB=0` 使用各轴固件默认超时。
- Opcode 3 的 `ParamA` 为持续时间,`ParamB` 为驱动等级×10(默认 90% 即 900)。
- Opcode 1 的 `ParamA` 为 1–10000 绝对位置;后端显式发送 30 s 超时覆盖值。
- 前端目标 `0%`/“归零”不是普通绝对位置,而是 `HOME_KNOWN_RANGE (0x0B)`;只有收到 `COMPLETE/OK`、Near、Hall≤2 和 PositionValid 才算成功。
- Opcode 3 的 `ParamA` 为持续时间,`ParamB` 为驱动等级×10(默认 90%)。
- `STOP_ALL` 的专用 ACK 为 4;完成/故障 TaskState 分别为 6/7。
新版固件把 `0x304/0x305` 用于电流诊断,不能继续发送旧版身份请求。后端在任何控制帧之前被动观察 `0x301–0x305`、`0x4FA`、四个 MotorID 和 MaxHall `480/374/917/647`;真实硬件执行启用后,未观察到完整签名会自动使用仓库内 IAR 镜像烧写,重新被动验证成功后继续原请求。烧写或复验失败时禁止发送座椅控制帧。
新版固件把 `0x304/0x305` 用于电流诊断,不能继续发送旧版身份请求。后端在任何控制帧之前被动观察 `0x301–0x305`、`0x4FA`、四个 MotorID、固件 revision 6 和各轴标称 MaxHall(允许 5% 台架差异);真实硬件执行启用后,未观察到完整签名会自动使用仓库内 IAR 镜像烧写,重新被动验证成功后继续原请求。烧写或复验失败时禁止发送座椅控制帧。
## 启用配置
@@ -57,6 +58,7 @@ COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN_REQUIRED=false
COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN=123456
COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES=combined
COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED=true
COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED=true
COGNITIVE_AGENT_IAR_CSPYBAT=D:\Program Files\IAR Systems\Embedded Workbench 9.2\common\bin\cspybat.exe
COGNITIVE_AGENT_SEAT_CAN_BACKEND=zlgcan
COGNITIVE_AGENT_SEAT_CAN_CHANNEL=0
@@ -71,12 +73,24 @@ COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S=120
当前台架只公布并接受副驾目标 `passenger/front_right`。没有座位词的座椅指令默认副驾;显式主驾、主副驾或前排范围请求会在打开 CAN 前拒绝,不能静默重定向。热管理 CAN 中仍出现的 `Driver_Seat_*` 是板端既有协议字段名,不代表应用层可以控制主驾。
按时长移动使用 Opcode `0x03`,语义层先按轴钳制到安全时长上限:M0/M2 30 秒、M1 20 秒、M3 25 秒,最小时长 300 ms。运动提前碰到正确方向的机械端点时,执行器必须结合 `0x302` 的 Near/Far、`Position_Valid/Value_X100` 和 `0x303` Hall 证据判断;证据一致时端点返回属于成功完成并向前端报告实际执行秒数,证据不一致的 STALL/TIMEOUT 仍属于真实故障。相对或绝对位置超量程统一钳制到目标方向端点,并在前端明确显示备用目标,不能静默不执行。
## 绝对位置与已知量程归零
绝对命令发送前必须看到 `Position_Valid=1`。SMV8 clean 记录有效时,一句话可以直接下发绝对目标;位置无效时默认失败关闭,不会静默触发全行程标定。
`COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED` 在真实硬件执行启用时默认为 `true`。服务进程的第一次物理移动必须先对 M0–M3 依次执行 `HOME_KNOWN_RANGE(0x0B)`,以实际近端重建四轴 Hall 与 SMV8 基准;一旦固件守卫发现控制器运行过其他程序并重新烧写仓库固件,同一同步状态立即失效,下一次物理移动前再次执行四轴归零。完整 HIL 工具也会在每次套件开始时强制四轴归零,避免“其他程序移动过台架,但旧 WorkFlash 仍显示 PositionValid”的情况。显式四轴归零请求本身可完成同步,不会先重复执行另一轮归零。
台架无遮挡、急停就绪并且明确允许自动回最近端时,可设置 `COGNITIVE_AGENT_SEAT_AUTO_HOME_ENABLED=true`。后端仅对目标失效轴发送 `HOME_KNOWN_RANGE(0x0B)`,先核对 MaxHall,再等待 `COMPLETE/OK`、Near、Hall≤2 和 PositionValid,随后继续原绝对目标。此流程会产生真实机械运动,但不会访问最远端或重测量程。Opcode `0x0A` 全行程标定不会由普通一句话请求触发。
2026-08-01 长序列中记录到的 M0/M2 无 Hall、M1 高电流无 Hall 故障,后续确认由水平、坐盆和靠背 Hall 地线断开引起,并非软件标定或堵转阈值问题。2026-08-02 重新接好地线并用手工 HIL GUI 验证后,仓库 revision 6 自动烧写成功,四轴均从机械零位以 `PositionValid=1` 启动;自动前端 HIL 的 33 条热功能、单轴、四轴归零、组合和预设模式用例全部通过。因此测试清单中的临时 `safety_blocked` 已撤销。若以后再次出现同类现象,仍应以命令期间 `0x303` Hall 与 `0x304` 电流联合诊断,不能通过提高 Duty 或阈值掩盖线路故障。
2026-08-02 时长/超量程规则完成后,扩充清单的真实回归结果为 161/161 通过、0 失败、0 `safety_blocked`,最终清理和独立物理复核均通过。报告位于 `artifacts/hil/seat_frontend_hil_20260802_full_timed_overflow.json`;复核时加热/通风为 0 档,M0/M1/M2/M3 均为 0%,全部 `Position_Valid=1`。
2026-08-02 增加 GPT-5.6-sol 模糊意图与位置记忆同步后,完整回归为 162/162 通过;“前舱空间太挤了,把座椅调整到舒服的位置”明确记录 `provider_used=openai:gpt-5.6-sol`,物理目标为 M0/M1/M2/M3=`80/30/35/10%`。报告位于 `artifacts/hil/seat_frontend_hil_20260802_full_gpt56_position_sync.json`;最终独立复核仍为热管理关闭、四轴 0% 且全部 `Position_Valid=1`。
同日第二轮覆盖审计将清单扩充到 186 条,并修复未支持座位范围误默认到副驾、端点词跨轴污染两个风险。新增 24 条真实台架用例全部通过,包含 3 条 GPT-5.6-sol 模糊同义句、正常/最小时长、逐轴端点、超量程和 6 条无动作安全拒绝;报告位于 `artifacts/hil/seat_frontend_hil_20260802_new_coverage_targeted.json`。软件回归为 156 个测试、248 个子测试通过;最终独立 CAN 复核为热管理关闭、四轴 0% 且全部 `Position_Valid=1`。
## 上电与安全检查
1. 固定座椅,清空四轴全行程,准备硬件急停和 12 V 限流电源。
+7 -1
View File
@@ -1,7 +1,11 @@
"""Seat thermal domain plugin."""
from .compiler import SeatCompiler
from .executor import VirtualSeatExecutor
from .executor import (
SIMULATED_HARDWARE_FAULTS,
SimulatedHardwareSeatExecutor,
VirtualSeatExecutor,
)
from .generator import build_function_config, build_function_preset, build_intent_ir
from .hardware_compiler import T2GSeatCompiler
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
@@ -15,6 +19,8 @@ __all__ = [
"T2GSeatCompiler",
"HardwareExecutionError",
"HardwareSeatExecutor",
"SIMULATED_HARDWARE_FAULTS",
"SimulatedHardwareSeatExecutor",
"VirtualSeatExecutor",
"build_function_config",
"build_function_preset",
+107 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
@@ -83,7 +84,6 @@ class VirtualSeatExecutor:
"results": results,
},
}
def _execute_thermal(
self, command: dict[str, Any], operations: list[dict[str, Any]]
) -> list[dict[str, Any]]:
@@ -193,3 +193,109 @@ class VirtualSeatExecutor:
"simulated": True,
},
}
SIMULATED_HARDWARE_FAULTS = (
"normal",
"feedback_timeout",
"adapter_disconnect",
"motor_stall",
"firmware_mismatch",
"partial_feedback",
)
class SimulatedHardwareSeatExecutor:
"""Development-only HIL facade that never opens a physical CAN adapter."""
_FAULT_DETAILS = {
"feedback_timeout": ("FeedbackTimeout", "模拟台架未在限定时间内返回目标反馈。"),
"adapter_disconnect": ("CanAdapterDisconnected", "模拟 CAN 适配器在执行前断开连接。"),
"motor_stall": ("MotorStall", "模拟电机堵转,已触发停止保护。"),
"firmware_mismatch": ("FirmwareMismatch", "模拟固件协议签名不匹配,控制帧未发送。"),
"partial_feedback": ("PartialFeedback", "模拟台架只返回了部分执行器反馈。"),
}
def __init__(self, matrix: SeatCanMatrix):
self.virtual = VirtualSeatExecutor(matrix)
def execute(
self,
command_plan: dict[str, Any],
function_config: dict[str, Any],
actuator_state: dict[str, Any] | None = None,
*,
fault_scenario: str = "normal",
) -> dict[str, Any]:
if fault_scenario not in SIMULATED_HARDWARE_FAULTS:
raise ValueError(
"simulation_fault must be one of "
+ ", ".join(SIMULATED_HARDWARE_FAULTS)
)
report = deepcopy(
self.virtual.execute(command_plan, function_config, actuator_state)
)
report["metadata"]["name"] = "Seat simulated hardware execution report"
report["metadata"]["labels"] = {
"simulation": "true",
"seat_feedback": "simulated_hardware",
"fault_scenario": fault_scenario,
}
report["spec"]["mode"] = "hil"
results = report["spec"]["results"]
for item in results:
item.setdefault("telemetry", {}).update(
{
"simulation": True,
"simulation_kind": "simulated_hardware",
"fault_scenario": fault_scenario,
"physical_can_opened": False,
}
)
if fault_scenario == "normal":
for item in results:
item["message"] = (
item["message"]
.replace("虚构", "模拟台架")
.replace("虚拟", "模拟台架")
)
return report
error_type, message = self._FAULT_DETAILS[fault_scenario]
affected = results
if fault_scenario == "motor_stall":
affected = [
next(
(item for item in results if item.get("telemetry", {}).get("motion_axis")),
results[0],
)
]
elif fault_scenario == "partial_feedback" and len(results) > 1:
affected = results[1:]
for item in affected:
telemetry = item["telemetry"]
item["status"] = "failed"
item["message"] = message
telemetry.update(
{
"error_type": error_type,
"failure_category": fault_scenario,
"feedback_received": False,
}
)
telemetry.pop("feedback_frame", None)
if fault_scenario == "motor_stall":
telemetry.update(
{"motion_state": "stalled", "fault_mask": 1, "stop_all_attempted": True}
)
if fault_scenario == "firmware_mismatch":
telemetry["command_sent"] = False
if fault_scenario == "adapter_disconnect":
telemetry["adapter_connected"] = False
succeeded = sum(item["status"] == "succeeded" for item in results)
report["spec"]["status"] = "partially_succeeded" if succeeded else "failed"
report["spec"]["finished_at"] = _now()
return report
+111 -23
View File
@@ -167,26 +167,49 @@ class T2GSeatCompiler:
target = round(maximum * position_pct / 100)
tolerance = max(1, round(maximum / 100))
motion = operation.get("motion")
relative_fallback: dict[str, Any] | None = None
if motion is not None and motion.get("mode") == "timed":
commands.append(
self._compile_timed_motion(
operation,
physical_operations,
axis,
command_offset + index,
)
)
continue
if motion is not None and motion.get("mode") == "relative":
delta_pct = int(motion["delta_position_pct"])
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
if delta_pct <= maximum_delta:
commands.append(
self._compile_uncalibrated_relative(
operation,
physical_operations,
axis,
command_offset + index,
)
relative_fallback = self._compile_uncalibrated_relative(
operation,
physical_operations,
axis,
command_offset + index,
)
continue
# A large relative request cannot safely be approximated by a
# long open-loop duty command or by several chained commands.
# Compile its already-resolved target as an absolute move. The
# executor will check the board's live Position_Valid bit before
# putting Opcode 1 on the bus.
commands.append(
{
# Prefer the resolved closed-loop target whenever the board has
# a live calibrated position. The bounded timed-duty command
# remains available only as an uncalibrated fallback.
explicit_home = position_pct == 0
command_opcode = int(
self.protocol[
"home_known_range_opcode"
if explicit_home
else "absolute_position_opcode"
]
)
command_param_a = (
int(self.protocol["home_known_range_key"])
if explicit_home
else target
)
command_param_b = (
int(self.protocol["home_known_range_timeout_s"])
if explicit_home
else absolute_timeout_ms
)
command = {
"id": f"t2g_move_{operation['id']}",
"operation_ref": ",".join(
item["id"] for item in physical_operations
@@ -196,12 +219,14 @@ class T2GSeatCompiler:
"signals": {
"Direction": 0,
"Motor_Selector": int(axis["motor_selector"]),
"Opcode": int(self.protocol["absolute_position_opcode"]),
"Param_A": target,
# Opcode 1 Param_B is a timeout override, not duty.
# The explicit calibrated value covers a full M0 stroke;
# the legacy per-axis default can stop it at about 79%.
"Param_B": absolute_timeout_ms,
# An explicit 0% request is a physical zeroing request,
# not permission to trust a potentially drifted Hall
# counter. Opcode 0x0B proves the real near endpoint and
# rebuilds position zero while preserving MaxHall.
"Opcode": command_opcode,
"Param_A": command_param_a,
# Opcode 1 uses milliseconds; Opcode 0x0B uses seconds.
"Param_B": command_param_b,
"Protocol_Version": int(self.protocol["version"]),
# Allocated immediately before hardware transmission. Zero keeps
# the compiled plan deterministic and is never put on the bus.
@@ -219,13 +244,76 @@ class T2GSeatCompiler:
"tolerance": tolerance,
"timeout_ms": max(
int(operation.get("timeout_ms", 0)),
absolute_timeout_ms + 3000,
(
command_param_b * 1000 + 5000
if explicit_home
else absolute_timeout_ms + 3000
),
),
},
}
)
if relative_fallback is not None:
command["relative_fallback"] = relative_fallback
commands.append(command)
return commands
def _compile_timed_motion(
self,
operation: dict[str, Any],
physical_operations: list[dict[str, Any]],
axis: dict[str, Any],
command_index: int,
) -> dict[str, Any]:
motion = dict(operation["motion"])
duration_ms = int(motion["applied_duration_ms"])
minimum_duration = int(self.protocol["timed_duty_min_duration_ms"])
protocol_maximum = int(self.protocol["timed_duty_max_duration_ms"])
axis_maximum = int(
axis.get("max_user_timed_duration_ms", protocol_maximum)
)
if not minimum_duration <= duration_ms <= min(axis_maximum, protocol_maximum):
raise ValueError(
"timed seat movement duration was not clamped to the configured "
f"{minimum_duration}..{min(axis_maximum, protocol_maximum)} ms range"
)
if int(motion.get("max_duration_ms", axis_maximum)) != axis_maximum:
raise ValueError(
"timed seat movement semantic limit does not match hardware calibration"
)
direction_name = str(motion["direction"])
direction = int(self.protocol[f"{direction_name}_direction"])
timeout_ms = max(
int(operation.get("timeout_ms", 8_000)), duration_ms + 5_000
)
return {
"id": f"t2g_move_{operation['id']}",
"operation_ref": ",".join(
item["id"] for item in physical_operations
),
"bus": "seat_can",
"message": "T2G_Seat_Move_Command_300",
"signals": {
"Direction": direction,
"Motor_Selector": int(axis["motor_selector"]),
"Opcode": int(self.protocol["timed_duty_opcode"]),
"Param_A": duration_ms,
"Param_B": int(self.protocol["default_duty_percent"])
* int(self.protocol["timed_duty_level_scale"]),
"Protocol_Version": int(self.protocol["version"]),
"Sequence": 0,
},
"send_offset_ms": (
command_index * int(self.protocol["inter_axis_settle_ms"])
),
"completion": {
"message": "T2G_Seat_Move_Motor_Status_302",
"signal": "Task_State",
"operator": "eq",
"value": 6,
"timeout_ms": timeout_ms,
},
}
def _compile_uncalibrated_relative(
self,
operation: dict[str, Any],
+283 -16
View File
@@ -60,6 +60,18 @@ PHYSICAL_AXIS_CAPABILITIES = {
}
MOTION_ACTIVE_TASK_STATES = {1, 2, 3, 4, 5}
MOTION_FAULT_RESULTS = {5, 6, 7, 8, 9, 10, 11}
MOTION_AXIS_NOTICE_LABELS = {
"longitudinal": "水平",
"cushion_tilt": "坐垫",
"backrest_recline": "靠背",
"legrest_height": "腿托",
}
MOTION_ENDPOINT_NOTICE_LABELS = {
"longitudinal": {"near": "最前方", "far": "最后方"},
"cushion_tilt": {"near": "最上方", "far": "最下方"},
"backrest_recline": {"near": "最前方", "far": "最后方"},
"legrest_height": {"near": "最下方", "far": "最上方"},
}
class HardwareExecutionError(RuntimeError):
@@ -76,6 +88,10 @@ def _now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _seconds_text(duration_ms: int) -> str:
return f"{duration_ms / 1_000:g}"
class HardwareSeatExecutor:
"""Serialize access to one adapter and require real firmware feedback."""
@@ -95,6 +111,8 @@ class HardwareSeatExecutor:
auto_home: bool = False,
home_timeout_s: int = 120,
firmware_guard: T2GFirmwareGuard | None = None,
synchronize_position_before_first_motion: bool = False,
position_sync_state: dict[str, bool] | None = None,
):
self.matrix = matrix
self.bus_factory = bus_factory
@@ -108,6 +126,14 @@ class HardwareSeatExecutor:
self.auto_home = auto_home
self.home_timeout_s = home_timeout_s
self.firmware_guard = firmware_guard
self.synchronize_position_before_first_motion = (
synchronize_position_before_first_motion
)
self.position_sync_state = (
position_sync_state
if position_sync_state is not None
else {"synchronized": False}
)
self._current_samples: dict[int, list[dict[str, Any]]] = {}
@classmethod
@@ -133,6 +159,9 @@ class HardwareSeatExecutor:
has_motion = any(item["message"] == "T2G_Seat_Move_Command_300" for item in commands)
bus: CanBus | None = None
motion_armed = False
position_sync_required = False
position_sync_deferred_to_commands = False
position_sync_trigger = "process_first_motion"
physical_state: dict[str, Any] | None = None
self._current_samples = {}
@@ -140,6 +169,12 @@ class HardwareSeatExecutor:
try:
if self.firmware_guard is not None:
bus, firmware_telemetry = self.firmware_guard.prepare_bus()
if firmware_telemetry.get("programmed_this_request"):
# Code flashed by another program can move the bench
# without updating this repository firmware's WorkFlash
# record. Never trust that record after replacement.
self.position_sync_state["synchronized"] = False
position_sync_trigger = "repository_firmware_reprogrammed"
results.append(
{
"ref": "seat-controller-firmware",
@@ -161,6 +196,33 @@ class HardwareSeatExecutor:
if has_motion:
self._stop_all(bus, require_ack=True)
motion_armed = True
position_sync_required = (
self.synchronize_position_before_first_motion
and not self.position_sync_state.get("synchronized", False)
)
position_sync_deferred_to_commands = (
position_sync_required
and self._commands_are_four_axis_known_range_home(commands)
)
if position_sync_required and not position_sync_deferred_to_commands:
sync_axes = self._synchronize_position_memory(bus)
self.position_sync_state["synchronized"] = True
results.append(
{
"ref": "seat-position-memory-sync",
"domain": "seat",
"status": "succeeded",
"message": (
"four-axis physical endpoints were homed and "
"the Hall position memory baseline was rebuilt"
),
"telemetry": {
"trigger": position_sync_trigger,
"axes": sync_axes,
"simulated": False,
},
}
)
previous_offset = 0
for command in commands:
offset = int(command.get("send_offset_ms", 0))
@@ -192,6 +254,27 @@ class HardwareSeatExecutor:
raise HardwareExecutionError(
f"hardware executor refuses unknown message {command['message']}"
)
if position_sync_deferred_to_commands:
self.position_sync_state["synchronized"] = True
results.append(
{
"ref": "seat-position-memory-sync",
"domain": "seat",
"status": "succeeded",
"message": (
"the requested four-axis physical homing rebuilt "
"the Hall position memory baseline"
),
"telemetry": {
"trigger": position_sync_trigger,
"axes": [
{"motor_selector": motor, "mode": "home_known_range"}
for motor in range(4)
],
"simulated": False,
},
}
)
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
if bus is not None and motion_armed:
self._best_effort_stop(bus)
@@ -642,15 +725,32 @@ class HardwareSeatExecutor:
bus, command, operations, signals, motor
)
preflight_values: dict[str, int] | None = None
preflight_frame: CanFrame | None = None
relative_fallback = command.get("relative_fallback")
if isinstance(relative_fallback, dict):
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
if not preflight_values["Position_Valid"]:
fallback_signals = dict(relative_fallback["signals"])
return self._execute_timed_motion(
bus,
relative_fallback,
operations,
fallback_signals,
motor,
)
target = int(command["completion"]["value"])
tolerance = int(command["completion"].get("tolerance", 0))
axis_maximum = self.axis_maximum_x100.get(operation["capability"])
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
if preflight_values is None or preflight_frame is None:
preflight_values, preflight_frame = self._motor_status_snapshot(bus, motor)
baseline: dict[str, int] | None = None
baseline_frame: CanFrame | None = None
baseline_error: str | None = None
position_recovery: dict[str, Any] | None = None
if not preflight_values["Position_Valid"]:
explicit_home = opcode == 0x0B
if not preflight_values["Position_Valid"] and not explicit_home:
if not self.auto_home:
raise HardwareExecutionError(
f"T2G motor {motor} absolute-position command blocked before send: "
@@ -677,7 +777,7 @@ class HardwareSeatExecutor:
# Capture Hall telemetry after any required homing. It is required
# for endpoint recovery at 0% and makes non-zero failures diagnosable.
if opcode == 1:
if opcode in {1, 0x0B}:
try:
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
except HardwareExecutionError as exc:
@@ -717,6 +817,33 @@ class HardwareSeatExecutor:
)
task_state = int(values["Task_State"])
result = int(values["Result"])
if explicit_home and task_state == 6 and result == 2:
home_diagnostic, home_diagnostic_frame = self._motor_diagnostic_snapshot(
bus, motor
)
home_hall = int(home_diagnostic["Hall_Count"])
if home_hall > ENDPOINT_RECOVERY_MAX_STATIONARY_HALL_DELTA:
raise HardwareExecutionError(
f"T2G motor {motor} known-range home completed but Hall={home_hall}",
telemetry={
"motor_selector": motor,
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
operation["capability"]
],
"position_valid": bool(values["Position_Valid"]),
"home_diagnostic_frame": home_diagnostic_frame.public(),
},
)
position_recovery = {
"mode": "explicit_known_range_home",
"hall_before": int(baseline["Hall_Count"]) if baseline else None,
"hall_after": home_hall,
"max_hall_count": int(home_diagnostic["Max_Hall_Count"]),
"diagnostic_before_frame": (
baseline_frame.public() if baseline_frame else None
),
"diagnostic_after_frame": home_diagnostic_frame.public(),
}
far_endpoint_tolerance_acceptance = bool(
opcode == 1
and axis_maximum is not None
@@ -866,6 +993,11 @@ class HardwareSeatExecutor:
actual_pct = round(values["Value_X100"] * 100 / maximum)
common_telemetry = {
"motion_axis": axis,
"motion_mode": (
"relative_absolute_target"
if operation.get("motion", {}).get("mode") == "relative"
else "absolute_target"
),
"motor_selector": motor,
"physical_target": "t2g_primary_seat",
"sequence": sequence,
@@ -885,6 +1017,7 @@ class HardwareSeatExecutor:
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"hardware_feedback": True,
"current_diagnostic": self._current_diagnostic_summary(motor),
"simulated": False,
}
return [
@@ -1081,6 +1214,36 @@ class HardwareSeatExecutor:
"system_ack": ack_values["Ack"],
}
@staticmethod
def _commands_are_four_axis_known_range_home(
commands: list[dict[str, Any]],
) -> bool:
motion_commands = [
item
for item in commands
if item.get("message") == "T2G_Seat_Move_Command_300"
]
return bool(motion_commands) and {
int(item.get("signals", {}).get("Motor_Selector", -1))
for item in motion_commands
if int(item.get("signals", {}).get("Opcode", -1)) == 0x0B
} == set(range(4)) and all(
int(item.get("signals", {}).get("Opcode", -1)) == 0x0B
for item in motion_commands
)
def _synchronize_position_memory(self, bus: CanBus) -> list[dict[str, Any]]:
"""Rebuild every axis reference from a verified physical endpoint."""
axes: list[dict[str, Any]] = []
for motor in range(4):
axes.append(self._home_known_range(bus, motor))
if motor < 3:
# WorkFlash commits after a motor settles. Match the compiler's
# inter-axis spacing so a following home cannot race persistence.
time.sleep(2)
return axes
def _execute_timed_motion(
self,
bus: CanBus,
@@ -1089,10 +1252,13 @@ class HardwareSeatExecutor:
signals: dict[str, int],
motor: int,
) -> list[dict[str, Any]]:
motion = dict(operations[0].get("motion", {}))
user_timed_motion = motion.get("mode") == "timed"
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
sequence = self._next_sequence()
signals["Sequence"] = sequence
self._reset_current_samples(motor)
motion_started = time.perf_counter()
command_frame = self._send_encoded(bus, command["message"], signals)
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
@@ -1109,6 +1275,13 @@ class HardwareSeatExecutor:
int(command["completion"]["timeout_ms"]),
terminal,
)
executed_duration_ms = max(
1,
min(
int(signals["Param_A"]),
round((time.perf_counter() - motion_started) * 1_000),
),
)
diagnostic: dict[str, int] | None = None
diagnostic_frame: CanFrame | None = None
diagnostic_error: str | None = None
@@ -1142,8 +1315,45 @@ class HardwareSeatExecutor:
if diagnostic is not None
else int(baseline["Max_Hall_Count"]),
)
capability = operations[0]["capability"]
axis = MOTION_CAPABILITY_TO_AXIS[capability]
axis_maximum = max(1, int(self.axis_maximum_x100.get(capability, 10_000)))
endpoint_position_tolerance = max(100, round(axis_maximum * 0.02))
hall_endpoint_tolerance = max(2, round(max_hall * 0.02))
near_endpoint_confirmed = bool(
values.get("Near_Endpoint")
or (
values["Position_Valid"]
and int(values["Value_X100"]) <= endpoint_position_tolerance
)
or hall_after <= hall_endpoint_tolerance
)
far_endpoint_confirmed = bool(
values.get("Far_Endpoint")
or (
values["Position_Valid"]
and int(values["Value_X100"])
>= axis_maximum - endpoint_position_tolerance
)
or hall_after >= max_hall - hall_endpoint_tolerance
)
expected_endpoint = "far" if direction == 1 else "near"
endpoint_reached = (
expected_endpoint
if (
(expected_endpoint == "near" and near_endpoint_confirmed)
or (expected_endpoint == "far" and far_endpoint_confirmed)
)
else None
)
endpoint_terminal_acceptance = bool(
user_timed_motion
and endpoint_reached is not None
and task_state == 7
and result in {4, 5, 6}
)
if task_state != 6 or result != 2:
if (task_state != 6 or result != 2) and not endpoint_terminal_acceptance:
if result == 5 and hall_delta <= 2:
failure_category = "stall_no_hall_activity"
diagnostic_hint = (
@@ -1177,10 +1387,12 @@ class HardwareSeatExecutor:
f"{diagnostic_hint}",
telemetry={
"motor_selector": motor,
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
operations[0]["capability"]
],
"motion_mode": "relative_timed_duty",
"motion_axis": axis,
"motion_mode": (
"timed_user_request"
if user_timed_motion
else "relative_timed_duty"
),
"position_valid": bool(values["Position_Valid"]),
"task_state": task_state,
"motor_result": result,
@@ -1210,7 +1422,10 @@ class HardwareSeatExecutor:
},
)
if diagnostic is None or diagnostic_frame is None:
if (
(diagnostic is None or diagnostic_frame is None)
and not endpoint_terminal_acceptance
):
raise HardwareExecutionError(
f"T2G timed motor {motor} completed but diagnostic Hall feedback "
f"was unavailable: {diagnostic_error}",
@@ -1239,7 +1454,7 @@ class HardwareSeatExecutor:
# the public Hall position. COMPLETE/OK still proves raw Hall edges
# because the firmware refuses timed success without one.
direction_verified = None
if direction_verified is False:
if direction_verified is False and endpoint_reached is None:
raise HardwareExecutionError(
f"T2G timed motor {motor} completed but Hall direction did not match "
f"the command: before={hall_before}, after={hall_after}, direction={direction}",
@@ -1254,16 +1469,53 @@ class HardwareSeatExecutor:
},
)
axis = MOTION_CAPABILITY_TO_AXIS[operations[0]["capability"]]
effective_position_pct = (
round(int(values["Value_X100"]) * 100 / axis_maximum)
if values["Position_Valid"]
else round(hall_after * 100 / max_hall)
)
effective_position_pct = max(0, min(100, effective_position_pct))
requested_duration_ms = int(
motion.get("requested_duration_ms", signals["Param_A"])
)
applied_duration_ms = int(
motion.get("applied_duration_ms", signals["Param_A"])
)
maximum_duration_ms = int(
motion.get("max_duration_ms", applied_duration_ms)
)
user_notice: str | None = None
if user_timed_motion:
notice_parts: list[str] = []
if requested_duration_ms > maximum_duration_ms:
notice_parts.append(
f"{MOTION_AXIS_NOTICE_LABELS[axis]}轴最大允许时长为"
f"{_seconds_text(maximum_duration_ms)}秒"
)
execution_notice = (
f"座椅{MOTION_AXIS_NOTICE_LABELS[axis]}已执行"
f"{_seconds_text(executed_duration_ms)}秒"
)
if endpoint_reached is not None:
execution_notice += (
f",到达{MOTION_ENDPOINT_NOTICE_LABELS[axis][endpoint_reached]}"
)
notice_parts.append(execution_notice)
user_notice = ";".join(notice_parts) + "。"
common_telemetry = {
"motion_axis": axis,
"motion_mode": "relative_timed_duty",
"motion_mode": (
"timed_user_request"
if user_timed_motion
else "relative_timed_duty"
),
"motor_selector": motor,
"physical_target": "t2g_primary_seat",
"sequence": sequence,
"requested_position_pct": int(
operations[0]["parameters"]["position_pct"]
),
"effective_position_pct": effective_position_pct,
"requested_delta_position_pct": int(
operations[0].get("motion", {}).get("delta_position_pct", 0)
),
@@ -1271,6 +1523,14 @@ class HardwareSeatExecutor:
.get("motion", {})
.get("delta_angle_deg"),
"duration_ms": int(signals["Param_A"]),
"requested_duration_ms": requested_duration_ms,
"applied_duration_ms": applied_duration_ms,
"max_duration_ms": maximum_duration_ms,
"duration_clamped": bool(motion.get("duration_clamped", False)),
"executed_duration_ms": executed_duration_ms,
"endpoint_reached": endpoint_reached,
"endpoint_terminal_acceptance": endpoint_terminal_acceptance,
"user_notice": user_notice,
"duty_percent": int(signals["Param_B"]),
"direction": direction,
"task_state": task_state,
@@ -1291,8 +1551,11 @@ class HardwareSeatExecutor:
"ack_frame": ack_frame.public(),
"feedback_frame": feedback.public(),
"diagnostic_before_frame": baseline_frame.public(),
"diagnostic_frame": diagnostic_frame.public(),
"diagnostic_frame": (
diagnostic_frame.public() if diagnostic_frame is not None else None
),
"hardware_feedback": True,
"current_diagnostic": self._current_diagnostic_summary(motor),
"simulated": False,
}
return [
@@ -1301,9 +1564,13 @@ class HardwareSeatExecutor:
"domain": "seat",
"status": "succeeded",
"message": (
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
f"movement with Hall {hall_before}->{hall_after}; absolute position "
"remains unavailable until endpoint calibration"
user_notice
if user_notice is not None
else (
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
f"movement with Hall {hall_before}->{hall_after}; absolute position "
"remains unavailable until endpoint calibration"
)
),
"telemetry": {
**common_telemetry,
+728 -35
View File
@@ -1,19 +1,46 @@
"""Deterministic fallback parser for the supported seat thermal vocabulary."""
"""Deterministic fallback parser for supported seat-control vocabulary."""
from __future__ import annotations
from copy import deepcopy
import re
import unicodedata
from copy import deepcopy
from typing import Any
TARGET_PATTERN = re.compile(r"主副驾|前排|主驾|驾驶位|司机位|副驾|乘客位")
TARGET_PATTERN = re.compile(
r"主副驾|前排右侧|右前座椅|右前座|副驾驶座椅|副驾驶座|副驾驶|副驾位|"
r"乘客座椅|乘客座|乘客位|主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶座|"
r"司机位|司机座|主驾|前排|副驾"
)
FEATURE_PATTERN = re.compile(r"通风|加热")
STATE_PATTERN = re.compile(r"关闭|关掉|停止|取消|打开|开启|启动|开")
LEVEL_PATTERN = re.compile(r"([1-5一二三四五])\s*档")
STATE_PATTERN = re.compile(
r"设置为|调到|调至|设为|开到|关闭|关掉|停止|取消|打开|开启|启动|开"
)
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
POSITION_PATTERN_TEMPLATE = (
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
r"(?P<position>\d{{1,3}})(?:%|%)"
r"(?P<position>\d{{1,3}})%"
)
ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?:夹角|角度)?(?:调到|放倒到|抬到|移动到|移到|设为|设置为|"
r"调至|到|为)?(?P<angle>\d{{2,3}})(?:度|°)"
)
RELATIVE_ANGLE_PATTERN_TEMPLATE = (
r"{alias}(?P<direction>向前|往前|朝前|前倾|向后|往后|朝后|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})(?:度|°)"
)
RELATIVE_PERCENT_PATTERN_TEMPLATE = (
r"{alias}(?P<direction>向前|往前|朝前|前移|向后|往后|朝后|后移|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|调节|调整|调|倾斜|抬|升|降)?(?P<delta>\d{{1,3}})%"
)
TIMED_MOTION_PATTERN_TEMPLATE = (
r"{alias}(?:持续|保持)?(?P<direction>向前|往前|朝前|前移|前倾|向后|往后|朝后|后移|后仰|"
r"向上|往上|抬高|抬起|升高|向下|往下|降低|降下)"
r"(?:移动|调节|调整|调|倾斜|抬|升|降|运行|运动|持续|保持|时长)*"
r"(?P<duration>\d+(?:\.\d+)?)(?P<unit>毫秒|ms|秒|s|分钟|分|min)"
)
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
VENTILATION_FEEDBACK_PATTERN = re.compile(r"(?<!不)(?<!加)热|(?<!不)闷|出汗")
@@ -24,13 +51,35 @@ AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
r"右后座椅|右后座|右后|左后座椅|左后座|左后|"
r"所有座椅|全部座椅|全车座椅"
)
UNSUPPORTED_SEAT_SCOPE_QUESTION = (
"当前台架仅支持副驾座椅,不能把后排、其他方位或全部座椅请求默认到副驾。"
)
TARGETS = {
"主驾": ["driver"],
"驾驶位": ["driver"],
"司机位": ["driver"],
"主驾驶": ["driver"],
"主驾驶座": ["driver"],
"主驾驶座椅": ["driver"],
"驾驶座": ["driver"],
"司机座": ["driver"],
"副驾": ["passenger"],
"副驾驶": ["passenger"],
"副驾驶座": ["passenger"],
"副驾驶座椅": ["passenger"],
"副驾位": ["passenger"],
"乘客位": ["passenger"],
"乘客座": ["passenger"],
"乘客座椅": ["passenger"],
"右前座": ["passenger"],
"右前座椅": ["passenger"],
"前排右侧": ["passenger"],
"主副驾": ["driver", "passenger"],
"前排": ["driver", "passenger"],
}
@@ -40,18 +89,384 @@ STATES = {
"开启": "on",
"启动": "on",
"开": "on",
"开到": "on",
"调到": "on",
"调至": "on",
"设置为": "on",
"设为": "on",
"关闭": "off",
"关掉": "off",
"停止": "off",
"取消": "off",
}
LEVELS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5}
MOTION_AXIS_ALIASES = {
"longitudinal": ("水平", "前后", "纵向"),
"cushion_tilt": ("坐盆", "坐垫"),
"backrest_recline": ("靠背",),
"legrest_height": ("腿托", "腿部支撑"),
LEVELS = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"最低": 1,
"低": 1,
"中": 2,
"最高": 3,
"高": 3,
}
MOTION_AXIS_ALIASES = {
"longitudinal": ("水平", "前后", "纵向", "滑轨"),
"cushion_tilt": ("坐垫倾角", "坐盆", "坐垫"),
"backrest_recline": ("靠背", "椅背"),
"legrest_height": ("腿部支撑", "小腿托", "腿托", "脚托"),
}
MOTION_ANGLE_RANGES = {
"backrest_recline": (95, 163),
"legrest_height": (95, 167),
}
CHINESE_PERCENT_PATTERN = re.compile(r"百分之([零一二三四五六七八九十百两\d]+)")
CHINESE_DEGREE_PATTERN = re.compile(r"([零一二三四五六七八九十百两]+)(度)")
CHINESE_DURATION_PATTERN = re.compile(
r"([零一二三四五六七八九十百两]+)(毫秒|秒|分钟|分)"
)
RELATIVE_DIRECTION_SIGNS = {
"longitudinal": {
"increase": {"向后", "往后", "朝后", "后移"},
"decrease": {"向前", "往前", "朝前", "前移"},
},
"cushion_tilt": {
"increase": {"向下", "往下", "降低", "降下"},
"decrease": {"向上", "往上", "抬高", "抬起", "升高"},
},
"backrest_recline": {
"increase": {"向后", "往后", "朝后", "后仰"},
"decrease": {"向前", "往前", "朝前", "前倾"},
},
"legrest_height": {
"increase": {"向上", "往上", "抬高", "抬起", "升高"},
"decrease": {"向下", "往下", "降低", "降下"},
},
}
TIMED_MOTION_MIN_DURATION_MS = 300
TIMED_AXIS_MAX_DURATION_MS = {
"longitudinal": 30_000,
"cushion_tilt": 20_000,
"backrest_recline": 30_000,
"legrest_height": 25_000,
}
TIMED_AXIS_MS_PER_PCT = {
"longitudinal": 120,
"cushion_tilt": 80,
"backrest_recline": 120,
"legrest_height": 100,
}
MOTION_AXIS_LABELS = {
"longitudinal": "水平轴",
"cushion_tilt": "坐垫轴",
"backrest_recline": "靠背轴",
"legrest_height": "腿托轴",
}
MOTION_ENDPOINT_LABELS = {
"longitudinal": {"increase": "最后方", "decrease": "最前方"},
"cushion_tilt": {"increase": "最下方", "decrease": "最上方"},
"backrest_recline": {"increase": "最后方", "decrease": "最前方"},
"legrest_height": {"increase": "最上方", "decrease": "最下方"},
}
def _chinese_integer(token: str) -> int | None:
if token.isdigit():
return int(token)
digits = {
"零": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
total = 0
current = 0
for char in token:
if char in digits:
current = digits[char]
elif char == "十":
total += max(current, 1) * 10
current = 0
elif char == "百":
total += max(current, 1) * 100
current = 0
else:
return None
return total + current
def _normalize_text(value: str) -> str:
text = unicodedata.normalize("NFKC", value.strip())
text = text.replace("半分钟", "0.5分钟").replace("半秒", "0.5秒")
text = re.sub(r"座椅(?:吹风|送风)", "座椅通风", text)
text = re.sub(r"座椅(?:取暖|加温)", "座椅加热", text)
text = text.replace("座椅暖一下", "座椅加热打开")
def replace_percent(match: re.Match[str]) -> str:
number = _chinese_integer(match.group(1))
return match.group(0) if number is None else f"{number}%"
text = CHINESE_PERCENT_PATTERN.sub(replace_percent, text)
def replace_degree(match: re.Match[str]) -> str:
number = _chinese_integer(match.group(1))
return match.group(0) if number is None else f"{number}{match.group(2)}"
text = CHINESE_DEGREE_PATTERN.sub(replace_degree, text)
def replace_duration(match: re.Match[str]) -> str:
number = _chinese_integer(match.group(1))
return match.group(0) if number is None else f"{number}{match.group(2)}"
text = CHINESE_DURATION_PATTERN.sub(replace_duration, text)
return re.sub(r"\s+", "", text)
def _relative_direction(axis: str, token: str) -> str | None:
directions = RELATIVE_DIRECTION_SIGNS[axis]
for operation, tokens in directions.items():
if token in tokens:
return operation
return None
def _context_motion_state(
context: dict[str, Any] | None, axis: str
) -> dict[str, Any] | None:
if not context:
return None
state = (
context.get("devices", {})
.get("passenger", {})
.get("position", {})
.get(axis)
)
return state if isinstance(state, dict) else None
def _motor_range(
context: dict[str, Any] | None, axis: str
) -> tuple[float, tuple[float, float] | None]:
motor = (
(context or {})
.get("vehicle", {})
.get("seat", {})
.get("motors", {})
.get(axis, {})
)
configured_range = motor.get("range") if isinstance(motor, dict) else None
angle_range = MOTION_ANGLE_RANGES.get(axis)
if angle_range is not None:
display_angle = motor.get("display_angle") if isinstance(motor, dict) else None
if isinstance(display_angle, dict):
zero = display_angle.get("zero_deg")
maximum = display_angle.get("max_deg")
if isinstance(zero, (int, float)) and isinstance(maximum, (int, float)):
angle_range = (float(zero), float(maximum))
if isinstance(configured_range, (int, float)) and configured_range > 0:
return float(configured_range), angle_range
if angle_range is not None:
return float(angle_range[1] - angle_range[0]), angle_range
return 100.0, None
def _clean_number(value: float) -> int | float:
return int(value) if float(value).is_integer() else round(float(value), 1)
def _duration_ms(value: str, unit: str) -> int:
multiplier = {
"毫秒": 1,
"ms": 1,
"秒": 1_000,
"s": 1_000,
"分钟": 60_000,
"分": 60_000,
"min": 60_000,
}[unit]
return round(float(value) * multiplier)
def _format_seconds(duration_ms: int) -> str:
seconds = duration_ms / 1_000
return f"{seconds:g}"
def _timed_motion_action(
axis: str,
direction_token: str,
requested_duration_ms: int,
context: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, str | None]:
operation = _relative_direction(axis, direction_token)
if operation is None:
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
if requested_duration_ms <= 0:
return None, "座椅按时长移动的时长必须大于0。"
state = _context_motion_state(context, axis)
if state is None:
return None, "缺少当前座椅位置,无法安全计算按时长移动的方向和备用端点。"
current_pct = state.get("position_pct")
if (
not isinstance(current_pct, (int, float))
or isinstance(current_pct, bool)
or not 0 <= float(current_pct) <= 100
):
return None, "当前座椅位置无效,无法安全执行按时长移动。"
maximum_duration_ms = TIMED_AXIS_MAX_DURATION_MS[axis]
applied_duration_ms = min(
maximum_duration_ms,
max(TIMED_MOTION_MIN_DURATION_MS, requested_duration_ms),
)
duration_clamped = applied_duration_ms != requested_duration_ms
estimated_delta_pct = applied_duration_ms / TIMED_AXIS_MS_PER_PCT[axis]
signed_delta = estimated_delta_pct if operation == "increase" else -estimated_delta_pct
raw_target = float(current_pct) + signed_delta
target_pct = round(max(0.0, min(100.0, raw_target)))
endpoint_expected = None
if raw_target >= 100:
endpoint_expected = "far"
elif raw_target <= 0:
endpoint_expected = "near"
motion_intent: dict[str, Any] = {
"mode": "timed",
"direction": operation,
"start_position_pct": round(float(current_pct)),
"delta_position_pct": abs(target_pct - round(float(current_pct))),
"requested_duration_ms": requested_duration_ms,
"applied_duration_ms": applied_duration_ms,
"max_duration_ms": maximum_duration_ms,
"min_duration_ms": TIMED_MOTION_MIN_DURATION_MS,
"duration_clamped": duration_clamped,
}
if endpoint_expected is not None:
motion_intent["endpoint_expected"] = endpoint_expected
action: dict[str, Any] = {
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": target_pct,
"motion_intent": motion_intent,
}
_, angle_range = _motor_range(context, axis)
if angle_range is not None:
target_angle = angle_range[0] + (
angle_range[1] - angle_range[0]
) * target_pct / 100
action["angle_deg"] = _clean_number(target_angle)
axis_label = MOTION_AXIS_LABELS[axis]
if requested_duration_ms > maximum_duration_ms:
action["user_notice"] = (
f"{axis_label}最大允许时长为{_format_seconds(maximum_duration_ms)}秒;"
f"已将用户请求的{_format_seconds(requested_duration_ms)}秒钳制为"
f"{_format_seconds(applied_duration_ms)}秒并继续执行。"
)
elif requested_duration_ms < TIMED_MOTION_MIN_DURATION_MS:
action["user_notice"] = (
f"{axis_label}最小允许时长为{_format_seconds(TIMED_MOTION_MIN_DURATION_MS)}秒;"
f"已按{_format_seconds(applied_duration_ms)}秒继续执行。"
)
else:
action["user_notice"] = (
f"{axis_label}将按时长执行{_format_seconds(applied_duration_ms)}秒。"
)
if endpoint_expected is not None:
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
action["user_notice"] += f" 若提前触达机械端点,将在{endpoint_label}停止并按成功返回。"
return action, None
def _relative_motion_action(
axis: str,
direction_token: str,
delta: int,
unit: str,
context: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, str | None]:
operation = _relative_direction(axis, direction_token)
if operation is None:
return None, f"{direction_token}不适用于该座椅轴,请确认移动方向。"
if delta <= 0:
return None, "座椅相对移动量必须大于0。"
state = _context_motion_state(context, axis)
if state is None:
return None, "缺少当前座椅位置,无法安全计算相对移动目标。"
current_pct = state.get("position_pct")
if (
not isinstance(current_pct, (int, float))
or isinstance(current_pct, bool)
or not 0 <= float(current_pct) <= 100
):
return None, "当前座椅位置无效,无法安全计算相对移动目标。"
physical_range, angle_range = _motor_range(context, axis)
if unit == "degree" and angle_range is None:
return None, "该座椅轴没有角度标定,不能按角度执行相对移动。"
delta_pct = float(delta) if unit == "percent" else delta / physical_range * 100
signed_delta = delta_pct if operation == "increase" else -delta_pct
raw_target = float(current_pct) + signed_delta
clamped = not 0 <= raw_target <= 100
target_pct = round(max(0.0, min(100.0, raw_target)))
if target_pct == round(float(current_pct)) and not clamped:
return None, "相对移动量换算后小于该座椅轴的最小可执行步长。"
motion_intent: dict[str, Any] = {
"mode": "relative",
"direction": operation,
"start_position_pct": round(float(current_pct)),
"delta_position_pct": abs(target_pct - round(float(current_pct))),
}
action: dict[str, Any] = {
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": target_pct,
"motion_intent": motion_intent,
}
if unit == "degree" and angle_range is not None:
current_angle = state.get("angle_deg")
if not isinstance(current_angle, (int, float)) or isinstance(
current_angle, bool
):
current_angle = angle_range[0] + (
angle_range[1] - angle_range[0]
) * float(current_pct) / 100
signed_angle = float(delta) if operation == "increase" else -float(delta)
raw_target_angle = float(current_angle) + signed_angle
target_angle = max(angle_range[0], min(angle_range[1], raw_target_angle))
clamped = clamped or target_angle != raw_target_angle
action["angle_deg"] = _clean_number(target_angle)
motion_intent["start_angle_deg"] = float(current_angle)
motion_intent["delta_angle_deg"] = abs(target_angle - float(current_angle))
if clamped:
motion_intent["requested_delta_angle_deg"] = float(delta)
if clamped:
motion_intent["range_clamped"] = True
motion_intent["requested_delta_position_pct"] = round(abs(delta_pct))
endpoint_label = MOTION_ENDPOINT_LABELS[axis][operation]
requested_value = f"{delta}度" if unit == "degree" else f"{delta}%"
applied_value = (
f"{_clean_number(motion_intent.get('delta_angle_deg', 0))}度"
if unit == "degree"
else f"{motion_intent['delta_position_pct']}%"
)
action["user_notice"] = (
f"{MOTION_AXIS_LABELS[axis]}{direction_token}移动{requested_value}超过剩余行程;"
f"已采用备用目标,实际移动最多{applied_value}并到达{endpoint_label}。"
)
return action, None
def _nearest_preceding_target(clause: str, position: int) -> str | None:
@@ -175,7 +590,7 @@ def resolve_contextual_intent(
"""Resolve implicit comfort feedback against the current virtual seat state."""
result = deepcopy(parsed)
text = re.sub(r"\s+", "", utterance.strip())
text = _normalize_text(utterance)
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
if not ventilation_cues and not cold_cues:
@@ -369,12 +784,28 @@ def parse_local_intent(
) -> dict[str, Any]:
"""Parse common Chinese seat commands without making hardware assumptions."""
text = re.sub(r"\s+", "", utterance.strip())
text = _normalize_text(utterance)
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
actions: list[dict[str, Any]] = []
motion_actions: list[dict[str, Any]] = []
ambiguities: list[dict[str, str]] = []
unsupported_scope = UNSUPPORTED_SEAT_SCOPE_PATTERN.search(text)
if unsupported_scope is not None:
return {
"actions": [],
"ambiguities": [
{
"field": "target",
"question": UNSUPPORTED_SEAT_SCOPE_QUESTION,
}
],
"decision_summary": [
f"识别到未支持的座椅目标范围“{unsupported_scope.group(0)}”,未生成副驾动作。"
],
"confidence": 0.99,
}
mentioned_axes = [
axis
for axis, aliases in MOTION_AXIS_ALIASES.items()
@@ -399,7 +830,21 @@ def parse_local_intent(
for clause in clauses:
level = _level_for_clause(clause)
for feature_match in FEATURE_PATTERN.finditer(clause):
clause_states = {
STATES[match.group(0)] for match in STATE_PATTERN.finditer(clause)
}
feature_matches = list(FEATURE_PATTERN.finditer(clause))
contradictory_feature_state = len(clause_states) > 1 and bool(feature_matches)
if contradictory_feature_state:
ambiguities.append(
{
"field": "passenger.thermal.state",
"question": "同一条指令同时包含打开和关闭,请确认座椅功能的最终状态。",
}
)
for feature_match in feature_matches:
if contradictory_feature_state:
continue
feature_zh = feature_match.group(0)
target_zh = _nearest_preceding_target(clause, feature_match.start())
state = _state_for_feature(clause, feature_match.end())
@@ -410,28 +855,245 @@ def parse_local_intent(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
continue
if state is None:
if level is not None and level > 3:
ambiguities.append(
{"field": "state", "question": f"请确认{target_zh}{feature_zh}是打开还是关闭。"}
{
"field": f"passenger.{FEATURES[feature_zh]}.level",
"question": f"{feature_zh}档位必须在1到3档之间。",
}
)
continue
if state is None and level is not None:
state = "on"
if state is None:
ambiguities.append(
{
"field": "state",
"question": f"请确认{target_zh}{feature_zh}是打开还是关闭。",
}
)
continue
requested_level = level
if state == "on" and requested_level is None:
requested_level = 1
for target in TARGETS[target_zh]:
actions.append(
{
"target": target,
"function": FEATURES[feature_zh],
"state": state,
"requested_level": level,
"requested_level": requested_level,
}
)
if ZERO_POSITION_PATTERN.search(text):
continue
for axis, aliases in MOTION_AXIS_ALIASES.items():
clause_axes = [
axis
for axis, aliases in MOTION_AXIS_ALIASES.items()
if any(alias in clause for alias in aliases)
]
percentages = list(re.finditer(r"(?P<position>\d{1,3})%", clause))
shared_position = None
if len(clause_axes) > 1 and len(percentages) == 1 and re.search(
r"都|全部|均", clause
):
shared_position = int(percentages[0].group("position"))
target_zh = (
_nearest_preceding_target(clause, percentages[0].start()) or "副驾"
)
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
else:
requested_shared_position = shared_position
shared_position = min(100, shared_position)
motion_actions.extend(
{
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": shared_position,
**(
{
"user_notice": (
f"{MOTION_AXIS_LABELS[axis]}请求位置"
f"{requested_shared_position}%超过最大行程;"
"已采用备用目标并移动到对应机械末端。"
)
}
if requested_shared_position > 100
else {}
),
}
for axis in clause_axes
)
if shared_position is not None:
continue
timed_axes: set[str] = set()
timed_aliases = {
**MOTION_AXIS_ALIASES,
"longitudinal": (*MOTION_AXIS_ALIASES["longitudinal"], "座椅", "座位"),
}
for axis, aliases in timed_aliases.items():
for alias in aliases:
match = re.search(
TIMED_MOTION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
flags=re.IGNORECASE,
)
if match is None:
continue
timed_axes.add(axis)
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
break
action, question = _timed_motion_action(
axis,
match.group("direction"),
_duration_ms(match.group("duration"), match.group("unit").lower()),
context,
)
if action is None:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": question or "无法计算座椅按时长移动目标。",
}
)
else:
motion_actions.append(action)
break
relative_axes: set[str] = set()
for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in timed_axes:
continue
for alias in aliases:
angle_match = re.search(
RELATIVE_ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
)
percent_match = re.search(
RELATIVE_PERCENT_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
)
match = angle_match or percent_match
if match is None:
continue
relative_axes.add(axis)
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
break
unit = "degree" if angle_match is not None else "percent"
action, question = _relative_motion_action(
axis,
match.group("direction"),
int(match.group("delta")),
unit,
context,
)
if action is None:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": question or "无法计算座椅相对移动目标。",
}
)
else:
motion_actions.append(action)
break
endpoint_actions: dict[str, int] = {}
has_specific_non_longitudinal_axis = any(
any(alias in clause for alias in MOTION_AXIS_ALIASES[axis])
for axis in ("cushion_tilt", "backrest_recline", "legrest_height")
)
has_longitudinal_axis = any(
alias in clause for alias in MOTION_AXIS_ALIASES["longitudinal"]
) or (
not has_specific_non_longitudinal_axis
and any(alias in clause for alias in ("座椅", "座位"))
)
if has_longitudinal_axis:
if re.search(r"最前面|最前|向前移到底|推到最前", clause):
endpoint_actions["longitudinal"] = 0
elif re.search(r"最后面|最后|向后移到底|移到最后", clause):
endpoint_actions["longitudinal"] = 100
cushion_alias = r"(?:坐垫倾角|坐盆|坐垫)"
if re.search(
cushion_alias + r"(?:抬到最上方|调到最上方|移到最上方|最高|抬到头|抬到底)",
clause,
):
endpoint_actions["cushion_tilt"] = 0
elif re.search(
cushion_alias + r"(?:降到最下方|调到最下方|移到最下方|最低|降到头|降到底)",
clause,
):
endpoint_actions["cushion_tilt"] = 100
backrest_alias = r"(?:靠背|椅背)"
if re.search(backrest_alias + r"(?:调直|立直|竖直)", clause):
endpoint_actions["backrest_recline"] = 0
elif re.search(
backrest_alias
+ r"(?:放倒到最后|放倒到底|完全放倒|后仰到底|向后移到底|最大角度)",
clause,
):
endpoint_actions["backrest_recline"] = 100
legrest_alias = r"(?:腿部支撑|小腿托|腿托|脚托)"
if re.search(
legrest_alias
+ r"(?:收起|收回|降到最下方|调到最下方|降到最低|最低|降到头|降到底)",
clause,
):
endpoint_actions["legrest_height"] = 0
elif re.search(
legrest_alias
+ r"(?:升到最上方|抬到最上方|调到最上方|升到最高|抬到最高|最高|完全抬起|升到头|升到底|抬到底)",
clause,
):
endpoint_actions["legrest_height"] = 100
for axis, position_pct in endpoint_actions.items():
target_zh = _nearest_preceding_target(clause, len(clause)) or "副驾"
if "driver" in TARGETS[target_zh]:
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
continue
motion_actions.append(
{
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": position_pct,
}
)
for axis, aliases in MOTION_AXIS_ALIASES.items():
if axis in endpoint_actions or axis in relative_axes or axis in timed_axes:
continue
for alias in aliases:
position_match = re.search(
POSITION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
)
angle_match = None
if axis in MOTION_ANGLE_RANGES:
angle_match = re.search(
ANGLE_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
clause,
)
match = position_match or angle_match
if match is None:
continue
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
@@ -440,23 +1102,43 @@ def parse_local_intent(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
break
position_pct = int(match.group("position"))
if position_pct > 100:
ambiguities.append(
{
"field": f"passenger.position.{axis}",
"question": f"{alias}位置百分比必须在0到100之间。",
}
absolute_angle = None
if position_match is not None:
requested_position_pct = int(position_match.group("position"))
position_pct = min(100, requested_position_pct)
else:
requested_angle = int(angle_match.group("angle"))
minimum, maximum = MOTION_ANGLE_RANGES[axis]
angle = max(minimum, min(maximum, requested_angle))
position_pct = round(
(angle - minimum) * 100 / (maximum - minimum)
)
break
motion_actions.append(
{
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": position_pct,
}
)
absolute_angle = angle
motion_action = {
"target": "passenger",
"action_type": "position",
"axis": axis,
"position_pct": position_pct,
}
if absolute_angle is not None:
motion_action["angle_deg"] = absolute_angle
if requested_angle != absolute_angle:
endpoint_label = (
MOTION_ENDPOINT_LABELS[axis]["increase"]
if requested_angle > maximum
else MOTION_ENDPOINT_LABELS[axis]["decrease"]
)
motion_action["user_notice"] = (
f"{MOTION_AXIS_LABELS[axis]}请求角度{requested_angle}度超过"
f"{minimum}到{maximum}度行程;已采用备用目标"
f"{absolute_angle}度并移动到{endpoint_label}。"
)
elif requested_position_pct != position_pct:
motion_action["user_notice"] = (
f"{MOTION_AXIS_LABELS[axis]}请求位置{requested_position_pct}%超过"
"0到100%行程;已采用100%的备用目标并移动到机械末端。"
)
motion_actions.append(motion_action)
break
merged: dict[tuple[str, str], dict[str, Any]] = {}
@@ -498,6 +1180,15 @@ def parse_local_intent(
actions = parsed["actions"]
ambiguities = parsed["ambiguities"]
if (
not actions
and _contains_unsupported_driver_target(text)
and not any(item.get("field") == "target" for item in ambiguities)
):
ambiguities.append(
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
)
if not actions and not ambiguities:
ambiguities.append(
{
@@ -527,6 +1218,8 @@ def parse_local_intent(
f"目标:副驾座椅{axis_labels[action['axis']]}位置"
f"{action['position_pct']}%。"
)
if action.get("user_notice"):
summary.append(str(action["user_notice"]))
else:
summary.append(
f"目标:{labels[action['target']]}座椅"
@@ -0,0 +1,80 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.infrastructure.can.hardware import HardwareSafetyError
class SimulatedHardwareIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
self.service = SeatDemoService(REPOSITORY_ROOT)
self.service.simulated_hardware_enabled = True
self.temporary_directory = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary_directory.cleanup)
self.service.artifact_root = Path(self.temporary_directory.name)
def test_normal_simulated_hardware_is_explicit_and_isolated(self) -> None:
result = self.service.process(
"副驾靠背调到50%",
provider="local",
execution_mode="simulated_hardware",
)
self.assertEqual(result["status"], "succeeded")
report = result["execution_report"]
self.assertEqual(report["spec"]["mode"], "hil")
self.assertEqual(report["metadata"]["labels"]["simulation"], "true")
self.assertEqual(
report["metadata"]["labels"]["seat_feedback"],
"simulated_hardware",
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertTrue(telemetry["simulation"])
self.assertFalse(telemetry["physical_can_opened"])
def test_each_fault_scenario_produces_a_failed_simulated_report(self) -> None:
expected_errors = {
"feedback_timeout": "FeedbackTimeout",
"adapter_disconnect": "CanAdapterDisconnected",
"motor_stall": "MotorStall",
"firmware_mismatch": "FirmwareMismatch",
"partial_feedback": "PartialFeedback",
}
for scenario, error_type in expected_errors.items():
with self.subTest(scenario=scenario):
result = self.service.process(
"副驾靠背调到50%",
provider="local",
execution_mode="simulated_hardware",
simulation_fault=scenario,
)
self.assertEqual(result["status"], "execution_failed")
report = result["execution_report"]
self.assertEqual(
report["metadata"]["labels"]["fault_scenario"], scenario
)
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["error_type"], error_type)
self.assertFalse(telemetry["physical_can_opened"])
def test_simulated_hardware_requires_server_side_enablement(self) -> None:
self.service.simulated_hardware_enabled = False
with self.assertRaises(HardwareSafetyError):
self.service.process(
"副驾靠背调到50%",
provider="local",
execution_mode="simulated_hardware",
)
def test_runtime_publishes_fault_catalog_without_enabling_real_hardware(self) -> None:
runtime = self.service.runtime_info()
self.assertTrue(runtime["simulated_hardware"]["enabled"])
self.assertFalse(runtime["simulated_hardware"]["physical_can_opened"])
self.assertIn("motor_stall", runtime["simulated_hardware"]["fault_scenarios"])
if __name__ == "__main__":
unittest.main()
+235 -1
View File
@@ -22,6 +22,17 @@ class LocalIntentParserTests(unittest.TestCase):
},
}
@classmethod
def motion_context(cls) -> dict:
context = cls.context()
context["devices"]["passenger"]["position"] = {
"longitudinal": {"position_pct": 75},
"cushion_tilt": {"position_pct": 60},
"backrest_recline": {"position_pct": 51, "angle_deg": 130},
"legrest_height": {"position_pct": 69, "angle_deg": 145},
}
return context
def test_parses_passenger_ventilation_on(self) -> None:
result = parse_local_intent("打开副驾座椅通风")
@@ -32,7 +43,7 @@ class LocalIntentParserTests(unittest.TestCase):
"target": "passenger",
"function": "ventilation",
"state": "on",
"requested_level": None,
"requested_level": 1,
}
],
)
@@ -82,6 +93,229 @@ class LocalIntentParserTests(unittest.TestCase):
[("longitudinal", 63), ("backrest_recline", 54)],
)
def test_normalizes_target_level_and_fullwidth_percentage_aliases(self) -> None:
thermal = parse_local_intent("右前座加热设置为中档")
motion = parse_local_intent("副驾驶滑轨调到百分之五十")
self.assertEqual(thermal["actions"][0]["requested_level"], 2)
self.assertEqual(motion["actions"][0]["axis"], "longitudinal")
self.assertEqual(motion["actions"][0]["position_pct"], 50)
def test_converts_backrest_angle_to_calibrated_percentage(self) -> None:
result = parse_local_intent("把副驾驶椅背设为130°")
self.assertEqual(result["ambiguities"], [])
self.assertEqual(result["actions"][0]["axis"], "backrest_recline")
self.assertEqual(result["actions"][0]["position_pct"], 51)
def test_parses_relative_backrest_angle_from_current_state(self) -> None:
result = parse_local_intent("靠背向前移动10度", self.motion_context())
self.assertEqual(result["ambiguities"], [])
action = result["actions"][0]
self.assertEqual(action["axis"], "backrest_recline")
self.assertEqual(action["position_pct"], 36)
self.assertEqual(action["angle_deg"], 120)
self.assertEqual(
action["motion_intent"],
{
"mode": "relative",
"direction": "decrease",
"start_position_pct": 51,
"delta_position_pct": 15,
"start_angle_deg": 130.0,
"delta_angle_deg": 10.0,
},
)
def test_parses_relative_direction_synonyms_and_chinese_angle(self) -> None:
backward = parse_local_intent("椅背后仰十度", self.motion_context())
legrest = parse_local_intent("腿托向下调10度", self.motion_context())
self.assertEqual(backward["actions"][0]["position_pct"], 66)
self.assertEqual(backward["actions"][0]["angle_deg"], 140)
self.assertEqual(legrest["actions"][0]["position_pct"], 55)
self.assertEqual(legrest["actions"][0]["angle_deg"], 135)
def test_parses_relative_percentage_for_linear_axis(self) -> None:
result = parse_local_intent("副驾滑轨向前移动10%", self.motion_context())
self.assertEqual(result["ambiguities"], [])
self.assertEqual(result["actions"][0]["position_pct"], 65)
self.assertEqual(result["actions"][0]["motion_intent"]["direction"], "decrease")
def test_rejects_relative_motion_without_state_and_clamps_endpoint_overflow(self) -> None:
missing = parse_local_intent("靠背向前移动10度")
context = self.motion_context()
context["devices"]["passenger"]["position"]["backrest_recline"] = {
"position_pct": 0,
"angle_deg": 95,
}
overflow = parse_local_intent("靠背向前移动10度", context)
self.assertEqual(missing["actions"], [])
self.assertIn("缺少当前座椅位置", missing["ambiguities"][0]["question"])
self.assertEqual(overflow["ambiguities"], [])
self.assertEqual(overflow["actions"][0]["position_pct"], 0)
self.assertTrue(overflow["actions"][0]["motion_intent"]["range_clamped"])
self.assertIn("最前方", overflow["actions"][0]["user_notice"])
def test_clamps_oversized_relative_angle_to_backrest_endpoint(self) -> None:
context = self.motion_context()
context["devices"]["passenger"]["position"]["backrest_recline"] = {
"position_pct": 0,
"angle_deg": 95,
}
result = parse_local_intent("靠背向后移动70度", context)
self.assertEqual(result["ambiguities"], [])
action = result["actions"][0]
self.assertEqual(action["position_pct"], 100)
self.assertEqual(action["angle_deg"], 163)
self.assertEqual(action["motion_intent"]["requested_delta_angle_deg"], 70)
self.assertEqual(action["motion_intent"]["delta_angle_deg"], 68)
self.assertIn("备用目标", action["user_notice"])
def test_parses_and_clamps_user_timed_motion(self) -> None:
result = parse_local_intent("靠背向后移动70秒", self.motion_context())
self.assertEqual(result["ambiguities"], [])
action = result["actions"][0]
self.assertEqual(action["position_pct"], 100)
self.assertEqual(
action["motion_intent"],
{
"mode": "timed",
"direction": "increase",
"start_position_pct": 51,
"delta_position_pct": 49,
"requested_duration_ms": 70_000,
"applied_duration_ms": 30_000,
"max_duration_ms": 30_000,
"min_duration_ms": 300,
"duration_clamped": True,
"endpoint_expected": "far",
},
)
self.assertIn("最大允许时长为30秒", action["user_notice"])
def test_supports_chinese_seconds_minutes_and_minimum_duration(self) -> None:
chinese = parse_local_intent("椅背后仰两秒", self.motion_context())
minute = parse_local_intent("座椅向前移动1分钟", self.motion_context())
minimum = parse_local_intent("坐垫向下移动0.1秒", self.motion_context())
self.assertEqual(chinese["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000)
self.assertEqual(minute["actions"][0]["axis"], "longitudinal")
self.assertEqual(minute["actions"][0]["position_pct"], 0)
self.assertEqual(minute["actions"][0]["motion_intent"]["applied_duration_ms"], 30_000)
self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300)
def test_clamps_absolute_position_and_angle_to_endpoint(self) -> None:
percentage = parse_local_intent("副驾水平位置调到101%")
angle = parse_local_intent("副驾靠背调到200度")
self.assertEqual(percentage["ambiguities"], [])
self.assertEqual(percentage["actions"][0]["position_pct"], 100)
self.assertIn("备用目标", percentage["actions"][0]["user_notice"])
self.assertEqual(angle["ambiguities"], [])
self.assertEqual(angle["actions"][0]["position_pct"], 100)
self.assertEqual(angle["actions"][0]["angle_deg"], 163)
def test_applies_one_shared_percentage_to_all_named_axes(self) -> None:
result = parse_local_intent("副驾驶水平、坐垫、椅背、脚托都调到40%")
self.assertEqual(result["ambiguities"], [])
self.assertEqual(
{(action["axis"], action["position_pct"]) for action in result["actions"]},
{
("longitudinal", 40),
("cushion_tilt", 40),
("backrest_recline", 40),
("legrest_height", 40),
},
)
def test_normalizes_common_heating_and_ventilation_synonyms(self) -> None:
warming = parse_local_intent("副驾座椅取暖开到2档")
brief_warming = parse_local_intent("副驾座椅暖一下")
airflow = parse_local_intent("副驾座椅吹风开到2档")
self.assertEqual(
(warming["actions"][0]["function"], warming["actions"][0]["requested_level"]),
("heating", 2),
)
self.assertEqual(
(
brief_warming["actions"][0]["function"],
brief_warming["actions"][0]["requested_level"],
),
("heating", 1),
)
self.assertEqual(
(airflow["actions"][0]["function"], airflow["actions"][0]["requested_level"]),
("ventilation", 2),
)
def test_rejects_unsupported_seat_scopes_instead_of_defaulting_to_passenger(self) -> None:
for utterance in (
"打开后排座椅加热",
"打开左前座椅通风",
"打开所有座椅加热",
):
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"][0]["field"], "target")
self.assertIn("不能把", result["ambiguities"][0]["question"])
def test_axis_specific_endpoint_phrases_do_not_move_the_longitudinal_axis(self) -> None:
cases = {
"坐盆抬到最上方": ("cushion_tilt", 0),
"坐盆降到最下方": ("cushion_tilt", 100),
"靠背放倒到最后": ("backrest_recline", 100),
"腿托升到最高": ("legrest_height", 100),
"腿托降到最低": ("legrest_height", 0),
}
for utterance, expected in cases.items():
with self.subTest(utterance=utterance):
result = parse_local_intent(utterance)
self.assertEqual(result["ambiguities"], [])
self.assertEqual(
[(item["axis"], item["position_pct"]) for item in result["actions"]],
[expected],
)
def test_parses_normal_half_second_and_minimum_clamped_timed_motion(self) -> None:
normal = parse_local_intent("滑轨向后移动2秒", self.motion_context())
half_second = parse_local_intent("靠背向后移动半秒", self.motion_context())
minimum = parse_local_intent("腿托向上移动200毫秒", self.motion_context())
self.assertEqual(normal["actions"][0]["motion_intent"]["applied_duration_ms"], 2_000)
self.assertFalse(normal["actions"][0]["motion_intent"]["duration_clamped"])
self.assertEqual(
half_second["actions"][0]["motion_intent"]["requested_duration_ms"],
500,
)
self.assertEqual(minimum["actions"][0]["motion_intent"]["applied_duration_ms"], 300)
self.assertTrue(minimum["actions"][0]["motion_intent"]["duration_clamped"])
self.assertIn("最小允许时长为0.3秒", minimum["actions"][0]["user_notice"])
def test_rejects_unsupported_level_and_conflicting_state(self) -> None:
unsupported = parse_local_intent("副驾加热开到4档")
conflicting = parse_local_intent("打开副驾加热然后关闭副驾加热")
self.assertEqual(unsupported["actions"], [])
self.assertEqual(unsupported["ambiguities"][0]["field"], "passenger.heating.level")
self.assertEqual(conflicting["actions"], [])
self.assertEqual(conflicting["ambiguities"][0]["field"], "passenger.thermal.state")
def test_rejects_driver_target_even_when_only_a_mode_is_named(self) -> None:
result = parse_local_intent("打开主驾零重力模式")
self.assertEqual(result["actions"], [])
self.assertEqual(result["ambiguities"][0]["field"], "target")
def test_rejects_driver_four_axis_zero(self) -> None:
result = parse_local_intent("把主驾座椅四轴归零")
+329 -50
View File
@@ -76,6 +76,7 @@ class ReactiveFakeBus:
absolute_failure_targets: dict[int, int] | None = None,
timed_failure_result: int | None = None,
timed_failure_event: int = 4,
timed_endpoint: str | None = None,
):
self.matrix = matrix
self.complete_motion = complete_motion
@@ -86,6 +87,7 @@ class ReactiveFakeBus:
self.absolute_failure_targets = dict(absolute_failure_targets or {})
self.timed_failure_result = timed_failure_result
self.timed_failure_event = timed_failure_event
self.timed_endpoint = timed_endpoint
self.hall = [0, 0, 0, 0]
self.position = [0, 0, 0, 0]
self.max_hall = [480, 374, 917, 647]
@@ -268,12 +270,70 @@ class ReactiveFakeBus:
},
)
)
# The real controller publishes cyclic status. Keep one more
# sample queued for the executor's post-home preflight read.
# The real controller publishes all four cyclic motor status
# samples after a home completes. Keep a complete cycle queued
# for the next axis preflight in a four-axis zero request.
for cyclic_motor in range(4):
self.received.append(
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": cyclic_motor,
"Task_State": 0,
"Result": 0,
"Position_Valid": 1,
"Motor_Sequence": sequence,
"Value_X100": self.position[cyclic_motor],
"Duty_Percent": 0,
},
)
)
for cyclic_motor in range(4):
self.received.append(
self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": cyclic_motor,
"Hall_Count": self.hall[cyclic_motor],
"Max_Hall_Count": self.max_hall[cyclic_motor],
"Hall_Speed_Half_Pps": 180,
},
)
)
if opcode == 3 and self.timed_endpoint is not None:
motor = command["Motor_Selector"]
at_far_endpoint = self.timed_endpoint == "far"
self.hall[motor] = self.max_hall[motor] if at_far_endpoint else 0
self.position[motor] = 10_000 if at_far_endpoint else 0
self.received.append(
self._frame("T2G_Seat_Move_Motor_Status_302", home_status)
self._frame(
"T2G_Seat_Move_Motor_Status_302",
{
"Motor_Sub_ID": motor,
"Task_State": 7,
"Result": 4,
"Direction": command["Direction"],
"Position_Valid": 1,
"Near_Endpoint": int(not at_far_endpoint),
"Far_Endpoint": int(at_far_endpoint),
"Motor_Sequence": sequence,
"Value_X100": self.position[motor],
"Duty_Percent": 0,
},
)
)
if opcode == 3 and self.timed_failure_result is not None:
self.received.append(
self._frame(
"T2G_Seat_Move_Diagnostic_303",
{
"Motor_Sub_ID": motor,
"Last_Motor_Event": 3 if at_far_endpoint else 2,
"Hall_Count": self.hall[motor],
"Max_Hall_Count": self.max_hall[motor],
},
)
)
elif opcode == 3 and self.timed_failure_result is not None:
motor = command["Motor_Selector"]
self.received.append(
self._frame(
@@ -387,7 +447,7 @@ class ProtocolSignatureFakeBus:
self.received: deque[CanFrame] = deque()
self.closed = False
if signature:
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 30")))
self.received.append(CanFrame(0x301, bytes.fromhex("10 00 00 00 00 00 F0 60")))
for motor in range(4):
self.received.append(
CanFrame(0x302, bytes([motor, 0, 0x08, 0, 0, 0, 0, 0]))
@@ -480,7 +540,7 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(command["signals"]["Param_B"], 30_000)
self.assertEqual(command["completion"]["timeout_ms"], 33_000)
def test_compiler_maps_small_relative_backrest_request_to_timed_duty(self) -> None:
def test_compiler_prefers_absolute_relative_target_with_timed_fallback(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
@@ -498,11 +558,16 @@ class T2GHardwareTests(unittest.TestCase):
command = self.compiler.compile(config, {})["spec"]["commands"][0]
self.assertEqual(command["signals"]["Opcode"], 3)
self.assertEqual(command["signals"]["Direction"], 1)
self.assertEqual(command["signals"]["Param_A"], 1320)
self.assertEqual(command["signals"]["Param_B"], 900)
self.assertEqual(command["completion"]["signal"], "Task_State")
self.assertEqual(command["signals"]["Opcode"], 1)
self.assertEqual(command["signals"]["Direction"], 0)
self.assertEqual(command["signals"]["Param_A"], 3100)
self.assertEqual(command["completion"]["signal"], "Value_X100")
fallback = command["relative_fallback"]
self.assertEqual(fallback["signals"]["Opcode"], 3)
self.assertEqual(fallback["signals"]["Direction"], 1)
self.assertEqual(fallback["signals"]["Param_A"], 1320)
self.assertEqual(fallback["signals"]["Param_B"], 900)
self.assertEqual(fallback["completion"]["signal"], "Task_State")
def test_compiler_maps_large_relative_backrest_request_to_absolute_target(self) -> None:
config = function_config(
@@ -527,6 +592,34 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(command["signals"]["Param_A"], 6400)
self.assertEqual(command["completion"]["signal"], "Value_X100")
def test_compiler_emits_bounded_user_timed_motion(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 100, "angle_deg": 163},
motion={
"mode": "timed",
"direction": "increase",
"start_position_pct": 0,
"delta_position_pct": 100,
"requested_duration_ms": 70_000,
"applied_duration_ms": 30_000,
"max_duration_ms": 30_000,
"min_duration_ms": 300,
"duration_clamped": True,
"endpoint_expected": "far",
},
)
)
command = self.compiler.compile(config, {})["spec"]["commands"][0]
self.assertEqual(command["signals"]["Opcode"], 3)
self.assertEqual(command["signals"]["Direction"], 1)
self.assertEqual(command["signals"]["Param_A"], 30_000)
self.assertEqual(command["signals"]["Param_B"], 900)
self.assertEqual(command["completion"]["timeout_ms"], 35_000)
def test_driver_target_is_rejected_by_hardware_compiler(self) -> None:
driver = function_config(
operation(
@@ -603,6 +696,87 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
self.assertTrue(bus.closed)
def test_firmware_replacement_forces_four_axis_position_memory_sync(self) -> None:
config = function_config(
operation("seat.set_backrest_recline", {"position_pct": 50})
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix)
bus.hall = [220, 180, 600, 310]
bus.position = [4500, 4800, 6500, 4700]
position_sync_state = {"synchronized": True}
class ReprogrammedGuard:
def prepare_bus(self) -> tuple[ReactiveFakeBus, dict[str, object]]:
return bus, {
"firmware_verified": True,
"programmed_this_request": True,
}
with patch("seat_domain.hardware_executor.time.sleep"):
report = HardwareSeatExecutor(
self.matrix,
lambda: bus,
ack_timeout_ms=20,
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
firmware_guard=ReprogrammedGuard(), # type: ignore[arg-type]
synchronize_position_before_first_motion=True,
position_sync_state=position_sync_state,
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
sent_commands = [
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
for frame in bus.sent
if frame.arbitration_id == 0x300
]
self.assertEqual(
[(item["Opcode"], item["Motor_Selector"]) for item in sent_commands],
[(9, 0), (0x0B, 0), (0x0B, 1), (0x0B, 2), (0x0B, 3), (1, 2)],
)
sync = next(
item
for item in report["spec"]["results"]
if item["ref"] == "seat-position-memory-sync"
)
self.assertEqual(
sync["telemetry"]["trigger"], "repository_firmware_reprogrammed"
)
self.assertEqual(len(sync["telemetry"]["axes"]), 4)
self.assertTrue(position_sync_state["synchronized"])
def test_first_process_motion_syncs_once_and_reuses_the_reference(self) -> None:
config = function_config(
operation("seat.set_longitudinal_position", {"position_pct": 40})
)
plan = self.compiler.compile(config, {})
state = {"synchronized": False}
first_bus = ReactiveFakeBus(self.matrix)
with patch("seat_domain.hardware_executor.time.sleep"):
first = HardwareSeatExecutor(
self.matrix,
lambda: first_bus,
ack_timeout_ms=20,
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
synchronize_position_before_first_motion=True,
position_sync_state=state,
).execute(plan, config)
second_bus = ReactiveFakeBus(self.matrix)
second = HardwareSeatExecutor(
self.matrix,
lambda: second_bus,
ack_timeout_ms=20,
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
synchronize_position_before_first_motion=True,
position_sync_state=state,
).execute(plan, config)
self.assertEqual(first["spec"]["status"], "succeeded")
self.assertEqual(second["spec"]["status"], "succeeded")
self.assertEqual([item.data[0] for item in first_bus.sent], [9, 0x0B, 0x0B, 0x0B, 0x0B, 1])
self.assertEqual([item.data[0] for item in second_bus.sent], [9, 1])
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
bus = ReactiveFakeBus(self.matrix)
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
@@ -693,12 +867,15 @@ class T2GHardwareTests(unittest.TestCase):
)
self.assertTrue(
all(
command["signals"]["Param_A"] == 0
command["signals"]["Opcode"] == 0x0B
and command["signals"]["Param_A"] == 0x4B52
and command["signals"]["Param_B"] == 120
for command in plan["spec"]["commands"]
)
)
bus = ReactiveFakeBus(self.matrix)
position_sync_state = {"synchronized": False}
report = HardwareSeatExecutor(
self.matrix,
lambda: bus,
@@ -706,6 +883,8 @@ class T2GHardwareTests(unittest.TestCase):
stop_all_motor_selector=int(
self.compiler.protocol["stop_all_motor_selector"]
),
synchronize_position_before_first_motion=True,
position_sync_state=position_sync_state,
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
@@ -716,7 +895,15 @@ class T2GHardwareTests(unittest.TestCase):
]
self.assertEqual(
[(item["Opcode"], item["Motor_Selector"]) for item in sent_commands],
[(9, 0), (1, 0), (1, 1), (1, 2), (1, 3)],
[(9, 0), (0x0B, 0), (0x0B, 1), (0x0B, 2), (0x0B, 3)],
)
self.assertTrue(position_sync_state["synchronized"])
self.assertEqual(
sum(
item["ref"] == "seat-position-memory-sync"
for item in report["spec"]["results"]
),
1,
)
def test_zero_recovers_verified_stale_near_endpoint_offset(self) -> None:
@@ -741,15 +928,12 @@ class T2GHardwareTests(unittest.TestCase):
for frame in bus.sent
if frame.arbitration_id == 0x300
]
self.assertEqual(
[item["Opcode"] for item in sent_commands],
[9, 1, 3, 0x0B],
)
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
telemetry = report["spec"]["results"][0]["telemetry"]
recovery = telemetry["position_recovery"]
self.assertEqual(recovery["mode"], "verified_endpoint_drift_rehome")
self.assertGreater(recovery["away_probe"]["hall_delta"], 2)
self.assertEqual(recovery["home"]["hall_after"], 0)
self.assertEqual(recovery["mode"], "explicit_known_range_home")
self.assertEqual(recovery["hall_before"], 6)
self.assertEqual(recovery["hall_after"], 0)
self.assertEqual(telemetry["effective_position_pct"], 0)
def test_zero_recovers_after_full_travel_reaches_near_endpoint_window(self) -> None:
@@ -778,14 +962,10 @@ class T2GHardwareTests(unittest.TestCase):
for frame in bus.sent
if frame.arbitration_id == 0x300
]
self.assertEqual(
[item["Opcode"] for item in sent_commands],
[9, 1, 3, 0x0B],
)
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
self.assertEqual(recovery["failed_absolute_hall_before"], 374)
self.assertEqual(recovery["failed_absolute_hall_after"], 4)
self.assertEqual(recovery["home"]["hall_after"], 0)
self.assertEqual(recovery["hall_before"], 374)
self.assertEqual(recovery["hall_after"], 0)
def test_zero_recovers_real_m0_stall_at_calibrated_near_endpoint(self) -> None:
config = function_config(
@@ -813,22 +993,16 @@ class T2GHardwareTests(unittest.TestCase):
for frame in bus.sent
if frame.arbitration_id == 0x300
]
self.assertEqual(
[item["Opcode"] for item in sent_commands],
[9, 1, 3, 0x0B],
)
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
self.assertEqual(recovery["failed_absolute_hall_before"], 480)
self.assertEqual(recovery["failed_absolute_hall_after"], 52)
self.assertEqual(recovery["endpoint_recovery_max_near_hall"], 72)
self.assertGreater(recovery["away_probe"]["hall_delta"], 2)
self.assertEqual(recovery["home"]["hall_after"], 0)
self.assertEqual(recovery["hall_before"], 480)
self.assertEqual(recovery["hall_after"], 0)
self.assertEqual(
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
0,
)
def test_zero_does_not_rehome_a_midrange_stall_as_an_endpoint(self) -> None:
def test_explicit_zero_homes_even_when_the_stored_position_is_midrange(self) -> None:
config = function_config(
operation("seat.set_longitudinal_position", {"position_pct": 0})
)
@@ -844,20 +1018,16 @@ class T2GHardwareTests(unittest.TestCase):
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual(report["spec"]["status"], "succeeded")
sent_commands = [
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
for frame in bus.sent
if frame.arbitration_id == 0x300
]
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 1, 9])
self.assertIn("STALL", report["spec"]["results"][-1]["message"])
current = report["spec"]["results"][-1]["telemetry"][
"current_diagnostic"
]
self.assertEqual(current["sample_count"], 1)
self.assertEqual(current["max_current_delta_raw"], 700)
self.assertTrue(current["stall_overlap_seen"])
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
self.assertEqual(recovery["hall_before"], 116)
self.assertEqual(recovery["hall_after"], 0)
def test_zero_recovers_near_endpoint_timeout_after_full_travel(self) -> None:
config = function_config(
@@ -881,9 +1051,8 @@ class T2GHardwareTests(unittest.TestCase):
self.assertEqual(report["spec"]["status"], "succeeded")
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
self.assertEqual(recovery["failed_absolute_hall_before"], 605)
self.assertEqual(recovery["failed_absolute_hall_after"], 3)
self.assertEqual(recovery["home"]["hall_after"], 0)
self.assertEqual(recovery["hall_before"], 605)
self.assertEqual(recovery["hall_after"], 0)
def test_far_endpoint_timeout_within_position_tolerance_is_accepted(self) -> None:
config = function_config(
@@ -1036,6 +1205,114 @@ class T2GHardwareTests(unittest.TestCase):
self.assertGreater(telemetry["hall_delta"], 0)
self.assertTrue(telemetry["hall_direction_verified"])
def test_executor_accepts_user_timed_motion_that_reaches_endpoint_early(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 100, "angle_deg": 163},
motion={
"mode": "timed",
"direction": "increase",
"start_position_pct": 0,
"delta_position_pct": 100,
"requested_duration_ms": 70_000,
"applied_duration_ms": 30_000,
"max_duration_ms": 30_000,
"min_duration_ms": 300,
"duration_clamped": True,
"endpoint_expected": "far",
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, timed_endpoint="far")
report = HardwareSeatExecutor(
self.matrix,
lambda: bus,
ack_timeout_ms=20,
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual([item.data[0] for item in bus.sent], [9, 3])
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["motion_mode"], "timed_user_request")
self.assertEqual(telemetry["endpoint_reached"], "far")
self.assertTrue(telemetry["endpoint_terminal_acceptance"])
self.assertEqual(telemetry["effective_position_pct"], 100)
self.assertIn("最大允许时长为30秒", telemetry["user_notice"])
self.assertIn("到达最后方", telemetry["user_notice"])
def test_executor_does_not_hide_non_endpoint_user_timed_stall(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 100, "angle_deg": 163},
motion={
"mode": "timed",
"direction": "increase",
"start_position_pct": 0,
"delta_position_pct": 100,
"requested_duration_ms": 2_000,
"applied_duration_ms": 2_000,
"max_duration_ms": 30_000,
"min_duration_ms": 300,
"duration_clamped": False,
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(
self.matrix,
timed_failure_result=5,
timed_failure_event=4,
)
report = HardwareSeatExecutor(
self.matrix,
lambda: bus,
ack_timeout_ms=20,
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "failed")
self.assertEqual(
report["spec"]["results"][-1]["telemetry"]["failure_category"],
"stall_no_hall_activity",
)
def test_executor_uses_closed_loop_target_for_calibrated_relative_motion(self) -> None:
config = function_config(
operation(
"seat.set_backrest_recline",
{"position_pct": 31, "angle_deg": 109},
motion={
"mode": "relative",
"direction": "increase",
"start_position_pct": 20,
"delta_position_pct": 11,
"start_angle_deg": 104,
"delta_angle_deg": 5,
},
)
)
plan = self.compiler.compile(config, {})
bus = ReactiveFakeBus(self.matrix, position_valid=True)
report = HardwareSeatExecutor(
self.matrix,
lambda: bus,
ack_timeout_ms=20,
axis_maximum_x100={"seat.set_backrest_recline": 10_000},
).execute(plan, config)
self.assertEqual(report["spec"]["status"], "succeeded")
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
telemetry = report["spec"]["results"][0]["telemetry"]
self.assertEqual(telemetry["motion_mode"], "relative_absolute_target")
self.assertEqual(telemetry["effective_position_pct"], 31)
def test_executor_reports_stall_with_post_failure_hall_diagnostics(self) -> None:
config = function_config(
operation(
@@ -1209,6 +1486,7 @@ class T2GHardwareTests(unittest.TestCase):
automatic = SeatHardwareSettings.from_env(ROOT)
self.assertTrue(automatic.enabled)
self.assertTrue(automatic.auto_flash_enabled)
self.assertTrue(automatic.position_sync_enabled)
with patch.dict(
os.environ,
@@ -1225,6 +1503,7 @@ class T2GHardwareTests(unittest.TestCase):
dry_run = SeatHardwareSettings.from_env(ROOT)
self.assertFalse(dry_run.enabled)
self.assertFalse(dry_run.auto_flash_enabled)
self.assertFalse(dry_run.position_sync_enabled)
def test_settings_allow_explicit_local_debug_mode_without_token(self) -> None:
environment = {
@@ -1280,7 +1559,7 @@ class T2GHardwareTests(unittest.TestCase):
self.assertIs(prepared, bus)
self.assertFalse(telemetry["programmed_this_request"])
self.assertEqual(telemetry["firmware_revision"], 3)
self.assertEqual(telemetry["firmware_revision"], 6)
self.assertEqual(bus.sent, [])
self.assertEqual(telemetry["max_hall_counts"], [480, 374, 917, 647])
self.assertFalse(bus.closed)
@@ -915,14 +915,22 @@ bool SeatMotor_CalibrateRange(motor_mode_t mode,
uint32_t commandSequence)
{
motor_calibration_result_t result;
uint8_t dutyPercent;
if((false == SeatMotor_ModeValid(mode)) || (0ul == legTimeoutMs))
{
return false;
}
/* The connected four-axis bench needs the same verified loaded duty used
* by normal absolute moves and known-range homing. The protocol minimum
* (50%) cannot reliably start M0/M1 at a loaded endpoint; using it makes
* the first quiet window look like an endpoint and invalidates an
* otherwise healthy calibration. Hall endpoint, timeout and current
* protections remain active throughout both legs. */
dutyPercent = SeatMotor_DefaultDutyPercent(mode);
if((false == SeatMotor_PrepareNewTask(mode, commandSequence)) ||
(MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent(
mode, MOTOR_SPEED_MIN_DUTY_PERCENT)))
mode, dutyPercent)))
{
SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED);
return false;
@@ -67,7 +67,7 @@
#define SEAT_MOVE_CAN_PROTOCOL_VERSION (1u)
/* Repository image identity carried by 0x301 byte 7 high nibble. Increment
* whenever a host-visible safety or motion fix requires automatic reflash. */
#define SEAT_MOVE_FIRMWARE_REVISION (3u)
#define SEAT_MOVE_FIRMWARE_REVISION (6u)
#define SEAT_MOVE_CAN_COMMAND_ID (0x300u)
#define SEAT_MOVE_CAN_SYSTEM_STATUS_ID (0x301u)
@@ -44,12 +44,13 @@ CAN 为 500 kbit/s、经典标准帧、11-bit ID、DLC 8。移动协议版本为
- 任何热/风输出初始化或运行故障都会关断热/风并锁存全局 driver fault;移动也进入 inhibit。
- 四电机使用独立任务、Sequence、Hall、超时和故障上下文;同轴换向先全关并等待死区,不同轴命令不会互相取消。
- 四轴分别具有启动宽限(M0/M1/M2=1 s,M3=2 s)。普通移动仅在“500 ms Hall双边沿窗口不超过2个边沿”且“相对停止态零点的轴电流超过独立阈值并持续200 ms”同时成立时报告 `STALL`;低Hall但电流低或采样过期时不误报堵转,连续5 s无运动后关断并报告 `TIMEOUT`。
- 真实台架诊断必须同时保存命令期间的 `0x304` 电流峰值和 `0x303` Hall 变化:高电流且无 Hall 表示机械阻塞或 Hall 公共参考异常,低电流且双向无 Hall 更符合驱动桥、连接器、电机线束或 Hall 地线开路。2026-08-01 的 M0/M1/M2 异常最终定位为 Hall 地线断开;2026-08-02 恢复接线后完整 33 条自动 HIL 全部通过。不得靠提高 Duty、阈值或超时掩盖同类线路问题。
- 四轴普通绝对/相对移动默认使用90% PWM占空比;堵转电流差值阈值在重新带载标定前统一使用500 raw保守默认值,优先避免正常运动误报。PWM软件上限保持95%。
- 位置任务因端点/堵转事件停止时,若最终Hall已进入目标±2脉冲范围则按到位完成;底层LastEvent仍由`0x303`保留用于诊断。超过该范围的STALL/TIMEOUT仍按失败处理。
- `HOME_KNOWN_RANGE`/全量标定的寻端语义与普通移动分离。端点要求500 ms内Hall双边沿为0,避免慢速移动仅有下降沿时被误判为端点;已知量程归零若第一次寻近端少于4个真实Hall边沿,会先向远端验证至少4个双边沿再重新寻近端,并校验回程与离端边沿数(允许1边沿误差),禁止把电机未启动直接清零。无法证明往返运动时归零失败并使PositionValid失效。位置和量程仍按Hall上升沿计数。
- `STOP_ALL` 不受旧 Sequence 限制;CAN RX ISR 先立即终止四路 PWM,因此也能打断阻塞式全行程标定,主流程随后完成 ACK 和 Sequence 重同步。
- `CALIBRATE_RANGE` 的 ParamB 是每个寻端行程的实际超时(10~600 s);标定期间继续发送 `0x301/0x302/0x303` 周期反馈。
- `HOME_KNOWN_RANGE(0x0B)` 的 ParamA 必须为 `0x4B52`,ParamB 为最近端寻找超时(10~600 s)。它使用各轴正常90% Duty,只寻找最近端并置 Hall=0/PositionValid=1,保留480/374/917/647,不访问最远端;已经在最近端时会执行约3 Hall的短离端验证后返回。
- `HOME_KNOWN_RANGE(0x0B)` 的 ParamA 必须为 `0x4B52`,ParamB 为最近端寻找超时(10~600 s)。它使用各轴正常90% Duty,只寻找最近端并置 Hall=0/PositionValid=1,保留当前 MaxHall,不访问最远端;已经在最近端时会执行约3 Hall的短离端验证后返回。
- 位置记忆使用 SMV8 双槽、版本、序号和 CRC。运动命令启动前先提交较新的 dirty 记录,防止运动中掉电后恢复旧位置;所有电机停稳后保存四轴当前 Hall、最大 Hall、物理绝对量和 PositionValid。正常掉电重启后 `0x302/0x303` 可直接取得上次停止位置。运动中掉电或存储不兼容时位置无效,应用只对失效轴执行 `0x0B` 回最近端,然后继续绝对目标。SMV5/SMV6 的旧量程记录和 SMV7 的旧通道映射记录均不会直接恢复。
- C-SPY `execUserExit()` 同时关断四电机 PWM、风扇 TCPWM60 和加热 TCPWM65,并验证物理输出 latch 为低。
- 正常车辆上电不要求调试器连接;真正 POR 会清除调试会话标记。若 C-SPY 会话结束或调试器脱离,则本次供电周期锁存安全停机,防止烧录结束后执行器继续动作。
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ protocol:
stop_all_motor_selector: 0
home_known_range_opcode: 11
home_known_range_key: 19282
home_known_range_timeout_s: 120
normalized_position_max: 10000
# A full M0 stroke takes more than the firmware's legacy 12 s default.
# Always send this override with Opcode 1 so every calibrated axis has
@@ -28,7 +29,10 @@ protocol:
default_duty_percent: 90
timed_duty_level_scale: 10
timed_duty_min_duration_ms: 300
timed_duty_max_duration_ms: 8000
# User-specified duration commands are bounded per axis below. A long
# request is clamped, but reaching a proven endpoint before the timer ends
# is a successful completion rather than a motion error.
timed_duty_max_duration_ms: 30000
increase_direction: 1
decrease_direction: 2
axes:
@@ -41,6 +45,7 @@ axes:
default_max_hall_count: 480
default_timeout_ms: 12000
timed_duty_ms_per_pct: 120
max_user_timed_duration_ms: 30000
max_uncalibrated_relative_pct: 15
hardware_verified: true
seat.set_cushion_tilt:
@@ -54,6 +59,7 @@ axes:
default_max_hall_count: 374
default_timeout_ms: 8000
timed_duty_ms_per_pct: 80
max_user_timed_duration_ms: 20000
max_uncalibrated_relative_pct: 15
hardware_verified: true
seat.set_backrest_recline:
@@ -66,6 +72,7 @@ axes:
default_max_hall_count: 917
default_timeout_ms: 12000
timed_duty_ms_per_pct: 120
max_user_timed_duration_ms: 30000
max_uncalibrated_relative_pct: 15
hardware_verified: true
seat.set_legrest_height:
@@ -78,5 +85,6 @@ axes:
default_max_hall_count: 647
default_timeout_ms: 10000
timed_duty_ms_per_pct: 100
max_user_timed_duration_ms: 25000
max_uncalibrated_relative_pct: 15
hardware_verified: true
+178 -13
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import os
import re
import sys
import time
@@ -22,9 +23,11 @@ if str(SEAT_SOURCE) not in sys.path:
from seat_domain import ( # noqa: E402
HardwareSeatExecutor,
SIMULATED_HARDWARE_FAULTS,
SeatCanMatrix,
SeatCompiler,
T2GSeatCompiler,
SimulatedHardwareSeatExecutor,
VirtualSeatExecutor,
build_function_config,
build_function_preset,
@@ -120,6 +123,10 @@ class SeatDemoService:
)
self.compiler = SeatCompiler(self.domain_root / "can" / "signal-mapping.yaml")
self.executor = VirtualSeatExecutor(self.matrix)
self.simulated_hardware_executor = SimulatedHardwareSeatExecutor(self.matrix)
self.simulated_hardware_enabled = os.getenv(
"COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED", "false"
).strip().lower() in {"1", "true", "yes", "on"}
self.hardware_matrix = SeatCanMatrix(
[
self.domain_root / "can" / "seat-thermal-hardware-matrix.yaml",
@@ -131,6 +138,10 @@ class SeatDemoService:
repository_root / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
)
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
# Shared by the short-lived hardware executor instances. A process must
# establish a fresh physical four-axis reference before trusting stored
# Hall counts, and a firmware replacement invalidates it again.
self._seat_position_memory_state = {"synchronized": False}
self.openai = OpenAIIntentInterpreter()
self.domain_runtimes = {
domain: ConfiguredDomainRuntime(repository_root, domain)
@@ -171,11 +182,21 @@ class SeatDemoService:
"domain": "cabin",
"platform": "reference_vehicle",
"execution_mode": "dry_run",
"execution_modes": ["dry_run", "hardware"],
"execution_modes": ["dry_run", "simulated_hardware", "hardware"],
"hardware_execution_allowed": self.hardware_settings.configured,
"hardware": self.hardware_settings.public_info(),
"seat_position_memory_synchronized": bool(
self._seat_position_memory_state.get("synchronized", False)
),
"simulated_hardware": {
"enabled": self.simulated_hardware_enabled,
"default_fault": "normal",
"fault_scenarios": list(SIMULATED_HARDWARE_FAULTS),
"physical_can_opened": False,
},
"openai_available": self.openai.available,
"openai_model": self.openai.model,
"openai_responses_endpoint": self.openai.responses_endpoint,
"conversation_memory": True,
"default_vehicle_id": default_vehicle.id,
"vehicle": default_vehicle.public_dict(),
@@ -272,6 +293,10 @@ class SeatDemoService:
auto_home=self.hardware_settings.auto_home_enabled,
home_timeout_s=self.hardware_settings.home_timeout_s,
firmware_guard=self.hardware_settings.firmware_guard(),
synchronize_position_before_first_motion=(
self.hardware_settings.position_sync_enabled
),
position_sync_state=self._seat_position_memory_state,
)
def process(
@@ -282,6 +307,7 @@ class SeatDemoService:
execution_mode: str = "dry_run",
approval_token: str | None = None,
vehicle_id: str | None = None,
simulation_fault: str = "normal",
) -> dict[str, Any]:
utterance = utterance.strip()
if not utterance:
@@ -290,8 +316,21 @@ class SeatDemoService:
raise ValueError("utterance must be at most 1000 characters")
if provider not in {"auto", "openai", "local"}:
raise ValueError("provider must be auto, openai, or local")
if execution_mode not in {"dry_run", "hardware"}:
raise ValueError("execution_mode must be dry_run or hardware")
if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
raise ValueError(
"execution_mode must be dry_run, simulated_hardware, or hardware"
)
simulated_hardware_request = execution_mode == "simulated_hardware"
if simulated_hardware_request and not self.simulated_hardware_enabled:
raise HardwareSafetyError(
"simulated hardware is disabled; set "
"COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED=true"
)
if simulation_fault not in SIMULATED_HARDWARE_FAULTS:
raise ValueError(
"simulation_fault must be one of "
+ ", ".join(SIMULATED_HARDWARE_FAULTS)
)
mode_save_name = self._parse_mode_profile_save_name(utterance)
mode_restore_name = self._parse_mode_profile_restore_name(utterance)
mode_update = self._parse_mode_profile_update(utterance)
@@ -329,6 +368,8 @@ class SeatDemoService:
"hardware_execution_allowed": (
hardware_request and not control_plane_request
),
"simulated_hardware": simulated_hardware_request,
"simulation_fault": simulation_fault if simulated_hardware_request else None,
"vehicle": vehicle_profile.public_dict(),
"matched_modes": [
profile.public_dict() for profile in matched_modes
@@ -483,6 +524,8 @@ class SeatDemoService:
result,
traces,
hardware_request=hardware_request,
simulated_hardware_request=simulated_hardware_request,
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
)
@@ -763,11 +806,19 @@ class SeatDemoService:
)
+ "板端 Position_Valid 将在发送绝对移动命令前实时检查。"
if hardware_request
else "平台、矩阵和策略均限制为 dry-run;硬件发送路径未启用。"
else (
"模拟台架隔离门禁已通过;仅生成故障注入反馈,物理 CAN、ZLG 与 IAR 路径均未打开。"
if simulated_hardware_request
else "平台、矩阵和策略均限制为 dry-run;硬件发送路径未启用。"
)
),
safety_started,
{
"hardware_execution_allowed": hardware_request,
"simulated_hardware": simulated_hardware_request,
"simulation_fault": (
simulation_fault if simulated_hardware_request else None
),
"position_calibration_gate": (
"runtime_can_preflight" if hardware_request else "not_applicable"
),
@@ -788,6 +839,13 @@ class SeatDemoService:
execution_report = hardware_executor.execute(
command_plan, function_config, context["devices"]
)
elif simulated_hardware_request:
execution_report = self.simulated_hardware_executor.execute(
command_plan,
function_config,
context["devices"],
fault_scenario=simulation_fault,
)
else:
execution_report = self.executor.execute(
command_plan, function_config, context["devices"]
@@ -845,7 +903,15 @@ class SeatDemoService:
else hardware_failure_summary
)
if hardware_request
else "虚拟 CAN 已编码命令帧并生成模拟回读。"
else (
"模拟台架已生成隔离的 HIL 反馈,未访问物理 CAN。"
if simulated_hardware_request and execution_succeeded
else (
f"模拟台架故障已触发:{failed_execution['message']}"
if simulated_hardware_request and failed_execution
else "虚拟 CAN 已编码命令帧并生成模拟回读。"
)
)
)
),
execute_started,
@@ -861,6 +927,14 @@ class SeatDemoService:
},
)
if execution_succeeded:
execution_notices = [
str(item.get("telemetry", {}).get("user_notice"))
for item in execution_results
if item.get("telemetry", {}).get("user_notice")
]
for notice in execution_notices:
if notice not in parsed["decision_summary"]:
parsed["decision_summary"].append(notice)
state_actions = (
self._hardware_feedback_actions(
parsed["actions"], function_config, execution_report
@@ -950,7 +1024,15 @@ class SeatDemoService:
else "未获得满足目标的真实硬件反馈。"
)
if hardware_request
else "虚构反馈帧中的最终状态与请求目标一致。"
else (
"模拟台架反馈与请求目标一致,物理 CAN 始终关闭。"
if simulated_hardware_request and execution_succeeded
else (
f"模拟台架故障反馈:{failed_execution['message']}"
if simulated_hardware_request and failed_execution
else "虚构反馈帧中的最终状态与请求目标一致。"
)
)
)
),
time.perf_counter(),
@@ -958,6 +1040,9 @@ class SeatDemoService:
"command_frame": telemetry.get("command_frame", {}).get("data_hex"),
"feedback_frame": telemetry.get("feedback_frame", {}).get("data_hex"),
"simulated": not hardware_request,
"simulation_fault": (
simulation_fault if simulated_hardware_request else None
),
},
)
self._persist(result)
@@ -1026,9 +1111,16 @@ class SeatDemoService:
action["position_valid"] = bool(
telemetry["position_valid"]
)
if telemetry.get("motion_mode") == "relative_timed_duty":
if telemetry.get("motion_mode") in {
"relative_timed_duty",
"timed_user_request",
}:
action["position_estimated"] = True
action["feedback_basis"] = "relative_hall_confirmed_open_loop"
action["feedback_basis"] = (
"timed_endpoint_or_hall_confirmed_open_loop"
if telemetry.get("motion_mode") == "timed_user_request"
else "relative_hall_confirmed_open_loop"
)
continue
raw = telemetry.get("feedback_value")
if raw is not None:
@@ -1141,6 +1233,35 @@ class SeatDemoService:
return parsed
if self._contains_seat_preset_request(parsed):
return parsed
if len(matched_modes) > 1:
rejected = deepcopy(parsed)
for key in (
"actions",
"domain_actions",
"preset_actions",
"preset_events",
"deferred_requests",
"model_requests",
):
rejected[key] = []
rejected["ambiguities"] = [
{
"field": "mode_profile",
"question": "一条请求只能执行一个座椅功能模式,请确认要执行的模式。",
}
]
rejected["decision_summary"] = [
"同一条请求命中多个功能模式,已停止生成控制指令以避免模式冲突。"
]
return rejected
safety_ambiguities = [
item
for item in parsed.get("ambiguities", [])
if item.get("field") != "capability"
]
if safety_ambiguities:
return parsed
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
detected_domains = []
@@ -1969,6 +2090,8 @@ class SeatDemoService:
traces: list[dict[str, Any]],
*,
hardware_request: bool = False,
simulated_hardware_request: bool = False,
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
) -> dict[str, Any]:
"""Run isolated adapters, with only the passenger seat eligible for HIL."""
@@ -2165,11 +2288,19 @@ class SeatDemoService:
(
"副驾座椅已通过 T2G HIL 安全门,其他座舱领域保持虚拟 CAN。"
if hardware_request
else "所有领域均为 dry-run;矩阵缺少实车完整性信息,硬件发送保持禁用。"
else (
"模拟台架仅对座椅结果注入故障;全部领域的物理发送路径保持关闭。"
if simulated_hardware_request
else "所有领域均为 dry-run;矩阵缺少实车完整性信息,硬件发送保持禁用。"
)
),
time.perf_counter(),
{
"hardware_execution_allowed": hardware_request,
"simulated_hardware": simulated_hardware_request,
"simulation_fault": (
simulation_fault if simulated_hardware_request else None
),
"hardware_domains": ["seat"] if hardware_request else [],
"virtual_domains": [
domain
@@ -2190,6 +2321,13 @@ class SeatDemoService:
reports[domain] = self._new_hardware_executor().execute(
plans[domain], config, context["devices"]
)
elif simulated_hardware_request:
reports[domain] = self.simulated_hardware_executor.execute(
plans[domain],
config,
context["devices"],
fault_scenario=simulation_fault,
)
else:
reports[domain] = self.executor.execute(
plans[domain], config, context["devices"]
@@ -2212,8 +2350,9 @@ class SeatDemoService:
if hardware_request
else None
)
simulated_seat = simulated_hardware_request and "seat" in reports
report_spec: dict[str, Any] = {
"mode": "hil" if hardware_request else "dry_run",
"mode": "hil" if hardware_request or simulated_seat else "dry_run",
"status": "succeeded" if execution_succeeded else "failed",
"started_at": execution_started_at,
"finished_at": _now(),
@@ -2229,14 +2368,27 @@ class SeatDemoService:
"name": (
"Cabin orchestration mixed HIL execution report"
if hardware_request
else "Cabin orchestration virtual execution report"
else (
"Cabin orchestration simulated hardware execution report"
if simulated_seat
else "Cabin orchestration virtual execution report"
)
),
"version": "1.0.0",
"request_id": request_id,
"labels": {
"simulation": "mixed" if hardware_request else "true",
"seat_feedback": (
"physical_can" if hardware_request else "simulated"
"physical_can"
if hardware_request
else (
"simulated_hardware"
if simulated_seat
else "simulated"
)
),
"fault_scenario": (
simulation_fault if simulated_seat else "none"
),
"orchestration": "cross_domain"
if len(configs) > 1
@@ -2272,7 +2424,20 @@ class SeatDemoService:
else (
f"副驾座椅真实执行失败:{failed_execution['message']}"
if hardware_request and failed_execution
else f"已虚拟执行 {len(report_results)} 个领域操作并汇总反馈。"
else (
f"模拟台架已执行 {len(report_results)} 个领域操作,物理 CAN 未打开。"
if simulated_seat and execution_succeeded
else (
f"模拟台架故障已触发:{failed_execution['message']}"
if simulated_seat and failed_execution
else (
"本次没有座椅动作,故障注入未应用;"
f"已虚拟执行 {len(report_results)} 个领域操作。"
if simulated_hardware_request
else f"已虚拟执行 {len(report_results)} 个领域操作并汇总反馈。"
)
)
)
)
),
execute_started,
+6 -3
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
def load_local_env(path: Path) -> None:
def load_local_env(path: Path, *, override: bool = False) -> None:
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
@@ -18,6 +18,9 @@ def load_local_env(path: Path) -> None:
value = value.strip()
if value[:1] == value[-1:] and value.startswith(("'", '"')):
value = value[1:-1]
if key:
os.environ.setdefault(key, value)
if key and (override or not os.environ.get(key, "").strip()):
# A number of launchers export optional variables as empty strings.
# Treat those exactly like an unset value so a deliberate local
# development configuration is not silently shadowed.
os.environ[key] = value
@@ -88,6 +88,7 @@ class SeatHardwareSettings:
cspybat: Path
auto_flash_enabled: bool
auto_home_enabled: bool
position_sync_enabled: bool
home_timeout_s: int
firmware_probe_timeout_ms: int
firmware_boot_wait_ms: int
@@ -171,6 +172,10 @@ class SeatHardwareSettings:
),
auto_flash_enabled=auto_flash_enabled,
auto_home_enabled=_env_bool("COGNITIVE_AGENT_SEAT_AUTO_HOME_ENABLED"),
position_sync_enabled=_env_bool(
"COGNITIVE_AGENT_SEAT_POSITION_SYNC_ENABLED",
default=hardware_enabled,
),
home_timeout_s=_env_int(
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
),
@@ -375,12 +380,19 @@ class SeatHardwareSettings:
"physical_target": "t2g_primary_seat",
"target_aliasing": "disabled_front_passenger_only",
"auto_home_enabled": self.auto_home_enabled,
"position_sync_enabled": self.position_sync_enabled,
"position_sync_policy": (
"four_axis_known_range_home_before_first_process_motion_and_after_firmware_change"
if self.position_sync_enabled
else "disabled"
),
"home_timeout_s": self.home_timeout_s,
"limitations": [
"hardware execution requires the unified Seat_Controller_demo image on one development board",
"all seat capabilities are restricted to passenger/front_right",
"M0/M1/M2/M3 map to horizontal, seat-pan, backrest and legrest on one physical seat",
"known-range homing can move an invalid axis to its near endpoint and remains separately disabled by default",
"on-demand recovery of an unexpectedly invalid target axis remains separately disabled by default",
"when position synchronization is enabled, the first process motion and every repository firmware replacement rebuild all four physical Hall references",
"current diagnostics use conservative bench thresholds and do not replace external power limiting",
],
}
@@ -12,9 +12,9 @@ from typing import Any, Callable
from cognitive_agent.infrastructure.can.gateway import CanBus
FIRMWARE_PROTOCOL_SIGNATURE = "seat-controller-smv8-current-diagnostics-v3"
FIRMWARE_PROTOCOL_SIGNATURE = "seat-controller-smv8-current-diagnostics-v6"
FIRMWARE_PROTOCOL_FRAME_IDS = frozenset({0x301, 0x302, 0x303, 0x304, 0x305, 0x4FA})
EXPECTED_FIRMWARE_REVISION = 3
EXPECTED_FIRMWARE_REVISION = 6
EXPECTED_MOTOR_MAX_HALL = (480, 374, 917, 647)
@@ -10,6 +10,13 @@ import urllib.request
from typing import Any
def _env_text(name: str, default: str) -> str:
"""Return a non-blank environment value or its documented default."""
value = os.getenv(name, "").strip()
return value or default
def build_intent_schema(capability_context: dict[str, Any]) -> dict[str, Any]:
domains = [item["domain"] for item in capability_context.get("domains", [])] or ["seat"]
capabilities = [
@@ -122,6 +129,17 @@ An explicit scope in the current utterance overrides conversation history.
When multiple capabilities address the goal equally well, prefer one whose domain is executable.
Never substitute a materially less suitable capability merely because it is executable.
When the registry exposes exactly one executable seat target and the user refers only to "the seat",
"the front cabin", or the occupant without a conflicting target, use that sole executable target; do
not manufacture a driver-seat target. Understand causal and fuzzy ergonomic requests as goals, not as
unsupported phrases. In particular, when the occupant says the front cabin is cramped and asks for a
comfortable seat position without numeric targets, emit the four registered seat motion capabilities
with this conservative deterministic baseline: longitudinal 80%, cushion tilt 30%, backrest recline
35%, and legrest height 10%. This baseline moves the seat rearward for usable occupant space while
remaining upright and reversible. State this inferred ergonomic baseline in decision_summary. Apply
the same causal reasoning to paraphrases such as insufficient legroom, feeling squeezed, wanting more
space, or asking for a comfortable posture; do not require the user to name a motor or percentage.
When execution_policy.planning_environment is dry_run_candidate_synthesis, a named preset is not
required. For a commonly understood mode, activity, or comfort goal, decompose the goal into the
registered atomic capabilities and infer conservative absolute targets from their normalized ranges
@@ -173,12 +191,14 @@ class OpenAIIntentError(RuntimeError):
class OpenAIIntentInterpreter:
def __init__(self) -> None:
self.api_key = os.getenv("OPENAI_API_KEY", "")
self.model = os.getenv("OPENAI_MODEL", "gpt-5.6-sol")
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
self.api_key = os.getenv("OPENAI_API_KEY", "").strip()
self.model = _env_text("OPENAI_MODEL", "gpt-5.6-sol")
self.base_url = _env_text(
"OPENAI_BASE_URL", "https://api.openai.com/v1"
).rstrip("/")
self.timeout_seconds = float(os.getenv("OPENAI_TIMEOUT_SECONDS", "30"))
self.max_retries = max(0, int(os.getenv("OPENAI_MAX_RETRIES", "2")))
self.reasoning_effort = os.getenv("OPENAI_REASONING_EFFORT", "medium")
self.reasoning_effort = _env_text("OPENAI_REASONING_EFFORT", "medium")
self.streaming = os.getenv("OPENAI_STREAM", "false").strip().lower() in {
"1",
"true",
@@ -190,6 +210,10 @@ class OpenAIIntentInterpreter:
def available(self) -> bool:
return bool(self.api_key)
@property
def responses_endpoint(self) -> str:
return f"{self.base_url}/responses"
def interpret(
self,
utterance: str,
@@ -269,7 +293,7 @@ class OpenAIIntentInterpreter:
def _request_output_text(self, payload: dict[str, Any]) -> str:
request = urllib.request.Request(
f"{self.base_url}/responses",
self.responses_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self.api_key}",
+20 -6
View File
@@ -20,8 +20,10 @@ from cognitive_agent.config_loader import load_local_env
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
load_local_env(REPOSITORY_ROOT / ".env")
load_local_env(REPOSITORY_ROOT / ".env.auth")
# Repository-local launch configuration is deliberate and must not be shadowed
# by stale variables inherited from an IDE or an earlier model experiment.
load_local_env(REPOSITORY_ROOT / ".env", override=True)
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
@@ -31,12 +33,14 @@ from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentErro
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
mimetypes.add_type("application/manifest+json", ".webmanifest")
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
PUBLIC_AUTH_PATHS = {
"/login",
"/login.html",
"/login.css",
"/login.js",
"/network.js",
"/api/auth/status",
"/api/auth/login",
}
@@ -182,6 +186,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if payload.get("vehicle_id")
else None
),
simulation_fault=str(payload.get("simulation_fault", "normal")),
)
self._json(result, HTTPStatus.CREATED)
except HardwareSafetyError as exc:
@@ -370,7 +375,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", f"{content_type or 'application/octet-stream'}; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
cache_control = "no-cache"
if relative_path.startswith(("vendor/", "assets/", "icons/")):
cache_control = "public, max-age=31536000, immutable"
elif candidate.name in {"index.html", "login.html", "service-worker.js"}:
cache_control = "no-cache"
self.send_header("Cache-Control", cache_control)
if candidate.name == "service-worker.js":
self.send_header("Service-Worker-Allowed", "/")
self._send_security_headers()
self.end_headers()
self.wfile.write(content)
@@ -427,10 +439,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval' https://unpkg.com https://cdn.jsdelivr.net; "
"script-src 'self' 'wasm-unsafe-eval'; "
"style-src 'self'; "
"img-src 'self' data:; "
"connect-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self'; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
)
if self._is_https():
@@ -462,7 +474,9 @@ def main() -> None:
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
hardware = SeatDemoHandler.service.hardware_settings
print(
"Execution modes: dry_run + hardware "
"Execution modes: dry_run + simulated_hardware "
f"({'enabled' if SeatDemoHandler.service.simulated_hardware_enabled else 'locked'}) "
"+ hardware "
f"({'configured' if hardware.configured else 'locked'})",
flush=True,
)
+123
View File
@@ -95,6 +95,9 @@ class SeatWebApiTests(unittest.TestCase):
self.assertIn("/login", final_url)
self.assertIn("体验账号登录", login_html)
with unauthenticated.open(f"{self.base_url}/network.js", timeout=3) as response:
network_script = response.read().decode("utf-8")
self.assertIn("CognitiveAgentNetwork", network_script)
with self.assertRaises(urllib.error.HTTPError) as caught:
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
self.assertEqual(caught.exception.code, 401)
@@ -102,6 +105,8 @@ class SeatWebApiTests(unittest.TestCase):
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=seat-hil-7", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
runtime = json.loads(response.read().decode("utf-8"))
@@ -110,6 +115,11 @@ class SeatWebApiTests(unittest.TestCase):
self.assertIn('data-vehicle-model="byd-tang-2024-reference"', html)
self.assertIn("整车孪生界面", html)
self.assertIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=seat-hil-7"', html)
self.assertIn("HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000", app_script)
self.assertIn(
"requestTimeoutForExecutionMode(state.executionMode)", app_script
)
self.assertIn("FBXLoader.js", html)
self.assertIn('data-seat-layout="five-seat"', html)
self.assertIn('data-active-seat="front-right"', html)
@@ -202,6 +212,119 @@ class SeatWebApiTests(unittest.TestCase):
self.assertEqual(result["vehicle"]["id"], "denza_n7")
self.assertEqual(result["actuator_state"]["passenger"]["ventilation"]["level"], 1)
def test_relative_backrest_phrase_uses_current_conversation_position(self) -> None:
conversation_id = "conv-e2e-relative-backrest"
for utterance in ("副驾靠背角度调到130度", "靠背向前移动10度"):
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": utterance,
"provider": "local",
"conversation_id": conversation_id,
"vehicle_id": "denza_n7",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
self.assertEqual(result["status"], "succeeded")
operation = result["function_config"]["spec"]["operations"][0]
self.assertEqual(operation["capability"], "seat.set_backrest_recline")
self.assertEqual(operation["parameters"]["position_pct"], 36)
self.assertEqual(operation["parameters"]["angle_deg"], 120)
self.assertEqual(operation["motion"]["direction"], "decrease")
self.assertEqual(operation["motion"]["delta_angle_deg"], 10.0)
def test_timed_and_oversized_backrest_requests_use_endpoint_fallbacks(self) -> None:
conversation_id = "conv-e2e-timed-endpoint-fallbacks"
def submit(utterance: str) -> dict:
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": utterance,
"provider": "local",
"conversation_id": conversation_id,
"vehicle_id": "denza_n7",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
return json.loads(response.read().decode("utf-8"))
submit("副驾靠背归零")
timed = submit("靠背向后移动70秒")
self.assertEqual(timed["status"], "succeeded")
timed_operation = timed["function_config"]["spec"]["operations"][0]
self.assertEqual(timed_operation["parameters"]["position_pct"], 100)
self.assertEqual(timed_operation["motion"]["mode"], "timed")
self.assertEqual(timed_operation["motion"]["requested_duration_ms"], 70_000)
self.assertEqual(timed_operation["motion"]["applied_duration_ms"], 30_000)
self.assertTrue(timed_operation["motion"]["duration_clamped"])
self.assertIn("最大允许时长为30秒", "\n".join(timed["decision_summary"]))
submit("副驾靠背角度调到130度")
overflow = submit("靠背向后移动70度")
self.assertEqual(overflow["status"], "succeeded")
overflow_operation = overflow["function_config"]["spec"]["operations"][0]
self.assertEqual(overflow_operation["parameters"]["position_pct"], 100)
self.assertEqual(overflow_operation["parameters"]["angle_deg"], 163)
self.assertTrue(overflow_operation["motion"]["range_clamped"])
self.assertEqual(
overflow_operation["motion"]["requested_delta_angle_deg"], 70
)
self.assertIn("备用目标", "\n".join(overflow["decision_summary"]))
def test_open_zero_gravity_phrase_matches_complete_mode(self) -> None:
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": "打开零重力模式",
"provider": "local",
"conversation_id": "conv-e2e-open-zero-gravity",
"vehicle_id": "denza_n7",
"execution_mode": "dry_run",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
self.assertEqual(result["status"], "succeeded")
self.assertIn("zero_gravity", {item["id"] for item in result["matched_modes"]})
operations = result["function_config"]["spec"]["operations"]
self.assertEqual(len(operations), 4)
self.assertEqual(
{
operation["capability"]: operation["parameters"]["position_pct"]
for operation in operations
},
{
"seat.set_longitudinal_position": 100,
"seat.set_cushion_tilt": 100,
"seat.set_backrest_recline": 81,
"seat.set_legrest_height": 94,
},
)
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
def test_hardware_state_endpoint_returns_physical_passenger_snapshot(self) -> None:
synchronized = {
"status": "succeeded",
+423
View File
@@ -0,0 +1,423 @@
{
"schema_version": 2,
"suite": "t2g-seat-frontend-comprehensive-hil",
"vehicle_id": "denza_n7",
"execution_policy": {
"mode": "hardware",
"provider": "local",
"continue_after_failure": true,
"require_physical_feedback": true,
"passenger_only": true,
"position_tolerance_pct": 2,
"known_safety_blocks": {}
},
"coverage_dimensions": {
"targets": ["副驾", "副驾驶", "副驾位", "右前座", "乘客座", "省略座位", "后排/左前/全部座椅安全拒绝"],
"verbs": ["打开", "开启", "启动", "调到", "设置为", "进入", "切换到", "执行", "关闭", "停止", "归零", "复位"],
"values": ["阿拉伯数字", "中文数字", "全角百分号", "百分之表达", "高/中/低档", "正常秒数/半秒/毫秒", "时长上下限钳制", "绝对角度", "相对角度", "相对百分比", "逐轴机械端点", "超量程备用端点", "小数/负数安全拒绝"],
"functions": ["加热/取暖", "通风/吹风", "水平", "坐盆", "靠背", "腿托", "四轴组合", "相对移动", "按时长移动", "预设模式", "GPT模糊舒适意图"],
"safety": ["主驾", "前排范围", "越界容错", "缺少状态", "缺少当前位置", "矛盾指令", "未知能力", "多模式冲突", "非端点堵转不容错"]
},
"cases": [
{
"group": "thermal",
"expected": {"heating": 1},
"variants": [
{"id": "THERM-H-LOW-NUMERIC", "utterance": "副驾座椅加热开到1档"},
{"id": "THERM-H-LOW-CHINESE", "utterance": "把副驾驶座椅加热调到一档"},
{"id": "THERM-H-LOW-WORD", "utterance": "副驾位座椅加热最低档"},
{"id": "THERM-H-LOW-DEFAULT", "utterance": "打开座椅加热"}
]
},
{
"group": "thermal",
"expected": {"heating": 2},
"variants": [
{"id": "THERM-H-MID-NUMERIC", "utterance": "副驾座椅加热开到2档"},
{"id": "THERM-H-MID-CHINESE", "utterance": "乘客座加热调到二档"},
{"id": "THERM-H-MID-WORD", "utterance": "座椅加热设置为中档"}
]
},
{
"group": "thermal",
"expected": {"heating": 3},
"variants": [
{"id": "THERM-H-HIGH-NUMERIC", "utterance": "副驾座椅加热开到3档"},
{"id": "THERM-H-HIGH-PREFIX", "utterance": "右前座椅开启3档加热"},
{"id": "THERM-H-HIGH-WORD", "utterance": "乘客座椅加热调到最高档"}
]
},
{
"group": "thermal",
"expected": {"heating": 0},
"variants": [
{"id": "THERM-H-OFF-CLOSE", "utterance": "关闭副驾座椅加热"},
{"id": "THERM-H-OFF-DROP", "utterance": "关掉副驾驶座椅加热"},
{"id": "THERM-H-OFF-STOP", "utterance": "停止乘客座加热"},
{"id": "THERM-H-OFF-DEFAULT", "utterance": "座椅加热关闭"}
]
},
{"id": "THERM-H-COMFORT-MILD", "group": "thermal", "utterance": "副驾驶有点冷", "expected": {"heating": 1}},
{"id": "THERM-H-COMFORT-STRONG", "group": "thermal", "utterance": "右前座椅太冷了", "expected": {"heating": 3}},
{"id": "THERM-H-COMFORT-OFF", "group": "thermal", "utterance": "把右前座加热关掉", "expected": {"heating": 0}},
{
"group": "thermal",
"expected": {"ventilation": 1},
"variants": [
{"id": "THERM-V-LOW-NUMERIC", "utterance": "副驾座椅通风开到1档"},
{"id": "THERM-V-LOW-CHINESE", "utterance": "副驾驶座椅通风调到一档"},
{"id": "THERM-V-LOW-WORD", "utterance": "乘客座通风最低档"}
]
},
{
"group": "thermal",
"expected": {"ventilation": 2},
"variants": [
{"id": "THERM-V-MID-NUMERIC", "utterance": "副驾座椅通风开到2档"},
{"id": "THERM-V-MID-PREFIX", "utterance": "右前座椅开启2档通风"},
{"id": "THERM-V-MID-WORD", "utterance": "打开座椅通风中档"}
]
},
{
"group": "thermal",
"expected": {"ventilation": 3},
"variants": [
{"id": "THERM-V-HIGH-NUMERIC", "utterance": "副驾座椅通风开到3档"},
{"id": "THERM-V-HIGH-CHINESE", "utterance": "乘客座椅通风调到三档"},
{"id": "THERM-V-HIGH-WORD", "utterance": "右前座通风最高档"}
]
},
{
"group": "thermal",
"expected": {"ventilation": 0},
"variants": [
{"id": "THERM-V-OFF-CLOSE", "utterance": "关闭副驾座椅通风"},
{"id": "THERM-V-OFF-DROP", "utterance": "关掉副驾驶座椅通风"},
{"id": "THERM-V-OFF-STOP", "utterance": "停止右前座通风"}
]
},
{"id": "THERM-V-COMFORT-MILD", "group": "thermal", "utterance": "副驾驶有点热", "expected": {"ventilation": 1}},
{"id": "THERM-V-COMFORT-STRONG", "group": "thermal", "utterance": "右前座椅太闷了", "expected": {"ventilation": 3}},
{"id": "THERM-V-COMFORT-OFF", "group": "thermal", "utterance": "把乘客座通风关掉", "expected": {"ventilation": 0}},
{"id": "THERM-MIXED-COMFORT", "group": "thermal", "utterance": "副驾驶有点冷又有点闷", "expected": {"heating": 1, "ventilation": 1}},
{"id": "THERM-MIXED-OFF", "group": "thermal", "utterance": "关闭右前座的加热和通风", "expected": {"heating": 0, "ventilation": 0}},
{"id": "THERM-AIRFLOW-SETUP", "group": "thermal", "utterance": "右前座通风调到2档", "expected": {"ventilation": 2}},
{"id": "THERM-AIRFLOW-COLD", "group": "thermal", "utterance": "副驾被风吹得有点冷", "expected": {"ventilation": 1}},
{"id": "THERM-AIRFLOW-OFF", "group": "thermal", "utterance": "关闭座椅通风", "expected": {"ventilation": 0}},
{
"group": "motion",
"expected": {"longitudinal": 25},
"variants": [
{"id": "MOTION-M0-25-HORIZONTAL", "utterance": "把副驾座椅水平位置调到25%"},
{"id": "MOTION-M0-25-FRONTBACK", "utterance": "副驾驶座椅前后位置设置为25%"},
{"id": "MOTION-M0-25-RAIL", "utterance": "右前座滑轨移到25%"}
]
},
{
"group": "motion",
"expected": {"longitudinal": 75},
"variants": [
{"id": "MOTION-M0-75-HORIZONTAL", "utterance": "副驾座椅水平位置调到75%"},
{"id": "MOTION-M0-75-LONGITUDINAL", "utterance": "乘客座纵向移动至75%"},
{"id": "MOTION-M0-75-RAIL", "utterance": "把副驾驶滑轨设为75%"}
]
},
{
"group": "motion",
"expected": {"longitudinal": 50},
"variants": [
{"id": "MOTION-M0-50-CN-PERCENT", "utterance": "座椅前后移动到百分之五十"},
{"id": "MOTION-M0-50-FULLWIDTH", "utterance": "右前座水平位置调到50%"}
]
},
{
"group": "motion",
"expected": {"longitudinal": 0},
"variants": [
{"id": "MOTION-M0-FRONTMOST", "utterance": "把副驾座椅推到最前面"},
{"id": "MOTION-M0-HOME", "utterance": "副驾驶座椅前后复位"},
{"id": "MOTION-M0-ZERO", "utterance": "右前座滑轨归零"}
]
},
{
"group": "motion",
"expected": {"longitudinal": 100},
"variants": [
{"id": "MOTION-M0-REARMOST", "utterance": "副驾驶座椅移动到最后面"},
{"id": "MOTION-M0-BACK-END", "utterance": "把右前座滑轨向后移到底"}
]
},
{"id": "MOTION-M0-RELATIVE-FORWARD", "group": "motion", "utterance": "副驾滑轨向前移动10%", "expected": {"longitudinal": 90}, "expected_motion": {"axis": "longitudinal", "direction": "decrease", "delta_position_pct": 10}},
{"id": "MOTION-M0-RELATIVE-BACKWARD", "group": "motion", "utterance": "座椅前后向后移动10%", "expected": {"longitudinal": 100}, "expected_motion": {"axis": "longitudinal", "direction": "increase", "delta_position_pct": 10}},
{"id": "MOTION-M0-TIMED-FORWARD-CLAMP", "group": "motion", "utterance": "座椅向前移动60秒", "expected": {"longitudinal": 0}, "expected_motion": {"axis": "longitudinal", "mode": "timed", "direction": "decrease", "requested_duration_ms": 60000, "applied_duration_ms": 30000, "max_duration_ms": 30000, "duration_clamped": true, "endpoint_expected": "near"}, "expected_notice_contains": ["最大允许时长为30秒", "到达最前方"]},
{"id": "MOTION-M0-TIMED-BACK-MINUTE", "group": "motion", "utterance": "副驾座椅向后移动1分钟", "expected": {"longitudinal": 100}, "expected_motion": {"axis": "longitudinal", "mode": "timed", "direction": "increase", "requested_duration_ms": 60000, "applied_duration_ms": 30000, "max_duration_ms": 30000, "duration_clamped": true, "endpoint_expected": "far"}, "expected_notice_contains": ["最大允许时长为30秒", "到达最后方"]},
{"id": "MOTION-M0-ABS-OVERFLOW", "group": "motion", "utterance": "副驾座椅水平位置调到101%", "expected": {"longitudinal": 100}, "expected_notice_contains": ["备用目标", "机械末端"]},
{
"group": "motion",
"expected": {"cushion_tilt": 20},
"variants": [
{"id": "MOTION-M1-20-PAN", "utterance": "把副驾坐盆调到20%"},
{"id": "MOTION-M1-20-CUSHION", "utterance": "副驾驶坐垫位置设置为20%"}
]
},
{
"group": "motion",
"expected": {"cushion_tilt": 60},
"variants": [
{"id": "MOTION-M1-60-PAN", "utterance": "副驾坐盆调到60%"},
{"id": "MOTION-M1-60-TILT", "utterance": "右前座坐垫倾角调到60%"}
]
},
{"id": "MOTION-M1-RELATIVE-UP", "group": "motion", "utterance": "副驾坐垫向上调整10%", "expected": {"cushion_tilt": 50}, "expected_motion": {"axis": "cushion_tilt", "direction": "decrease", "delta_position_pct": 10}},
{"id": "MOTION-M1-RELATIVE-DOWN", "group": "motion", "utterance": "坐盆向下移动10%", "expected": {"cushion_tilt": 60}, "expected_motion": {"axis": "cushion_tilt", "direction": "increase", "delta_position_pct": 10}},
{
"group": "motion",
"expected": {"cushion_tilt": 50},
"variants": [
{"id": "MOTION-M1-50-CN-PERCENT", "utterance": "乘客座坐盆调到百分之五十"},
{"id": "MOTION-M1-50-FULLWIDTH", "utterance": "副驾驶坐垫设置为50%"}
]
},
{
"group": "motion",
"expected": {"cushion_tilt": 0},
"variants": [
{"id": "MOTION-M1-HOME", "utterance": "副驾坐盆归零"},
{"id": "MOTION-M1-RESET", "utterance": "右前座坐垫复位"}
]
},
{"id": "MOTION-M1-TIMED-DOWN-CLAMP", "group": "motion", "utterance": "坐垫向下移动1分钟", "expected": {"cushion_tilt": 100}, "expected_motion": {"axis": "cushion_tilt", "mode": "timed", "direction": "increase", "requested_duration_ms": 60000, "applied_duration_ms": 20000, "max_duration_ms": 20000, "duration_clamped": true, "endpoint_expected": "far"}, "expected_notice_contains": ["最大允许时长为20秒", "到达最下方"]},
{"id": "MOTION-M1-TIMED-UP-CLAMP", "group": "motion", "utterance": "坐盆向上移动60秒", "expected": {"cushion_tilt": 0}, "expected_motion": {"axis": "cushion_tilt", "mode": "timed", "direction": "decrease", "requested_duration_ms": 60000, "applied_duration_ms": 20000, "max_duration_ms": 20000, "duration_clamped": true, "endpoint_expected": "near"}, "expected_notice_contains": ["最大允许时长为20秒", "到达最上方"]},
{
"group": "motion",
"expected": {"backrest_recline": 25},
"variants": [
{"id": "MOTION-M2-25-BACKREST", "utterance": "副驾靠背调到25%"},
{"id": "MOTION-M2-25-SEATBACK", "utterance": "副驾驶椅背设置为25%"}
]
},
{
"group": "motion",
"expected": {"backrest_recline": 60},
"variants": [
{"id": "MOTION-M2-60-BACKREST", "utterance": "把副驾靠背调到60%"},
{"id": "MOTION-M2-60-SEATBACK", "utterance": "右前座椅背调至60%"}
]
},
{
"group": "motion",
"expected": {"backrest_recline": 51},
"variants": [
{"id": "MOTION-M2-ANGLE-130", "utterance": "副驾靠背角度调到130度"},
{"id": "MOTION-M2-ANGLE-130-SEATBACK", "utterance": "把副驾驶椅背设为130°"},
{"id": "MOTION-M2-ANGLE-130-RIGHT", "utterance": "右前座靠背夹角调至130度"}
]
},
{"id": "MOTION-M2-RELATIVE-FORWARD", "group": "motion", "utterance": "靠背向前移动10度", "expected": {"backrest_recline": 36}, "expected_motion": {"axis": "backrest_recline", "direction": "decrease", "delta_position_pct": 15, "delta_angle_deg": 10.0}},
{"id": "MOTION-M2-RELATIVE-BACKWARD", "group": "motion", "utterance": "副驾椅背向后移动10度", "expected": {"backrest_recline": 51}, "expected_motion": {"axis": "backrest_recline", "direction": "increase", "delta_position_pct": 15, "delta_angle_deg": 10.0}},
{"id": "MOTION-M2-RELATIVE-FORWARD-CN", "group": "motion", "utterance": "椅背往前调十度", "expected": {"backrest_recline": 36}, "expected_motion": {"axis": "backrest_recline", "direction": "decrease", "delta_position_pct": 15, "delta_angle_deg": 10.0}},
{"id": "MOTION-M2-RELATIVE-BACKWARD-CN", "group": "motion", "utterance": "靠背后仰十度", "expected": {"backrest_recline": 51}, "expected_motion": {"axis": "backrest_recline", "direction": "increase", "delta_position_pct": 15, "delta_angle_deg": 10.0}},
{
"group": "motion",
"expected": {"backrest_recline": 81},
"variants": [
{"id": "MOTION-M2-ANGLE-150", "utterance": "副驾靠背放倒到150度"},
{"id": "MOTION-M2-ANGLE-150-SEATBACK", "utterance": "乘客座椅背调到150°"}
]
},
{
"group": "motion",
"expected": {"backrest_recline": 0},
"variants": [
{"id": "MOTION-M2-UPRIGHT", "utterance": "把副驾靠背调直"},
{"id": "MOTION-M2-HOME", "utterance": "副驾驶椅背归零"},
{"id": "MOTION-M2-RESET", "utterance": "右前座靠背复位"}
]
},
{"id": "MOTION-M2-RELATIVE-OVERFLOW-BACK", "group": "motion", "utterance": "靠背向后移动70度", "expected": {"backrest_recline": 100}, "expected_motion": {"axis": "backrest_recline", "mode": "relative", "direction": "increase", "delta_position_pct": 100, "delta_angle_deg": 68.0, "requested_delta_angle_deg": 70.0, "range_clamped": true}, "expected_notice_contains": ["备用目标", "最后方"]},
{"id": "MOTION-M2-RELATIVE-OVERFLOW-FORWARD", "group": "motion", "utterance": "靠背向前移动70度", "expected": {"backrest_recline": 0}, "expected_motion": {"axis": "backrest_recline", "mode": "relative", "direction": "decrease", "delta_position_pct": 100, "delta_angle_deg": 68.0, "requested_delta_angle_deg": 70.0, "range_clamped": true}, "expected_notice_contains": ["备用目标", "最前方"]},
{"id": "MOTION-M2-TIMED-BACK-CLAMP", "group": "motion", "utterance": "靠背向后移动70秒", "expected": {"backrest_recline": 100}, "expected_motion": {"axis": "backrest_recline", "mode": "timed", "direction": "increase", "requested_duration_ms": 70000, "applied_duration_ms": 30000, "max_duration_ms": 30000, "duration_clamped": true, "endpoint_expected": "far"}, "expected_notice_contains": ["最大允许时长为30秒", "到达最后方"]},
{"id": "MOTION-M2-TIMED-FORWARD-MINUTE", "group": "motion", "utterance": "椅背向前移动1分钟", "expected": {"backrest_recline": 0}, "expected_motion": {"axis": "backrest_recline", "mode": "timed", "direction": "decrease", "requested_duration_ms": 60000, "applied_duration_ms": 30000, "max_duration_ms": 30000, "duration_clamped": true, "endpoint_expected": "near"}, "expected_notice_contains": ["最大允许时长为30秒", "到达最前方"]},
{"id": "MOTION-M2-ABS-ANGLE-OVERFLOW", "group": "motion", "utterance": "副驾靠背调到200度", "expected": {"backrest_recline": 100}, "expected_notice_contains": ["备用目标", "163度", "最后方"]},
{"id": "MOTION-M2-POST-OVERFLOW-HOME", "group": "motion", "utterance": "副驾靠背归零", "expected": {"backrest_recline": 0}},
{
"group": "motion",
"expected": {"legrest_height": 20},
"variants": [
{"id": "MOTION-M3-20-LEGREST", "utterance": "副驾腿托调到20%"},
{"id": "MOTION-M3-20-FOOTREST", "utterance": "副驾驶脚托设置为20%"}
]
},
{
"group": "motion",
"expected": {"legrest_height": 60},
"variants": [
{"id": "MOTION-M3-60-LEGREST", "utterance": "把副驾腿托调到60%"},
{"id": "MOTION-M3-60-SUPPORT", "utterance": "右前座腿部支撑调至60%"}
]
},
{
"group": "motion",
"expected": {"legrest_height": 69},
"variants": [
{"id": "MOTION-M3-ANGLE-145", "utterance": "副驾腿托抬到145度"},
{"id": "MOTION-M3-ANGLE-145-FOOTREST", "utterance": "把副驾驶脚托设为145°"}
]
},
{"id": "MOTION-M3-RELATIVE-DOWN", "group": "motion", "utterance": "脚托往下调10度", "expected": {"legrest_height": 55}, "expected_motion": {"axis": "legrest_height", "direction": "decrease", "delta_position_pct": 14, "delta_angle_deg": 10.0}},
{"id": "MOTION-M3-RELATIVE-UP", "group": "motion", "utterance": "腿托向上抬十度", "expected": {"legrest_height": 69}, "expected_motion": {"axis": "legrest_height", "direction": "increase", "delta_position_pct": 14, "delta_angle_deg": 10.0}},
{
"group": "motion",
"expected": {"legrest_height": 90},
"variants": [
{"id": "MOTION-M3-ANGLE-160", "utterance": "右前座小腿托调到160度"},
{"id": "MOTION-M3-ANGLE-160-SUPPORT", "utterance": "乘客座腿部支撑夹角设为160°"}
]
},
{
"group": "motion",
"expected": {"legrest_height": 0},
"variants": [
{"id": "MOTION-M3-RETRACT", "utterance": "把副驾腿托收起"},
{"id": "MOTION-M3-HOME", "utterance": "副驾驶脚托归零"},
{"id": "MOTION-M3-RESET", "utterance": "右前座腿部支撑复位"}
]
},
{"id": "MOTION-M3-TIMED-UP-CLAMP", "group": "motion", "utterance": "腿托向上移动1分钟", "expected": {"legrest_height": 100}, "expected_motion": {"axis": "legrest_height", "mode": "timed", "direction": "increase", "requested_duration_ms": 60000, "applied_duration_ms": 25000, "max_duration_ms": 25000, "duration_clamped": true, "endpoint_expected": "far"}, "expected_notice_contains": ["最大允许时长为25秒", "到达最上方"]},
{"id": "MOTION-M3-TIMED-DOWN-CLAMP", "group": "motion", "utterance": "脚托向下移动60秒", "expected": {"legrest_height": 0}, "expected_motion": {"axis": "legrest_height", "mode": "timed", "direction": "decrease", "requested_duration_ms": 60000, "applied_duration_ms": 25000, "max_duration_ms": 25000, "duration_clamped": true, "endpoint_expected": "near"}, "expected_notice_contains": ["最大允许时长为25秒", "到达最下方"]},
{"id": "MOTION-ALL-40", "group": "motion", "utterance": "副驾驶水平、坐垫、椅背、脚托都调到40%", "expected": {"longitudinal": 40, "cushion_tilt": 40, "backrest_recline": 40, "legrest_height": 40}},
{"id": "MOTION-ALL-40-FULLWIDTH", "group": "motion", "utterance": "右前座前后、坐盆、靠背、腿托全部设置为40%", "expected": {"longitudinal": 40, "cushion_tilt": 40, "backrest_recline": 40, "legrest_height": 40}},
{"id": "MOTION-ALL-ZERO", "group": "motion", "utterance": "把副驾水平、坐盆、靠背、腿托四轴归零", "expected": {"longitudinal": 0, "cushion_tilt": 0, "backrest_recline": 0, "legrest_height": 0}},
{"id": "MOTION-ALL-RESET", "group": "motion", "utterance": "右前座椅四轴全部复位", "expected": {"longitudinal": 0, "cushion_tilt": 0, "backrest_recline": 0, "legrest_height": 0}},
{"id": "COMBINED-ALL", "group": "combined", "utterance": "打开副驾加热1档、通风1档,座椅水平到30%,坐盆到40%,靠背到37%,腿托到42%", "expected": {"heating": 1, "ventilation": 1, "longitudinal": 30, "cushion_tilt": 40, "backrest_recline": 37, "legrest_height": 42}},
{"id": "COMBINED-ALL-OFF", "group": "combined", "utterance": "关闭副驾驶座椅加热和通风", "expected": {"heating": 0, "ventilation": 0}},
{"id": "COMBINED-HEAT-MOTION", "group": "combined", "utterance": "副驾驶加热调到二档,前后位置50%,椅背130度", "expected": {"heating": 2, "longitudinal": 50, "backrest_recline": 51}},
{"id": "COMBINED-VENT-MOTION", "group": "combined", "utterance": "右前座通风最高档 坐垫40% 脚托145度", "expected": {"ventilation": 3, "cushion_tilt": 40, "legrest_height": 69}},
{"id": "COMBINED-FULLWIDTH", "group": "combined", "utterance": "乘客座加热1档,通风2档,前后30%,坐盆40%,靠背130度,腿托145度", "expected": {"heating": 1, "ventilation": 2, "longitudinal": 30, "cushion_tilt": 40, "backrest_recline": 51, "legrest_height": 69}},
{"id": "COMBINED-CLEANUP", "group": "combined", "utterance": "关闭右前座加热和通风,并把水平、坐盆、靠背、腿托四轴归零", "expected": {"heating": 0, "ventilation": 0, "longitudinal": 0, "cushion_tilt": 0, "backrest_recline": 0, "legrest_height": 0}},
{"id": "THERM-H-SYNONYM-WARM", "group": "thermal", "utterance": "副驾座椅取暖开到2档", "expected": {"heating": 2}},
{"id": "THERM-H-SYNONYM-BRIEF", "group": "thermal", "utterance": "副驾座椅暖一下", "expected": {"heating": 1}},
{"id": "THERM-V-SYNONYM-AIR", "group": "thermal", "utterance": "副驾座椅吹风开到2档", "expected": {"ventilation": 2}},
{"id": "MOTION-M0-TIMED-WITHIN-RANGE", "group": "motion", "utterance": "副驾滑轨向后移动2秒", "expected": {}, "expected_motion": {"axis": "longitudinal", "mode": "timed", "direction": "increase", "requested_duration_ms": 2000, "applied_duration_ms": 2000, "max_duration_ms": 30000, "min_duration_ms": 300, "duration_clamped": false}},
{"id": "MOTION-M1-TIMED-ENGLISH-UNIT", "group": "motion", "utterance": "坐垫向下移动2s", "expected": {}, "expected_motion": {"axis": "cushion_tilt", "mode": "timed", "direction": "increase", "requested_duration_ms": 2000, "applied_duration_ms": 2000, "max_duration_ms": 20000, "min_duration_ms": 300, "duration_clamped": false}},
{"id": "MOTION-M2-TIMED-HALF-SECOND", "group": "motion", "utterance": "靠背向后移动半秒", "expected": {}, "expected_motion": {"axis": "backrest_recline", "mode": "timed", "direction": "increase", "requested_duration_ms": 500, "applied_duration_ms": 500, "max_duration_ms": 30000, "min_duration_ms": 300, "duration_clamped": false}},
{"id": "MOTION-M3-TIMED-MIN-CLAMP", "group": "motion", "utterance": "腿托向上移动200毫秒", "expected": {}, "expected_motion": {"axis": "legrest_height", "mode": "timed", "direction": "increase", "requested_duration_ms": 200, "applied_duration_ms": 300, "max_duration_ms": 25000, "min_duration_ms": 300, "duration_clamped": true}, "expected_notice_contains": ["最小允许时长为0.3秒", "已按0.3秒"]},
{"id": "MOTION-M1-ENDPOINT-UPPER", "group": "motion", "utterance": "坐盆抬到最上方", "expected": {"cushion_tilt": 0}},
{"id": "MOTION-M1-ENDPOINT-LOWER", "group": "motion", "utterance": "坐盆降到最下方", "expected": {"cushion_tilt": 100}},
{"id": "MOTION-M2-ENDPOINT-RECLINED", "group": "motion", "utterance": "靠背放倒到最后", "expected": {"backrest_recline": 100}},
{"id": "MOTION-M3-ENDPOINT-UPPER", "group": "motion", "utterance": "腿托升到最高", "expected": {"legrest_height": 100}},
{"id": "MOTION-M3-ENDPOINT-LOWER", "group": "motion", "utterance": "腿托降到最低", "expected": {"legrest_height": 0}},
{"id": "MOTION-M1-ABS-OVERFLOW", "group": "motion", "utterance": "坐垫位置调到150%", "expected": {"cushion_tilt": 100}, "expected_notice_contains": ["备用目标", "机械末端"]},
{"id": "MOTION-M2-ABS-ANGLE-UNDERFLOW", "group": "motion", "utterance": "副驾靠背调到80度", "expected": {"backrest_recline": 0}, "expected_notice_contains": ["备用目标", "95度", "最前方"]},
{"id": "MOTION-M3-ABS-ANGLE-OVERFLOW", "group": "motion", "utterance": "腿托调到200度", "expected": {"legrest_height": 100}, "expected_notice_contains": ["备用目标", "167度", "最上方"]}
],
"mode_cases": [
{"mode_id": "easy_entry_exit", "expected": {"longitudinal": 100, "cushion_tilt": 0, "backrest_recline": 0, "legrest_height": 0}, "variants": [
{"id": "MODE-EASY-OPEN", "utterance": "打开轻松进出模式"}, {"id": "MODE-EASY-ENTER", "utterance": "副驾进入轻松进出"}, {"id": "MODE-EASY-SWITCH", "utterance": "切换到轻松进出模式"}
]},
{"mode_id": "office", "expected": {"longitudinal": 72, "cushion_tilt": 22, "backrest_recline": 22, "legrest_height": 21}, "variants": [
{"id": "MODE-OFFICE-OPEN", "utterance": "打开办公模式"}, {"id": "MODE-OFFICE-ENTER", "utterance": "副驾进入办公"}, {"id": "MODE-OFFICE-START", "utterance": "启动办公模式"}
]},
{"mode_id": "queen_passenger_driving", "expected": {"longitudinal": 72, "cushion_tilt": 38, "backrest_recline": 44, "legrest_height": 31}, "variants": [
{"id": "MODE-QUEEN-OPEN", "utterance": "打开女王副驾模式"}, {"id": "MODE-QUEEN-ENTER", "utterance": "进入女王副驾"}, {"id": "MODE-QUEEN-SWITCH", "utterance": "切换到女王副驾行驶模式"}
]},
{"mode_id": "baby_nap", "expected": {"longitudinal": 81, "cushion_tilt": 30, "backrest_recline": 41, "legrest_height": 0}, "variants": [
{"id": "MODE-BABY-OPEN", "utterance": "打开宝宝午睡模式"}, {"id": "MODE-BABY-ENTER", "utterance": "副驾进入宝宝午睡"}, {"id": "MODE-BABY-START", "utterance": "启动宝宝午睡模式"}
]},
{"mode_id": "cruise_sofa", "expected": {"longitudinal": 51, "cushion_tilt": 30, "backrest_recline": 44, "legrest_height": 53}, "variants": [
{"id": "MODE-CRUISE-OPEN", "utterance": "打开巡航沙发模式"}, {"id": "MODE-CRUISE-ENTER", "utterance": "进入巡航沙发"}, {"id": "MODE-CRUISE-SWITCH", "utterance": "切换到巡航沙发模式"}
]},
{"mode_id": "meditation_space", "expected": {"longitudinal": 100, "cushion_tilt": 44, "backrest_recline": 44, "legrest_height": 58}, "variants": [
{"id": "MODE-MEDITATION-OPEN", "utterance": "打开冥想空间模式"}, {"id": "MODE-MEDITATION-ENTER", "utterance": "副驾进入冥想"}, {"id": "MODE-MEDITATION-START", "utterance": "启动冥想空间模式"}
]},
{"mode_id": "leisure_lounger", "expected": {"longitudinal": 51, "cushion_tilt": 30, "backrest_recline": 66, "legrest_height": 76}, "variants": [
{"id": "MODE-LEISURE-OPEN", "utterance": "打开休闲躺椅模式"}, {"id": "MODE-LEISURE-ENTER", "utterance": "进入休闲躺椅"}, {"id": "MODE-LEISURE-SWITCH", "utterance": "切换到休闲躺椅模式"}
]},
{"mode_id": "calf_decompression_float", "expected": {"longitudinal": 51, "cushion_tilt": 75, "backrest_recline": 0, "legrest_height": 100}, "variants": [
{"id": "MODE-CALF-OPEN", "utterance": "打开小腿解压悬浮模式"}, {"id": "MODE-CALF-ENTER", "utterance": "进入小腿解压"}, {"id": "MODE-CALF-START", "utterance": "启动小腿悬浮模式"}
]},
{"mode_id": "electric_beach_chair", "expected": {"longitudinal": 100, "cushion_tilt": 0, "backrest_recline": 100, "legrest_height": 60}, "variants": [
{"id": "MODE-BEACH-OPEN", "utterance": "打开电感沙滩椅"}, {"id": "MODE-BEACH-ENTER", "utterance": "进入沙滩椅模式"}, {"id": "MODE-BEACH-SWITCH", "utterance": "切换到电感沙滩椅模式"}
]},
{"mode_id": "nap", "expected": {"longitudinal": 100, "cushion_tilt": 100, "backrest_recline": 81, "legrest_height": 94}, "variants": [
{"id": "MODE-NAP-OPEN", "utterance": "打开小憩模式"}, {"id": "MODE-NAP-ENTER", "utterance": "副驾进入小憩"}, {"id": "MODE-NAP-START", "utterance": "启动小憩模式"}
]},
{"mode_id": "zero_gravity", "expected": {"longitudinal": 100, "cushion_tilt": 100, "backrest_recline": 81, "legrest_height": 94}, "variants": [
{"id": "MODE-ZERO-GRAVITY-OPEN", "utterance": "打开零重力模式"}, {"id": "MODE-ZERO-GRAVITY-ENTER", "utterance": "副驾进入零重力"}, {"id": "MODE-ZERO-GRAVITY-ENGLISH", "utterance": "开启 zero-gravity 模式"}
]}
],
"model_cases": [
{
"id": "MODEL-FUZZY-CRAMPED-COMFORT",
"utterance": "前舱空间太挤了,把座椅调整到舒服的位置",
"provider": "openai",
"expected_provider_prefix": "openai:gpt-5.6-sol",
"expected": {
"longitudinal": 80,
"cushion_tilt": 30,
"backrest_recline": 35,
"legrest_height": 10
}
},
{
"id": "MODEL-FUZZY-LEGROOM-COMFORT",
"utterance": "副驾腿部空间不够,把座椅调到舒适位置",
"provider": "openai",
"expected_provider_prefix": "openai:gpt-5.6-sol",
"expected": {"longitudinal": 80, "cushion_tilt": 30, "backrest_recline": 35, "legrest_height": 10}
},
{
"id": "MODEL-FUZZY-FRONT-CABIN-PARAPHRASE",
"utterance": "感觉前舱有点挤,帮我把座椅调整舒服些",
"provider": "openai",
"expected_provider_prefix": "openai:gpt-5.6-sol",
"expected": {"longitudinal": 80, "cushion_tilt": 30, "backrest_recline": 35, "legrest_height": 10}
},
{
"id": "MODEL-FUZZY-FIRST-PERSON-COMFORT",
"utterance": "我在副驾坐着太挤了,给我调整一个舒服的坐姿",
"provider": "openai",
"expected_provider_prefix": "openai:gpt-5.6-sol",
"expected": {"longitudinal": 80, "cushion_tilt": 30, "backrest_recline": 35, "legrest_height": 10}
}
],
"rejection_cases": [
{"id": "SAFE-DRIVER-THERMAL", "utterance": "打开主驾座椅通风", "expected_status": "needs_confirmation"},
{"id": "SAFE-DRIVER-MOTION", "utterance": "把驾驶位靠背调到50%", "expected_status": "needs_confirmation"},
{"id": "SAFE-DRIVER-MODE", "utterance": "主驾进入零重力模式", "expected_status": "needs_confirmation"},
{"id": "SAFE-FRONT-ROW", "utterance": "打开前排座椅加热", "expected_status": "needs_confirmation"},
{"id": "SAFE-LEVEL-UNSUPPORTED", "utterance": "副驾座椅加热开到4档", "expected_status": "needs_confirmation"},
{"id": "SAFE-MISSING-STATE", "utterance": "副驾座椅通风", "expected_status": "needs_confirmation"},
{"id": "SAFE-CONTRADICTORY", "utterance": "打开副驾加热然后关闭副驾加热", "expected_status": "needs_confirmation"},
{"id": "SAFE-UNKNOWN-CAPABILITY", "utterance": "把副驾座椅按摩打开", "expected_status": "needs_confirmation"},
{"id": "SAFE-UNKNOWN-MODE", "utterance": "打开老板模式", "expected_status": "needs_confirmation"},
{"id": "SAFE-MULTIPLE-MODES", "utterance": "进入办公模式和零重力模式", "expected_status": "needs_confirmation"},
{"id": "SAFE-LEGREST-EXTENSION", "utterance": "把副驾腿托伸长到50%", "expected_status": "needs_confirmation"},
{"id": "SAFE-RELATIVE-WRONG-UNIT", "utterance": "坐垫向前移动10度", "expected_status": "needs_confirmation"},
{"id": "SAFE-RELATIVE-MISSING-AXIS", "utterance": "向前移动10度", "expected_status": "needs_confirmation"},
{"id": "SAFE-VAGUE", "utterance": "把座椅调舒服一点", "expected_status": "needs_confirmation"},
{"id": "SAFE-UNSUPPORTED-REAR-ROW", "utterance": "打开后排座椅加热", "expected_status": "needs_confirmation"},
{"id": "SAFE-UNSUPPORTED-FRONT-LEFT", "utterance": "打开左前座椅通风", "expected_status": "needs_confirmation"},
{"id": "SAFE-UNSUPPORTED-ALL-SEATS", "utterance": "打开所有座椅加热", "expected_status": "needs_confirmation"},
{"id": "SAFE-DECIMAL-POSITION", "utterance": "副驾座椅水平位置调到50.5%", "expected_status": "needs_confirmation"},
{"id": "SAFE-NEGATIVE-POSITION", "utterance": "副驾座椅水平位置调到-10%", "expected_status": "needs_confirmation"},
{"id": "SAFE-ZERO-DURATION", "utterance": "副驾靠背向后移动0秒", "expected_status": "needs_confirmation"}
],
"final_state": {
"utterance": "关闭副驾座椅加热和通风,并把水平、坐盆、靠背、腿托四轴归零",
"expected": {
"heating": 0,
"ventilation": 0,
"longitudinal": 0,
"cushion_tilt": 0,
"backrest_recline": 0,
"legrest_height": 0
}
}
}
@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
SUITE_PATH = REPOSITORY_ROOT / "tests" / "fixtures" / "seat_frontend_hil_suite.json"
EXPECTED_CAPABILITIES = {
"heating": "seat.set_heating",
"ventilation": "seat.set_ventilation",
"longitudinal": "seat.set_longitudinal_position",
"cushion_tilt": "seat.set_cushion_tilt",
"backrest_recline": "seat.set_backrest_recline",
"legrest_height": "seat.set_legrest_height",
}
def expand_entries(entries: list[dict], group: str | None = None) -> list[dict]:
expanded = []
for entry in entries:
base = {key: value for key, value in entry.items() if key != "variants"}
if group is not None:
base["group"] = group
variants = entry.get("variants")
if not variants:
expanded.append(base)
continue
expanded.extend({**base, **variant} for variant in variants)
return expanded
class SeatFrontendInputCoverageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
with patch.dict(
os.environ,
{"COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false"},
clear=False,
):
cls.service = SeatDemoService(REPOSITORY_ROOT)
cls.service.artifact_root = Path(cls.temporary_directory.name)
cls.suite = json.loads(SUITE_PATH.read_text(encoding="utf-8"))
cls.positive_cases = expand_entries(cls.suite["cases"]) + expand_entries(
cls.suite["mode_cases"], "mode"
)
cls.model_cases = expand_entries(cls.suite.get("model_cases", []), "model")
cls.rejection_cases = expand_entries(cls.suite["rejection_cases"], "safety")
@classmethod
def tearDownClass(cls) -> None:
cls.temporary_directory.cleanup()
def test_suite_has_broad_equivalence_and_safety_coverage(self) -> None:
self.assertGreaterEqual(len(self.positive_cases), 160)
self.assertGreaterEqual(len(self.model_cases), 4)
self.assertGreaterEqual(len(self.rejection_cases), 20)
self.assertEqual(
{case["group"] for case in self.positive_cases},
{"thermal", "motion", "combined", "mode"},
)
self.assertEqual(
{case["mode_id"] for case in self.positive_cases if case["group"] == "mode"},
{
"easy_entry_exit",
"office",
"queen_passenger_driving",
"baby_nap",
"cruise_sofa",
"meditation_space",
"leisure_lounger",
"calf_decompression_float",
"electric_beach_chair",
"nap",
"zero_gravity",
},
)
def test_all_expanded_case_ids_and_utterances_are_unique(self) -> None:
cases = [*self.positive_cases, *self.model_cases, *self.rejection_cases]
self.assertEqual(len({case["id"] for case in cases}), len(cases))
self.assertEqual(len({case["utterance"] for case in cases}), len(cases))
def test_model_cases_require_gpt56_and_define_physical_targets(self) -> None:
for case in self.model_cases:
with self.subTest(case=case["id"]):
self.assertEqual(case["provider"], "openai")
self.assertEqual(
case["expected_provider_prefix"], "openai:gpt-5.6-sol"
)
self.assertEqual(
set(case["expected"]),
{
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
},
)
def test_every_positive_utterance_generates_the_expected_dry_run(self) -> None:
for case in self.positive_cases:
with self.subTest(case=case["id"], utterance=case["utterance"]):
result = self.service.process(
case["utterance"],
provider=case.get("provider", "local"),
conversation_id="conv-coverage-positive-sequence",
execution_mode="dry_run",
vehicle_id=self.suite["vehicle_id"],
)
self.assertEqual(result["status"], "succeeded")
if case.get("mode_id"):
self.assertIn(
case["mode_id"],
{item["id"] for item in result["matched_modes"]},
)
seat_config = result["function_configs"].get("seat") or result[
"function_config"
]
self.assertIsNotNone(seat_config)
operations = seat_config["spec"]["operations"]
by_capability = {item["capability"]: item for item in operations}
passenger = result["actuator_state"]["passenger"]
for field, expected in case["expected"].items():
capability = EXPECTED_CAPABILITIES[field]
self.assertIn(capability, by_capability)
operation = by_capability[capability]
self.assertEqual(operation["target"]["instance"], "passenger")
self.assertEqual(operation["target"]["zone"], "front_right")
if field in {"heating", "ventilation"}:
if expected == 0:
self.assertEqual(operation["parameters"]["state"], "off")
else:
self.assertEqual(passenger[field]["level"], expected)
else:
self.assertEqual(operation["parameters"]["position_pct"], expected)
self.assertEqual(
passenger["position"][field]["position_pct"], expected
)
expected_motion = case.get("expected_motion")
if expected_motion:
capability = EXPECTED_CAPABILITIES[expected_motion["axis"]]
operation = by_capability[capability]
self.assertIn("motion", operation)
for field, expected in expected_motion.items():
if field != "axis":
self.assertEqual(operation["motion"][field], expected)
def test_every_rejection_utterance_stops_before_execution(self) -> None:
for case in self.rejection_cases:
with self.subTest(case=case["id"], utterance=case["utterance"]):
result = self.service.process(
case["utterance"],
provider=case.get("provider", "local"),
conversation_id=f"conv-coverage-{case['id'].lower()}",
execution_mode="dry_run",
vehicle_id=self.suite["vehicle_id"],
)
self.assertEqual(result["status"], case["expected_status"])
self.assertIsNone(result["function_config"])
self.assertEqual(result["function_configs"], {})
self.assertIsNone(result["execution_report"])
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from cognitive_agent.config_loader import load_local_env
class LocalEnvironmentLoaderTests(unittest.TestCase):
def test_file_replaces_blank_inherited_value_but_preserves_non_blank_override(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
path.write_text(
"OPENAI_MODEL=gpt-5.6-sol\nOPENAI_BASE_URL=https://configured.example/v1\n",
encoding="utf-8",
)
with patch.dict(
os.environ,
{
"OPENAI_MODEL": "",
"OPENAI_BASE_URL": "https://explicit.example/v1",
},
clear=True,
):
load_local_env(path)
self.assertEqual(os.environ["OPENAI_MODEL"], "gpt-5.6-sol")
self.assertEqual(
os.environ["OPENAI_BASE_URL"], "https://explicit.example/v1"
)
def test_explicit_override_replaces_stale_non_blank_launcher_value(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
path.write_text("OPENAI_MODEL=gpt-5.6-sol\n", encoding="utf-8")
with patch.dict(
os.environ, {"OPENAI_MODEL": "gpt-5-mini"}, clear=True
):
load_local_env(path, override=True)
self.assertEqual(os.environ["OPENAI_MODEL"], "gpt-5.6-sol")
if __name__ == "__main__":
unittest.main()
+36 -1
View File
@@ -6,7 +6,10 @@ import unittest
import urllib.error
from unittest.mock import patch
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentInterpreter
from cognitive_agent.infrastructure.llm.openai_responses import (
SYSTEM_PROMPT,
OpenAIIntentInterpreter,
)
INTENT = {
@@ -45,10 +48,37 @@ class FakeResponse:
class OpenAIResponsesTests(unittest.TestCase):
def test_blank_model_and_base_url_use_documented_defaults(self) -> None:
with patch.dict(
os.environ,
{
"OPENAI_API_KEY": " test-key ",
"OPENAI_MODEL": " ",
"OPENAI_BASE_URL": "",
"OPENAI_REASONING_EFFORT": " ",
},
clear=True,
):
interpreter = OpenAIIntentInterpreter()
self.assertEqual(interpreter.api_key, "test-key")
self.assertEqual(interpreter.model, "gpt-5.6-sol")
self.assertEqual(interpreter.responses_endpoint, "https://api.openai.com/v1/responses")
self.assertEqual(interpreter.reasoning_effort, "medium")
def test_fuzzy_front_cabin_goal_has_deterministic_ergonomic_policy(self) -> None:
normalized = " ".join(SYSTEM_PROMPT.split())
self.assertIn("front cabin is cramped", normalized)
self.assertIn("longitudinal 80%", normalized)
self.assertIn("backrest recline 35%", normalized)
self.assertIn("do not manufacture a driver-seat target", normalized)
def test_non_streaming_structured_output(self) -> None:
response_body = json.dumps({"output_text": json.dumps(INTENT)})
environment = {
"OPENAI_API_KEY": "test-key",
"OPENAI_MODEL": "gpt-5.6-sol",
"OPENAI_BASE_URL": "https://api.openai.com/v1",
"OPENAI_STREAM": "false",
}
@@ -73,6 +103,11 @@ class OpenAIResponsesTests(unittest.TestCase):
request_payload = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
self.assertEqual(result, INTENT)
self.assertNotIn("stream", request_payload)
self.assertEqual(request_payload["model"], "gpt-5.6-sol")
self.assertEqual(
urlopen.call_args.args[0].full_url,
"https://api.openai.com/v1/responses",
)
self.assertIn("capability registry", request_payload["input"][1]["content"][0]["text"])
self.assertIn('"level": 1', request_payload["input"][2]["content"][0]["text"])
+145
View File
@@ -0,0 +1,145 @@
[CmdletBinding()]
param(
[int]$Port = 8000,
[string]$AvdName = "CognitiveAgent_Pixel_Tablet_API36",
[switch]$Stop,
[switch]$NoEmulator,
[switch]$NoBrowser
)
$ErrorActionPreference = "Stop"
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$runtimeRoot = Join-Path $repositoryRoot "artifacts\server\android-pad"
$pidPath = Join-Path $runtimeRoot "server.json"
function Resolve-AndroidTool {
param([Parameter(Mandatory = $true)][string]$RelativePath)
$sdkCandidates = @(
$env:ANDROID_SDK_ROOT,
$env:ANDROID_HOME,
"D:\Android\Sdk",
(Join-Path $env:LOCALAPPDATA "Android\Sdk")
) | Where-Object { $_ }
foreach ($sdkRoot in $sdkCandidates) {
$candidate = Join-Path $sdkRoot $RelativePath
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return (Resolve-Path -LiteralPath $candidate).Path
}
}
throw "Android SDK tool not found: $RelativePath"
}
function Read-ServerProcess {
if (-not (Test-Path -LiteralPath $pidPath -PathType Leaf)) { return $null }
try {
$record = Get-Content -LiteralPath $pidPath -Raw | ConvertFrom-Json
$processInfo = Get-CimInstance Win32_Process `
-Filter "ProcessId = $([int]$record.pid)" `
-ErrorAction SilentlyContinue
if (-not $processInfo -or -not $processInfo.CommandLine.Contains("cognitive_agent.server")) {
return $null
}
return Get-Process -Id ([int]$record.pid) -ErrorAction SilentlyContinue
} catch {
return $null
}
}
$adb = Resolve-AndroidTool "platform-tools\adb.exe"
if ($Stop) {
$serverProcess = Read-ServerProcess
if ($serverProcess) {
Stop-Process -Id $serverProcess.Id -Force
Write-Host "Stopped Cognitive Agent server (PID $($serverProcess.Id))."
}
& $adb reverse --remove "tcp:$Port" 2>$null | Out-Null
if (Test-Path -LiteralPath $pidPath -PathType Leaf) {
Remove-Item -LiteralPath $pidPath -Force
}
exit 0
}
New-Item -ItemType Directory -Force -Path $runtimeRoot | Out-Null
$emulator = Resolve-AndroidTool "emulator\emulator.exe"
$device = (& $adb devices) |
Select-String -Pattern '^(emulator-\d+)\s+device$' |
Select-Object -First 1
if (-not $device -and -not $NoEmulator) {
Write-Host "Starting Android tablet AVD: $AvdName"
Start-Process -FilePath $emulator `
-ArgumentList @("-avd", $AvdName, "-no-snapshot-save") `
-WindowStyle Hidden | Out-Null
}
if (-not $NoEmulator) {
& $adb wait-for-device
$deadline = (Get-Date).AddMinutes(3)
do {
$bootCompleted = (& $adb shell getprop sys.boot_completed 2>$null).Trim()
if ($bootCompleted -eq "1") { break }
if ((Get-Date) -gt $deadline) { throw "Android emulator boot timed out." }
Start-Sleep -Seconds 2
} while ($true)
& $adb reverse "tcp:$Port" "tcp:$Port"
}
$serverProcess = Read-ServerProcess
if (-not $serverProcess) {
$python = Get-Command python -ErrorAction Stop
$oldPythonPath = $env:PYTHONPATH
$oldHardware = $env:COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED
$oldSimulation = $env:COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED
try {
$env:PYTHONPATH = "$repositoryRoot\src;$repositoryRoot\domains\seat\src"
$env:COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED = "false"
$env:COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED = "true"
$serverProcess = Start-Process -FilePath $python.Source `
-ArgumentList @(
"-m", "cognitive_agent.server",
"--host", "127.0.0.1",
"--port", "$Port"
) `
-WorkingDirectory $repositoryRoot `
-RedirectStandardOutput (Join-Path $runtimeRoot "stdout.log") `
-RedirectStandardError (Join-Path $runtimeRoot "stderr.log") `
-WindowStyle Hidden `
-PassThru
} finally {
$env:PYTHONPATH = $oldPythonPath
$env:COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED = $oldHardware
$env:COGNITIVE_AGENT_SIMULATED_HARDWARE_ENABLED = $oldSimulation
}
@{
pid = $serverProcess.Id
port = $Port
started_at = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content -LiteralPath $pidPath -Encoding UTF8
Write-Host "Started Cognitive Agent server (PID $($serverProcess.Id))."
}
$healthUrl = "http://127.0.0.1:$Port/api/health"
$deadline = (Get-Date).AddSeconds(45)
do {
try {
$health = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 3
if ($health.status -eq "ok") { break }
} catch {
if ($serverProcess.HasExited) {
throw "Server exited during startup. See $runtimeRoot\stderr.log"
}
}
if ((Get-Date) -gt $deadline) { throw "Server health check timed out: $healthUrl" }
Start-Sleep -Milliseconds 750
} while ($true)
$padUrl = "http://127.0.0.1:$Port"
if (-not $NoEmulator -and -not $NoBrowser) {
& $adb shell am start -a android.intent.action.VIEW -d $padUrl | Out-Null
}
Write-Host "Windows: $padUrl"
Write-Host "Android: $padUrl (adb reverse tcp:$Port tcp:$Port)"
Write-Host "Simulation: enabled; physical hardware execution: disabled"
Write-Host "Stop: .\tools\android_pad_dev.ps1 -Port $Port -Stop"
+620
View File
@@ -0,0 +1,620 @@
"""Run the repository-owned front-end seat suite against a connected T2G bench."""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SUITE = ROOT / "tests" / "fixtures" / "seat_frontend_hil_suite.json"
DEFAULT_OUTPUT = ROOT / "artifacts" / "hil" / "seat_frontend_hil_latest.json"
MOTION_AXES = {
"longitudinal",
"cushion_tilt",
"backrest_recline",
"legrest_height",
}
EXPECTED_CAPABILITIES = {
"heating": "seat.set_heating",
"ventilation": "seat.set_ventilation",
"longitudinal": "seat.set_longitudinal_position",
"cushion_tilt": "seat.set_cushion_tilt",
"backrest_recline": "seat.set_backrest_recline",
"legrest_height": "seat.set_legrest_height",
}
def utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def request_json(
url: str,
*,
payload: dict[str, Any] | None = None,
timeout_s: int = 180,
) -> tuple[int, dict[str, Any]]:
data = None
method = "GET"
headers: dict[str, str] = {}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
method = "POST"
headers["Content-Type"] = "application/json; charset=utf-8"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=timeout_s) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
detail = json.loads(body)
except json.JSONDecodeError:
detail = {"message": body}
return exc.code, detail
def dig(value: dict[str, Any], *path: str) -> Any:
current: Any = value
for item in path:
if not isinstance(current, dict) or item not in current:
return None
current = current[item]
return current
def failed_hardware_result(result: dict[str, Any]) -> dict[str, Any] | None:
items = dig(result, "execution_report", "spec", "results") or []
for item in reversed(items):
if item.get("status") != "succeeded":
return item
return None
def expand_case_entries(
entries: list[dict[str, Any]], *, group: str | None = None
) -> list[dict[str, Any]]:
"""Expand compact input-equivalence families into individually reported cases."""
expanded: list[dict[str, Any]] = []
for entry in entries:
base = {key: value for key, value in entry.items() if key != "variants"}
if group is not None:
base["group"] = group
variants = entry.get("variants")
if not variants:
expanded.append(base)
continue
for variant in variants:
case = {**base, **variant}
if "id" not in case or "utterance" not in case:
raise ValueError("each case variant requires id and utterance")
expanded.append(case)
return expanded
def expected_operation_errors(
result: dict[str, Any],
expected: dict[str, int],
expected_motion: dict[str, Any] | None = None,
) -> list[str]:
errors: list[str] = []
seat_config = (result.get("function_configs") or {}).get("seat") or result.get(
"function_config"
) or {}
operations = dig(seat_config, "spec", "operations") or []
by_capability = {item.get("capability"): item for item in operations}
for field, expected_value in expected.items():
capability = EXPECTED_CAPABILITIES.get(field)
if capability is None:
continue
operation = by_capability.get(capability)
if operation is None:
errors.append(f"expected capability was not generated: {capability}")
continue
if dig(operation, "target", "instance") != "passenger" or dig(
operation, "target", "zone"
) != "front_right":
errors.append(f"{capability} did not target passenger/front_right")
parameter = "level" if field in {"heating", "ventilation"} else "position_pct"
actual_value = dig(operation, "parameters", parameter)
if field in {"heating", "ventilation"} and expected_value == 0:
if dig(operation, "parameters", "state") != "off":
errors.append(f"{capability} state is not off")
elif actual_value != expected_value:
errors.append(
f"{capability} {parameter}={actual_value}, expected={expected_value}"
)
if expected_motion:
axis = str(expected_motion["axis"])
capability = EXPECTED_CAPABILITIES[axis]
operation = by_capability.get(capability)
motion = operation.get("motion") if operation else None
if not isinstance(motion, dict):
errors.append(f"{capability} did not preserve motion metadata")
else:
for field, expected_value in expected_motion.items():
if field == "axis":
continue
actual_value = motion.get(field)
if actual_value != expected_value:
errors.append(
f"{capability} motion.{field}={actual_value}, "
f"expected={expected_value}"
)
return errors
def physical_fingerprint(state_result: dict[str, Any]) -> dict[str, Any]:
physical = state_result.get("physical_state", {})
return {
"source": physical.get("source"),
"complete": physical.get("complete"),
"heating": dig(physical, "heating", "level"),
"ventilation": dig(physical, "ventilation", "level"),
"position": {
axis: dig(physical, "position", axis, "position_pct")
for axis in sorted(MOTION_AXES)
},
}
def physical_state_request(
base_url: str, vehicle_id: str, conversation_id: str
) -> tuple[int, dict[str, Any]]:
query = urllib.parse.urlencode(
{"conversation_id": conversation_id, "vehicle_id": vehicle_id}
)
return request_json(
f"{base_url}/api/hardware/seat-state?{query}", timeout_s=20
)
def validate_physical_state(
state_result: dict[str, Any], expected: dict[str, int], tolerance: int
) -> list[str]:
errors: list[str] = []
physical = state_result.get("physical_state", {})
synchronized = state_result.get("actuator_state", {}).get("passenger", {})
if physical.get("source") != "physical_can":
errors.append("physical state source is not physical_can")
if not physical.get("complete"):
errors.append("physical state is incomplete: " + ",".join(physical.get("missing", [])))
for feature in ("heating", "ventilation"):
if feature not in expected:
continue
actual = dig(physical, feature, "level")
twin = dig(synchronized, feature, "level")
if actual != expected[feature]:
errors.append(f"{feature} physical={actual}, expected={expected[feature]}")
if twin != actual:
errors.append(f"{feature} twin={twin}, physical={actual}")
if dig(synchronized, feature, "feedback_source") != "physical_can":
errors.append(f"{feature} twin feedback source is not physical_can")
for axis in MOTION_AXES:
if axis not in expected:
continue
actual = dig(physical, "position", axis, "position_pct")
twin = dig(synchronized, "position", axis, "position_pct")
if not isinstance(actual, (int, float)) or abs(actual - expected[axis]) > tolerance:
errors.append(f"{axis} physical={actual}, expected={expected[axis]}±{tolerance}")
if twin != actual:
errors.append(f"{axis} twin={twin}, physical={actual}")
if dig(synchronized, "position", axis, "feedback_source") != "physical_can":
errors.append(f"{axis} twin feedback source is not physical_can")
return errors
def safety_block_for_case(
case: dict[str, Any], safety_blocks: dict[str, Any]
) -> dict[str, Any] | None:
"""Refuse only motions covered by a documented, evidence-backed bench hazard."""
expected = case.get("expected", {})
for axis, policy in safety_blocks.items():
target = expected.get(axis)
if target is None:
continue
if policy.get("block_all_targets") is True:
return {
"axis": axis,
"target_pct": target,
"block_all_targets": True,
"reason": policy.get("reason", "documented hardware safety block"),
"evidence": policy.get("evidence", {}),
}
minimum = policy.get("minimum_safe_target_pct")
if isinstance(minimum, (int, float)) and target < minimum:
return {
"axis": axis,
"target_pct": target,
"minimum_safe_target_pct": minimum,
"reason": policy.get("reason", "documented hardware safety boundary"),
"evidence": policy.get("evidence", {}),
}
return None
def run_case(
base_url: str,
vehicle_id: str,
conversation_id: str,
case: dict[str, Any],
tolerance: int,
) -> dict[str, Any]:
started = time.monotonic()
record: dict[str, Any] = {
"id": case["id"],
"group": case.get("group", "mode"),
"utterance": case["utterance"],
"started_at": utc_now(),
"status": "failed",
"errors": [],
}
payload = {
"utterance": case["utterance"],
"provider": case.get("provider", "local"),
"conversation_id": conversation_id,
"execution_mode": "hardware",
"vehicle_id": vehicle_id,
}
try:
status_code, result = request_json(
f"{base_url}/api/requests", payload=payload, timeout_s=900
)
record["http_status"] = status_code
record["request_id"] = result.get("request_id")
record["provider_used"] = result.get("provider_used")
if status_code != 201:
record["errors"].append(result.get("message", f"HTTP {status_code}"))
elif result.get("status") != "succeeded":
failed = failed_hardware_result(result)
record["errors"].append(
failed.get("message", "request failed") if failed else "request failed"
)
if failed:
record["failure_telemetry"] = failed.get("telemetry", {})
elif dig(result, "execution_report", "spec", "status") != "succeeded":
failed = failed_hardware_result(result)
record["errors"].append(
failed.get("message", "hardware execution failed")
if failed
else "hardware execution failed"
)
if failed:
record["failure_telemetry"] = failed.get("telemetry", {})
else:
expected_provider_prefix = case.get("expected_provider_prefix")
if expected_provider_prefix and not str(result.get("provider_used", "")).startswith(
str(expected_provider_prefix)
):
record["errors"].append(
f"provider_used={result.get('provider_used')}, "
f"expected prefix={expected_provider_prefix}"
)
record["errors"].extend(
expected_operation_errors(
result,
case.get("expected", {}),
case.get("expected_motion"),
)
)
seat_config = (result.get("function_configs") or {}).get(
"seat"
) or result.get("function_config") or {}
operations = dig(seat_config, "spec", "operations") or []
seat_operations = [
item for item in operations if str(item.get("capability", "")).startswith("seat.")
]
invalid_targets = [
item.get("target")
for item in seat_operations
if dig(item, "target", "instance") != "passenger"
or dig(item, "target", "zone") != "front_right"
]
if invalid_targets:
record["errors"].append(f"non-passenger targets generated: {invalid_targets}")
visible_notices = [
*[str(item) for item in result.get("decision_summary", [])],
*[
str(item.get("message", ""))
for item in dig(result, "execution_report", "spec", "results") or []
],
*[
str(item.get("telemetry", {}).get("user_notice", ""))
for item in dig(result, "execution_report", "spec", "results") or []
],
]
visible_notice_text = "\n".join(visible_notices)
for expected_notice in case.get("expected_notice_contains", []):
if expected_notice not in visible_notice_text:
record["errors"].append(
f"visible execution notice does not contain {expected_notice!r}"
)
expected_mode = case.get("mode_id")
matched_modes = [item.get("id") for item in result.get("matched_modes", [])]
record["matched_modes"] = matched_modes
if expected_mode and expected_mode not in matched_modes:
record["errors"].append(
f"mode {expected_mode} was not matched: {matched_modes}"
)
state_code, state = physical_state_request(
base_url, vehicle_id, conversation_id
)
record["state_http_status"] = state_code
if state_code != 200:
record["errors"].append(
state.get("message", f"physical state HTTP {state_code}")
)
else:
record["errors"].extend(
validate_physical_state(state, case.get("expected", {}), tolerance)
)
record["physical_state"] = state.get("physical_state")
record["status"] = "passed" if not record["errors"] else "failed"
except Exception as exc: # continue the suite after transport and parse errors
record["errors"].append(f"{type(exc).__name__}: {exc}")
record["finished_at"] = utc_now()
record["duration_ms"] = round((time.monotonic() - started) * 1000)
return record
def run_rejection_case(
base_url: str,
vehicle_id: str,
conversation_id: str,
case: dict[str, Any],
) -> dict[str, Any]:
started = time.monotonic()
record: dict[str, Any] = {
"id": case["id"],
"group": case.get("group", "safety"),
"utterance": case["utterance"],
"started_at": utc_now(),
"status": "failed",
"errors": [],
}
try:
before_code, before = physical_state_request(base_url, vehicle_id, conversation_id)
if before_code != 200:
record["errors"].append(f"pre-state HTTP {before_code}")
payload = {
"utterance": case["utterance"],
"provider": case.get("provider", "local"),
"conversation_id": conversation_id,
"execution_mode": "hardware",
"vehicle_id": vehicle_id,
}
status_code, result = request_json(
f"{base_url}/api/requests", payload=payload, timeout_s=60
)
record["http_status"] = status_code
record["request_id"] = result.get("request_id")
expected_http = int(case.get("expected_http_status", 201))
if status_code != expected_http:
record["errors"].append(f"HTTP {status_code}, expected {expected_http}")
if expected_http == 201:
expected_status = case.get("expected_status", "needs_confirmation")
if result.get("status") != expected_status:
record["errors"].append(
f"status={result.get('status')}, expected={expected_status}"
)
if result.get("function_config") is not None:
record["errors"].append("rejected request generated a function_config")
if dig(result, "execution_report", "spec", "mode") == "hil":
record["errors"].append("rejected request produced a HIL execution report")
else:
expected_error = case.get("expected_error")
if expected_error and result.get("error") != expected_error:
record["errors"].append(
f"error={result.get('error')}, expected={expected_error}"
)
after_code, after = physical_state_request(base_url, vehicle_id, conversation_id)
if after_code != 200:
record["errors"].append(f"post-state HTTP {after_code}")
before_fingerprint = physical_fingerprint(before)
after_fingerprint = physical_fingerprint(after)
record["physical_state_before"] = before_fingerprint
record["physical_state_after"] = after_fingerprint
if before_fingerprint != after_fingerprint:
record["errors"].append("physical state changed for a rejected request")
record["status"] = "passed" if not record["errors"] else "failed"
except Exception as exc:
record["errors"].append(f"{type(exc).__name__}: {exc}")
record["finished_at"] = utc_now()
record["duration_ms"] = round((time.monotonic() - started) * 1000)
return record
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--suite", type=Path, default=DEFAULT_SUITE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--start-at")
parser.add_argument(
"--case",
action="append",
dest="case_ids",
help="Run only this case ID; repeat the option to select an ordered subset.",
)
parser.add_argument(
"--only-group",
choices=("thermal", "motion", "combined", "mode", "model", "safety"),
)
parser.add_argument(
"--confirm-hardware",
action="store_true",
help="Required acknowledgement that a secured, clear T2G bench is connected.",
)
args = parser.parse_args()
if not args.confirm_hardware:
parser.error("--confirm-hardware is required")
suite = json.loads(args.suite.read_text(encoding="utf-8"))
runtime_code, runtime = request_json(f"{args.base_url}/api/runtime", timeout_s=10)
if runtime_code != 200 or not runtime.get("hardware_execution_allowed"):
raise SystemExit("hardware execution is not enabled by the running front-end service")
vehicle_id = suite.get("vehicle_id", "denza_n7")
conversation_id = "conv-hil-full-" + datetime.now(UTC).strftime("%Y%m%d%H%M%S")
tolerance = int(suite["execution_policy"].get("position_tolerance_pct", 2))
cases = (
expand_case_entries(list(suite.get("cases", [])))
+ expand_case_entries(list(suite.get("mode_cases", [])), group="mode")
+ expand_case_entries(list(suite.get("model_cases", [])), group="model")
+ expand_case_entries(list(suite.get("rejection_cases", [])), group="safety")
)
if args.only_group:
cases = [item for item in cases if item.get("group") == args.only_group]
if args.case_ids:
if args.start_at:
parser.error("--case and --start-at cannot be used together")
by_id = {item["id"]: item for item in cases}
missing = [item for item in args.case_ids if item not in by_id]
if missing:
raise SystemExit("unknown --case ID(s): " + ", ".join(missing))
cases = [by_id[item] for item in args.case_ids]
if args.start_at:
ids = [item["id"] for item in cases]
if args.start_at not in ids:
raise SystemExit(f"unknown --start-at case {args.start_at}")
cases = cases[ids.index(args.start_at) :]
report: dict[str, Any] = {
"suite": suite["suite"],
"started_at": utc_now(),
"base_url": args.base_url,
"vehicle_id": vehicle_id,
"conversation_id": conversation_id,
"runtime": {
"hardware_execution_allowed": runtime.get("hardware_execution_allowed"),
"firmware_profiles": dig(runtime, "hardware", "firmware_profiles"),
},
"results": [],
}
sync_case = {
"id": "PREFLIGHT-POSITION-MEMORY-SYNC",
"group": "preflight",
**suite["final_state"],
}
print(
"[PREFLIGHT] Four-axis physical homing and Hall-memory synchronization",
flush=True,
)
sync_result = run_case(
args.base_url, vehicle_id, conversation_id, sync_case, tolerance
)
report["position_memory_sync"] = sync_result
print(
f" {sync_result['status'].upper()} {sync_result['duration_ms']} ms"
+ (
""
if sync_result["status"] == "passed"
else ": " + " | ".join(sync_result["errors"])
),
flush=True,
)
if sync_result["status"] != "passed":
report["final_state"] = sync_result
report["finished_at"] = utc_now()
report["summary"] = {
"total": 0,
"passed": 0,
"failed": 0,
"safety_blocked": 0,
"executed": 0,
"position_memory_sync_passed": False,
"final_state_passed": False,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report["summary"], ensure_ascii=False), flush=True)
print(str(args.output.resolve()), flush=True)
return 1
for index, case in enumerate(cases, start=1):
print(f"[{index:02d}/{len(cases):02d}] {case['id']} {case['utterance']}", flush=True)
safety_block = safety_block_for_case(
case, suite["execution_policy"].get("known_safety_blocks", {})
)
if safety_block is not None:
result = {
"id": case["id"],
"group": case.get("group", "mode"),
"utterance": case["utterance"],
"status": "safety_blocked",
"started_at": utc_now(),
"finished_at": utc_now(),
"duration_ms": 0,
"errors": [],
"safety_block": safety_block,
}
report["results"].append(result)
print(
" SAFETY_BLOCKED: "
+ (
f"all {safety_block['axis']} motion is blocked by current bench evidence"
if safety_block.get("block_all_targets")
else f"{safety_block['axis']} target {safety_block['target_pct']}% is below "
f"the verified {safety_block['minimum_safe_target_pct']}% boundary"
),
flush=True,
)
continue
if case.get("expected_status") or case.get("expected_http_status"):
result = run_rejection_case(
args.base_url, vehicle_id, conversation_id, case
)
else:
result = run_case(
args.base_url, vehicle_id, conversation_id, case, tolerance
)
report["results"].append(result)
suffix = "" if result["status"] == "passed" else ": " + " | ".join(result["errors"])
print(f" {result['status'].upper()} {result['duration_ms']} ms{suffix}", flush=True)
final_case = {
"id": "FINAL-THERMAL-OFF",
"group": "cleanup",
**suite["final_state"],
}
final_result = run_case(
args.base_url, vehicle_id, conversation_id, final_case, tolerance
)
report["final_state"] = final_result
report["finished_at"] = utc_now()
report["summary"] = {
"total": len(report["results"]),
"passed": sum(item["status"] == "passed" for item in report["results"]),
"failed": sum(item["status"] == "failed" for item in report["results"]),
"safety_blocked": sum(
item["status"] == "safety_blocked" for item in report["results"]
),
"executed": sum(
item["status"] != "safety_blocked" for item in report["results"]
),
"position_memory_sync_passed": sync_result["status"] == "passed",
"final_state_passed": final_result["status"] == "passed",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report["summary"], ensure_ascii=False), flush=True)
print(str(args.output.resolve()), flush=True)
return 0 if report["summary"]["failed"] == 0 and final_result["status"] == "passed" else 1
if __name__ == "__main__":
sys.exit(main())