feat: harden seat intent and expand HIL coverage
This commit is contained in:
@@ -17,6 +17,20 @@ UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模
|
|||||||
make dev
|
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
|
```bash
|
||||||
@@ -31,7 +45,7 @@ make dev-lan
|
|||||||
HTTPS。部署方式见
|
HTTPS。部署方式见
|
||||||
[`deploy/server/README.md`](../../deploy/server/README.md)。
|
[`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
|
../../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`。
|
安全规则:张掌保持 1 秒激活;动作保持 600 ms 且至少连续 8 帧才执行;换动作不重新锁止;握拳立即停止但不解除激活;3 秒无有效动作自动锁止;单次连续运动最多 1.5 秒;检测到多只手时不执行。自定义映射保存在浏览器 `localStorage`。
|
||||||
|
|
||||||
|
|||||||
+112
-25
@@ -22,10 +22,22 @@ const state = {
|
|||||||
seatActuatorState: {},
|
seatActuatorState: {},
|
||||||
seatFeedbackSource: "simulated",
|
seatFeedbackSource: "simulated",
|
||||||
availableSeatAxes: null,
|
availableSeatAxes: null,
|
||||||
|
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector, root = document) => root.querySelector(selector);
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
const $$ = (selector, root = document) => [...root.querySelectorAll(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() {
|
function refreshIcons() {
|
||||||
if (window.lucide) {
|
if (window.lucide) {
|
||||||
@@ -2519,6 +2531,10 @@ function updateSeatFeedbackModeLabel() {
|
|||||||
label.textContent = "PHYSICAL CAN · SYNC UNAVAILABLE";
|
label.textContent = "PHYSICAL CAN · SYNC UNAVAILABLE";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.seatFeedbackSource === "simulated_hardware") {
|
||||||
|
label.textContent = "SIMULATED HIL · NO PHYSICAL CAN";
|
||||||
|
return;
|
||||||
|
}
|
||||||
label.textContent = state.executionMode === "hardware"
|
label.textContent = state.executionMode === "hardware"
|
||||||
? "PHYSICAL CAN FEEDBACK"
|
? "PHYSICAL CAN FEEDBACK"
|
||||||
: "SIMULATED FEEDBACK";
|
: "SIMULATED FEEDBACK";
|
||||||
@@ -2526,30 +2542,41 @@ function updateSeatFeedbackModeLabel() {
|
|||||||
|
|
||||||
function selectExecutionMode(mode) {
|
function selectExecutionMode(mode) {
|
||||||
if (mode === "hardware" && !state.runtime?.hardware?.configured) return;
|
if (mode === "hardware" && !state.runtime?.hardware?.configured) return;
|
||||||
|
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return;
|
||||||
state.executionMode = mode;
|
state.executionMode = mode;
|
||||||
$$('[data-execution-mode]').forEach((button) => {
|
$$('[data-execution-mode]').forEach((button) => {
|
||||||
button.classList.toggle("active", button.dataset.executionMode === mode);
|
button.classList.toggle("active", button.dataset.executionMode === mode);
|
||||||
});
|
});
|
||||||
const hardware = mode === "hardware";
|
const hardware = mode === "hardware";
|
||||||
|
const simulatedHardware = mode === "simulated_hardware";
|
||||||
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
|
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
|
||||||
$("#approvalField").hidden = !hardware || !approvalRequired;
|
$("#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
|
$("#executionBadge").innerHTML = hardware
|
||||||
? '<i data-lucide="radio-tower"></i>HIL · 真实 CAN'
|
? '<i data-lucide="radio-tower"></i>HIL · 真实 CAN'
|
||||||
: '<i data-lucide="flask-conical"></i>DRY-RUN';
|
: simulatedHardware
|
||||||
$("#safetyTitle").textContent = hardware ? "真实台架安全门禁" : "虚拟执行边界";
|
? '<i data-lucide="bug-play"></i>SIM · 模拟台架'
|
||||||
|
: '<i data-lucide="flask-conical"></i>DRY-RUN';
|
||||||
|
$("#safetyTitle").textContent = hardware
|
||||||
|
? "真实台架安全门禁"
|
||||||
|
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
|
||||||
$("#safetyText").textContent = hardware
|
$("#safetyText").textContent = hardware
|
||||||
? "副驾座椅映射到统一实体执行器;绝对位置会实时检查板端标定,小幅相对移动可走受限定时/Hall 回读,异常会尝试 STOP_ALL。"
|
? "副驾座椅映射到统一实体执行器;绝对位置会实时检查板端标定,小幅相对移动可走受限定时/Hall 回读,异常会尝试 STOP_ALL。"
|
||||||
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
|
: simulatedHardware
|
||||||
$("#executeStageLabel").textContent = hardware ? "台架执行" : "虚拟执行";
|
? "故障只注入执行报告;物理 CAN、ZLG 适配器和 IAR 固件路径保持关闭。"
|
||||||
|
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
|
||||||
|
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
|
||||||
$("#feedbackModeLabel").textContent = hardware
|
$("#feedbackModeLabel").textContent = hardware
|
||||||
? "T2G BENCH · PHYSICAL CAN FEEDBACK"
|
? "T2G BENCH · PHYSICAL CAN FEEDBACK"
|
||||||
: "MATRIX-BACKED · SIMULATED FEEDBACK";
|
: simulatedHardware
|
||||||
|
? "SIMULATED HIL · FAULT INJECTION"
|
||||||
|
: "MATRIX-BACKED · SIMULATED FEEDBACK";
|
||||||
const runButtonLabel = $("#runButton span");
|
const runButtonLabel = $("#runButton span");
|
||||||
if (runButtonLabel) {
|
if (runButtonLabel) {
|
||||||
runButtonLabel.textContent = hardware
|
runButtonLabel.textContent = hardware
|
||||||
? (approvalRequired ? "授权并真实执行" : "真实执行")
|
? (approvalRequired ? "授权并真实执行" : "真实执行")
|
||||||
: "生成并执行";
|
: simulatedHardware ? "运行故障仿真" : "生成并执行";
|
||||||
}
|
}
|
||||||
updateSeatFeedbackModeLabel();
|
updateSeatFeedbackModeLabel();
|
||||||
refreshIcons();
|
refreshIcons();
|
||||||
@@ -2730,7 +2757,7 @@ function saveCurrentModeProfile() {
|
|||||||
|
|
||||||
async function loadRuntime() {
|
async function loadRuntime() {
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
state.runtime = await response.json();
|
state.runtime = await response.json();
|
||||||
populateVehicleSelector();
|
populateVehicleSelector();
|
||||||
@@ -2741,7 +2768,8 @@ async function loadRuntime() {
|
|||||||
if (state.runtime.openai_available) {
|
if (state.runtime.openai_available) {
|
||||||
badge.classList.remove("danger");
|
badge.classList.remove("danger");
|
||||||
badge.innerHTML = `<i data-lucide="sparkles"></i>${state.runtime.openai_model}`;
|
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");
|
selectProvider("openai");
|
||||||
} else {
|
} else {
|
||||||
badge.classList.add("danger");
|
badge.classList.add("danger");
|
||||||
@@ -2754,6 +2782,11 @@ async function loadRuntime() {
|
|||||||
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
|
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
|
||||||
const hardwareButton = $("#hardwareMode");
|
const hardwareButton = $("#hardwareMode");
|
||||||
hardwareButton.disabled = !hardware.configured;
|
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 profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
|
||||||
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
|
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
|
||||||
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
|
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
|
||||||
@@ -2772,7 +2805,12 @@ async function loadRuntime() {
|
|||||||
? `可选 · ${approvalText}`
|
? `可选 · ${approvalText}`
|
||||||
: "已阻断";
|
: "已阻断";
|
||||||
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
|
$("#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();
|
refreshIcons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("#modelBadge").textContent = "运行时离线";
|
$("#modelBadge").textContent = "运行时离线";
|
||||||
@@ -2789,7 +2827,7 @@ async function synchronizePhysicalSeatState() {
|
|||||||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/hardware/seat-state?${query}`, {
|
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -2814,7 +2852,7 @@ async function synchronizePhysicalSeatState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAuthStatus() {
|
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;
|
if (!response.ok) return;
|
||||||
const auth = await response.json();
|
const auth = await response.json();
|
||||||
if (auth.enabled && !auth.authenticated) {
|
if (auth.enabled && !auth.authenticated) {
|
||||||
@@ -2825,7 +2863,7 @@ async function loadAuthStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await apiRequest("/api/auth/logout", { method: "POST" });
|
||||||
window.location.replace("/login");
|
window.location.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3423,7 +3461,10 @@ function renderCode(result) {
|
|||||||
const feedback = telemetry?.feedback_frame
|
const feedback = telemetry?.feedback_frame
|
||||||
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
|
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
|
||||||
: "NO DIRECT FEEDBACK";
|
: "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>`;
|
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}`;
|
$("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`;
|
||||||
} else if (telemetry?.operation === "save_posture_preset") {
|
} else if (telemetry?.operation === "save_posture_preset") {
|
||||||
@@ -3459,7 +3500,8 @@ function renderExecution(result) {
|
|||||||
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
|
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
|
||||||
const presetSave = Boolean(result.preset_config);
|
const presetSave = Boolean(result.preset_config);
|
||||||
const modeSave = Boolean(result.mode_profile);
|
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 executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
|
||||||
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
|
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
|
||||||
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
|
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
|
||||||
@@ -3468,6 +3510,8 @@ function renderExecution(result) {
|
|||||||
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
|
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
|
||||||
: presetSave
|
: presetSave
|
||||||
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
|
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
|
||||||
|
: simulatedHil
|
||||||
|
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
|
||||||
: physical
|
: physical
|
||||||
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||||||
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
|
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
|
||||||
@@ -3480,7 +3524,9 @@ function renderExecution(result) {
|
|||||||
if (!reportSucceeded) {
|
if (!reportSucceeded) {
|
||||||
const failure = report.spec.results.find((item) => item.status === "failed");
|
const failure = report.spec.results.find((item) => item.status === "failed");
|
||||||
const failureTelemetry = failure?.telemetry || {};
|
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 适配器。"}`
|
? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}`
|
||||||
: failureTelemetry.error_type === "FirmwareProgrammingError"
|
: failureTelemetry.error_type === "FirmwareProgrammingError"
|
||||||
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
|
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
|
||||||
@@ -3496,9 +3542,15 @@ function renderExecution(result) {
|
|||||||
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
|
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
|
||||||
: failure?.message || "真实台架未返回满足目标的物理反馈。";
|
: 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
|
$("#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").textContent = reportSucceeded ? "SUCCEEDED" : "FAILED";
|
||||||
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : "failed"}`;
|
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : "failed"}`;
|
||||||
@@ -3513,10 +3565,19 @@ function renderExecution(result) {
|
|||||||
detail.textContent = item.message;
|
detail.textContent = item.message;
|
||||||
text.append(title, detail);
|
text.append(title, detail);
|
||||||
const frame = document.createElement("code");
|
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
|
frame.textContent = item.telemetry?.command_frame
|
||||||
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
|
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
|
||||||
: item.telemetry?.firmware_verified
|
: firmwareTelemetry
|
||||||
? `${item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED"} · ${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
|
? firmwareTelemetry
|
||||||
: "CONTROL PLANE · NO CAN";
|
: "CONTROL PLANE · NO CAN";
|
||||||
row.append(text, frame);
|
row.append(text, frame);
|
||||||
list.append(row);
|
list.append(row);
|
||||||
@@ -3527,7 +3588,10 @@ function renderExecution(result) {
|
|||||||
function renderResult(result) {
|
function renderResult(result) {
|
||||||
state.result = result;
|
state.result = result;
|
||||||
const reportMode = result.execution_report?.spec?.mode;
|
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
|
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
|
||||||
? "physical_can"
|
? "physical_can"
|
||||||
: "physical_partial";
|
: "physical_partial";
|
||||||
@@ -3597,8 +3661,9 @@ async function submitRequest(event) {
|
|||||||
if (approvalTokenInput) approvalTokenInput.value = "";
|
if (approvalTokenInput) approvalTokenInput.value = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/requests", {
|
const response = await apiRequest("/api/requests", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
utterance,
|
utterance,
|
||||||
@@ -3606,6 +3671,9 @@ async function submitRequest(event) {
|
|||||||
conversation_id: state.conversationId,
|
conversation_id: state.conversationId,
|
||||||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||||||
execution_mode: state.executionMode,
|
execution_mode: state.executionMode,
|
||||||
|
...(state.executionMode === "simulated_hardware"
|
||||||
|
? { simulation_fault: $("#simulationFault").value }
|
||||||
|
: {}),
|
||||||
...(approvalRequired
|
...(approvalRequired
|
||||||
? { approval_token: approvalToken }
|
? { approval_token: approvalToken }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -3622,9 +3690,14 @@ async function submitRequest(event) {
|
|||||||
await synchronizePhysicalSeatState();
|
await synchronizePhysicalSeatState();
|
||||||
}
|
}
|
||||||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||||||
|
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||||||
showToast(completed
|
showToast(completed
|
||||||
? (payload.execution_report?.spec?.mode === "hil" ? "真实台架执行与反馈确认已完成" : "可执行动作的 dry-run 已完成")
|
? (simulated
|
||||||
: "请求未执行");
|
? "模拟台架执行完成"
|
||||||
|
: payload.execution_report?.spec?.mode === "hil"
|
||||||
|
? "真实台架执行与反馈确认已完成"
|
||||||
|
: "可执行动作的 dry-run 已完成")
|
||||||
|
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.clearInterval(state.progressTimer);
|
window.clearInterval(state.progressTimer);
|
||||||
resetPipeline();
|
resetPipeline();
|
||||||
@@ -3637,7 +3710,10 @@ async function submitRequest(event) {
|
|||||||
const hardwareLabel = state.runtime?.hardware?.approval_required
|
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();
|
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 () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
initJointTwinLayout();
|
initJointTwinLayout();
|
||||||
initResultWorkspace();
|
initResultWorkspace();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const VISION_VERSION = "0.10.14";
|
const VISION_VERSION = "0.10.14";
|
||||||
const VISION_MODULE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/vision_bundle.mjs`;
|
const VISION_MODULE = `/vendor/mediapipe/tasks-vision/${VISION_VERSION}/vision_bundle.mjs`;
|
||||||
const VISION_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/wasm`;
|
const VISION_WASM = `/vendor/mediapipe/tasks-vision/${VISION_VERSION}/wasm`;
|
||||||
|
|
||||||
const CONNECTIONS = [
|
const CONNECTIONS = [
|
||||||
[0, 1], [1, 2], [2, 3], [3, 4],
|
[0, 1], [1, 2], [2, 3], [3, 4],
|
||||||
@@ -464,7 +464,7 @@ async function recognizeLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadGestureRuntime() {
|
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("无法读取手势能力配置");
|
if (!response.ok) throw new Error("无法读取手势能力配置");
|
||||||
state.runtime = await response.json();
|
state.runtime = await response.json();
|
||||||
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
|
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
|
||||||
@@ -477,7 +477,10 @@ async function loadRecognizer() {
|
|||||||
$("#gestureSafetyText").textContent = "正在加载手势模型…";
|
$("#gestureSafetyText").textContent = "正在加载手势模型…";
|
||||||
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
|
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
|
||||||
const fileset = await FilesetResolver.forVisionTasks(VISION_WASM);
|
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("内置手势模型加载失败");
|
if (!modelResponse.ok) throw new Error("内置手势模型加载失败");
|
||||||
const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer());
|
const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer());
|
||||||
state.recognizer = await GestureRecognizer.createFromOptions(fileset, {
|
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 |
@@ -2,16 +2,22 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="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>
|
<title>智舱随行 · 一句话与手势控制</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css?v=android-pad-2" />
|
||||||
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer></script>
|
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
|
||||||
<script src="https://unpkg.com/three@0.147.0/build/three.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
|
||||||
<script src="https://unpkg.com/three@0.147.0/examples/js/libs/fflate.min.js" defer></script>
|
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||||
<script src="https://unpkg.com/three@0.147.0/examples/js/loaders/FBXLoader.js" defer></script>
|
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||||
<script src="/app.js?v=unified-cabin-23" defer></script>
|
<script src="/network.js?v=android-pad-1" defer></script>
|
||||||
<script type="module" src="/gesture-control.js?v=gesture-core-3"></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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -26,6 +32,8 @@
|
|||||||
<button class="gesture-entry" id="gestureToggleButton" type="button" aria-controls="gestureDrawer" aria-expanded="false">
|
<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>手势控制
|
<span class="gesture-entry-dot"></span><i data-lucide="scan-face"></i>手势控制
|
||||||
</button>
|
</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 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" id="modelBadge"><i data-lucide="sparkles"></i>模型检测中</span>
|
||||||
<span class="status-badge danger" id="hardwareBadge"><i data-lucide="shield-off"></i>硬件检测中</span>
|
<span class="status-badge danger" id="hardwareBadge"><i data-lucide="shield-off"></i>硬件检测中</span>
|
||||||
@@ -83,18 +91,34 @@
|
|||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<span class="field-label">执行目标</span>
|
<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">
|
<button type="button" class="mode-option active" data-execution-mode="dry_run">
|
||||||
<i data-lucide="flask-conical"></i>虚拟 CAN
|
<i data-lucide="flask-conical"></i>虚拟 CAN
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="mode-option" id="hardwareMode" data-execution-mode="hardware" disabled>
|
<button type="button" class="mode-option" id="hardwareMode" data-execution-mode="hardware" disabled>
|
||||||
<i data-lucide="radio-tower"></i>真实台架
|
<i data-lucide="radio-tower"></i>真实台架
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="mode-option" id="simulationMode" data-execution-mode="simulated_hardware" disabled>
|
||||||
|
<i data-lucide="bug-play"></i>模拟台架
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
|
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
|
||||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||||
</div>
|
</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">
|
<div class="field-group vehicle-field">
|
||||||
<label class="field-label" for="vehicleSelect">车型配置</label>
|
<label class="field-label" for="vehicleSelect">车型配置</label>
|
||||||
<div class="vehicle-select-control">
|
<div class="vehicle-select-control">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>登录 · Cognitive Agent</title>
|
<title>登录 · Cognitive Agent</title>
|
||||||
<link rel="stylesheet" href="/login.css" />
|
<link rel="stylesheet" href="/login.css" />
|
||||||
|
<script src="/network.js?v=android-pad-1" defer></script>
|
||||||
<script src="/login.js" defer></script>
|
<script src="/login.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function nextPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readAuthStatus() {
|
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;
|
if (!response.ok) return;
|
||||||
const status = await response.json();
|
const status = await response.json();
|
||||||
if (status.authenticated) window.location.replace(nextPath());
|
if (status.authenticated) window.location.replace(nextPath());
|
||||||
@@ -18,7 +18,7 @@ async function submitLogin(event) {
|
|||||||
message.textContent = "";
|
message.textContent = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/login", {
|
const response = await window.CognitiveAgentNetwork.request("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -4356,3 +4356,172 @@ pre {
|
|||||||
grid-column: 2;
|
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
@@ -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
+20
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+20
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
+3681
File diff suppressed because it is too large
Load Diff
+21
@@ -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},
|
"tolerance": {"type": "number", "minimum": 0},
|
||||||
"timeout_ms": {"type": "integer", "minimum": 1, "maximum": 3600000}
|
"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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,36 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["mode", "direction", "start_position_pct", "delta_position_pct"],
|
"required": ["mode", "direction", "start_position_pct", "delta_position_pct"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"mode": {"const": "relative"},
|
"mode": {"type": "string", "enum": ["relative", "timed"]},
|
||||||
"direction": {"type": "string", "enum": ["increase", "decrease"]},
|
"direction": {"type": "string", "enum": ["increase", "decrease"]},
|
||||||
"start_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
"start_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
"delta_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
"delta_position_pct": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
"start_angle_deg": {"type": "number"},
|
"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"},
|
"transition": {"$ref": "common.schema.json#/$defs/transition"},
|
||||||
"depends_on": {
|
"depends_on": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# T2G 座椅前端能力测试集
|
# 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-02 | 副驾坐盆倾角35% | M1、`0x302≈3500`、COMPLETE/OK |
|
||||||
| MOTION-03 | 副驾靠背130度 | M2、角度标定和真实位置反馈 |
|
| MOTION-03 | 副驾靠背130度 | M2、角度标定和真实位置反馈 |
|
||||||
| MOTION-04 | 副驾腿托145度 | M3、角度标定和真实位置反馈 |
|
| MOTION-04 | 副驾腿托145度 | M3、角度标定和真实位置反馈 |
|
||||||
|
| MOTION-TIME | 靠背向后移动70秒、座椅向前移动1分钟 | 秒/分钟解析、四轴时长上限钳制、提前到端点按成功返回及可见提示 |
|
||||||
|
| MOTION-FALLBACK | 靠背向后移动70度、靠背200度、水平101% | 超量程意图钳制到对应机械端点,不因越界静默拒绝 |
|
||||||
| COMBINED-01 | 一句话同时设置热管理和四轴 | 多操作编排、逐项反馈与失败传播 |
|
| COMBINED-01 | 一句话同时设置热管理和四轴 | 多操作编排、逐项反馈与失败传播 |
|
||||||
| DEFAULT-01 | 未指定座位的通风请求 | 自动使用副驾,不生成主驾操作 |
|
| DEFAULT-01 | 未指定座位的通风请求 | 自动使用副驾,不生成主驾操作 |
|
||||||
| SAFE-01 | 显式主驾通风请求 | 返回仅支持副驾,不得重定向或发 CAN |
|
| SAFE-01 | 显式主驾通风请求 | 返回仅支持副驾,不得重定向或发 CAN |
|
||||||
@@ -31,13 +33,34 @@ python -m unittest tests.e2e.test_seat_web_api
|
|||||||
|
|
||||||
自动测试会逐条向 `/api/requests` 提交测试集中的自然语言输入,验证所有六项真实台架能力均生成副驾目标和正确参数,并确认执行模式保持为 `dry_run`。领域集成测试另行验证未指定座位时默认副驾,以及本地解析和模型输出中的显式主驾目标都被拒绝。
|
自动测试会逐条向 `/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`,随后才出现动作反馈。
|
2. 先执行 RECOVERY-01。可使用未烧写控制器,或仅在确认允许覆盖当前固件后进行;前端结果必须显示 `programmed_this_request=true`、`firmware_verified=true`,随后才出现动作反馈。
|
||||||
3. 依次执行 THERM-01~04,确认开、关和档位回读。
|
3. 依次执行 THERM-01~04,确认开、关和档位回读。
|
||||||
4. 让四轴处于有效位置后,依次执行 MOTION-01~04。每次只观察一个物理轴,映射或方向错误立即急停。
|
4. 让四轴处于有效位置后,执行 M0~M3 双向中间位置,并分别验证安全轴的 `0%` 会编译为 `HOME_KNOWN_RANGE (0x0B)`,而不是把累计 Hall 计数清零。
|
||||||
5. 单项全部通过后再执行 COMBINED-01;逐项核对 ACK、TaskState、位置、Hall、电流诊断及最终报告。
|
5. 单项全部通过后再执行 COMBINED-01;逐项核对 ACK、TaskState、位置、Hall、电流诊断及最终报告。
|
||||||
6. 最后执行 SAFE-01~03,确认所有安全用例均未产生 CAN 动作。
|
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`。
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ powershell -ExecutionPolicy Bypass -File .\tools\build_seat_firmware.ps1
|
|||||||
|
|
||||||
| MotorID | 前端能力 | 物理执行器 | 最近端 → 最远端 | MaxHall | 全量程 |
|
| 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 |
|
| M1 | `seat.set_cushion_tilt` | 坐盆斜上下 | 最高 → 斜向下 | 374 | 水平 51 mm / 垂直 56 mm |
|
||||||
| M2 | `seat.set_backrest_recline` | 靠背 | 竖直 → 后躺 | 917 | 68° |
|
| M2 | `seat.set_backrest_recline` | 靠背 | 竖直 → 后躺 | 917 | 68° |
|
||||||
| M3 | `seat.set_legrest_height` | 腿托 | 最下 → 抬起 | 647 | 72° |
|
| M3 | `seat.set_legrest_height` | 腿托 | 最下 → 抬起 | 647 | 72° |
|
||||||
@@ -43,11 +43,12 @@ powershell -ExecutionPolicy Bypass -File .\tools\build_seat_firmware.ps1
|
|||||||
- 热管理命令:`0x29E/0x3CE`;档位反馈:`0x4FA`。
|
- 热管理命令:`0x29E/0x3CE`;档位反馈:`0x4FA`。
|
||||||
- 移动命令:`0x300`;系统 ACK:`0x301`;归一化位置:`0x302`;Hall/量程:`0x303`。
|
- 移动命令:`0x300`;系统 ACK:`0x301`;归一化位置:`0x302`;Hall/量程:`0x303`。
|
||||||
- 电流诊断:`0x304` 四轴轮询,`0x305` 通风/加热;这些值用于台架可见性,当前保守阈值不能替代外部限流或量产保护。
|
- 电流诊断:`0x304` 四轴轮询,`0x305` 通风/加热;这些值用于台架可见性,当前保守阈值不能替代外部限流或量产保护。
|
||||||
- Opcode 1 的 `ParamA` 为 0–10000 绝对位置,`ParamB=0` 使用各轴固件默认超时。
|
- Opcode 1 的 `ParamA` 为 1–10000 绝对位置;后端显式发送 30 s 超时覆盖值。
|
||||||
- Opcode 3 的 `ParamA` 为持续时间,`ParamB` 为驱动等级×10(默认 90% 即 900)。
|
- 前端目标 `0%`/“归零”不是普通绝对位置,而是 `HOME_KNOWN_RANGE (0x0B)`;只有收到 `COMPLETE/OK`、Near、Hall≤2 和 PositionValid 才算成功。
|
||||||
|
- Opcode 3 的 `ParamA` 为持续时间,`ParamB` 为驱动等级×10(默认 90%)。
|
||||||
- `STOP_ALL` 的专用 ACK 为 4;完成/故障 TaskState 分别为 6/7。
|
- `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_HARDWARE_APPROVAL_TOKEN=123456
|
||||||
COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES=combined
|
COGNITIVE_AGENT_SEAT_FIRMWARE_PROFILES=combined
|
||||||
COGNITIVE_AGENT_SEAT_AUTO_FLASH_ENABLED=true
|
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_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_BACKEND=zlgcan
|
||||||
COGNITIVE_AGENT_SEAT_CAN_CHANNEL=0
|
COGNITIVE_AGENT_SEAT_CAN_CHANNEL=0
|
||||||
@@ -71,12 +73,24 @@ COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S=120
|
|||||||
|
|
||||||
当前台架只公布并接受副驾目标 `passenger/front_right`。没有座位词的座椅指令默认副驾;显式主驾、主副驾或前排范围请求会在打开 CAN 前拒绝,不能静默重定向。热管理 CAN 中仍出现的 `Driver_Seat_*` 是板端既有协议字段名,不代表应用层可以控制主驾。
|
当前台架只公布并接受副驾目标 `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 记录有效时,一句话可以直接下发绝对目标;位置无效时默认失败关闭,不会静默触发全行程标定。
|
绝对命令发送前必须看到 `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` 全行程标定不会由普通一句话请求触发。
|
台架无遮挡、急停就绪并且明确允许自动回最近端时,可设置 `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 限流电源。
|
1. 固定座椅,清空四轴全行程,准备硬件急停和 12 V 限流电源。
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Seat thermal domain plugin."""
|
"""Seat thermal domain plugin."""
|
||||||
|
|
||||||
from .compiler import SeatCompiler
|
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 .generator import build_function_config, build_function_preset, build_intent_ir
|
||||||
from .hardware_compiler import T2GSeatCompiler
|
from .hardware_compiler import T2GSeatCompiler
|
||||||
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
|
from .hardware_executor import HardwareExecutionError, HardwareSeatExecutor
|
||||||
@@ -15,6 +19,8 @@ __all__ = [
|
|||||||
"T2GSeatCompiler",
|
"T2GSeatCompiler",
|
||||||
"HardwareExecutionError",
|
"HardwareExecutionError",
|
||||||
"HardwareSeatExecutor",
|
"HardwareSeatExecutor",
|
||||||
|
"SIMULATED_HARDWARE_FAULTS",
|
||||||
|
"SimulatedHardwareSeatExecutor",
|
||||||
"VirtualSeatExecutor",
|
"VirtualSeatExecutor",
|
||||||
"build_function_config",
|
"build_function_config",
|
||||||
"build_function_preset",
|
"build_function_preset",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -83,7 +84,6 @@ class VirtualSeatExecutor:
|
|||||||
"results": results,
|
"results": results,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _execute_thermal(
|
def _execute_thermal(
|
||||||
self, command: dict[str, Any], operations: list[dict[str, Any]]
|
self, command: dict[str, Any], operations: list[dict[str, Any]]
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
@@ -193,3 +193,109 @@ class VirtualSeatExecutor:
|
|||||||
"simulated": True,
|
"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
|
||||||
|
|||||||
@@ -167,26 +167,49 @@ class T2GSeatCompiler:
|
|||||||
target = round(maximum * position_pct / 100)
|
target = round(maximum * position_pct / 100)
|
||||||
tolerance = max(1, round(maximum / 100))
|
tolerance = max(1, round(maximum / 100))
|
||||||
motion = operation.get("motion")
|
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":
|
if motion is not None and motion.get("mode") == "relative":
|
||||||
delta_pct = int(motion["delta_position_pct"])
|
delta_pct = int(motion["delta_position_pct"])
|
||||||
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
|
maximum_delta = int(axis["max_uncalibrated_relative_pct"])
|
||||||
if delta_pct <= maximum_delta:
|
if delta_pct <= maximum_delta:
|
||||||
commands.append(
|
relative_fallback = self._compile_uncalibrated_relative(
|
||||||
self._compile_uncalibrated_relative(
|
operation,
|
||||||
operation,
|
physical_operations,
|
||||||
physical_operations,
|
axis,
|
||||||
axis,
|
command_offset + index,
|
||||||
command_offset + index,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
continue
|
# Prefer the resolved closed-loop target whenever the board has
|
||||||
# A large relative request cannot safely be approximated by a
|
# a live calibrated position. The bounded timed-duty command
|
||||||
# long open-loop duty command or by several chained commands.
|
# remains available only as an uncalibrated fallback.
|
||||||
# Compile its already-resolved target as an absolute move. The
|
explicit_home = position_pct == 0
|
||||||
# executor will check the board's live Position_Valid bit before
|
command_opcode = int(
|
||||||
# putting Opcode 1 on the bus.
|
self.protocol[
|
||||||
commands.append(
|
"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']}",
|
"id": f"t2g_move_{operation['id']}",
|
||||||
"operation_ref": ",".join(
|
"operation_ref": ",".join(
|
||||||
item["id"] for item in physical_operations
|
item["id"] for item in physical_operations
|
||||||
@@ -196,12 +219,14 @@ class T2GSeatCompiler:
|
|||||||
"signals": {
|
"signals": {
|
||||||
"Direction": 0,
|
"Direction": 0,
|
||||||
"Motor_Selector": int(axis["motor_selector"]),
|
"Motor_Selector": int(axis["motor_selector"]),
|
||||||
"Opcode": int(self.protocol["absolute_position_opcode"]),
|
# An explicit 0% request is a physical zeroing request,
|
||||||
"Param_A": target,
|
# not permission to trust a potentially drifted Hall
|
||||||
# Opcode 1 Param_B is a timeout override, not duty.
|
# counter. Opcode 0x0B proves the real near endpoint and
|
||||||
# The explicit calibrated value covers a full M0 stroke;
|
# rebuilds position zero while preserving MaxHall.
|
||||||
# the legacy per-axis default can stop it at about 79%.
|
"Opcode": command_opcode,
|
||||||
"Param_B": absolute_timeout_ms,
|
"Param_A": command_param_a,
|
||||||
|
# Opcode 1 uses milliseconds; Opcode 0x0B uses seconds.
|
||||||
|
"Param_B": command_param_b,
|
||||||
"Protocol_Version": int(self.protocol["version"]),
|
"Protocol_Version": int(self.protocol["version"]),
|
||||||
# Allocated immediately before hardware transmission. Zero keeps
|
# Allocated immediately before hardware transmission. Zero keeps
|
||||||
# the compiled plan deterministic and is never put on the bus.
|
# the compiled plan deterministic and is never put on the bus.
|
||||||
@@ -219,13 +244,76 @@ class T2GSeatCompiler:
|
|||||||
"tolerance": tolerance,
|
"tolerance": tolerance,
|
||||||
"timeout_ms": max(
|
"timeout_ms": max(
|
||||||
int(operation.get("timeout_ms", 0)),
|
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
|
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(
|
def _compile_uncalibrated_relative(
|
||||||
self,
|
self,
|
||||||
operation: dict[str, Any],
|
operation: dict[str, Any],
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ PHYSICAL_AXIS_CAPABILITIES = {
|
|||||||
}
|
}
|
||||||
MOTION_ACTIVE_TASK_STATES = {1, 2, 3, 4, 5}
|
MOTION_ACTIVE_TASK_STATES = {1, 2, 3, 4, 5}
|
||||||
MOTION_FAULT_RESULTS = {5, 6, 7, 8, 9, 10, 11}
|
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):
|
class HardwareExecutionError(RuntimeError):
|
||||||
@@ -76,6 +88,10 @@ def _now() -> str:
|
|||||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
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:
|
class HardwareSeatExecutor:
|
||||||
"""Serialize access to one adapter and require real firmware feedback."""
|
"""Serialize access to one adapter and require real firmware feedback."""
|
||||||
|
|
||||||
@@ -95,6 +111,8 @@ class HardwareSeatExecutor:
|
|||||||
auto_home: bool = False,
|
auto_home: bool = False,
|
||||||
home_timeout_s: int = 120,
|
home_timeout_s: int = 120,
|
||||||
firmware_guard: T2GFirmwareGuard | None = None,
|
firmware_guard: T2GFirmwareGuard | None = None,
|
||||||
|
synchronize_position_before_first_motion: bool = False,
|
||||||
|
position_sync_state: dict[str, bool] | None = None,
|
||||||
):
|
):
|
||||||
self.matrix = matrix
|
self.matrix = matrix
|
||||||
self.bus_factory = bus_factory
|
self.bus_factory = bus_factory
|
||||||
@@ -108,6 +126,14 @@ class HardwareSeatExecutor:
|
|||||||
self.auto_home = auto_home
|
self.auto_home = auto_home
|
||||||
self.home_timeout_s = home_timeout_s
|
self.home_timeout_s = home_timeout_s
|
||||||
self.firmware_guard = firmware_guard
|
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]]] = {}
|
self._current_samples: dict[int, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -133,6 +159,9 @@ class HardwareSeatExecutor:
|
|||||||
has_motion = any(item["message"] == "T2G_Seat_Move_Command_300" for item in commands)
|
has_motion = any(item["message"] == "T2G_Seat_Move_Command_300" for item in commands)
|
||||||
bus: CanBus | None = None
|
bus: CanBus | None = None
|
||||||
motion_armed = False
|
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
|
physical_state: dict[str, Any] | None = None
|
||||||
self._current_samples = {}
|
self._current_samples = {}
|
||||||
|
|
||||||
@@ -140,6 +169,12 @@ class HardwareSeatExecutor:
|
|||||||
try:
|
try:
|
||||||
if self.firmware_guard is not None:
|
if self.firmware_guard is not None:
|
||||||
bus, firmware_telemetry = self.firmware_guard.prepare_bus()
|
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(
|
results.append(
|
||||||
{
|
{
|
||||||
"ref": "seat-controller-firmware",
|
"ref": "seat-controller-firmware",
|
||||||
@@ -161,6 +196,33 @@ class HardwareSeatExecutor:
|
|||||||
if has_motion:
|
if has_motion:
|
||||||
self._stop_all(bus, require_ack=True)
|
self._stop_all(bus, require_ack=True)
|
||||||
motion_armed = 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
|
previous_offset = 0
|
||||||
for command in commands:
|
for command in commands:
|
||||||
offset = int(command.get("send_offset_ms", 0))
|
offset = int(command.get("send_offset_ms", 0))
|
||||||
@@ -192,6 +254,27 @@ class HardwareSeatExecutor:
|
|||||||
raise HardwareExecutionError(
|
raise HardwareExecutionError(
|
||||||
f"hardware executor refuses unknown message {command['message']}"
|
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
|
except Exception as exc: # noqa: BLE001 - normalize transport and protocol failures
|
||||||
if bus is not None and motion_armed:
|
if bus is not None and motion_armed:
|
||||||
self._best_effort_stop(bus)
|
self._best_effort_stop(bus)
|
||||||
@@ -642,15 +725,32 @@ class HardwareSeatExecutor:
|
|||||||
bus, command, operations, signals, motor
|
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"])
|
target = int(command["completion"]["value"])
|
||||||
tolerance = int(command["completion"].get("tolerance", 0))
|
tolerance = int(command["completion"].get("tolerance", 0))
|
||||||
axis_maximum = self.axis_maximum_x100.get(operation["capability"])
|
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: dict[str, int] | None = None
|
||||||
baseline_frame: CanFrame | None = None
|
baseline_frame: CanFrame | None = None
|
||||||
baseline_error: str | None = None
|
baseline_error: str | None = None
|
||||||
position_recovery: dict[str, Any] | 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:
|
if not self.auto_home:
|
||||||
raise HardwareExecutionError(
|
raise HardwareExecutionError(
|
||||||
f"T2G motor {motor} absolute-position command blocked before send: "
|
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
|
# Capture Hall telemetry after any required homing. It is required
|
||||||
# for endpoint recovery at 0% and makes non-zero failures diagnosable.
|
# for endpoint recovery at 0% and makes non-zero failures diagnosable.
|
||||||
if opcode == 1:
|
if opcode in {1, 0x0B}:
|
||||||
try:
|
try:
|
||||||
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
|
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
|
||||||
except HardwareExecutionError as exc:
|
except HardwareExecutionError as exc:
|
||||||
@@ -717,6 +817,33 @@ class HardwareSeatExecutor:
|
|||||||
)
|
)
|
||||||
task_state = int(values["Task_State"])
|
task_state = int(values["Task_State"])
|
||||||
result = int(values["Result"])
|
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(
|
far_endpoint_tolerance_acceptance = bool(
|
||||||
opcode == 1
|
opcode == 1
|
||||||
and axis_maximum is not None
|
and axis_maximum is not None
|
||||||
@@ -866,6 +993,11 @@ class HardwareSeatExecutor:
|
|||||||
actual_pct = round(values["Value_X100"] * 100 / maximum)
|
actual_pct = round(values["Value_X100"] * 100 / maximum)
|
||||||
common_telemetry = {
|
common_telemetry = {
|
||||||
"motion_axis": axis,
|
"motion_axis": axis,
|
||||||
|
"motion_mode": (
|
||||||
|
"relative_absolute_target"
|
||||||
|
if operation.get("motion", {}).get("mode") == "relative"
|
||||||
|
else "absolute_target"
|
||||||
|
),
|
||||||
"motor_selector": motor,
|
"motor_selector": motor,
|
||||||
"physical_target": "t2g_primary_seat",
|
"physical_target": "t2g_primary_seat",
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
@@ -885,6 +1017,7 @@ class HardwareSeatExecutor:
|
|||||||
"ack_frame": ack_frame.public(),
|
"ack_frame": ack_frame.public(),
|
||||||
"feedback_frame": feedback.public(),
|
"feedback_frame": feedback.public(),
|
||||||
"hardware_feedback": True,
|
"hardware_feedback": True,
|
||||||
|
"current_diagnostic": self._current_diagnostic_summary(motor),
|
||||||
"simulated": False,
|
"simulated": False,
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -1081,6 +1214,36 @@ class HardwareSeatExecutor:
|
|||||||
"system_ack": ack_values["Ack"],
|
"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(
|
def _execute_timed_motion(
|
||||||
self,
|
self,
|
||||||
bus: CanBus,
|
bus: CanBus,
|
||||||
@@ -1089,10 +1252,13 @@ class HardwareSeatExecutor:
|
|||||||
signals: dict[str, int],
|
signals: dict[str, int],
|
||||||
motor: int,
|
motor: int,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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)
|
baseline, baseline_frame = self._motor_diagnostic_snapshot(bus, motor)
|
||||||
sequence = self._next_sequence()
|
sequence = self._next_sequence()
|
||||||
signals["Sequence"] = sequence
|
signals["Sequence"] = sequence
|
||||||
self._reset_current_samples(motor)
|
self._reset_current_samples(motor)
|
||||||
|
motion_started = time.perf_counter()
|
||||||
command_frame = self._send_encoded(bus, command["message"], signals)
|
command_frame = self._send_encoded(bus, command["message"], signals)
|
||||||
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
|
ack_values, ack_frame = self._wait_ack(bus, sequence, 3)
|
||||||
|
|
||||||
@@ -1109,6 +1275,13 @@ class HardwareSeatExecutor:
|
|||||||
int(command["completion"]["timeout_ms"]),
|
int(command["completion"]["timeout_ms"]),
|
||||||
terminal,
|
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: dict[str, int] | None = None
|
||||||
diagnostic_frame: CanFrame | None = None
|
diagnostic_frame: CanFrame | None = None
|
||||||
diagnostic_error: str | None = None
|
diagnostic_error: str | None = None
|
||||||
@@ -1142,8 +1315,45 @@ class HardwareSeatExecutor:
|
|||||||
if diagnostic is not None
|
if diagnostic is not None
|
||||||
else int(baseline["Max_Hall_Count"]),
|
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:
|
if result == 5 and hall_delta <= 2:
|
||||||
failure_category = "stall_no_hall_activity"
|
failure_category = "stall_no_hall_activity"
|
||||||
diagnostic_hint = (
|
diagnostic_hint = (
|
||||||
@@ -1177,10 +1387,12 @@ class HardwareSeatExecutor:
|
|||||||
f"{diagnostic_hint}",
|
f"{diagnostic_hint}",
|
||||||
telemetry={
|
telemetry={
|
||||||
"motor_selector": motor,
|
"motor_selector": motor,
|
||||||
"motion_axis": MOTION_CAPABILITY_TO_AXIS[
|
"motion_axis": axis,
|
||||||
operations[0]["capability"]
|
"motion_mode": (
|
||||||
],
|
"timed_user_request"
|
||||||
"motion_mode": "relative_timed_duty",
|
if user_timed_motion
|
||||||
|
else "relative_timed_duty"
|
||||||
|
),
|
||||||
"position_valid": bool(values["Position_Valid"]),
|
"position_valid": bool(values["Position_Valid"]),
|
||||||
"task_state": task_state,
|
"task_state": task_state,
|
||||||
"motor_result": result,
|
"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(
|
raise HardwareExecutionError(
|
||||||
f"T2G timed motor {motor} completed but diagnostic Hall feedback "
|
f"T2G timed motor {motor} completed but diagnostic Hall feedback "
|
||||||
f"was unavailable: {diagnostic_error}",
|
f"was unavailable: {diagnostic_error}",
|
||||||
@@ -1239,7 +1454,7 @@ class HardwareSeatExecutor:
|
|||||||
# the public Hall position. COMPLETE/OK still proves raw Hall edges
|
# the public Hall position. COMPLETE/OK still proves raw Hall edges
|
||||||
# because the firmware refuses timed success without one.
|
# because the firmware refuses timed success without one.
|
||||||
direction_verified = None
|
direction_verified = None
|
||||||
if direction_verified is False:
|
if direction_verified is False and endpoint_reached is None:
|
||||||
raise HardwareExecutionError(
|
raise HardwareExecutionError(
|
||||||
f"T2G timed motor {motor} completed but Hall direction did not match "
|
f"T2G timed motor {motor} completed but Hall direction did not match "
|
||||||
f"the command: before={hall_before}, after={hall_after}, direction={direction}",
|
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 = {
|
common_telemetry = {
|
||||||
"motion_axis": axis,
|
"motion_axis": axis,
|
||||||
"motion_mode": "relative_timed_duty",
|
"motion_mode": (
|
||||||
|
"timed_user_request"
|
||||||
|
if user_timed_motion
|
||||||
|
else "relative_timed_duty"
|
||||||
|
),
|
||||||
"motor_selector": motor,
|
"motor_selector": motor,
|
||||||
"physical_target": "t2g_primary_seat",
|
"physical_target": "t2g_primary_seat",
|
||||||
"sequence": sequence,
|
"sequence": sequence,
|
||||||
"requested_position_pct": int(
|
"requested_position_pct": int(
|
||||||
operations[0]["parameters"]["position_pct"]
|
operations[0]["parameters"]["position_pct"]
|
||||||
),
|
),
|
||||||
|
"effective_position_pct": effective_position_pct,
|
||||||
"requested_delta_position_pct": int(
|
"requested_delta_position_pct": int(
|
||||||
operations[0].get("motion", {}).get("delta_position_pct", 0)
|
operations[0].get("motion", {}).get("delta_position_pct", 0)
|
||||||
),
|
),
|
||||||
@@ -1271,6 +1523,14 @@ class HardwareSeatExecutor:
|
|||||||
.get("motion", {})
|
.get("motion", {})
|
||||||
.get("delta_angle_deg"),
|
.get("delta_angle_deg"),
|
||||||
"duration_ms": int(signals["Param_A"]),
|
"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"]),
|
"duty_percent": int(signals["Param_B"]),
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"task_state": task_state,
|
"task_state": task_state,
|
||||||
@@ -1291,8 +1551,11 @@ class HardwareSeatExecutor:
|
|||||||
"ack_frame": ack_frame.public(),
|
"ack_frame": ack_frame.public(),
|
||||||
"feedback_frame": feedback.public(),
|
"feedback_frame": feedback.public(),
|
||||||
"diagnostic_before_frame": baseline_frame.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,
|
"hardware_feedback": True,
|
||||||
|
"current_diagnostic": self._current_diagnostic_summary(motor),
|
||||||
"simulated": False,
|
"simulated": False,
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -1301,9 +1564,13 @@ class HardwareSeatExecutor:
|
|||||||
"domain": "seat",
|
"domain": "seat",
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"message": (
|
"message": (
|
||||||
f"physical motor {motor} COMPLETE/OK confirmed relative {axis} "
|
user_notice
|
||||||
f"movement with Hall {hall_before}->{hall_after}; absolute position "
|
if user_notice is not None
|
||||||
"remains unavailable until endpoint calibration"
|
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": {
|
"telemetry": {
|
||||||
**common_telemetry,
|
**common_telemetry,
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from copy import deepcopy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
TARGET_PATTERN = re.compile(r"主副驾|前排|主驾|驾驶位|司机位|副驾|乘客位")
|
TARGET_PATTERN = re.compile(
|
||||||
|
r"主副驾|前排右侧|右前座椅|右前座|副驾驶座椅|副驾驶座|副驾驶|副驾位|"
|
||||||
|
r"乘客座椅|乘客座|乘客位|主驾驶座椅|主驾驶座|主驾驶|驾驶位|驾驶座|"
|
||||||
|
r"司机位|司机座|主驾|前排|副驾"
|
||||||
|
)
|
||||||
FEATURE_PATTERN = re.compile(r"通风|加热")
|
FEATURE_PATTERN = re.compile(r"通风|加热")
|
||||||
STATE_PATTERN = re.compile(r"关闭|关掉|停止|取消|打开|开启|启动|开")
|
STATE_PATTERN = re.compile(
|
||||||
LEVEL_PATTERN = re.compile(r"([1-5一二三四五])\s*档")
|
r"设置为|调到|调至|设为|开到|关闭|关掉|停止|取消|打开|开启|启动|开"
|
||||||
|
)
|
||||||
|
LEVEL_PATTERN = re.compile(r"(最高|最低|[1-5一二三四五高中低])\s*档")
|
||||||
POSITION_PATTERN_TEMPLATE = (
|
POSITION_PATTERN_TEMPLATE = (
|
||||||
r"{alias}(?:位置)?(?:调到|移动到|移到|设为|设置为|调至|移动至|到|为)?"
|
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"归零|回零|复位|零位")
|
ZERO_POSITION_PATTERN = re.compile(r"归零|回零|复位|零位")
|
||||||
VENTILATION_FEEDBACK_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"我|自己")
|
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
|
||||||
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
|
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
|
||||||
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
|
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
|
||||||
|
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
|
||||||
|
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
|
||||||
|
r"右后座椅|右后座|右后|左后座椅|左后座|左后|"
|
||||||
|
r"所有座椅|全部座椅|全车座椅"
|
||||||
|
)
|
||||||
|
UNSUPPORTED_SEAT_SCOPE_QUESTION = (
|
||||||
|
"当前台架仅支持副驾座椅,不能把后排、其他方位或全部座椅请求默认到副驾。"
|
||||||
|
)
|
||||||
|
|
||||||
TARGETS = {
|
TARGETS = {
|
||||||
"主驾": ["driver"],
|
"主驾": ["driver"],
|
||||||
"驾驶位": ["driver"],
|
"驾驶位": ["driver"],
|
||||||
"司机位": ["driver"],
|
"司机位": ["driver"],
|
||||||
|
"主驾驶": ["driver"],
|
||||||
|
"主驾驶座": ["driver"],
|
||||||
|
"主驾驶座椅": ["driver"],
|
||||||
|
"驾驶座": ["driver"],
|
||||||
|
"司机座": ["driver"],
|
||||||
"副驾": ["passenger"],
|
"副驾": ["passenger"],
|
||||||
|
"副驾驶": ["passenger"],
|
||||||
|
"副驾驶座": ["passenger"],
|
||||||
|
"副驾驶座椅": ["passenger"],
|
||||||
|
"副驾位": ["passenger"],
|
||||||
"乘客位": ["passenger"],
|
"乘客位": ["passenger"],
|
||||||
|
"乘客座": ["passenger"],
|
||||||
|
"乘客座椅": ["passenger"],
|
||||||
|
"右前座": ["passenger"],
|
||||||
|
"右前座椅": ["passenger"],
|
||||||
|
"前排右侧": ["passenger"],
|
||||||
"主副驾": ["driver", "passenger"],
|
"主副驾": ["driver", "passenger"],
|
||||||
"前排": ["driver", "passenger"],
|
"前排": ["driver", "passenger"],
|
||||||
}
|
}
|
||||||
@@ -40,18 +89,384 @@ STATES = {
|
|||||||
"开启": "on",
|
"开启": "on",
|
||||||
"启动": "on",
|
"启动": "on",
|
||||||
"开": "on",
|
"开": "on",
|
||||||
|
"开到": "on",
|
||||||
|
"调到": "on",
|
||||||
|
"调至": "on",
|
||||||
|
"设置为": "on",
|
||||||
|
"设为": "on",
|
||||||
"关闭": "off",
|
"关闭": "off",
|
||||||
"关掉": "off",
|
"关掉": "off",
|
||||||
"停止": "off",
|
"停止": "off",
|
||||||
"取消": "off",
|
"取消": "off",
|
||||||
}
|
}
|
||||||
LEVELS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5}
|
LEVELS = {
|
||||||
MOTION_AXIS_ALIASES = {
|
"一": 1,
|
||||||
"longitudinal": ("水平", "前后", "纵向"),
|
"二": 2,
|
||||||
"cushion_tilt": ("坐盆", "坐垫"),
|
"三": 3,
|
||||||
"backrest_recline": ("靠背",),
|
"四": 4,
|
||||||
"legrest_height": ("腿托", "腿部支撑"),
|
"五": 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:
|
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."""
|
"""Resolve implicit comfort feedback against the current virtual seat state."""
|
||||||
|
|
||||||
result = deepcopy(parsed)
|
result = deepcopy(parsed)
|
||||||
text = re.sub(r"\s+", "", utterance.strip())
|
text = _normalize_text(utterance)
|
||||||
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
|
ventilation_cues = list(dict.fromkeys(VENTILATION_FEEDBACK_PATTERN.findall(text)))
|
||||||
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
|
cold_cues = list(dict.fromkeys(HEATING_FEEDBACK_PATTERN.findall(text)))
|
||||||
if not ventilation_cues and not cold_cues:
|
if not ventilation_cues and not cold_cues:
|
||||||
@@ -369,12 +784,28 @@ def parse_local_intent(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Parse common Chinese seat commands without making hardware assumptions."""
|
"""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]
|
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
|
||||||
actions: list[dict[str, Any]] = []
|
actions: list[dict[str, Any]] = []
|
||||||
motion_actions: list[dict[str, Any]] = []
|
motion_actions: list[dict[str, Any]] = []
|
||||||
ambiguities: list[dict[str, str]] = []
|
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 = [
|
mentioned_axes = [
|
||||||
axis
|
axis
|
||||||
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
for axis, aliases in MOTION_AXIS_ALIASES.items()
|
||||||
@@ -399,7 +830,21 @@ def parse_local_intent(
|
|||||||
|
|
||||||
for clause in clauses:
|
for clause in clauses:
|
||||||
level = _level_for_clause(clause)
|
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)
|
feature_zh = feature_match.group(0)
|
||||||
target_zh = _nearest_preceding_target(clause, feature_match.start())
|
target_zh = _nearest_preceding_target(clause, feature_match.start())
|
||||||
state = _state_for_feature(clause, feature_match.end())
|
state = _state_for_feature(clause, feature_match.end())
|
||||||
@@ -410,28 +855,245 @@ def parse_local_intent(
|
|||||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if state is None:
|
if level is not None and level > 3:
|
||||||
ambiguities.append(
|
ambiguities.append(
|
||||||
{"field": "state", "question": f"请确认{target_zh}{feature_zh}是打开还是关闭。"}
|
{
|
||||||
|
"field": f"passenger.{FEATURES[feature_zh]}.level",
|
||||||
|
"question": f"{feature_zh}档位必须在1到3档之间。",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
continue
|
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]:
|
for target in TARGETS[target_zh]:
|
||||||
actions.append(
|
actions.append(
|
||||||
{
|
{
|
||||||
"target": target,
|
"target": target,
|
||||||
"function": FEATURES[feature_zh],
|
"function": FEATURES[feature_zh],
|
||||||
"state": state,
|
"state": state,
|
||||||
"requested_level": level,
|
"requested_level": requested_level,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if ZERO_POSITION_PATTERN.search(text):
|
if ZERO_POSITION_PATTERN.search(text):
|
||||||
continue
|
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:
|
for alias in aliases:
|
||||||
match = re.search(
|
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)),
|
POSITION_PATTERN_TEMPLATE.format(alias=re.escape(alias)),
|
||||||
clause,
|
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:
|
if match is None:
|
||||||
continue
|
continue
|
||||||
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
target_zh = _nearest_preceding_target(clause, match.start()) or "副驾"
|
||||||
@@ -440,23 +1102,43 @@ def parse_local_intent(
|
|||||||
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
{"field": "target", "question": PASSENGER_ONLY_QUESTION}
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
position_pct = int(match.group("position"))
|
absolute_angle = None
|
||||||
if position_pct > 100:
|
if position_match is not None:
|
||||||
ambiguities.append(
|
requested_position_pct = int(position_match.group("position"))
|
||||||
{
|
position_pct = min(100, requested_position_pct)
|
||||||
"field": f"passenger.position.{axis}",
|
else:
|
||||||
"question": f"{alias}位置百分比必须在0到100之间。",
|
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
|
absolute_angle = angle
|
||||||
motion_actions.append(
|
motion_action = {
|
||||||
{
|
"target": "passenger",
|
||||||
"target": "passenger",
|
"action_type": "position",
|
||||||
"action_type": "position",
|
"axis": axis,
|
||||||
"axis": axis,
|
"position_pct": position_pct,
|
||||||
"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
|
break
|
||||||
|
|
||||||
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
@@ -498,6 +1180,15 @@ def parse_local_intent(
|
|||||||
actions = parsed["actions"]
|
actions = parsed["actions"]
|
||||||
ambiguities = parsed["ambiguities"]
|
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:
|
if not actions and not ambiguities:
|
||||||
ambiguities.append(
|
ambiguities.append(
|
||||||
{
|
{
|
||||||
@@ -527,6 +1218,8 @@ def parse_local_intent(
|
|||||||
f"目标:副驾座椅{axis_labels[action['axis']]}位置"
|
f"目标:副驾座椅{axis_labels[action['axis']]}位置"
|
||||||
f"{action['position_pct']}%。"
|
f"{action['position_pct']}%。"
|
||||||
)
|
)
|
||||||
|
if action.get("user_notice"):
|
||||||
|
summary.append(str(action["user_notice"]))
|
||||||
else:
|
else:
|
||||||
summary.append(
|
summary.append(
|
||||||
f"目标:{labels[action['target']]}座椅"
|
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()
|
||||||
@@ -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:
|
def test_parses_passenger_ventilation_on(self) -> None:
|
||||||
result = parse_local_intent("打开副驾座椅通风")
|
result = parse_local_intent("打开副驾座椅通风")
|
||||||
|
|
||||||
@@ -32,7 +43,7 @@ class LocalIntentParserTests(unittest.TestCase):
|
|||||||
"target": "passenger",
|
"target": "passenger",
|
||||||
"function": "ventilation",
|
"function": "ventilation",
|
||||||
"state": "on",
|
"state": "on",
|
||||||
"requested_level": None,
|
"requested_level": 1,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -82,6 +93,229 @@ class LocalIntentParserTests(unittest.TestCase):
|
|||||||
[("longitudinal", 63), ("backrest_recline", 54)],
|
[("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:
|
def test_rejects_driver_four_axis_zero(self) -> None:
|
||||||
result = parse_local_intent("把主驾座椅四轴归零")
|
result = parse_local_intent("把主驾座椅四轴归零")
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class ReactiveFakeBus:
|
|||||||
absolute_failure_targets: dict[int, int] | None = None,
|
absolute_failure_targets: dict[int, int] | None = None,
|
||||||
timed_failure_result: int | None = None,
|
timed_failure_result: int | None = None,
|
||||||
timed_failure_event: int = 4,
|
timed_failure_event: int = 4,
|
||||||
|
timed_endpoint: str | None = None,
|
||||||
):
|
):
|
||||||
self.matrix = matrix
|
self.matrix = matrix
|
||||||
self.complete_motion = complete_motion
|
self.complete_motion = complete_motion
|
||||||
@@ -86,6 +87,7 @@ class ReactiveFakeBus:
|
|||||||
self.absolute_failure_targets = dict(absolute_failure_targets or {})
|
self.absolute_failure_targets = dict(absolute_failure_targets or {})
|
||||||
self.timed_failure_result = timed_failure_result
|
self.timed_failure_result = timed_failure_result
|
||||||
self.timed_failure_event = timed_failure_event
|
self.timed_failure_event = timed_failure_event
|
||||||
|
self.timed_endpoint = timed_endpoint
|
||||||
self.hall = [0, 0, 0, 0]
|
self.hall = [0, 0, 0, 0]
|
||||||
self.position = [0, 0, 0, 0]
|
self.position = [0, 0, 0, 0]
|
||||||
self.max_hall = [480, 374, 917, 647]
|
self.max_hall = [480, 374, 917, 647]
|
||||||
@@ -268,12 +270,70 @@ class ReactiveFakeBus:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# The real controller publishes cyclic status. Keep one more
|
# The real controller publishes all four cyclic motor status
|
||||||
# sample queued for the executor's post-home preflight read.
|
# 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.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"]
|
motor = command["Motor_Selector"]
|
||||||
self.received.append(
|
self.received.append(
|
||||||
self._frame(
|
self._frame(
|
||||||
@@ -387,7 +447,7 @@ class ProtocolSignatureFakeBus:
|
|||||||
self.received: deque[CanFrame] = deque()
|
self.received: deque[CanFrame] = deque()
|
||||||
self.closed = False
|
self.closed = False
|
||||||
if signature:
|
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):
|
for motor in range(4):
|
||||||
self.received.append(
|
self.received.append(
|
||||||
CanFrame(0x302, bytes([motor, 0, 0x08, 0, 0, 0, 0, 0]))
|
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["signals"]["Param_B"], 30_000)
|
||||||
self.assertEqual(command["completion"]["timeout_ms"], 33_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(
|
config = function_config(
|
||||||
operation(
|
operation(
|
||||||
"seat.set_backrest_recline",
|
"seat.set_backrest_recline",
|
||||||
@@ -498,11 +558,16 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
|
|
||||||
command = self.compiler.compile(config, {})["spec"]["commands"][0]
|
command = self.compiler.compile(config, {})["spec"]["commands"][0]
|
||||||
|
|
||||||
self.assertEqual(command["signals"]["Opcode"], 3)
|
self.assertEqual(command["signals"]["Opcode"], 1)
|
||||||
self.assertEqual(command["signals"]["Direction"], 1)
|
self.assertEqual(command["signals"]["Direction"], 0)
|
||||||
self.assertEqual(command["signals"]["Param_A"], 1320)
|
self.assertEqual(command["signals"]["Param_A"], 3100)
|
||||||
self.assertEqual(command["signals"]["Param_B"], 900)
|
self.assertEqual(command["completion"]["signal"], "Value_X100")
|
||||||
self.assertEqual(command["completion"]["signal"], "Task_State")
|
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:
|
def test_compiler_maps_large_relative_backrest_request_to_absolute_target(self) -> None:
|
||||||
config = function_config(
|
config = function_config(
|
||||||
@@ -527,6 +592,34 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
self.assertEqual(command["signals"]["Param_A"], 6400)
|
self.assertEqual(command["signals"]["Param_A"], 6400)
|
||||||
self.assertEqual(command["completion"]["signal"], "Value_X100")
|
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:
|
def test_driver_target_is_rejected_by_hardware_compiler(self) -> None:
|
||||||
driver = function_config(
|
driver = function_config(
|
||||||
operation(
|
operation(
|
||||||
@@ -603,6 +696,87 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
|
self.assertEqual([item.data[0] for item in bus.sent], [9, 1])
|
||||||
self.assertTrue(bus.closed)
|
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:
|
def test_passive_snapshot_reads_all_passenger_axes_and_thermal_state(self) -> None:
|
||||||
bus = ReactiveFakeBus(self.matrix)
|
bus = ReactiveFakeBus(self.matrix)
|
||||||
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
|
for motor, value_x100 in enumerate((1200, 3400, 5600, 7800)):
|
||||||
@@ -693,12 +867,15 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(
|
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"]
|
for command in plan["spec"]["commands"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
bus = ReactiveFakeBus(self.matrix)
|
bus = ReactiveFakeBus(self.matrix)
|
||||||
|
position_sync_state = {"synchronized": False}
|
||||||
report = HardwareSeatExecutor(
|
report = HardwareSeatExecutor(
|
||||||
self.matrix,
|
self.matrix,
|
||||||
lambda: bus,
|
lambda: bus,
|
||||||
@@ -706,6 +883,8 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
stop_all_motor_selector=int(
|
stop_all_motor_selector=int(
|
||||||
self.compiler.protocol["stop_all_motor_selector"]
|
self.compiler.protocol["stop_all_motor_selector"]
|
||||||
),
|
),
|
||||||
|
synchronize_position_before_first_motion=True,
|
||||||
|
position_sync_state=position_sync_state,
|
||||||
).execute(plan, config)
|
).execute(plan, config)
|
||||||
|
|
||||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||||
@@ -716,7 +895,15 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[(item["Opcode"], item["Motor_Selector"]) for item in sent_commands],
|
[(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:
|
def test_zero_recovers_verified_stale_near_endpoint_offset(self) -> None:
|
||||||
@@ -741,15 +928,12 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
for frame in bus.sent
|
for frame in bus.sent
|
||||||
if frame.arbitration_id == 0x300
|
if frame.arbitration_id == 0x300
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||||
[item["Opcode"] for item in sent_commands],
|
|
||||||
[9, 1, 3, 0x0B],
|
|
||||||
)
|
|
||||||
telemetry = report["spec"]["results"][0]["telemetry"]
|
telemetry = report["spec"]["results"][0]["telemetry"]
|
||||||
recovery = telemetry["position_recovery"]
|
recovery = telemetry["position_recovery"]
|
||||||
self.assertEqual(recovery["mode"], "verified_endpoint_drift_rehome")
|
self.assertEqual(recovery["mode"], "explicit_known_range_home")
|
||||||
self.assertGreater(recovery["away_probe"]["hall_delta"], 2)
|
self.assertEqual(recovery["hall_before"], 6)
|
||||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
self.assertEqual(recovery["hall_after"], 0)
|
||||||
self.assertEqual(telemetry["effective_position_pct"], 0)
|
self.assertEqual(telemetry["effective_position_pct"], 0)
|
||||||
|
|
||||||
def test_zero_recovers_after_full_travel_reaches_near_endpoint_window(self) -> None:
|
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
|
for frame in bus.sent
|
||||||
if frame.arbitration_id == 0x300
|
if frame.arbitration_id == 0x300
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||||
[item["Opcode"] for item in sent_commands],
|
|
||||||
[9, 1, 3, 0x0B],
|
|
||||||
)
|
|
||||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||||
self.assertEqual(recovery["failed_absolute_hall_before"], 374)
|
self.assertEqual(recovery["hall_before"], 374)
|
||||||
self.assertEqual(recovery["failed_absolute_hall_after"], 4)
|
self.assertEqual(recovery["hall_after"], 0)
|
||||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
|
||||||
|
|
||||||
def test_zero_recovers_real_m0_stall_at_calibrated_near_endpoint(self) -> None:
|
def test_zero_recovers_real_m0_stall_at_calibrated_near_endpoint(self) -> None:
|
||||||
config = function_config(
|
config = function_config(
|
||||||
@@ -813,22 +993,16 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
for frame in bus.sent
|
for frame in bus.sent
|
||||||
if frame.arbitration_id == 0x300
|
if frame.arbitration_id == 0x300
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||||
[item["Opcode"] for item in sent_commands],
|
|
||||||
[9, 1, 3, 0x0B],
|
|
||||||
)
|
|
||||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||||
self.assertEqual(recovery["failed_absolute_hall_before"], 480)
|
self.assertEqual(recovery["hall_before"], 480)
|
||||||
self.assertEqual(recovery["failed_absolute_hall_after"], 52)
|
self.assertEqual(recovery["hall_after"], 0)
|
||||||
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(
|
self.assertEqual(
|
||||||
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
report["spec"]["results"][0]["telemetry"]["effective_position_pct"],
|
||||||
0,
|
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(
|
config = function_config(
|
||||||
operation("seat.set_longitudinal_position", {"position_pct": 0})
|
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},
|
motor_max_hall={0: 480, 1: 374, 2: 917, 3: 647},
|
||||||
).execute(plan, config)
|
).execute(plan, config)
|
||||||
|
|
||||||
self.assertEqual(report["spec"]["status"], "failed")
|
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||||
sent_commands = [
|
sent_commands = [
|
||||||
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
|
self.matrix.decode("T2G_Seat_Move_Command_300", frame.data)
|
||||||
for frame in bus.sent
|
for frame in bus.sent
|
||||||
if frame.arbitration_id == 0x300
|
if frame.arbitration_id == 0x300
|
||||||
]
|
]
|
||||||
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 1, 9])
|
self.assertEqual([item["Opcode"] for item in sent_commands], [9, 0x0B])
|
||||||
self.assertIn("STALL", report["spec"]["results"][-1]["message"])
|
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||||
current = report["spec"]["results"][-1]["telemetry"][
|
self.assertEqual(recovery["hall_before"], 116)
|
||||||
"current_diagnostic"
|
self.assertEqual(recovery["hall_after"], 0)
|
||||||
]
|
|
||||||
self.assertEqual(current["sample_count"], 1)
|
|
||||||
self.assertEqual(current["max_current_delta_raw"], 700)
|
|
||||||
self.assertTrue(current["stall_overlap_seen"])
|
|
||||||
|
|
||||||
def test_zero_recovers_near_endpoint_timeout_after_full_travel(self) -> None:
|
def test_zero_recovers_near_endpoint_timeout_after_full_travel(self) -> None:
|
||||||
config = function_config(
|
config = function_config(
|
||||||
@@ -881,9 +1051,8 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(report["spec"]["status"], "succeeded")
|
self.assertEqual(report["spec"]["status"], "succeeded")
|
||||||
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
recovery = report["spec"]["results"][0]["telemetry"]["position_recovery"]
|
||||||
self.assertEqual(recovery["failed_absolute_hall_before"], 605)
|
self.assertEqual(recovery["hall_before"], 605)
|
||||||
self.assertEqual(recovery["failed_absolute_hall_after"], 3)
|
self.assertEqual(recovery["hall_after"], 0)
|
||||||
self.assertEqual(recovery["home"]["hall_after"], 0)
|
|
||||||
|
|
||||||
def test_far_endpoint_timeout_within_position_tolerance_is_accepted(self) -> None:
|
def test_far_endpoint_timeout_within_position_tolerance_is_accepted(self) -> None:
|
||||||
config = function_config(
|
config = function_config(
|
||||||
@@ -1036,6 +1205,114 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
self.assertGreater(telemetry["hall_delta"], 0)
|
self.assertGreater(telemetry["hall_delta"], 0)
|
||||||
self.assertTrue(telemetry["hall_direction_verified"])
|
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:
|
def test_executor_reports_stall_with_post_failure_hall_diagnostics(self) -> None:
|
||||||
config = function_config(
|
config = function_config(
|
||||||
operation(
|
operation(
|
||||||
@@ -1209,6 +1486,7 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
automatic = SeatHardwareSettings.from_env(ROOT)
|
automatic = SeatHardwareSettings.from_env(ROOT)
|
||||||
self.assertTrue(automatic.enabled)
|
self.assertTrue(automatic.enabled)
|
||||||
self.assertTrue(automatic.auto_flash_enabled)
|
self.assertTrue(automatic.auto_flash_enabled)
|
||||||
|
self.assertTrue(automatic.position_sync_enabled)
|
||||||
|
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -1225,6 +1503,7 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
dry_run = SeatHardwareSettings.from_env(ROOT)
|
dry_run = SeatHardwareSettings.from_env(ROOT)
|
||||||
self.assertFalse(dry_run.enabled)
|
self.assertFalse(dry_run.enabled)
|
||||||
self.assertFalse(dry_run.auto_flash_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:
|
def test_settings_allow_explicit_local_debug_mode_without_token(self) -> None:
|
||||||
environment = {
|
environment = {
|
||||||
@@ -1280,7 +1559,7 @@ class T2GHardwareTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIs(prepared, bus)
|
self.assertIs(prepared, bus)
|
||||||
self.assertFalse(telemetry["programmed_this_request"])
|
self.assertFalse(telemetry["programmed_this_request"])
|
||||||
self.assertEqual(telemetry["firmware_revision"], 3)
|
self.assertEqual(telemetry["firmware_revision"], 6)
|
||||||
self.assertEqual(bus.sent, [])
|
self.assertEqual(bus.sent, [])
|
||||||
self.assertEqual(telemetry["max_hall_counts"], [480, 374, 917, 647])
|
self.assertEqual(telemetry["max_hall_counts"], [480, 374, 917, 647])
|
||||||
self.assertFalse(bus.closed)
|
self.assertFalse(bus.closed)
|
||||||
|
|||||||
@@ -915,14 +915,22 @@ bool SeatMotor_CalibrateRange(motor_mode_t mode,
|
|||||||
uint32_t commandSequence)
|
uint32_t commandSequence)
|
||||||
{
|
{
|
||||||
motor_calibration_result_t result;
|
motor_calibration_result_t result;
|
||||||
|
uint8_t dutyPercent;
|
||||||
|
|
||||||
if((false == SeatMotor_ModeValid(mode)) || (0ul == legTimeoutMs))
|
if((false == SeatMotor_ModeValid(mode)) || (0ul == legTimeoutMs))
|
||||||
{
|
{
|
||||||
return false;
|
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)) ||
|
if((false == SeatMotor_PrepareNewTask(mode, commandSequence)) ||
|
||||||
(MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent(
|
(MOTOR_API_SUCCESS != Motor_SetFixedDutyPercent(
|
||||||
mode, MOTOR_SPEED_MIN_DUTY_PERCENT)))
|
mode, dutyPercent)))
|
||||||
{
|
{
|
||||||
SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED);
|
SeatMotor_SetFailed(mode, SEAT_MOTOR_RESULT_CALIBRATION_FAILED);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
#define SEAT_MOVE_CAN_PROTOCOL_VERSION (1u)
|
#define SEAT_MOVE_CAN_PROTOCOL_VERSION (1u)
|
||||||
/* Repository image identity carried by 0x301 byte 7 high nibble. Increment
|
/* Repository image identity carried by 0x301 byte 7 high nibble. Increment
|
||||||
* whenever a host-visible safety or motion fix requires automatic reflash. */
|
* 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_COMMAND_ID (0x300u)
|
||||||
#define SEAT_MOVE_CAN_SYSTEM_STATUS_ID (0x301u)
|
#define SEAT_MOVE_CAN_SYSTEM_STATUS_ID (0x301u)
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ CAN 为 500 kbit/s、经典标准帧、11-bit ID、DLC 8。移动协议版本为
|
|||||||
- 任何热/风输出初始化或运行故障都会关断热/风并锁存全局 driver fault;移动也进入 inhibit。
|
- 任何热/风输出初始化或运行故障都会关断热/风并锁存全局 driver fault;移动也进入 inhibit。
|
||||||
- 四电机使用独立任务、Sequence、Hall、超时和故障上下文;同轴换向先全关并等待死区,不同轴命令不会互相取消。
|
- 四电机使用独立任务、Sequence、Hall、超时和故障上下文;同轴换向先全关并等待死区,不同轴命令不会互相取消。
|
||||||
- 四轴分别具有启动宽限(M0/M1/M2=1 s,M3=2 s)。普通移动仅在“500 ms Hall双边沿窗口不超过2个边沿”且“相对停止态零点的轴电流超过独立阈值并持续200 ms”同时成立时报告 `STALL`;低Hall但电流低或采样过期时不误报堵转,连续5 s无运动后关断并报告 `TIMEOUT`。
|
- 四轴分别具有启动宽限(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%。
|
- 四轴普通绝对/相对移动默认使用90% PWM占空比;堵转电流差值阈值在重新带载标定前统一使用500 raw保守默认值,优先避免正常运动误报。PWM软件上限保持95%。
|
||||||
- 位置任务因端点/堵转事件停止时,若最终Hall已进入目标±2脉冲范围则按到位完成;底层LastEvent仍由`0x303`保留用于诊断。超过该范围的STALL/TIMEOUT仍按失败处理。
|
- 位置任务因端点/堵转事件停止时,若最终Hall已进入目标±2脉冲范围则按到位完成;底层LastEvent仍由`0x303`保留用于诊断。超过该范围的STALL/TIMEOUT仍按失败处理。
|
||||||
- `HOME_KNOWN_RANGE`/全量标定的寻端语义与普通移动分离。端点要求500 ms内Hall双边沿为0,避免慢速移动仅有下降沿时被误判为端点;已知量程归零若第一次寻近端少于4个真实Hall边沿,会先向远端验证至少4个双边沿再重新寻近端,并校验回程与离端边沿数(允许1边沿误差),禁止把电机未启动直接清零。无法证明往返运动时归零失败并使PositionValid失效。位置和量程仍按Hall上升沿计数。
|
- `HOME_KNOWN_RANGE`/全量标定的寻端语义与普通移动分离。端点要求500 ms内Hall双边沿为0,避免慢速移动仅有下降沿时被误判为端点;已知量程归零若第一次寻近端少于4个真实Hall边沿,会先向远端验证至少4个双边沿再重新寻近端,并校验回程与离端边沿数(允许1边沿误差),禁止把电机未启动直接清零。无法证明往返运动时归零失败并使PositionValid失效。位置和量程仍按Hall上升沿计数。
|
||||||
- `STOP_ALL` 不受旧 Sequence 限制;CAN RX ISR 先立即终止四路 PWM,因此也能打断阻塞式全行程标定,主流程随后完成 ACK 和 Sequence 重同步。
|
- `STOP_ALL` 不受旧 Sequence 限制;CAN RX ISR 先立即终止四路 PWM,因此也能打断阻塞式全行程标定,主流程随后完成 ACK 和 Sequence 重同步。
|
||||||
- `CALIBRATE_RANGE` 的 ParamB 是每个寻端行程的实际超时(10~600 s);标定期间继续发送 `0x301/0x302/0x303` 周期反馈。
|
- `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 的旧通道映射记录均不会直接恢复。
|
- 位置记忆使用 SMV8 双槽、版本、序号和 CRC。运动命令启动前先提交较新的 dirty 记录,防止运动中掉电后恢复旧位置;所有电机停稳后保存四轴当前 Hall、最大 Hall、物理绝对量和 PositionValid。正常掉电重启后 `0x302/0x303` 可直接取得上次停止位置。运动中掉电或存储不兼容时位置无效,应用只对失效轴执行 `0x0B` 回最近端,然后继续绝对目标。SMV5/SMV6 的旧量程记录和 SMV7 的旧通道映射记录均不会直接恢复。
|
||||||
- C-SPY `execUserExit()` 同时关断四电机 PWM、风扇 TCPWM60 和加热 TCPWM65,并验证物理输出 latch 为低。
|
- C-SPY `execUserExit()` 同时关断四电机 PWM、风扇 TCPWM60 和加热 TCPWM65,并验证物理输出 latch 为低。
|
||||||
- 正常车辆上电不要求调试器连接;真正 POR 会清除调试会话标记。若 C-SPY 会话结束或调试器脱离,则本次供电周期锁存安全停机,防止烧录结束后执行器继续动作。
|
- 正常车辆上电不要求调试器连接;真正 POR 会清除调试会话标记。若 C-SPY 会话结束或调试器脱离,则本次供电周期锁存安全停机,防止烧录结束后执行器继续动作。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,7 @@ protocol:
|
|||||||
stop_all_motor_selector: 0
|
stop_all_motor_selector: 0
|
||||||
home_known_range_opcode: 11
|
home_known_range_opcode: 11
|
||||||
home_known_range_key: 19282
|
home_known_range_key: 19282
|
||||||
|
home_known_range_timeout_s: 120
|
||||||
normalized_position_max: 10000
|
normalized_position_max: 10000
|
||||||
# A full M0 stroke takes more than the firmware's legacy 12 s default.
|
# 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
|
# Always send this override with Opcode 1 so every calibrated axis has
|
||||||
@@ -28,7 +29,10 @@ protocol:
|
|||||||
default_duty_percent: 90
|
default_duty_percent: 90
|
||||||
timed_duty_level_scale: 10
|
timed_duty_level_scale: 10
|
||||||
timed_duty_min_duration_ms: 300
|
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
|
increase_direction: 1
|
||||||
decrease_direction: 2
|
decrease_direction: 2
|
||||||
axes:
|
axes:
|
||||||
@@ -41,6 +45,7 @@ axes:
|
|||||||
default_max_hall_count: 480
|
default_max_hall_count: 480
|
||||||
default_timeout_ms: 12000
|
default_timeout_ms: 12000
|
||||||
timed_duty_ms_per_pct: 120
|
timed_duty_ms_per_pct: 120
|
||||||
|
max_user_timed_duration_ms: 30000
|
||||||
max_uncalibrated_relative_pct: 15
|
max_uncalibrated_relative_pct: 15
|
||||||
hardware_verified: true
|
hardware_verified: true
|
||||||
seat.set_cushion_tilt:
|
seat.set_cushion_tilt:
|
||||||
@@ -54,6 +59,7 @@ axes:
|
|||||||
default_max_hall_count: 374
|
default_max_hall_count: 374
|
||||||
default_timeout_ms: 8000
|
default_timeout_ms: 8000
|
||||||
timed_duty_ms_per_pct: 80
|
timed_duty_ms_per_pct: 80
|
||||||
|
max_user_timed_duration_ms: 20000
|
||||||
max_uncalibrated_relative_pct: 15
|
max_uncalibrated_relative_pct: 15
|
||||||
hardware_verified: true
|
hardware_verified: true
|
||||||
seat.set_backrest_recline:
|
seat.set_backrest_recline:
|
||||||
@@ -66,6 +72,7 @@ axes:
|
|||||||
default_max_hall_count: 917
|
default_max_hall_count: 917
|
||||||
default_timeout_ms: 12000
|
default_timeout_ms: 12000
|
||||||
timed_duty_ms_per_pct: 120
|
timed_duty_ms_per_pct: 120
|
||||||
|
max_user_timed_duration_ms: 30000
|
||||||
max_uncalibrated_relative_pct: 15
|
max_uncalibrated_relative_pct: 15
|
||||||
hardware_verified: true
|
hardware_verified: true
|
||||||
seat.set_legrest_height:
|
seat.set_legrest_height:
|
||||||
@@ -78,5 +85,6 @@ axes:
|
|||||||
default_max_hall_count: 647
|
default_max_hall_count: 647
|
||||||
default_timeout_ms: 10000
|
default_timeout_ms: 10000
|
||||||
timed_duty_ms_per_pct: 100
|
timed_duty_ms_per_pct: 100
|
||||||
|
max_user_timed_duration_ms: 25000
|
||||||
max_uncalibrated_relative_pct: 15
|
max_uncalibrated_relative_pct: 15
|
||||||
hardware_verified: true
|
hardware_verified: true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -22,9 +23,11 @@ if str(SEAT_SOURCE) not in sys.path:
|
|||||||
|
|
||||||
from seat_domain import ( # noqa: E402
|
from seat_domain import ( # noqa: E402
|
||||||
HardwareSeatExecutor,
|
HardwareSeatExecutor,
|
||||||
|
SIMULATED_HARDWARE_FAULTS,
|
||||||
SeatCanMatrix,
|
SeatCanMatrix,
|
||||||
SeatCompiler,
|
SeatCompiler,
|
||||||
T2GSeatCompiler,
|
T2GSeatCompiler,
|
||||||
|
SimulatedHardwareSeatExecutor,
|
||||||
VirtualSeatExecutor,
|
VirtualSeatExecutor,
|
||||||
build_function_config,
|
build_function_config,
|
||||||
build_function_preset,
|
build_function_preset,
|
||||||
@@ -120,6 +123,10 @@ class SeatDemoService:
|
|||||||
)
|
)
|
||||||
self.compiler = SeatCompiler(self.domain_root / "can" / "signal-mapping.yaml")
|
self.compiler = SeatCompiler(self.domain_root / "can" / "signal-mapping.yaml")
|
||||||
self.executor = VirtualSeatExecutor(self.matrix)
|
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.hardware_matrix = SeatCanMatrix(
|
||||||
[
|
[
|
||||||
self.domain_root / "can" / "seat-thermal-hardware-matrix.yaml",
|
self.domain_root / "can" / "seat-thermal-hardware-matrix.yaml",
|
||||||
@@ -131,6 +138,10 @@ class SeatDemoService:
|
|||||||
repository_root / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
|
repository_root / "platforms" / "t2g_seat_bench" / "calibration" / "seat.yaml",
|
||||||
)
|
)
|
||||||
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
|
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.openai = OpenAIIntentInterpreter()
|
||||||
self.domain_runtimes = {
|
self.domain_runtimes = {
|
||||||
domain: ConfiguredDomainRuntime(repository_root, domain)
|
domain: ConfiguredDomainRuntime(repository_root, domain)
|
||||||
@@ -171,11 +182,21 @@ class SeatDemoService:
|
|||||||
"domain": "cabin",
|
"domain": "cabin",
|
||||||
"platform": "reference_vehicle",
|
"platform": "reference_vehicle",
|
||||||
"execution_mode": "dry_run",
|
"execution_mode": "dry_run",
|
||||||
"execution_modes": ["dry_run", "hardware"],
|
"execution_modes": ["dry_run", "simulated_hardware", "hardware"],
|
||||||
"hardware_execution_allowed": self.hardware_settings.configured,
|
"hardware_execution_allowed": self.hardware_settings.configured,
|
||||||
"hardware": self.hardware_settings.public_info(),
|
"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_available": self.openai.available,
|
||||||
"openai_model": self.openai.model,
|
"openai_model": self.openai.model,
|
||||||
|
"openai_responses_endpoint": self.openai.responses_endpoint,
|
||||||
"conversation_memory": True,
|
"conversation_memory": True,
|
||||||
"default_vehicle_id": default_vehicle.id,
|
"default_vehicle_id": default_vehicle.id,
|
||||||
"vehicle": default_vehicle.public_dict(),
|
"vehicle": default_vehicle.public_dict(),
|
||||||
@@ -272,6 +293,10 @@ class SeatDemoService:
|
|||||||
auto_home=self.hardware_settings.auto_home_enabled,
|
auto_home=self.hardware_settings.auto_home_enabled,
|
||||||
home_timeout_s=self.hardware_settings.home_timeout_s,
|
home_timeout_s=self.hardware_settings.home_timeout_s,
|
||||||
firmware_guard=self.hardware_settings.firmware_guard(),
|
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(
|
def process(
|
||||||
@@ -282,6 +307,7 @@ class SeatDemoService:
|
|||||||
execution_mode: str = "dry_run",
|
execution_mode: str = "dry_run",
|
||||||
approval_token: str | None = None,
|
approval_token: str | None = None,
|
||||||
vehicle_id: str | None = None,
|
vehicle_id: str | None = None,
|
||||||
|
simulation_fault: str = "normal",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
utterance = utterance.strip()
|
utterance = utterance.strip()
|
||||||
if not utterance:
|
if not utterance:
|
||||||
@@ -290,8 +316,21 @@ class SeatDemoService:
|
|||||||
raise ValueError("utterance must be at most 1000 characters")
|
raise ValueError("utterance must be at most 1000 characters")
|
||||||
if provider not in {"auto", "openai", "local"}:
|
if provider not in {"auto", "openai", "local"}:
|
||||||
raise ValueError("provider must be auto, openai, or local")
|
raise ValueError("provider must be auto, openai, or local")
|
||||||
if execution_mode not in {"dry_run", "hardware"}:
|
if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
|
||||||
raise ValueError("execution_mode must be dry_run or 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_save_name = self._parse_mode_profile_save_name(utterance)
|
||||||
mode_restore_name = self._parse_mode_profile_restore_name(utterance)
|
mode_restore_name = self._parse_mode_profile_restore_name(utterance)
|
||||||
mode_update = self._parse_mode_profile_update(utterance)
|
mode_update = self._parse_mode_profile_update(utterance)
|
||||||
@@ -329,6 +368,8 @@ class SeatDemoService:
|
|||||||
"hardware_execution_allowed": (
|
"hardware_execution_allowed": (
|
||||||
hardware_request and not control_plane_request
|
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(),
|
"vehicle": vehicle_profile.public_dict(),
|
||||||
"matched_modes": [
|
"matched_modes": [
|
||||||
profile.public_dict() for profile in matched_modes
|
profile.public_dict() for profile in matched_modes
|
||||||
@@ -483,6 +524,8 @@ class SeatDemoService:
|
|||||||
result,
|
result,
|
||||||
traces,
|
traces,
|
||||||
hardware_request=hardware_request,
|
hardware_request=hardware_request,
|
||||||
|
simulated_hardware_request=simulated_hardware_request,
|
||||||
|
simulation_fault=simulation_fault,
|
||||||
vehicle_profile=vehicle_profile,
|
vehicle_profile=vehicle_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -763,11 +806,19 @@ class SeatDemoService:
|
|||||||
)
|
)
|
||||||
+ "板端 Position_Valid 将在发送绝对移动命令前实时检查。"
|
+ "板端 Position_Valid 将在发送绝对移动命令前实时检查。"
|
||||||
if hardware_request
|
if hardware_request
|
||||||
else "平台、矩阵和策略均限制为 dry-run;硬件发送路径未启用。"
|
else (
|
||||||
|
"模拟台架隔离门禁已通过;仅生成故障注入反馈,物理 CAN、ZLG 与 IAR 路径均未打开。"
|
||||||
|
if simulated_hardware_request
|
||||||
|
else "平台、矩阵和策略均限制为 dry-run;硬件发送路径未启用。"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
safety_started,
|
safety_started,
|
||||||
{
|
{
|
||||||
"hardware_execution_allowed": hardware_request,
|
"hardware_execution_allowed": hardware_request,
|
||||||
|
"simulated_hardware": simulated_hardware_request,
|
||||||
|
"simulation_fault": (
|
||||||
|
simulation_fault if simulated_hardware_request else None
|
||||||
|
),
|
||||||
"position_calibration_gate": (
|
"position_calibration_gate": (
|
||||||
"runtime_can_preflight" if hardware_request else "not_applicable"
|
"runtime_can_preflight" if hardware_request else "not_applicable"
|
||||||
),
|
),
|
||||||
@@ -788,6 +839,13 @@ class SeatDemoService:
|
|||||||
execution_report = hardware_executor.execute(
|
execution_report = hardware_executor.execute(
|
||||||
command_plan, function_config, context["devices"]
|
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:
|
else:
|
||||||
execution_report = self.executor.execute(
|
execution_report = self.executor.execute(
|
||||||
command_plan, function_config, context["devices"]
|
command_plan, function_config, context["devices"]
|
||||||
@@ -845,7 +903,15 @@ class SeatDemoService:
|
|||||||
else hardware_failure_summary
|
else hardware_failure_summary
|
||||||
)
|
)
|
||||||
if hardware_request
|
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,
|
execute_started,
|
||||||
@@ -861,6 +927,14 @@ class SeatDemoService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if execution_succeeded:
|
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 = (
|
state_actions = (
|
||||||
self._hardware_feedback_actions(
|
self._hardware_feedback_actions(
|
||||||
parsed["actions"], function_config, execution_report
|
parsed["actions"], function_config, execution_report
|
||||||
@@ -950,7 +1024,15 @@ class SeatDemoService:
|
|||||||
else "未获得满足目标的真实硬件反馈。"
|
else "未获得满足目标的真实硬件反馈。"
|
||||||
)
|
)
|
||||||
if hardware_request
|
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(),
|
time.perf_counter(),
|
||||||
@@ -958,6 +1040,9 @@ class SeatDemoService:
|
|||||||
"command_frame": telemetry.get("command_frame", {}).get("data_hex"),
|
"command_frame": telemetry.get("command_frame", {}).get("data_hex"),
|
||||||
"feedback_frame": telemetry.get("feedback_frame", {}).get("data_hex"),
|
"feedback_frame": telemetry.get("feedback_frame", {}).get("data_hex"),
|
||||||
"simulated": not hardware_request,
|
"simulated": not hardware_request,
|
||||||
|
"simulation_fault": (
|
||||||
|
simulation_fault if simulated_hardware_request else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._persist(result)
|
self._persist(result)
|
||||||
@@ -1026,9 +1111,16 @@ class SeatDemoService:
|
|||||||
action["position_valid"] = bool(
|
action["position_valid"] = bool(
|
||||||
telemetry["position_valid"]
|
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["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
|
continue
|
||||||
raw = telemetry.get("feedback_value")
|
raw = telemetry.get("feedback_value")
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
@@ -1141,6 +1233,35 @@ class SeatDemoService:
|
|||||||
return parsed
|
return parsed
|
||||||
if self._contains_seat_preset_request(parsed):
|
if self._contains_seat_preset_request(parsed):
|
||||||
return 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)
|
mode_requests = self.mode_profiles.requests_for_profiles(matched_modes)
|
||||||
detected_domains = []
|
detected_domains = []
|
||||||
@@ -1969,6 +2090,8 @@ class SeatDemoService:
|
|||||||
traces: list[dict[str, Any]],
|
traces: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
hardware_request: bool = False,
|
hardware_request: bool = False,
|
||||||
|
simulated_hardware_request: bool = False,
|
||||||
|
simulation_fault: str = "normal",
|
||||||
vehicle_profile: VehicleProfile | None = None,
|
vehicle_profile: VehicleProfile | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run isolated adapters, with only the passenger seat eligible for HIL."""
|
"""Run isolated adapters, with only the passenger seat eligible for HIL."""
|
||||||
@@ -2165,11 +2288,19 @@ class SeatDemoService:
|
|||||||
(
|
(
|
||||||
"副驾座椅已通过 T2G HIL 安全门,其他座舱领域保持虚拟 CAN。"
|
"副驾座椅已通过 T2G HIL 安全门,其他座舱领域保持虚拟 CAN。"
|
||||||
if hardware_request
|
if hardware_request
|
||||||
else "所有领域均为 dry-run;矩阵缺少实车完整性信息,硬件发送保持禁用。"
|
else (
|
||||||
|
"模拟台架仅对座椅结果注入故障;全部领域的物理发送路径保持关闭。"
|
||||||
|
if simulated_hardware_request
|
||||||
|
else "所有领域均为 dry-run;矩阵缺少实车完整性信息,硬件发送保持禁用。"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
time.perf_counter(),
|
time.perf_counter(),
|
||||||
{
|
{
|
||||||
"hardware_execution_allowed": hardware_request,
|
"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 [],
|
"hardware_domains": ["seat"] if hardware_request else [],
|
||||||
"virtual_domains": [
|
"virtual_domains": [
|
||||||
domain
|
domain
|
||||||
@@ -2190,6 +2321,13 @@ class SeatDemoService:
|
|||||||
reports[domain] = self._new_hardware_executor().execute(
|
reports[domain] = self._new_hardware_executor().execute(
|
||||||
plans[domain], config, context["devices"]
|
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:
|
else:
|
||||||
reports[domain] = self.executor.execute(
|
reports[domain] = self.executor.execute(
|
||||||
plans[domain], config, context["devices"]
|
plans[domain], config, context["devices"]
|
||||||
@@ -2212,8 +2350,9 @@ class SeatDemoService:
|
|||||||
if hardware_request
|
if hardware_request
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
simulated_seat = simulated_hardware_request and "seat" in reports
|
||||||
report_spec: dict[str, Any] = {
|
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",
|
"status": "succeeded" if execution_succeeded else "failed",
|
||||||
"started_at": execution_started_at,
|
"started_at": execution_started_at,
|
||||||
"finished_at": _now(),
|
"finished_at": _now(),
|
||||||
@@ -2229,14 +2368,27 @@ class SeatDemoService:
|
|||||||
"name": (
|
"name": (
|
||||||
"Cabin orchestration mixed HIL execution report"
|
"Cabin orchestration mixed HIL execution report"
|
||||||
if hardware_request
|
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",
|
"version": "1.0.0",
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"labels": {
|
"labels": {
|
||||||
"simulation": "mixed" if hardware_request else "true",
|
"simulation": "mixed" if hardware_request else "true",
|
||||||
"seat_feedback": (
|
"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"
|
"orchestration": "cross_domain"
|
||||||
if len(configs) > 1
|
if len(configs) > 1
|
||||||
@@ -2272,7 +2424,20 @@ class SeatDemoService:
|
|||||||
else (
|
else (
|
||||||
f"副驾座椅真实执行失败:{failed_execution['message']}"
|
f"副驾座椅真实执行失败:{failed_execution['message']}"
|
||||||
if hardware_request and failed_execution
|
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,
|
execute_started,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
from pathlib import Path
|
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():
|
if not path.is_file():
|
||||||
return
|
return
|
||||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
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()
|
value = value.strip()
|
||||||
if value[:1] == value[-1:] and value.startswith(("'", '"')):
|
if value[:1] == value[-1:] and value.startswith(("'", '"')):
|
||||||
value = value[1:-1]
|
value = value[1:-1]
|
||||||
if key:
|
if key and (override or not os.environ.get(key, "").strip()):
|
||||||
os.environ.setdefault(key, value)
|
# 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
|
cspybat: Path
|
||||||
auto_flash_enabled: bool
|
auto_flash_enabled: bool
|
||||||
auto_home_enabled: bool
|
auto_home_enabled: bool
|
||||||
|
position_sync_enabled: bool
|
||||||
home_timeout_s: int
|
home_timeout_s: int
|
||||||
firmware_probe_timeout_ms: int
|
firmware_probe_timeout_ms: int
|
||||||
firmware_boot_wait_ms: int
|
firmware_boot_wait_ms: int
|
||||||
@@ -171,6 +172,10 @@ class SeatHardwareSettings:
|
|||||||
),
|
),
|
||||||
auto_flash_enabled=auto_flash_enabled,
|
auto_flash_enabled=auto_flash_enabled,
|
||||||
auto_home_enabled=_env_bool("COGNITIVE_AGENT_SEAT_AUTO_HOME_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(
|
home_timeout_s=_env_int(
|
||||||
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
|
"COGNITIVE_AGENT_SEAT_HOME_TIMEOUT_S", 120
|
||||||
),
|
),
|
||||||
@@ -375,12 +380,19 @@ class SeatHardwareSettings:
|
|||||||
"physical_target": "t2g_primary_seat",
|
"physical_target": "t2g_primary_seat",
|
||||||
"target_aliasing": "disabled_front_passenger_only",
|
"target_aliasing": "disabled_front_passenger_only",
|
||||||
"auto_home_enabled": self.auto_home_enabled,
|
"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,
|
"home_timeout_s": self.home_timeout_s,
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"hardware execution requires the unified Seat_Controller_demo image on one development board",
|
"hardware execution requires the unified Seat_Controller_demo image on one development board",
|
||||||
"all seat capabilities are restricted to passenger/front_right",
|
"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",
|
"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",
|
"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
|
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})
|
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)
|
EXPECTED_MOTOR_MAX_HALL = (480, 374, 917, 647)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import urllib.request
|
|||||||
from typing import Any
|
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]:
|
def build_intent_schema(capability_context: dict[str, Any]) -> dict[str, Any]:
|
||||||
domains = [item["domain"] for item in capability_context.get("domains", [])] or ["seat"]
|
domains = [item["domain"] for item in capability_context.get("domains", [])] or ["seat"]
|
||||||
capabilities = [
|
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.
|
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.
|
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
|
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
|
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
|
registered atomic capabilities and infer conservative absolute targets from their normalized ranges
|
||||||
@@ -173,12 +191,14 @@ class OpenAIIntentError(RuntimeError):
|
|||||||
|
|
||||||
class OpenAIIntentInterpreter:
|
class OpenAIIntentInterpreter:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.api_key = os.getenv("OPENAI_API_KEY", "")
|
self.api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||||
self.model = os.getenv("OPENAI_MODEL", "gpt-5.6-sol")
|
self.model = _env_text("OPENAI_MODEL", "gpt-5.6-sol")
|
||||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
|
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.timeout_seconds = float(os.getenv("OPENAI_TIMEOUT_SECONDS", "30"))
|
||||||
self.max_retries = max(0, int(os.getenv("OPENAI_MAX_RETRIES", "2")))
|
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 {
|
self.streaming = os.getenv("OPENAI_STREAM", "false").strip().lower() in {
|
||||||
"1",
|
"1",
|
||||||
"true",
|
"true",
|
||||||
@@ -190,6 +210,10 @@ class OpenAIIntentInterpreter:
|
|||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return bool(self.api_key)
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def responses_endpoint(self) -> str:
|
||||||
|
return f"{self.base_url}/responses"
|
||||||
|
|
||||||
def interpret(
|
def interpret(
|
||||||
self,
|
self,
|
||||||
utterance: str,
|
utterance: str,
|
||||||
@@ -269,7 +293,7 @@ class OpenAIIntentInterpreter:
|
|||||||
|
|
||||||
def _request_output_text(self, payload: dict[str, Any]) -> str:
|
def _request_output_text(self, payload: dict[str, Any]) -> str:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{self.base_url}/responses",
|
self.responses_endpoint,
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ from cognitive_agent.config_loader import load_local_env
|
|||||||
|
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||||
load_local_env(REPOSITORY_ROOT / ".env")
|
# Repository-local launch configuration is deliberate and must not be shadowed
|
||||||
load_local_env(REPOSITORY_ROOT / ".env.auth")
|
# 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.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
|
||||||
from cognitive_agent.application.seat_service import SeatDemoService # 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"
|
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
|
||||||
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
|
REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$")
|
||||||
PUBLIC_AUTH_PATHS = {
|
PUBLIC_AUTH_PATHS = {
|
||||||
"/login",
|
"/login",
|
||||||
"/login.html",
|
"/login.html",
|
||||||
"/login.css",
|
"/login.css",
|
||||||
"/login.js",
|
"/login.js",
|
||||||
|
"/network.js",
|
||||||
"/api/auth/status",
|
"/api/auth/status",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
}
|
}
|
||||||
@@ -182,6 +186,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
if payload.get("vehicle_id")
|
if payload.get("vehicle_id")
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||||
)
|
)
|
||||||
self._json(result, HTTPStatus.CREATED)
|
self._json(result, HTTPStatus.CREATED)
|
||||||
except HardwareSafetyError as exc:
|
except HardwareSafetyError as exc:
|
||||||
@@ -370,7 +375,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.send_header("Content-Type", f"{content_type or 'application/octet-stream'}; charset=utf-8")
|
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("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._send_security_headers()
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(content)
|
self.wfile.write(content)
|
||||||
@@ -427,10 +439,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
|||||||
self.send_header(
|
self.send_header(
|
||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'self'; "
|
"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'; "
|
"style-src 'self'; "
|
||||||
"img-src 'self' data:; "
|
"img-src 'self' data:; "
|
||||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
"connect-src 'self'; "
|
||||||
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
)
|
)
|
||||||
if self._is_https():
|
if self._is_https():
|
||||||
@@ -462,7 +474,9 @@ def main() -> None:
|
|||||||
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
|
print(f"Cabin orchestration demo: http://{args.host}:{args.port}", flush=True)
|
||||||
hardware = SeatDemoHandler.service.hardware_settings
|
hardware = SeatDemoHandler.service.hardware_settings
|
||||||
print(
|
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'})",
|
f"({'configured' if hardware.configured else 'locked'})",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("/login", final_url)
|
self.assertIn("/login", final_url)
|
||||||
self.assertIn("体验账号登录", login_html)
|
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:
|
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||||
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
|
unauthenticated.open(f"{self.base_url}/api/runtime", timeout=3)
|
||||||
self.assertEqual(caught.exception.code, 401)
|
self.assertEqual(caught.exception.code, 401)
|
||||||
@@ -102,6 +105,8 @@ class SeatWebApiTests(unittest.TestCase):
|
|||||||
def test_static_console_and_runtime_endpoint(self) -> None:
|
def test_static_console_and_runtime_endpoint(self) -> None:
|
||||||
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
|
||||||
html = response.read().decode("utf-8")
|
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:
|
with self.opener.open(f"{self.base_url}/api/runtime", timeout=3) as response:
|
||||||
runtime = json.loads(response.read().decode("utf-8"))
|
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('data-vehicle-model="byd-tang-2024-reference"', html)
|
||||||
self.assertIn("整车孪生界面", html)
|
self.assertIn("整车孪生界面", 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("FBXLoader.js", html)
|
||||||
self.assertIn('data-seat-layout="five-seat"', html)
|
self.assertIn('data-seat-layout="five-seat"', html)
|
||||||
self.assertIn('data-active-seat="front-right"', 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["vehicle"]["id"], "denza_n7")
|
||||||
self.assertEqual(result["actuator_state"]["passenger"]["ventilation"]["level"], 1)
|
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:
|
def test_hardware_state_endpoint_returns_physical_passenger_snapshot(self) -> None:
|
||||||
synchronized = {
|
synchronized = {
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
|
|||||||
+423
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -6,7 +6,10 @@ import unittest
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from unittest.mock import patch
|
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 = {
|
INTENT = {
|
||||||
@@ -45,10 +48,37 @@ class FakeResponse:
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIResponsesTests(unittest.TestCase):
|
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:
|
def test_non_streaming_structured_output(self) -> None:
|
||||||
response_body = json.dumps({"output_text": json.dumps(INTENT)})
|
response_body = json.dumps({"output_text": json.dumps(INTENT)})
|
||||||
environment = {
|
environment = {
|
||||||
"OPENAI_API_KEY": "test-key",
|
"OPENAI_API_KEY": "test-key",
|
||||||
|
"OPENAI_MODEL": "gpt-5.6-sol",
|
||||||
|
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||||
"OPENAI_STREAM": "false",
|
"OPENAI_STREAM": "false",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +103,11 @@ class OpenAIResponsesTests(unittest.TestCase):
|
|||||||
request_payload = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
request_payload = json.loads(urlopen.call_args.args[0].data.decode("utf-8"))
|
||||||
self.assertEqual(result, INTENT)
|
self.assertEqual(result, INTENT)
|
||||||
self.assertNotIn("stream", request_payload)
|
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("capability registry", request_payload["input"][1]["content"][0]["text"])
|
||||||
self.assertIn('"level": 1', request_payload["input"][2]["content"][0]["text"])
|
self.assertIn('"level": 1', request_payload["input"][2]["content"][0]["text"])
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user